Compare commits

..

71 Commits

Author SHA1 Message Date
nntrivi2001 81b3cbda65 chore: v2.8.56 — new tab / split pane starts in the Session defaults 2026-11-02 13:34:36 +07:00
nntrivi2001 0a99eed369 fix(workspace): a new tab or split pane starts in the account's Session defaults
newTabBody sent only prompt/tool/model, so a Claude tab opened from the tab
bar + or a split menu always started in auto mode even with Bypass
permissions set as the default; a Codex one ignored its sandbox, approval
and YOLO defaults. It now sends the same defaults the workspace home's
prompt box starts from.
2026-11-02 13:34:10 +07:00
nntrivi2001 1ec9f2e592 chore: v2.8.55 — chat + menu no longer covered by the controls under the prompt box 2026-11-02 13:30:33 +07:00
nntrivi2001 a401a318c5 fix(chat): + menu paints above the controls row under the prompt box
Opened downward, the panel had no z-index, so the AI Proxy / Permission /
Local selects (positioned, later in the DOM) drew over its first rows.
2026-11-02 13:29:08 +07:00
nntrivi2001 5caad0df2d chore: v2.8.54 — chat Stop shows while Claude thinks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:29:05 +07:00
nntrivi2001 01e66ba122 fix(chat): Stop shows while Claude thinks, not only while its footer says "esc to interrupt"
The chat's live line said "Thinking… 21s" while the box offered Send, so the
turn could not be stopped. Stop shows only when a hook marked main "working"
or the pane's busy line matched, and Claude Code 2.1.3xx writes
"esc to interrupt" only in its footer, which a narrow pane, a tip or a notice
replaces. Claude's busy line now also matches its spinner (verb… (41s · …)),
which the line a finished turn leaves ("Cooked for 1m 2s") does not; and the
chat re-reads "working" on transcript_changed, so a hook that reached another
dashboard on the same database no longer leaves Stop hidden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:29:05 +07:00
nntrivi2001 923c1635b4 chore: v2.8.53 — chat + menu opens downward when there is no room above
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:25:38 +07:00
nntrivi2001 c37bc5205b fix(chat): + menu opens downward when the prompt box sits too near the top
On the workspace home the box is high on the screen, so the menu and its
submenus (Project files) opened upward and were clipped off the top.
Measure the room above/below (viewport narrowed by clipping ancestors) at
open and drop the menu below the button, submenus top-aligned, when it fits better.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:25:05 +07:00
nntrivi2001 841263bce1 chore: v2.8.52 — git: switch with changes + shelves, branches & tags, history graph + multi cherry-pick, merge editor, rebase/reset/force-with-lease 2026-11-02 12:28:58 +07:00
nntrivi2001 e1f433804c feat(git): history graph with search, commit details, file history, blame/browse at a revision, compare refs, and multi-commit cherry-pick/revert with preview, pause banner and undo 2026-11-02 12:22:46 +07:00
nntrivi2001 2b5d7268ed feat(git): 3-way merge editor, reflog, rebase/interactive rebase, reset with undo, force-with-lease push, remote branches and remotes
Server: git-rewrite.js (rebase + continue/skip/abort, rebase-interactive with
no editor, reset soft/mixed/keep/hard, undo-rewrite, force-push with lease,
remote-branch-delete, remote add/rename/remove) and git-conflicts.js
(conflict stages read + conflict-save, reflog), dispatched from runGitAction;
operationInProgress now reports a rebase and only ALLOWED_WHILE_REBASING
writes run during one. New reads: /git/conflict, /git/reflog, /git/remotes,
/git/rewrite-plan. Client: History tools menu, MergeEditor, ReflogView,
RebaseDialog, InteractiveRebase, ResetDialog, ForcePushDialog, RemotesDialog,
rebase banner Skip. CLAUDE.md git-writes rule rewritten for the new actions.
2026-11-02 12:12:45 +07:00
nntrivi2001 2cc492bb88 feat(git): Branches & tags panel — rename, branch from any ref, upstream, tags
GET /git/refs lists local branches (upstream, ahead/behind, last commit,
merged, held by a worktree, protected), remote branches grouped by remote
and tags (target, annotated message/tagger/date). New writes in
server/lib/git-refs.js, dispatched from git-ops.js: branch-rename,
branch-upstream (set/unset), tag-create (light/annotated), tag-delete
(confirm), tag-push (one tag to origin, no --tags, no force), and
branch-create with a looked-up from + switch on a clean tree. Every ref is
looked up, never passed through; repo-wide writes are repo-root only
(ESUBDIR) and refused during any operation (EMERGING).

Client: git/BranchManager + TagManager dialog opened from the git bar's
tags icon, en+vi (gitRefs namespace), keyboard navigable.

# Conflicts:
#	CLAUDE.md
#	client/src/components/lanes/LaneGitBar.tsx
#	docs/API.md
#	server/__tests__/lanes-api.test.js
#	server/routes/lanes.js
2026-11-02 12:00:55 +07:00
nntrivi2001 f6eb39eb73 feat(git): switch with uncommitted changes (carry / shelve), Create & switch, and shelves with Restore/Delete
- switch takes changes: carry (plain git switch, 409 ECARRYCONFLICT with files) or shelve (shelf first, re-applied if the switch fails)
- branch-create switch: true runs git switch -c, the changes come along
- shelve / unshelve / shelf-drop + GET /git/shelves: only agenthub-shelf: stash entries, addressed by sha, repo root only, never mid-operation; a clean restore drops the shelf, a conflicted one keeps it
- 409 EAGENTRUNNING until confirm: true while the workspace's agent is working
- git bar: Bring my changes / Shelve and switch choice, Create & switch, restore offer after a switch; Changes panel: Shelve (n) and the Shelves list
- fix: after a switch the Changes panel showed the old branch — the post-write re-read joined an in-flight GET started before the write (request() dedupe); writes now close in-flight GETs, and every git write fires agenthub:git-changed so every view of the workspace re-reads
2026-11-02 11:44:35 +07:00
nntrivi2001 2b26fe1efa fix(jobs): a failed clone's chip is readable and clickable
A failed or stopped clone also raised a toast on the same corner, a layer
above the dock, so it covered the chip's x / Retry / Stop and repeated the
same long message; the chip now says it alone, and every toast stacks above
the dock (--jobs-dock-h). A URL with no space to break at (user@host:/PATH)
wraps inside the chip and inside a toast instead of running off the screen.
Open Settings -> Git is a router link; a plain <a href> reloaded the app.
2026-11-02 11:26:48 +07:00
nntrivi2001 1f18f151d9 chore: v2.8.51 — clones and uploads in one global dock; a clone survives an update; CodeCommit needs only the key ID 2026-11-02 10:49:01 +07:00
nntrivi2001 a27589418c feat(clone): background clone jobs that survive a reload and a server restart
Both clone POSTs take background: true: every check answers synchronously,
then 202 {job}. git runs in a detached runner (scripts/clone-runner.js, its
own process group, stderr to a file — no SIGPIPE when the server restarts);
the server tails it for progress and runs the after-step (workspace / extra
folder) once when it exits. Jobs persist in <data dir>/runtime/clone-jobs.json
(0600, atomic, throttled progress writes); on start a live runner is adopted
(counted against the clone limit), an ended one finished, a queued one
requeued (interrupted if it carried a typed login), the rest interrupted.
GET /clone/jobs, DELETE /clone/jobs/:id, POST /clone/jobs/:id/cancel (queued:
dequeued; running: SIGTERM to the runner's group -> cancelled, no after-step)
and /retry (new job from the stored request, never a credential). The
held-open path is unchanged; worktree.gitCommand builds the runner's argv
and git-identity.cloneFailure the shared error classification.
2026-11-02 10:47:51 +07:00
nntrivi2001 57cbab916a feat(clone): background clones and uploads in one global dock that survives F5 and restarts
Clone repository (Files), Add workspace -> Clone and a workspace's extra-folder
clones now start server-side jobs (background: true) and close at once; the
app-wide BackgroundJobsDock (Layout) shows each job's queue place, git progress,
Stop (click twice), and outcome: Open workspace, Retry, an inline login form on
ECREDENTIALS, a Settings -> Git link on ESSHAUTH, dismiss. lib/cloneJobs.ts
polls GET /workspaces/clone/jobs while any job is active, announces a finish it
saw once (toast + agenthub:clone-job event + the starter's onDone), and treats
an older server's synchronous answer as a finished clone. File Explorer uploads
report to lib/uploadJobs.ts (Stop aborts the request in flight; a reload-cut
upload shows as interrupted from localStorage). CloneProgress renders a job's
fields instead of polling a token.
2026-11-02 10:44:23 +07:00
nntrivi2001 a7a8bfd24c fix(changes): Discard (n) dialog shows its count and does not count a new file twice
The confirm button was translated without count, so it read Discard ({{count}}).
The message counted ticked untracked files both as changed files and as new
files to delete; tracked and untracked are now counted apart, with a wording
for each mix.
2026-11-02 10:34:34 +07:00
nntrivi2001 0c964909e0 feat(git): a CodeCommit SSH key ID is saved for every region; a clone tries each saved account
Settings → Git → AWS CodeCommit asks for the key ID only: the default key's
login is written for every CodeCommit region at once (allRegions), shown as
one row, with Use in every region for an older one-region save. A clone of
a CodeCommit URL with no user tries every saved CodeCommit login — the
region's own first, then other regions' and each named key's — so a second
AWS account clones from the plain AWS URL with no ssh://<alias>/ rewrite.
2026-11-02 10:32:20 +07:00
nntrivi2001 6511e4ec62 chore: v2.8.50 — a workspace whose folder was deleted leaves the Files places 2026-11-02 10:11:53 +07:00
nntrivi2001 220309db12 fix(files): a workspace whose folder was deleted leaves the Files places
The lane payload carries folder_missing (ENOENT/ENOTDIR only). Files hides
such a workspace and never reopens it as the default place; the Workspace
sidebar strikes its name through with a hint to remove it from its menu.
A folder that cannot be read no longer keeps the previous folder's entries
under its error, and a change in Files re-reads the workspace list.
2026-11-02 10:11:47 +07:00
nntrivi2001 c3d88c0685 chore: v2.8.49 — CodeCommit clone works across regions; Clone runs in the background 2026-11-02 10:00:09 +07:00
nntrivi2001 e4ecd116a2 fix(clone): a CodeCommit region borrows saved key IDs; Clone runs in the background
An IAM SSH key ID logs in to every region of its AWS account, but a login
was saved per host, so a repo in a region with no key ID of its own was
refused. Clone now tries the key IDs saved for the other CodeCommit regions
(each with its own key; the first ls-remote accepts, or the only one) and
keeps that user in the clone's origin.

Files → New → Clone repository steps aside for its progress chip as soon
as the clone starts; a failure asking for a login reopens the form.
2026-11-02 10:00:09 +07:00
nntrivi2001 8d0477e0f7 chore: v2.8.48 — a folder upload skips .git with one notice instead of an error per file 2026-11-02 09:53:03 +07:00
nntrivi2001 e7e9cb20d8 fix(files): a folder upload leaves .git out with one notice
Uploading a cloned repo from the desktop sent every .git object, and the
server refused each one (nothing is uploaded into .git) — one error toast
per object, thousands of them. The drop walker no longer reads a .git
folder, and uploadAll drops any path inside .git before sending, saying
once how many were skipped.
2026-11-02 09:53:03 +07:00
nntrivi2001 ebfae9b891 chore: v2.8.47 — a Codex ChatGPT pool account reaches its pane even while an older login's daemon runs 2026-10-30 16:06:33 +07:00
nntrivi2001 3ab484ddc0 fix(pool): a Codex ChatGPT pool pane runs on the login placed for it
Codex >= 0.164 hands a plain TUI (no -c override) to the CODEX_HOME's
shared app-server daemon, which outlives every TUI and keeps the login it
started with. A ChatGPT pool account written into a member's auth.json
therefore reached no pane while their daemon still ran on the previous
login (reproduced on 0.164.0: the daemon kept sending the old key, a
--no-daemon TUI sent the new one). Such a pane now runs `codex
--no-daemon`, only when that binary's --help lists the flag (probed once
per path/size/mtime), so an older Codex still starts.
2026-10-30 16:06:20 +07:00
nntrivi2001 6bcef26446 chore: v2.8.46 — last updated time and installers' versions in Artifacts; a web app's pages are no longer auto-captured 2026-10-30 15:23:03 +07:00
nntrivi2001 240444eafd fix(artifacts): auto-capture leaves a web app's own HTML pages alone
At a session's Stop every untracked .html was snapshotted as a report, so a
web project not yet committed had its index.html taken into its owner's
Artifacts. An index.html, a page under a site folder (www, public, dist,
build, src, static, templates, assets, site, _site) or one beside scripts or
stylesheets (outside the workspace root) is now a site's own page and left
alone; a standalone HTML report is still captured.
2026-10-30 15:22:19 +07:00
nntrivi2001 d09c9d6633 feat(artifacts): last updated time on skills and files; each installer's copy marked
A row said nothing about when its content last changed, and the install list
could not tell who still runs an old version. `artifacts.version_at` (new
column, existing rows from `updated_at`) is stamped only when a snapshot is
replaced — a rename or share toggle leaves it — and served as `versionAt`:
skill and file rows read "Updated 3h ago", the detail sheet "Last updated".
`GET /api/artifacts/:id/installs` gives each skill install a `state` —
latest / outdated / removed / unknown — from its Claude skill copies' hash
against the newest snapshot, shown as a badge.
2026-10-30 15:21:31 +07:00
nntrivi2001 bc44cbc8b7 chore: v2.8.45 — Share again updates a shared skill; a same-named upload updates it too 2026-10-30 14:20:51 +07:00
nntrivi2001 e7060a5e42 feat(artifacts): Share again takes a skill's newest version; a same-named upload updates it
A shared skill followed its owner's ~/.claude/skills folder only, so one
uploaded from a browser (or living in a plugin) stayed at its first snapshot
for good. Share again (a skill's ⋯ menu and detail sheet) now takes the
owner's copy on the server — its source folder, else the same-named skill of
a plugin they installed at user scope — and shares it; with no copy it asks
for the folder and uploads it onto that row (`id`). An upload whose skill is
named like one the uploader already has replaces that row's version instead
of adding a twin. Installers are then offered Update, as before.
2026-10-30 14:20:18 +07:00
nntrivi2001 4b8f59695b chore: v2.8.44 — Discard in the Changes panel takes only the ticked files and blocks 2026-10-30 13:05:48 +07:00
nntrivi2001 da7b86d5c8 fix(changes): Discard takes only the ticked files and blocks (#87)
The Changes panel's "Discard all" threw away every changed file, ticked or
not. It is now "Discard (n)": the same selection Commit sends — whole ticked
files as `paths`, a partly ticked file's ticked blocks as `hunks` — and is
disabled with nothing ticked. The server's `discard` accepts `hunks`
(`selectionPatch` in reverse, `git apply -R`), building every patch before
applying any so a stale selection undoes nothing.

Closes #87
2026-10-30 13:05:13 +07:00
nntrivi2001 efe21c79e9 chore: v2.8.43 — terminal glyphs stay sharp in every pane; a huge diff line no longer hides the rest 2026-10-30 10:34:51 +07:00
nntrivi2001 ee119fe437 fix(terminal): restamp every glyph-atlas page so no pane keeps a stale texture
v2.8.42 reacted to a page merge by toggling the font family on the next
frame, so each renderer re-acquired an atlas. Panes still showed squeezed,
overlapping glyphs: the toggle lands a frame late, swaps every pane onto a
fresh atlas (or onto one another pane already toggled to), and a page added
without a merge shifts nothing yet still raced it.

addon-webgl 0.20 fixes the root cause with globally monotonic page versions.
Do the same on 0.19: whenever the shared atlas adds, removes or merges a page,
give every page a version no texture slot has uploaded, synchronously, so
every renderer on that atlas re-uploads all pages before it draws again.
When the page comes back from the background, restamp and repaint. An addon
of another shape keeps the old font-toggle fallback.
2026-10-30 10:33:57 +07:00
nntrivi2001 d97c403aab fix(changes): a huge line no longer cuts the diff short; preview pages up to 64 MB
A page that inlines a video as base64 has rows of megabytes. Its patch was read
at the 200 KB per-file cap, so the first such row used the whole budget and
every hunk after it was lost ("Diff cut short"). The patch is now read up to
16 M characters, rows over 4,000 characters are cut to 1,000 with an `elided`
count the diff shows, and only then is the cap applied. Hunk actions re-read
git's own diff by header and are unchanged.

The HTML preview refused a page over 10 MB and showed only the file icon; a
self-contained 18 MB slide deck now opens (limit 64 MB).
2026-10-30 10:28:53 +07:00
nntrivi2001 c41114faf8 chore: v2.8.42 — terminal glyphs stay sharp after the WebGL atlas fills up 2026-10-30 10:10:33 +07:00
nntrivi2001 c850f7603a fix(terminal): re-upload the shared WebGL atlas after a page merge
@xterm/addon-webgl 0.19 shares one glyph atlas between every terminal
with the same font. When it fills, four pages are merged into a larger
one and the rest shift down; a GPU texture is re-uploaded only when its
page version differs, and those counters can coincide, so a slot keeps a
stale page of another size and every glyph is drawn squeezed or foreign
(fixed upstream only in the 0.20 betas). On a merge — and when the page
comes back — toggle the font family's trailing space on the next frame,
which makes each renderer re-acquire its atlas and re-upload all pages.

This also drops yesterday's clearTextureAtlas on focus: it emptied the
SHARED atlas but redrew only the calling pane, corrupting its siblings.
2026-10-30 10:09:28 +07:00
nntrivi2001 5463e354fa chore: v2.8.41 — a session outside every workspace counts toward its user's spend 2026-10-30 08:50:58 +07:00
nntrivi2001 783b7f4101 fix(cost): bill a session outside every workspace to its Linux account's user
A script's claude -p in a temp folder (the SG Flow eval runner strips the
pane's env) had no workspace, so its spend reached nobody's Today, the Users
page or a spend quota — ~$57 of a $74 day was missing from the status bar.

sessions.account records the Linux account a session ran as (the hook's
peer uid, else its folder for poller-made and old rows); a session no
workspace owns is owned by that account's user — a member's to them, the
host's to the admin ticked 'Owns this server's host account' (or the only
admin). Workspace ownership still wins.
2026-10-30 08:50:34 +07:00
nntrivi2001 c28395402b chore: v2.8.40 — a Codex account switch reaches running sessions 2026-10-29 15:59:19 +07:00
nntrivi2001 7798530528 fix(profiles): a Codex switch reaches running and new sessions
Codex >= 0.164 sends every request through a shared app-server daemon that
outlives its TUIs and keeps the login it started with, so writing a new
auth.json changed nothing: open sessions and even new ones stayed on the
old (out-of-quota) account. After a switch, restart that CODEX_HOME's
daemon when one runs; open TUIs reconnect to it on their own (verified on
0.164.0 with two keys). The response says so (following), and the toast
tells the user running sessions follow.
2026-10-29 15:54:20 +07:00
nntrivi2001 25f63d9ad3 fix(terminal): rebuild the WebGL glyph atlas when the page comes back
A browser can drop the atlas's GPU memory without a context-loss event
(sleep/resume, a long-backgrounded tab, display/zoom change on macOS),
leaving every cell painted with a clipped or foreign glyph.
2026-10-29 15:44:16 +07:00
nntrivi2001 d060d288fb fix(sessions): Top tools counts each tool call once
The session analysis page's Top tools list counted every event carrying a
tool_name, so each call was counted twice (its PreToolUse and its
PostToolUse) while the Tool calls total beside it counted once — a session
with 840 calls showed Bash at 1,533. The list now counts the same way as
the total: PreToolUse, or PostToolUse in a session with none (imported).
2026-10-29 14:53:45 +07:00
nntrivi2001 6099841cef fix(sessions): archiving a live session stops its pane
Archive on a session a live pane held only hid its row, and the list
deliberately does not bring such a tab back, so the pane kept running
with nothing on screen showing it — a member archived five sessions
believing them closed and kept ~2.7 GB of claude/omp/postgres-mcp alive
for up to two days. Archive on a live session is now a Stop (endRuns,
the tab X's confirm, DELETE /api/run/:id?archive=1) in the sidebar row,
the workspace home's Recents and their right-click menu.
2026-10-29 14:26:25 +07:00
nntrivi2001 d70a7c9403 chore: v2.8.39 — no one can take a colleague's display name 2026-10-29 13:47:31 +07:00
nntrivi2001 63eaa739d4 fix(accounts): a display name may not read as another account's
A member kept renaming themselves to a colleague's display name, plus
"(you)", so the CPU/RAM card and every owner list showed two of that person.
A display name is now refused (409 ENAMETAKEN) when, with marks, case,
spaces, punctuation, bracketed parts and look-alike Cyrillic/Greek letters
ignored, it equals another account's display name or username — in
Settings → Account and on the Users page alike. Keeping the name one already
has stays allowed, so an older clash does not block an admin's other edits.
2026-10-29 13:47:20 +07:00
nntrivi2001 9f8dc9082e chore: v2.8.38 — every user row shows its avatar 2026-10-29 13:40:39 +07:00
nntrivi2001 f1a90e120e fix(users): every user row shows its avatar, not only its initials
System Health's per-user CPU/RAM card asked UserAvatar for the viewer's own
picture with no user id, and the host stats never carried avatarAt, so every
card fell back to letters. The groups now carry avatarAt and the card passes
userId. The user editor's header, the pool board's member chips and the pool
member picker drew hand-made initials; they use UserAvatar too.
2026-10-29 13:40:28 +07:00
nntrivi2001 db5a7776cc chore: v2.8.37 — the Artifacts installs list is never cut off 2026-10-29 11:25:10 +07:00
nntrivi2001 6c2c094f64 fix(artifacts): the installs list floats over the page, never cut off
It was an absolute box inside the card list, whose overflow-hidden clipped it
at the last card, and it opened to the right off a button near the edge. It
is now a portal at the button: below it, above when there is no room, kept
inside the window, following scroll and resize.
2026-10-29 11:25:10 +07:00
nntrivi2001 609a35f251 chore: v2.8.36 — an HTML page opens in a modal over the screen (Changes Preview, Chat) 2026-10-29 11:20:54 +07:00
nntrivi2001 97c39d5e15 feat(files): an HTML page opens in a modal over the screen
- Changes panel: Preview beside Whole file opens the page in a modal
  (HtmlViewerModal, shared with the File Explorer) instead of inside the
  panel; Changes and Whole file stay as they were.
- Chat: an HTML file's card, Outputs row or a path in a reply (in the
  workspace or not) opens FilePreview as a modal — Preview | Code, versions,
  Save to Artifacts kept — rather than the side panel or the Changes panel.
- Esc closes both modals: they stop every key from reaching the view below,
  which also kept useDialog's document listener from seeing it.
2026-10-29 11:20:54 +07:00
nntrivi2001 4a82790734 chore: v2.8.35 — an HTML file's Preview sits beside Whole file 2026-10-29 10:47:39 +07:00
nntrivi2001 80000793f4 feat(files): an HTML file's Preview sits beside Whole file
The Changes panel's view toggle becomes Changes | Whole file | Preview for an
HTML file; the Preview/Raw button on the file's name row stays for markdown.
2026-10-29 10:47:39 +07:00
nntrivi2001 39bea78a9c chore: v2.8.34 — HTML page on Preview in the Changes panel (also from Changes) 2026-10-29 10:38:15 +07:00
nntrivi2001 364fe4a286 fix(files): an HTML file in the Changes panel shows as the page only on Preview
Changes stays the default view and Whole file shows the source, as for any
file. A Preview button — offered on the Changes diff too, where it was
missing — opens the page; Raw, Changes or Whole file go back to the source.
2026-10-29 10:38:15 +07:00
nntrivi2001 38ea78b489 chore: v2.8.33 — an HTML file opens as the page (Files panel, Chat preview, File Explorer) 2026-10-28 16:46:21 +07:00
nntrivi2001 7b5abc6ad9 feat(files): an HTML file opens as the page — Files panel, Chat preview, File Explorer
Clicking an .html/.htm file now renders it instead of showing its source or
downloading it:
- Changes/Files panel: a whole HTML file opens on Preview (Raw = the editor).
- Chat file preview: a workspace page brings its relative files along.
- File Explorer: double-click / Enter opens a viewer; Download stays on its
  header and right-click menu.

The page runs in the chat artifact's sandbox (iframe srcdoc, scripts, never
allow-same-origin). Its relative stylesheets (and their url()s), scripts,
images, media and icons are read by the dashboard through the existing
download routes and inlined as <style>/data: URLs (lib/htmlInline.ts: never
above the folder's root, ≤ 60 files / 30 MB) — the login cookie is
SameSite=Strict, so a frame could not fetch them itself, and no new route
serves files without it.
2026-10-28 16:46:07 +07:00
nntrivi2001 ca55ff8ec5 chore: v2.8.32 — a Claude plan is the one Anthropic reports now (Max 5x upgraded from Pro) 2026-10-28 15:11:22 +07:00
nntrivi2001 0841d3e5c8 fix(plan): a Claude plan is the one Anthropic reports now, not the sign-in's
An account signed in on Pro and upgraded to Max 5x kept showing "Pro": the
plan was read only from .credentials.json / .claude.json, whose
subscriptionType and rate-limit tiers are written at sign-in. fetchOwner
already asks GET /api/oauth/profile for a token's owner; it now also reads
organization_type / rate_limit_tier / seat_tier into a plan
(planFromProfile), remembered per account uuid (rememberLivePlan). The live
row, saved profiles (identityOf) and pool accounts (poolStatus, whose quota
refresh now asks a login's owner too) show that plan when one was seen
(shownPlan), else planLabel of the files as before.
2026-10-28 15:09:30 +07:00
nntrivi2001 33d30f5e1f chore: v2.8.31 — remote-only branches in the branch switcher, spend quota per user 2026-10-28 15:01:16 +07:00
nntrivi2001 14e979837e fix(git): the branch switcher lists and checks out branches only the remote has
GET /api/lanes/:id/git/branches read only refs/heads, so a branch someone
else pushed never showed after a fetch or pull, and `switch` refused it
(ENOBRANCH). It now also returns `remotes`: fetched remote-tracking branches
with no local branch of that name (origin/HEAD left out), listed under
"On the remote"; switching to one runs `git switch -c <b> --track
refs/remotes/<remote>/<b>` with the remote ref looked up, never passed
through (bare name only when exactly one remote has it, else EAMBIGUOUS).
The bar also re-reads the branch list after a fetch or pull instead of
keeping the list it loaded first.
2026-10-28 14:57:32 +07:00
nntrivi2001 fa27b299e7 feat(server-mode): spend quota per user — no new session, prompts refused once used up
An admin sets a default spend quota on the Users page and overrides it per
user (Edit user → Spend quota: default / no limit / their own), in dollars
per local day or per sliding 5 hours. Spend is the users page's own
hourlyTokenRows + calculateCost over the sessions a user owns.

Over the quota, until it opens again:
- spawnRun refuses every start/resume/home prompt with EQUOTA (429);
- a minute sweep writes each pane's AGENTHUB_QUOTA_FILE (host: data dir,
  member: handoff dir via write-as) and hook-handler.js exits 2 on
  UserPromptSubmit from that file alone (Claude Code, Codex with trusted
  hooks) — fail-open, never asks the dashboard, never for Antigravity;
- a member's omp on the shared login loses its gateway key (clientsFor);
- panes no hook can stop (Antigravity, Cursor, Codex without trusted
  hooks, omp on its own login) are ended.

GET /api/me/quota feeds a "Quota N%" chip in the status bar.
New: server/lib/user-quota.js, user_quotas table, PUT /api/users/quota-default,
PATCH /api/users/:id {quota}, docs in SERVER-MODE.md § Spend quotas.
2026-10-28 14:31:11 +07:00
nntrivi2001 9e3d6f4ca1 chore: v2.8.30 — account pool as one board of pools, real Claude plan names 2026-10-28 11:47:19 +07:00
nntrivi2001 26aabd6660 feat(pool): one board of pools with split/merge by drag; plan names from tier and seat
- The Users page account pool is one board: a column per pool holding its
  Claude Code, Codex and Cursor accounts (plan, quota, live panes) and its
  members. Accounts sit in one pool; drag moves them. Drop on New pool to
  split one off; drag a pool's only account into another pool to merge
  (members follow, the emptied pool is deleted on Save). One password Save.
- Claude plan label (planLabel in cc-profiles.js) reads rateLimitTier and
  seatTier: Max 5x/20x, Team Standard/Premium — subscriptionType alone said
  'pro' after an upgrade and 'team' for both seats. Shown on profiles, the
  status bar quota badge and pool cards; Codex pool cards show the ChatGPT plan.
2026-10-28 11:47:19 +07:00
nntrivi2001 9d07f64154 chore: v2.8.29 — skills with "token" in a doc name upload, System Health hides empty cards off Linux 2026-10-28 10:41:53 +07:00
nntrivi2001 0f17efe3b4 feat: choose your own pool account; skills with "token" in a doc name upload; System Health off Linux
- Skill snapshot/upload: a key file's name (.env, *.pem, id_rsa, .netrc,
  .credentials.json…) is always refused, but a word like token/secret/
  password/credential only in a data file's name — design-tokens.md or
  count_tokens.py is skill content, not a key.
- System Health: off Linux the History, CPU threads and Busiest processes
  cards (all read from /proc) are left out instead of shown empty; the CPU
  bar without a sample and Windows' always-zero Load row go too.
- Users pick their own pool account per tool (/api/me/pool-accounts,
  Settings → AI tools), within their groups, refused while pinned.
- Admin user detail, 9router update, session replay and Antigravity
  catalog changes from parallel sessions.
2026-10-28 10:41:53 +07:00
nntrivi2001 4999325210 chore: v2.8.28 — System Health grouped, a health score that sees lag 2026-10-27 16:40:01 +07:00
nntrivi2001 1c6dfea5b5 feat(health): System Health in three groups, a score that sees lag; admin user detail
Dashboard → System Health is grouped into AgentHub, Machine and Agent
activity. The machine (GET /api/host, every signed-in user) is drawn by
components/health/HostHealth.tsx: host card beside the CPU/memory history,
then CPU (threads as compact cells + temperatures/GPUs), disks & network,
busiest processes, and per-user tiles. Concurrency bars each show their own
agent type on hover.

The Health Score (client/src/lib/healthScore.ts) is now 100 minus what each
check costs, between an amber and a red threshold: event-loop p99
(server/lib/loop-delay.js, shared with /api/metrics), Linux PSI pressure for
CPU, memory and disk I/O (/api/host `pressure`; load per core without PSI),
RAM, the database's disk, V8 heap, hooks silent while agents work, and agent
errors in the last 24 h. All-time success rate, API errors and the transcript
cache hit rate no longer count. /api/settings/info adds server.event_loop and
activity {last_event_at, working_agents, agents_24h}.

Administration: a user row opens its detail in place (GET
/api/users/:id/details — spend, account pool share, live panes, workspaces,
newest sessions); the server panel is gone, member tmux folded into the table;
the Sessions list shows each row's owner in server mode.
2026-10-27 16:39:38 +07:00
251 changed files with 36652 additions and 4053 deletions
+6 -5
View File
@@ -1,4 +1,8 @@
{
"env": {
"ANTHROPIC_BASE_URL": "",
"ANTHROPIC_AUTH_TOKEN": ""
},
"permissions": {
"allow": [
"Bash(npm run:*)",
@@ -36,11 +40,8 @@
"Bash(node -e ':*)",
"Bash(node -e \"require\\('./server/lib/transcript-cache'\\); require\\('./scripts/import-history'\\); console.log\\('All modules load OK'\\)\")",
"Bash(claude --version)",
"Bash(gh pr *)"
"Bash(gh pr *)",
"Bash(grep *)"
]
},
"env": {
"ANTHROPIC_BASE_URL": "",
"ANTHROPIC_AUTH_TOKEN": ""
}
}
+6 -1
View File
@@ -327,6 +327,7 @@ graph TD
| `routes/settings.js` | System info (DB size, hook status, server uptime, transcript cache stats), data export as one versioned JSON bundle and matching import/restore (`POST /api/settings/import` via `server/lib/data-transfer.js` — idempotent, session-atomic, non-destructive; consolidates machines), session cleanup (abandon stale, purge old), clear all data (including the fired-alert feed and webhook delivery log; alert *rules* and webhook *targets* are preserved as user configuration), reset pricing, reinstall hooks |
| `routes/alerts.js` | HTTP surface for the rules-based alerting engine: alert-rule CRUD (`GET/POST /api/alerts/rules`, `PATCH/DELETE /api/alerts/rules/:id` — rule_type is immutable after creation, config re-validated against the stored type on PATCH), the fired-alert feed (`GET /api/alerts` with `?unacked=true` + pagination, response carries `total` and `unacked` counts), and acknowledgement (`POST /api/alerts/:id/ack`, `POST /api/alerts/ack-all`, broadcasting `alert_updated`), and the read-only dry run `POST /api/alerts/rules/preview` (`{rule_type, config, days?, cooldown_seconds?}` → what `previewRule` says it would have fired over the last `days`, 1–90, default 7). Every rule mutation calls `invalidateRuleCache()` so the evaluation engine picks up changes immediately |
| `routes/budgets.js` + `lib/budgets.js` | Spend budgets: a budget IS an alert rule of type `budget` (CRUD through `/api/alerts/rules`). `lib/budgets.js` computes a period's bounds in the user's local time (`tz_offset` saved with the rule; day / week from Monday / month), its spend priced like `GET /api/pricing/cost` by the day tokens were spent (via `dailyTokenRows` + `costWhere`, scoped to `tool`/`lane`), and the pace projection to the period's end; `GET /api/budgets/status` lists every enabled budget with `spend`, `pct`, `projected` and the thresholds `crossed` |
| `lib/user-quota.js` | Server mode's spend quota per user (`user_quotas`, row 0 = the default; `PUT /api/users/quota-default`, `PATCH /api/users/:id {quota}`, `GET /api/me/quota`): `limit_usd` per local `day` or sliding `5h`, spend = the users page's `hourlyTokenRows` + `calculateCost` over owned sessions. Over it: `spawnRun` throws `EQUOTA` (429); a 60 s `sweep` writes each pane's `AGENTHUB_QUOTA_FILE` (the hook handler then exits 2 on `UserPromptSubmit`, from the file alone, fail-open), leaves the owner's omp keys out of the gateway's clients file and ends panes no hook can stop |
| `lib/alerts.js` | Rule evaluation engine for the alerting feature. Six rule types — `cost_threshold` (a session's cost ≥ `usd`, on token-bearing events like `token_threshold`) and `budget` (on the sweep: each of `thresholds` % of `limit_usd` for the current `period` fires ONCE per period, deduped on `details.period_start` + `details.threshold` and bypassing the cooldown) plus the four original ones: `event_pattern` (match `event_type` / `tool_name` / `summary_contains`, optionally requiring ≥ `count` matching events within `window_minutes` — counted via a dynamically built, statement-cached SQL query), `token_threshold` (session total tokens ≥ `total_tokens`, only evaluated on token-bearing events: PostToolUse / Stop / SubagentStop / SessionEnd), `inactivity` (active session whose `updated_at` — bumped on every ingested event — is older than `minutes`), and `status_duration` (agent stuck in `working`/`waiting` with no activity for `minutes`, joined against active sessions). Event-driven types run via `evaluateEvent()` called from `routes/hooks.js` **after** the ingest transaction commits and the HTTP response is sent — alerting can never slow down or fail hook ingestion, and `evaluateEvent` is itself fully try/catch-guarded per rule. Time-based types run via `sweepTimeRules()` on a 60 s unref'd interval (same pattern as the hooks watchdog). `fireAlert()` applies per-(rule, session, agent) cooldown dedup (`cooldown_seconds`, default 300) by checking the most recent `alert_events` row for the scope, then persists and broadcasts `alert_triggered`. Enabled rules are cached in memory (hook ingest is hot) and invalidated on every CRUD mutation. `validateRuleConfig()` normalizes + validates type-specific config and is shared with the routes. After persisting and broadcasting a fired alert, `fireAlert()` hands it to `lib/webhooks.js` `dispatchAlert()` fire-and-forget (lazy-required to keep the module graph acyclic) — webhook delivery never blocks or fails alert firing |
| `routes/webhooks.js` | HTTP surface for universal webhook targets: target CRUD (`GET/POST /api/webhooks`, `PATCH/DELETE /api/webhooks/:id` — `type` is immutable after creation), a redacted provider catalog (`GET /api/webhooks/providers`, drives the UI form), a synchronous test probe (`POST /api/webhooks/:id/test` — always 200, the `ok` flag carries the downstream delivery result), and a per-target delivery log (`GET /api/webhooks/:id/deliveries`). Validation is registry-driven: required URL (per provider), per-provider config fields, https enforcement, generic-family secret/headers. **Security**: target URLs are masked (host + last 4 chars) and secret config fields + custom-header values are redacted in every response — full URLs, signing secrets, and credentials (routing keys, api keys, bot tokens) are stored server-side and never leave the server. PATCH uses "set-flag" semantics (omit `url`/`secret`/`headers`/`config` to leave unchanged); `config` is merged over the existing config so one field can change without re-sending secrets. Every mutation calls `invalidateWebhookCache()` |
| `lib/webhook-providers.js`| Declarative registry of the 14 first-class providers (+ generic). Each entry declares a `family` (`chat` / `api` / `generic`), a payload `format`ter, URL resolution (`urlFrom(config)` for Telegram/Opsgenie that derive the endpoint, `defaultUrl` for PagerDuty, or a user-supplied URL), optional `authFrom(config)` headers (Opsgenie GenieKey), and the credential `fields` the UI renders + the route validates. Formatters emit each platform's native body: Slack Block Kit, Discord embed, Teams Adaptive Card wrapped in the Power Automate Workflows `{ type: "message", attachments: [...] }` envelope (the legacy O365-connector MessageCard transport was retired May 2026), Google Chat text, Mattermost/Rocket.Chat Slack-style attachments, Telegram sendMessage (HTML), PagerDuty Events API v2 (with `dedup_key`), Opsgenie Alert API, Splunk On-Call/VictorOps, and the generic `{ event, alert }` envelope. A provider may also declare `verifyResponse(body)` to veto a 2xx that actually signals failure (Splunk On-Call returns 200 with `result:"failure"`). `publicProviders()` returns the redacted catalog. Adding a provider = one registry entry + a formatter — no route or delivery changes |
@@ -387,7 +388,11 @@ graph TD
| `lib/lane-features.js` | (B) Per-feature state and archive. `activateFeature` archives the lane's current active feature (if different) and restores the target's saved stage onto the live `lanes` row — the row stays the one live view every other reader already uses. `canonicalizeSlug` is a DELIBERATELY separate rule from `worktree.js:slugify` (drops a leading `feat/`, keeps `[A-Za-z0-9._-]`, does not lowercase) — the two must never be conflated. `clearLane` (`lib/lanes.js`) archives the active feature (if any) before resetting; a lane that never activated one is unaffected |
| `lib/git-diff.js` | (#34/#35) Reads a lane's code changes as structured hunks for the Workspace Changes panel — `work` (HEAD vs the working tree, untracked files included), `branch` (`<base>...HEAD`) or `commit` (one sha), at a caller-chosen `context` (default 3, clamped to 5000, which is how the viewer expands a diff towards the whole file). Also the four reads behind History, the whole-file view, quick-open and the file explorer: `listCommits`, `readFileAt` (the `work` revision is realpath-confined to the lane; anything else goes through `git show`), `listPaths` and `listTree` (both `git ls-files`, so `.gitignore` is honoured without a skip-list; in a folder that is not a git repo they, and `listTreeAll`, read the disk instead through `walkFolder` — realpath-confined, symlinks never followed, `.git` hidden, `node_modules` never entered, 20 000 entries at most). Read-only, argv-only through `worktree.js:git()`. An untracked file's patch is SYNTHESIZED from the file itself rather than fetched with `git diff --no-index`, which exits non-zero on a difference and would read as a failure. Three caps (300 files, 200 KB per file, 1 MB per full read) keep a lane mid-build from shipping megabytes of patch into a browser; a cap that bit sets `truncated` |
| `lib/lane-file-transfer.js` | (#78) The Files tab's download and upload. `openDownload` streams one working file of any type (up to 512 MB) behind the file read's guards (`lanePath`, `resolveInLane` realpath confinement, `isSecretOnDisk`, `openSafe` for a member's file); the route serves it as an `application/octet-stream` attachment under a `sandbox` CSP with `nosniff`. `saveUpload` only CREATES (`EEXISTS` for a taken name, a symlink included), into an existing folder, never under `.git`, never a secret name, at most 25 MB — under S3 as the folder's member through `write-as`, else with an exclusive `wx` create. Works in a folder that is not a git repo |
| `lib/git-ops.js` | (#34) The git WRITES a lane offers: `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`/`discard-hunk` (one hunk, matched by its `@@` line against a fresh diff — or one `block`, a run of +/- lines inside it, the others folded into context by `onlyBlock` in `git-diff.js` (#50); `discard-hunk` needs `confirm:true`), `commit` (everything, or `only:"staged"`), `switch`, `branch-create` (never switches) and `branch-delete` (`-d`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds), `publish` (`remote add origin -- <url>` + `push -u origin HEAD`, only when the repo has NO remote and a commit; the one write whose destination comes from the request, so the URL is allowlisted by `validateRemoteUrl` — `https://` without userinfo, `ssh://`, scp-like `user@host:path` — and `origin` is removed again when the push fails), and `merge`/`resolve`/`merge-commit`/`merge-abort` (issue #45: a named branch into a clean repo-root lane, conflicts resolved per file, abort behind `confirm:true`), `cherry-pick`/`revert` (a non-merge commit by sha, same guards and conflict flow — `merge-commit`/`merge-abort` finish whichever of the three is open, `operationInProgress` in worktree.js), and `undo-commit` (`reset --soft HEAD~1`, `confirm:true`) / `amend`, both refused once a remote-tracking branch holds HEAD (`EPUSHED`). A caller-supplied path is validated against `git status` before it reaches an argv, so only a path git already reports as changed is reachable; `discard` — the one irreversible action — additionally requires `confirm: true`. No rebase, hard reset or stash, because a dashboard button is the wrong place to start an operation whose recovery needs a terminal (a hard `reset` stays behind worktree.js's three-check destroy guard; `undo-commit`'s soft reset moves only an unpushed branch tip and keeps every change staged). `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent; `push` pushes `HEAD`, never a branch name from the request. Network actions carry a 60s timeout |
| `lib/git-ops.js` | (#34) The git WRITES a lane offers: `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`/`discard-hunk` (one hunk, matched by its `@@` line against a fresh diff — or one `block`, a run of +/- lines inside it, the others folded into context by `onlyBlock` in `git-diff.js` (#50); `discard-hunk` needs `confirm:true`), `commit` (everything, or `only:"staged"`), `switch`, `branch-create` (never switches) and `branch-delete` (`-d`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds), `publish` (`remote add origin -- <url>` + `push -u origin HEAD`, only when the repo has NO remote and a commit; the one write whose destination comes from the request, so the URL is allowlisted by `validateRemoteUrl` — `https://` without userinfo, `ssh://`, scp-like `user@host:path` — and `origin` is removed again when the push fails), and `merge`/`resolve`/`merge-commit`/`merge-abort` (issue #45: a named branch into a clean repo-root lane, conflicts resolved per file, abort behind `confirm:true`), `cherry-pick`/`revert` (a non-merge commit by sha, same guards and conflict flow — `merge-commit`/`merge-abort` finish whichever of the three is open, `operationInProgress` in worktree.js), and `undo-commit` (`reset --soft HEAD~1`, `confirm:true`) / `amend`, both refused once a remote-tracking branch holds HEAD (`EPUSHED`). A caller-supplied path is validated against `git status` before it reaches an argv, so only a path git already reports as changed is reachable; `discard` — the one irreversible action — additionally requires `confirm: true`. History rewrites and remote writes are dispatched to `lib/git-rewrite.js` (below); a stopped rebase lets through only `ALLOWED_WHILE_REBASING`. No stash beyond shelves. (`undo-commit`'s soft reset moves only an unpushed branch tip and keeps every change staged.) `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent; `push` pushes `HEAD`, never a branch name from the request. Network actions carry a 60s timeout |
| `lib/git-rewrite.js` | History rewrites and remote writes, each allowed because its recovery is a button too: `rebase` (+ `rebase-continue`/`-skip`/`-abort`; `merge-commit`/`merge-abort` map onto an open rebase), `rebase-interactive` (todo + messages as 0600 files in the repo's git dir, `cp` as sequence editor, `true` as editor, squash = `fixup` + `exec git commit --amend -F <file>`), `reset` soft/mixed/keep/hard (hard: clean tree + branch name typed back), `undo-rewrite` (`reset --keep <before>` while HEAD is still `after`), `force-push` (`--force-with-lease` on the sha the plan showed, HEAD only, protected names refused unless `AGENTHUB_GIT_FORCE_PUSH_PROTECTED=1`), `remote-branch-delete` (never the default or a protected branch), `remote-add`/`-rename`/`-remove` (URL through `validateRemoteUrl`). Every one: repo root (`ESUBDIR`), no open operation (`EMERGING`), the planned HEAD (`ESTALE`), pushed commits `EPUSHED` unless `force: true`. Plus the reads `rewritePlan` and `listRemotes` (passwords stripped) |
| `lib/git-conflicts.js` | The 3-way merge editor's read (`readConflict`: stages :1/:2/:3 by blob id for a path `ls-files -u` lists, the working file and its sha) and save (`conflict-save`: `lane-file-write.js` then `git add`, `EMARKERS` before writing), and the read-only reflog (`readReflog`) |
| `lib/git-history.js` | The History tab's READS (via `routes/lane-git-history.js`, plus `/git/log`): the commit graph paged with `skip`, `all=1` and literal search (`q`, a SHA prefix, `author`, `path`) and typed ref badges; one commit's details; its `format-patch` (a secret file left out); a file's `--follow` history; blame at a revision; a tree at a revision; two refs compared (left/right commits, `A...B` or `A B` files, one file's hunks); the plan of a multi-commit pick/revert; a paused pick's sequencer state. Every revision is `SAFE_REV`-checked and resolved to a sha first, every path `lanePath`/`isSecretOnDisk`, walks time out at 20 s, outputs are capped |
| `lib/git-pick.js` | The multi-commit `cherry-pick`/`revert` (`shas`, one git sequence in `pickPlan`'s order), the paused one's `pick-continue`/`pick-skip`/`pick-abort` (the last two `confirm:true`) and `undo-pick` (`reset --soft <from>` only while HEAD is the `to` the pick made and none of it is on a remote). Dispatched from `git-ops.js`'s `runGitAction`; same clean-tree, repo-root and `EMERGING` guards |
| `lib/proof.js` | (C) Proof gallery: lists/serves/deletes QC screenshots grouped by feature slug (Task B's `lane_features`) and phase, under `<lane.cwd>/.playwright-mcp/proof/<slug>/<group>/`. Every path is resolved, realpath'd, and containment-checked before touching `fs` — the entire security surface. `ensureProofLink` ports Shipyard's `ensure_proof_link` (converge a stray clone-root `proof/` onto the canonical dir); never auto-invoked, exposed only as `agenthub lanes proof-link` |
| `lib/named-lock.js` | (D) Cross-lane named locks — the OTHER axis from `lib/lane-lock.js`'s per-lane, in-process serialization, deliberately a separate module. `mkdir` is the atomicity primitive (EEXIST decides "already held" in one syscall, never check-then-create). `LOCK_MAX_HOLD` (default 2700s) breaks a stale holder on the next acquire, floored at 300s so the floor — not the configurable default — is the actual safety property: nothing can force-break a live holder by setting the env var low. Single-shot only; the CLI's `agenthub lock acquire` owns the polling loop, keeping the server side non-orchestrating like every other lane primitive |
+9 -8
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,380 @@
/**
* @file BackgroundJobsDock.tsx
* @description The one bottom-right stack of background work, rendered once
* by `Layout`: a chip per background clone (`lib/cloneJobs.ts`, kept by the
* server, so it is back after F5 or a server restart) and per File Explorer
* upload (`lib/uploadJobs.ts`). A clone chip shows its queue place, then
* git's progress, with **Stop** (click twice) while it runs; once finished,
* **Open workspace** (done), the reason plus **Retry** (failed, interrupted,
* stopped) — an inline login form when the host wants one, a link to
* Settings → Git when it refused the SSH key — and × to dismiss. An upload
* chip shows its progress with **Stop**; one a reload cut short says how far
* it got. Several chips collapse to one small pill.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { ChevronDown, FolderGit2, Square, Upload, X } from "lucide-react";
import type { CloneJob } from "../lib/api";
import {
cancelCloneJob,
cloneJobError,
dismissCloneJob,
ensureCloneJobs,
isActiveJob,
retryCloneJob,
useCloneJobs,
} from "../lib/cloneJobs";
import { dismissUpload, stopUpload, useUploadJobs, type UploadJob } from "../lib/uploadJobs";
import { useArmed } from "../hooks/useArmed";
import { CloneProgress } from "./lanes/CloneProgress";
const chipClass =
"pointer-events-auto w-full space-y-1.5 rounded-lg border border-border bg-surface-1 p-2.5 text-xs shadow-xl shadow-black/40";
function IconButton({
label,
onClick,
children,
danger,
testId,
}: {
label: string;
onClick: () => void;
children: ReactNode;
danger?: boolean;
testId?: string;
}) {
return (
<button
type="button"
onClick={onClick}
title={label}
aria-label={label}
data-testid={testId}
className={`-m-0.5 shrink-0 rounded p-1 hover:bg-surface-2 ${
danger ? "text-status-danger" : "text-fg-muted hover:text-fg-primary"
}`}
>
{children}
</button>
);
}
function CloneJobChip({ job }: { job: CloneJob }) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
const { arm, isArmed, disarm } = useArmed();
const [login, setLogin] = useState({ username: "", token: "", remember: true });
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const active = isActiveJob(job);
const code = job.error?.code;
const laneId = job.kind === "workspace" ? (job.result?.laneId ?? null) : job.laneId;
const title =
job.state === "done"
? t("cloneJobs.done", { name: job.name })
: job.state === "cancelled"
? t("cloneJobs.cancelledTitle", { name: job.name })
: active
? t("cloneJobs.cloning", { name: job.name })
: t("cloneJobs.failedTitle", { name: job.name });
const retry = async (withLogin: boolean) => {
if (busy) return;
setBusy(true);
setError(null);
try {
await retryCloneJob(
job.id,
withLogin
? { username: login.username.trim(), password: login.token, remember: login.remember }
: undefined
);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setBusy(false);
}
};
const stop = () => {
if (!isArmed("stop")) return arm("stop");
disarm();
void cancelCloneJob(job.id);
};
return (
<div className={chipClass} data-testid="clone-job" data-state={job.state} title={job.target}>
<div className="flex items-center gap-2">
<FolderGit2 className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
<span className="min-w-0 flex-1 truncate font-medium text-fg-primary">{title}</span>
{active ? (
<IconButton
label={isArmed("stop") ? t("cloneJobs.stopConfirm") : t("cloneJobs.stop")}
onClick={stop}
danger={isArmed("stop")}
testId="clone-job-stop"
>
<Square className="h-3 w-3" />
</IconButton>
) : (
<IconButton
label={t("cloneJobs.dismiss")}
onClick={() => void dismissCloneJob(job.id)}
testId="clone-job-dismiss"
>
<X className="h-3.5 w-3.5" />
</IconButton>
)}
</div>
{active && isArmed("stop") && (
<p className="text-[11px] text-status-danger">{t("cloneJobs.stopConfirm")}</p>
)}
{active && <CloneProgress view={job} />}
{job.state === "done" && (
<div className="flex items-center justify-between gap-2">
<span className="min-w-0 truncate font-mono text-[10px] text-fg-muted">
{job.result?.path || job.target}
</span>
{laneId !== null && (
<button
type="button"
className="shrink-0 text-accent hover:underline"
onClick={() => {
navigate(`/workspace?lane=${laneId}`);
void dismissCloneJob(job.id);
}}
>
{job.kind === "workspace" ? t("cloneJobs.openWorkspace") : t("cloneJobs.open")}
</button>
)}
</div>
)}
{!active && job.state !== "done" && (
<>
<p
role={job.state === "cancelled" ? undefined : "alert"}
className={`text-[11px] [overflow-wrap:anywhere] ${
job.state === "cancelled" ? "text-fg-secondary" : "text-status-danger"
}`}
>
{cloneJobError(job)}{" "}
{code === "ESSHAUTH" && (
// A router link: a plain <a href> reloaded the whole app.
<Link to="/settings/git#git-identity" className="underline">
{t("clone.sshSettings")}
</Link>
)}
</p>
{code === "ECREDENTIALS" && job.state === "failed" ? (
<form
data-testid="clone-job-login"
className="space-y-1.5"
onSubmit={(e) => {
e.preventDefault();
void retry(true);
}}
>
<input
aria-label={t("clone.username")}
placeholder={t("clone.username")}
autoComplete="username"
value={login.username}
onChange={(e) => setLogin({ ...login, username: e.target.value })}
className="input w-full text-xs"
/>
<input
type="password"
aria-label={t("clone.token")}
placeholder={t("clone.token")}
autoComplete="current-password"
value={login.token}
onChange={(e) => setLogin({ ...login, token: e.target.value })}
className="input w-full text-xs"
/>
<div className="flex items-center justify-between gap-2">
<label className="flex items-center gap-1.5 text-[11px] text-fg-secondary">
<input
type="checkbox"
checked={login.remember}
onChange={(e) => setLogin({ ...login, remember: e.target.checked })}
/>
{t("clone.remember")}
</label>
<button
type="submit"
disabled={busy || !login.username.trim() || !login.token}
className="btn-primary text-xs"
>
{t("cloneJobs.retryLogin")}
</button>
</div>
</form>
) : (
code !== "ESSHAUTH" && (
<div className="flex justify-end">
<button
type="button"
disabled={busy}
onClick={() => void retry(false)}
className="btn-ghost text-xs"
>
{t("cloneJobs.retry")}
</button>
</div>
)
)}
{error && (
<p role="alert" className="text-[11px] text-status-danger [overflow-wrap:anywhere]">
{error}
</p>
)}
</>
)}
</div>
);
}
function UploadChip({ job }: { job: UploadJob }) {
const { t } = useTranslation("lanes");
const { arm, isArmed, disarm } = useArmed();
if (job.state === "interrupted") {
return (
<div className={chipClass} data-testid="upload-job" data-state="interrupted">
<div className="flex items-start gap-2">
<Upload className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" />
<p className="min-w-0 flex-1 text-[11px] text-fg-secondary [overflow-wrap:anywhere]">
{t("uploads.interrupted", {
total: job.total,
folder: job.folder,
done: job.done,
})}
</p>
<IconButton label={t("cloneJobs.dismiss")} onClick={() => dismissUpload(job.id)}>
<X className="h-3.5 w-3.5" />
</IconButton>
</div>
</div>
);
}
const stopping = job.state === "stopping";
return (
<div className={chipClass} data-testid="fx-progress" data-state={job.state}>
<div className="flex items-center gap-2">
<Upload className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
<span className="min-w-0 flex-1 truncate font-medium text-fg-primary">
{stopping
? t("uploads.stopping")
: t("fx.uploading", { done: Math.min(job.done + 1, job.total), total: job.total })}
</span>
{!stopping && (
<IconButton
label={isArmed("stop") ? t("uploads.stopConfirm") : t("uploads.stop")}
danger={isArmed("stop")}
testId="upload-job-stop"
onClick={() => {
if (!isArmed("stop")) return arm("stop");
disarm();
stopUpload(job.id);
}}
>
<Square className="h-3 w-3" />
</IconButton>
)}
</div>
{isArmed("stop") && !stopping && (
<p className="text-[11px] text-status-danger">{t("uploads.stopConfirm")}</p>
)}
<p className="truncate text-[11px] text-fg-muted" title={job.folder}>
{job.folder}
{job.name ? ` · ${job.name}` : ""}
</p>
<div className="h-1.5 overflow-hidden rounded bg-surface-3">
<div
className="h-full bg-accent-solid transition-[width]"
style={{ width: `${Math.min(100, (job.sent / Math.max(1, job.bytes)) * 100)}%` }}
/>
</div>
</div>
);
}
export function BackgroundJobsDock() {
const { t } = useTranslation("lanes");
const jobs = useCloneJobs();
const uploads = useUploadJobs();
const [collapsed, setCollapsed] = useState(false);
const dockRef = useRef<HTMLDivElement>(null);
useEffect(() => ensureCloneJobs(), []);
const count = jobs.length + uploads.length;
// Toasts stack ABOVE the dock (Toaster reads --jobs-dock-h): they used to
// sit on the same corner and cover a chip's × / Retry / Stop.
useEffect(() => {
const root = document.documentElement;
const el = dockRef.current;
if (!count || !el) {
root.style.removeProperty("--jobs-dock-h");
return;
}
const set = () => root.style.setProperty("--jobs-dock-h", `${el.offsetHeight + 8}px`);
set();
const ro = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(set);
ro?.observe(el);
return () => {
ro?.disconnect();
root.style.removeProperty("--jobs-dock-h");
};
}, [count, collapsed]);
if (!count) return null;
const running =
jobs.filter(isActiveJob).length + uploads.filter((u) => u.state !== "interrupted").length;
return (
<div
ref={dockRef}
role="region"
aria-label={t("jobs.dock")}
data-testid="jobs-dock"
className="pointer-events-none fixed bottom-10 right-4 z-[60] flex max-h-[70vh] w-[min(20rem,calc(100vw-2rem))] flex-col items-end gap-2 overflow-y-auto"
>
{count > 1 && collapsed ? (
<button
type="button"
data-testid="jobs-dock-pill"
onClick={() => setCollapsed(false)}
title={t("jobs.expand")}
className="pointer-events-auto flex items-center gap-1.5 rounded-full border border-border bg-surface-1 px-3 py-1 text-xs text-fg-primary shadow-lg shadow-black/40 hover:bg-surface-2"
>
<FolderGit2 className="h-3.5 w-3.5 text-fg-muted" />
{t("jobs.count", { count })}
{running > 0 && (
<span className="text-fg-muted">· {t("jobs.running", { count: running })}</span>
)}
</button>
) : (
<>
{count > 1 && (
<button
type="button"
data-testid="jobs-dock-collapse"
onClick={() => setCollapsed(true)}
className="pointer-events-auto flex items-center gap-1 rounded-full border border-border bg-surface-1 px-2 py-0.5 text-[11px] text-fg-secondary shadow hover:bg-surface-2"
>
{t("jobs.count", { count })}
<ChevronDown className="h-3 w-3" />
</button>
)}
{uploads.map((u) => (
<UploadChip key={u.id} job={u} />
))}
{jobs.map((j) => (
<CloneJobChip key={j.id} job={j} />
))}
</>
)}
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
/**
* @file HtmlFrame.tsx
* @description An HTML file rendered as a page, the one way every file view
* shows one (a Chat file preview, the Changes panel's Preview, the File
* Explorer's viewer): its relative CSS, scripts and images pulled in through
* the caller's guarded read (`lib/htmlInline.ts`), then run in the chat
* artifact's sandbox — `<iframe srcdoc>` with scripts but never the
* dashboard's origin (no `allow-same-origin`). Given no `html`, it reads the
* file itself through the same `fetchFile`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { FileText, Loader2 } from "lucide-react";
import { artifactDocument, frameSandbox } from "../lib/artifactView";
import { blobText, inlineHtml, type FetchFile } from "../lib/htmlInline";
/** A page read by `fetchFile` is shown up to this size. A self-contained
* page (a slide deck with its video and images inlined as base64) is easily
* past 10 MB; the iframe renders far larger documents than this. */
const HTML_MAX = 64 * 1024 * 1024;
export function HtmlFrame({
path,
html,
fetchFile,
title,
className = "",
testId = "html-frame",
}: {
/** The file's path relative to the place `fetchFile` reads from. */
path: string;
/** The page's text; read through `fetchFile` when absent. */
html?: string;
/** Reads the page's relative files; none = the page as it is. */
fetchFile?: FetchFile | null;
title: string;
className?: string;
testId?: string;
}) {
const [doc, setDoc] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let live = true;
// The page shown stays until its successor is ready (a version step, a
// re-read): no flash of the spinner between two renders of one file.
setFailed(false);
(async () => {
let text = html;
if (text === undefined) {
if (!fetchFile) throw new Error("nothing to read the page with");
const b = await fetchFile(path);
if (b.size > HTML_MAX) throw new Error("too big");
text = await blobText(b);
}
const page = text;
return fetchFile ? inlineHtml(page, path, fetchFile).catch(() => page) : page;
})().then(
(d) => live && setDoc(d),
() => live && setFailed(true)
);
return () => {
live = false;
};
}, [html, path, fetchFile]);
if (failed) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-fg-muted">
<FileText className="h-8 w-8" />
<p className="break-all font-mono text-xs">{path}</p>
</div>
);
}
if (doc === null) {
return (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-fg-muted" />
</div>
);
}
return (
<iframe
data-testid={testId}
title={title}
srcDoc={artifactDocument("html", doc, false) ?? ""}
sandbox={frameSandbox("html")}
referrerPolicy="no-referrer"
className={`min-h-0 w-full flex-1 border-0 bg-white ${className}`}
/>
);
}
/** A `FetchFile` over a download URL builder (a workspace's or a home's). */
export function fetchVia(url: (relPath: string) => string): FetchFile {
return async (rel) => {
const r = await fetch(url(rel), { credentials: "same-origin" });
if (!r.ok) throw new Error(String(r.status));
return r.blob();
};
}
+109
View File
@@ -0,0 +1,109 @@
/**
* @file HtmlViewerModal.tsx
* @description An HTML file shown as the page in a modal over the whole
* screen — the one viewer the File Explorer and the Changes panel's Preview
* open (the Chat's file preview opens its own panel as a modal instead, for
* its versions and Code tab). The page itself is `HtmlFrame`: the chat
* artifact's sandbox, its relative files read through the caller's guarded
* `fetchFile`. Keys and clicks stop at the modal, so a shortcut of the view
* underneath (the explorer's Delete) never fires from inside it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Download, X } from "lucide-react";
import { useDialog } from "../hooks/useDialog";
import { HtmlFrame } from "./HtmlFrame";
import type { FetchFile } from "../lib/htmlInline";
export function HtmlViewerModal({
path,
html,
fetchFile,
onDownload,
onClose,
testId = "html-viewer",
}: {
/** The file's path relative to the place `fetchFile` reads from; also its title. */
path: string;
/** The page's text; read through `fetchFile` when absent. */
html?: string;
fetchFile?: FetchFile | null;
onDownload?: () => void;
onClose: () => void;
testId?: string;
}) {
const { t } = useTranslation(["lanes"]);
const panelRef = useRef<HTMLDivElement>(null);
useDialog(true, panelRef, onClose);
const name = path.slice(path.lastIndexOf("/") + 1);
return createPortal(
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
onKeyDown={(e) => {
// Nothing reaches the view underneath — so Esc, which useDialog's
// document listener would never see, closes here.
e.stopPropagation();
if (e.key === "Escape") onClose();
}}
role="presentation"
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={name}
data-testid={testId}
className="flex h-[92vh] w-[96vw] max-w-7xl flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/50"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<span className="min-w-0 flex-1 truncate font-mono text-[13px] text-fg-primary">
{path}
</span>
{onDownload && (
<HeadButton label={t("fx.download")} onClick={onDownload}>
<Download className="h-4 w-4" />
</HeadButton>
)}
<HeadButton label={t("fx.close")} onClick={onClose}>
<X className="h-4 w-4" />
</HeadButton>
</div>
<HtmlFrame path={path} html={html} fetchFile={fetchFile} title={name} />
</div>
</div>,
document.body
);
}
function HeadButton({
label,
onClick,
children,
}: {
label: string;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
aria-label={label}
title={label}
onClick={(e) => {
e.stopPropagation();
onClick();
}}
className="rounded-md p-1.5 text-fg-secondary transition-colors hover:bg-surface-2 hover:text-fg-primary"
>
{children}
</button>
);
}
+3
View File
@@ -51,6 +51,7 @@
* ## Internal dependencies
* - `./Sidebar`
* - `./UpdateNotifier`
* - `./BackgroundJobsDock` (background clones and uploads, app-wide)
* - `./lanes/InterruptedSessionsPrompt` (the "resume interrupted sessions?" dialog)
* - `./Sagi/SagiMount`
*
@@ -82,6 +83,7 @@ import { Menu } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar";
import { UpdateNotifier } from "./UpdateNotifier";
import { BackgroundJobsDock } from "./BackgroundJobsDock";
import { InterruptedSessionsPrompt } from "./lanes/InterruptedSessionsPrompt";
import { SagiMount } from "./Sagi/SagiMount";
import { StatusBar } from "./StatusBar";
@@ -182,6 +184,7 @@ export function Layout({ wsConnected }: LayoutProps) {
{t("skipToContent")}
</a>
<UpdateNotifier />
<BackgroundJobsDock />
<InterruptedSessionsPrompt />
<SagiMount />
{onboarding.loaded && !onboarding.onboardedAt && (
+2 -2
View File
@@ -1,8 +1,8 @@
/**
* @file SessionToolBadge — names the AI coding CLI that produced a session
* when it is not Claude Code (`sessions.tool` = `codex`, `antigravity`, …, by
* its AI Tools label). Renders nothing for a Claude session, so the Sessions
* list and session header look as before for every Claude row.
* its AI Tools label). Renders nothing for a Claude session, so the session
* header looks as before for every Claude row.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
+31 -2
View File
@@ -22,6 +22,9 @@
* its fullest window, live account only — and, on hover or focus, a panel
* above grouping every account per tool (saved Claude profiles too): name,
* sign-in expiry, and a row per window with its bar, percent and reset time.
* Server mode: the user's OWN spend quota (`GET /api/me/quota`, read with the
* costs) as "Quota N%" — amber from 80 %, red once used up, when the user can
* start nothing and their prompts are refused until it opens again.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -30,9 +33,9 @@ import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Link, useLocation } from "react-router-dom";
import { RefreshCw, Wifi, WifiOff } from "lucide-react";
import { api, type RunHandle } from "../lib/api";
import { api, type RunHandle, type UserQuotaStatus } from "../lib/api";
import type { BudgetStatus, CostResult, ToolQuota } from "../lib/types";
import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
import { fmt, fmtCostFull, formatDateTime, formatModelName, timeAgo } from "../lib/format";
import { AiToolIcon } from "./AiToolIcon";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { useSpin } from "../hooks/useSpin";
@@ -369,6 +372,8 @@ export function StatusBar({
const [runs, setRuns] = useState<RunHandle[]>([]);
// The budget furthest past one of its thresholds, if any.
const [budget, setBudget] = useState<BudgetStatus | null>(null);
// Server mode: this user's own spend quota (null: none, or local mode).
const [myQuota, setMyQuota] = useState<UserQuotaStatus | null>(null);
const location = useLocation();
const shown = selectedFrom(location.pathname, location.search);
const lane = shown.lane;
@@ -414,6 +419,10 @@ export function StatusBar({
if (live()) setBudget(hit[0] ?? null);
})
.catch(() => undefined);
api.me
.quota()
.then((r) => live() && setMyQuota(r?.quota ?? null))
.catch(() => undefined);
if (lane !== null) {
api.pricing
.totalCost({ lane })
@@ -524,6 +533,26 @@ export function StatusBar({
{t("statusBar.budget", { pct: Math.round(budget.pct) })}
</Link>
)}
{myQuota && (
<span
data-testid="status-bar-user-quota"
title={t(myQuota.exceeded ? "statusBar.userQuotaBlocked" : "statusBar.userQuotaTitle", {
spend: usd(myQuota.spend),
limit: usd(myQuota.limit_usd),
period: t(`statusBar.userQuotaPeriod.${myQuota.period}`),
at: myQuota.resetsAt ? formatDateTime(myQuota.resetsAt) : "",
})}
className={`tabular-nums ${
myQuota.exceeded
? "text-status-danger"
: myQuota.pct >= 80
? "text-status-warning"
: "text-fg-muted"
}`}
>
{t("statusBar.userQuota", { pct: Math.min(999, Math.round(myQuota.pct)) })}
</span>
)}
<QuotaChips />
</footer>
);
+6 -2
View File
@@ -25,7 +25,9 @@ function Item({ item }: { item: ToastItem }) {
className={`pointer-events-auto rounded-lg border px-3 py-2 shadow-lg flex items-start gap-2 ${TONE[item.kind]}`}
>
<Icon className="w-4 h-4 flex-shrink-0 mt-0.5" aria-hidden />
<span className="text-xs leading-relaxed flex-1 break-words">{item.message}</span>
<span className="text-xs leading-relaxed flex-1 min-w-0 break-words [overflow-wrap:anywhere]">
{item.message}
</span>
<button
type="button"
onClick={() => dismissToast(item.id)}
@@ -44,8 +46,10 @@ export function Toaster() {
useEffect(() => subscribeToasts(setItems), []);
const errors = items.filter((i) => i.kind === "error");
const rest = items.filter((i) => i.kind !== "error");
// Above the background-jobs dock (BackgroundJobsDock sets --jobs-dock-h
// to its height), so a toast never covers a chip's buttons.
return (
<div className="pointer-events-none fixed bottom-10 right-4 z-[70] flex w-[min(28rem,calc(100vw-2rem))] flex-col gap-2">
<div className="pointer-events-none fixed bottom-[calc(2.5rem+var(--jobs-dock-h,0px))] right-4 z-[70] flex w-[min(28rem,calc(100vw-2rem))] flex-col gap-2">
<div role="status" aria-live="polite" className="flex flex-col gap-2">
{rest.map((i) => (
<Item key={i.id} item={i} />
@@ -0,0 +1,317 @@
/**
* @file Tests for the background jobs store (`lib/cloneJobs.ts`,
* `lib/uploadJobs.ts`) and its dock: a clone started in the background is
* announced once when the store sees it finish (toast + window event); a
* fresh mount (F5) shows what the server kept — an interrupted clone with
* Retry, a login failure with an inline login form that retries with it —
* without toasting it again; × dismisses; Stop (click twice) cancels a
* running clone, which then reads as stopped and can be retried; an older
* server that cannot stop says so. An upload shows with Stop, and one a
* reload cut short is shown as interrupted until dismissed. Several chips
* collapse to one pill.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import "../../i18n";
vi.mock("../../lib/api", () => ({
api: {
lanes: {
clone: vi.fn(),
cloneFolder: vi.fn(),
cloneJobs: vi.fn(),
retryCloneJob: vi.fn(),
dismissCloneJob: vi.fn(),
cancelCloneJob: vi.fn(),
},
},
}));
vi.mock("../../lib/toast", () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
import { api, type CloneJob } from "../../lib/api";
import { toast } from "../../lib/toast";
import {
CLONE_JOB_EVENT,
refreshCloneJobs,
resetCloneJobsForTests,
startClone,
} from "../../lib/cloneJobs";
import { beginUpload, reloadUploadsForTests } from "../../lib/uploadJobs";
import { BackgroundJobsDock } from "../BackgroundJobsDock";
const base: CloneJob = {
id: "j1",
kind: "workspace",
state: "running",
url: "https://git.example/o/repo.git",
name: "repo",
parent: "/home/an",
target: "/home/an/repo",
laneId: null,
position: null,
phase: "receiving",
label: null,
percent: 40,
detail: null,
error: null,
result: null,
createdAt: "",
finishedAt: null,
request: { url: "https://git.example/o/repo.git", parent: "/home/an", name: "repo" },
};
const job = (over: Partial<CloneJob> = {}): CloneJob => ({ ...base, ...over });
const renderDock = () =>
render(
<MemoryRouter>
<BackgroundJobsDock />
</MemoryRouter>
);
beforeEach(() => {
vi.clearAllMocks();
resetCloneJobsForTests();
try {
localStorage.clear();
} catch {
/* none */
}
reloadUploadsForTests();
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [] });
vi.mocked(api.lanes.dismissCloneJob).mockResolvedValue({ ok: true });
});
describe("cloneJobs store", () => {
it("announces a background clone once, when it sees it finish", async () => {
const onDone = vi.fn();
const events: CloneJob[] = [];
const listen = (e: Event) => events.push((e as CustomEvent<CloneJob>).detail);
window.addEventListener(CLONE_JOB_EVENT, listen);
vi.mocked(api.lanes.clone).mockResolvedValue({ job: job({ state: "queued", position: 1 }) });
await startClone(
{ kind: "workspace", body: { url: base.url, parent: "/home/an" } },
{ onDone }
);
expect(api.lanes.clone).toHaveBeenCalledWith(expect.objectContaining({ background: true }));
expect(toast.success).not.toHaveBeenCalled();
const done = job({ state: "done", result: { laneId: 7, path: "/home/an/repo" } });
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [done] });
await refreshCloneJobs();
await refreshCloneJobs();
expect(toast.success).toHaveBeenCalledTimes(1);
expect(vi.mocked(toast.success).mock.calls[0]![0]).toMatch(/Cloned repo into \/home\/an\/repo/);
expect(events).toEqual([done]);
expect(onDone).toHaveBeenCalledWith(done, undefined);
window.removeEventListener(CLONE_JOB_EVENT, listen);
});
});
describe("BackgroundJobsDock", () => {
it("shows a running clone's progress after a fresh mount, without a toast", async () => {
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [job()] });
renderDock();
const chip = await screen.findByTestId("clone-job");
expect(chip).toHaveTextContent("Cloning repo");
expect(within(chip).getByRole("progressbar")).toHaveAttribute("aria-valuenow", "40");
expect(toast.success).not.toHaveBeenCalled();
expect(toast.error).not.toHaveBeenCalled();
});
it("shows an interrupted clone after F5 and retries it", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
jobs: [
job({
state: "interrupted",
error: { code: "EINTERRUPTED", message: "server restarted" },
}),
],
});
vi.mocked(api.lanes.retryCloneJob).mockResolvedValue({
job: job({ id: "j2", state: "queued", position: 1 }),
});
renderDock();
const chip = await screen.findByTestId("clone-job");
expect(chip).toHaveTextContent("AgentHub restarted while this clone was running.");
expect(toast.error).not.toHaveBeenCalled();
await user.click(within(chip).getByRole("button", { name: "Retry" }));
expect(api.lanes.retryCloneJob).toHaveBeenCalledWith("j1", undefined);
await waitFor(() =>
expect(screen.getByTestId("clone-job")).toHaveTextContent("Waiting in the clone queue — #1")
);
});
it("asks for a login on ECREDENTIALS and retries with it", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
jobs: [
job({
state: "failed",
error: {
code: "ECREDENTIALS",
message: "login",
auth: { protocol: "https", host: "git.example" },
},
}),
],
});
vi.mocked(api.lanes.retryCloneJob).mockResolvedValue({ job: job({ id: "j2" }) });
renderDock();
const form = await screen.findByTestId("clone-job-login");
expect(screen.getByTestId("clone-job")).toHaveTextContent("git.example needs a login");
await user.type(within(form).getByLabelText("Username"), "ann");
await user.type(within(form).getByLabelText("Password or access token"), "tok");
await user.click(within(form).getByRole("button", { name: "Retry with this login" }));
expect(api.lanes.retryCloneJob).toHaveBeenCalledWith("j1", {
username: "ann",
password: "tok",
remember: true,
});
await waitFor(() =>
expect(screen.getByTestId("clone-job")).toHaveAttribute("data-state", "running")
);
});
it("dismisses a finished clone", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
jobs: [job({ state: "done", result: { laneId: 7, path: "/home/an/repo" } })],
});
renderDock();
const chip = await screen.findByTestId("clone-job");
expect(within(chip).getByRole("button", { name: "Open workspace" })).toBeInTheDocument();
await user.click(within(chip).getByTestId("clone-job-dismiss"));
expect(api.lanes.dismissCloneJob).toHaveBeenCalledWith("j1");
expect(screen.queryByTestId("clone-job")).toBeNull();
});
it("stops a running clone on a second click, then lets it be retried", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [job()] });
vi.mocked(api.lanes.cancelCloneJob).mockResolvedValue({
job: job({ state: "cancelled", error: { code: "ECANCELLED", message: "cancelled" } }),
});
vi.mocked(api.lanes.retryCloneJob).mockResolvedValue({ job: job({ id: "j2" }) });
renderDock();
const stop = await screen.findByTestId("clone-job-stop");
await user.click(stop);
expect(api.lanes.cancelCloneJob).not.toHaveBeenCalled();
expect(screen.getByTestId("clone-job")).toHaveTextContent("Click again to stop");
await user.click(screen.getByTestId("clone-job-stop"));
expect(api.lanes.cancelCloneJob).toHaveBeenCalledWith("j1");
const chip = await screen.findByText("You stopped this clone.");
expect(chip).toBeInTheDocument();
// The chip says it; no toast repeats it on top of the chip.
expect(toast.info).not.toHaveBeenCalled();
expect(toast.error).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Retry" }));
expect(api.lanes.retryCloneJob).toHaveBeenCalledWith("j1", undefined);
await waitFor(() =>
expect(screen.getByTestId("clone-job")).toHaveAttribute("data-state", "running")
);
});
it("says so when the server cannot stop a clone yet", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [job()] });
vi.mocked(api.lanes.cancelCloneJob).mockRejectedValue(
Object.assign(new Error("HTTP 404"), { status: 404 })
);
renderDock();
await user.click(await screen.findByTestId("clone-job-stop"));
await user.click(screen.getByTestId("clone-job-stop"));
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/cannot stop a clone yet/))
);
});
it("shows an upload with Stop, which aborts it", async () => {
renderDock();
let up!: ReturnType<typeof beginUpload>;
act(() => {
up = beginUpload({ folder: "one/src", total: 3, bytes: 300 });
up.update({ name: "a.txt", done: 1, sent: 150 });
});
const chip = await screen.findByTestId("fx-progress");
expect(chip).toHaveTextContent("Uploading 2/3");
expect(chip).toHaveTextContent("one/src · a.txt");
fireEvent.click(within(chip).getByTestId("upload-job-stop"));
expect(up.signal.aborted).toBe(false);
fireEvent.click(within(chip).getByTestId("upload-job-stop"));
expect(up.signal.aborted).toBe(true);
act(() => up.end());
expect(screen.queryByTestId("fx-progress")).toBeNull();
});
it("shows an upload a reload cut short until it is dismissed", async () => {
localStorage.setItem(
"agenthub.uploads.inflight",
JSON.stringify([
{
id: "up-old",
folder: "one/src",
name: "b.txt",
done: 2,
total: 5,
sent: 10,
bytes: 50,
state: "running",
beat: Date.now() - 60_000,
},
])
);
act(() => reloadUploadsForTests());
renderDock();
const chip = await screen.findByTestId("upload-job");
expect(chip).toHaveTextContent(
"The upload of 5 file(s) to one/src was interrupted by a page reload — 2 sent."
);
fireEvent.click(within(chip).getByRole("button", { name: "Dismiss" }));
expect(screen.queryByTestId("upload-job")).toBeNull();
expect(localStorage.getItem("agenthub.uploads.inflight")).toBeNull();
});
it("collapses several chips to one pill", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
jobs: [job(), job({ id: "j2", name: "other" })],
});
renderDock();
await waitFor(() => expect(screen.getAllByTestId("clone-job")).toHaveLength(2));
await user.click(screen.getByTestId("jobs-dock-collapse"));
expect(screen.queryByTestId("clone-job")).toBeNull();
expect(screen.getByTestId("jobs-dock-pill")).toHaveTextContent("2 background tasks");
await user.click(screen.getByTestId("jobs-dock-pill"));
expect(screen.getAllByTestId("clone-job")).toHaveLength(2);
});
it("wraps a long SSH error, links to Settings in-app and lifts toasts above itself", async () => {
const long = "mdpartners@mdpartners.git.backlog.jp:/PETITGIFT_CONSOLE4/console4-specs.git";
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
jobs: [
job({
state: "failed",
url: long,
error: { code: "ESSHAUTH", message: "the SSH login was refused" },
}),
],
});
renderDock();
const alert = await screen.findByRole("alert");
// A URL with no space to break at must still wrap inside the chip.
expect(alert.className).toContain("[overflow-wrap:anywhere]");
// A router link, not a full page reload.
const link = screen.getByRole("link", { name: /Settings/ });
expect(link).toHaveAttribute("href", "/settings/git#git-identity");
await waitFor(() =>
expect(document.documentElement.style.getPropertyValue("--jobs-dock-h")).not.toBe("")
);
});
});
@@ -129,6 +129,20 @@ vi.mock("../../lib/api", () => ({
daily_costs: [],
})),
},
me: {
// Server mode: this user's own spend quota, used up.
quota: vi.fn().mockResolvedValue({
quota: {
limit_usd: 10,
period: "day",
spend: 12,
pct: 120,
exceeded: true,
resetsAt: "2026-10-29T17:00:00.000Z",
since: "2026-10-28T17:00:00.000Z",
},
}),
},
budgets: {
status: vi.fn().mockResolvedValue({
budgets: [
@@ -183,6 +197,16 @@ describe("StatusBar", () => {
expect(chip.getAttribute("title")).toBe("Monthly: $85.00 of $100.00 this period");
});
it("shows the user's own spend quota, red once used up", async () => {
at("/", <StatusBar wsConnected left="0" onConnectionClick={() => {}} />);
const chip = await screen.findByTestId("status-bar-user-quota");
expect(chip.textContent).toBe("Quota 120%");
expect(chip.className).toContain("text-status-danger");
expect(chip.getAttribute("title")).toMatch(
/^Your spend quota is used up \(\$12\.00 of \$10\.00 today\)/
);
});
it("says Disconnected when the socket is down", () => {
at("/", <StatusBar wsConnected={false} left="0" onConnectionClick={() => {}} />);
expect(screen.getByRole("button", { name: /Disconnected/ })).toBeInTheDocument();
@@ -3,7 +3,8 @@
* @description Tests for the sidebar's workspace list: one flat row per
* workspace in the stored order, a row expanding to its sessions (remembered),
* a live session opening its pane and an ended one its transcript, the
* workspace's OWN sessions only, a session's state dot, rename and archive,
* workspace's OWN sessions only, a session's state dot, rename and archive
* (a live one's pane stopped first),
* selection
* by URL, Alt+arrow reordering kept in localStorage, double-click rename, the
* search, the row menu's items by kind and tool, the status dot, and the
@@ -225,6 +226,8 @@ describe("WorkspaceTree", () => {
await user.type(input, "Better name{Enter}");
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { name: "Better name" });
await user.click(screen.getByTestId("workspace-session-archive-s-live"));
// A live session's Archive stops its pane first — never a hidden pane left running.
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2", { archive: true });
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
});
+165
View File
@@ -0,0 +1,165 @@
/**
* @file MiniChart.tsx
* @description The Administration page's small single-series charts: a bar
* chart (a user's daily spend) and a line chart (System Health's CPU and
* memory history), plain SVG in the app's theme tokens. One series each — the
* card's title names it, so there is no legend — thin marks, rounded bar ends
* on the baseline, a recessive grid with a max label, and a hover tooltip
* (crosshair for the line, per bar for the bars) whose text is the caller's.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useRef, useState, type MouseEvent } from "react";
export type ChartPoint = { x: string; y: number };
const H = 120;
const PAD_TOP = 14;
const PAD_BOTTOM = 18;
/**
* @param props.points - In x order; `x` is the axis/tooltip label.
* @param props.kind - `bar` or `line`.
* @param props.format - Formats a value for the max label and the tooltip.
* @param props.max - A fixed top (e.g. 100 for a percentage); else the data's max.
* @param props.label - The chart's accessible name.
*/
export function MiniChart({
points,
kind,
format,
max,
label,
emptyText,
}: {
points: ChartPoint[];
kind: "bar" | "line";
format: (v: number) => string;
max?: number;
label: string;
emptyText?: string;
}) {
const ref = useRef<SVGSVGElement>(null);
const [hover, setHover] = useState<number | null>(null);
if (points.length === 0)
return (
<div className="flex h-[120px] items-center justify-center text-xs text-fg-muted">
{emptyText ?? "—"}
</div>
);
const W = 600;
const top = Math.max(max ?? 0, ...points.map((p) => p.y), 1e-9);
const plotH = H - PAD_TOP - PAD_BOTTOM;
const yOf = (v: number) => PAD_TOP + plotH - (v / top) * plotH;
const step = W / points.length;
const xOf = (i: number) =>
kind === "bar" ? i * step + step / 2 : (i / Math.max(1, points.length - 1)) * W;
const onMove = (e: MouseEvent<SVGSVGElement>) => {
const r = ref.current?.getBoundingClientRect();
if (!r || r.width === 0) return;
const fx = ((e.clientX - r.left) / r.width) * W;
const i =
kind === "bar"
? Math.floor(fx / step)
: Math.round((fx / W) * Math.max(1, points.length - 1));
setHover(Math.max(0, Math.min(points.length - 1, i)));
};
const h = hover != null ? points[hover] : null;
const barW = Math.max(2, Math.min(24, step - 2));
const line = points
.map((p, i) => `${i ? "L" : "M"}${xOf(i).toFixed(1)},${yOf(p.y).toFixed(1)}`)
.join("");
return (
<div className="relative">
<svg
ref={ref}
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="none"
className="block h-[120px] w-full"
role="img"
aria-label={label}
onMouseMove={onMove}
onMouseLeave={() => setHover(null)}
>
{[0, 0.5, 1].map((f) => (
<line
key={f}
x1={0}
x2={W}
y1={yOf(top * f)}
y2={yOf(top * f)}
className="stroke-border"
strokeWidth={1}
vectorEffect="non-scaling-stroke"
strokeDasharray={f === 0 ? undefined : "3 3"}
/>
))}
{kind === "bar" ? (
points.map((p, i) => {
const y = yOf(p.y);
const bh = Math.max(0, PAD_TOP + plotH - y);
const r = Math.min(4, barW / 2, bh);
const x = xOf(i) - barW / 2;
const base = PAD_TOP + plotH;
// Rounded top only; the base sits flat on the axis.
const d =
bh <= 0
? ""
: `M${x},${base}V${y + r}Q${x},${y} ${x + r},${y}H${x + barW - r}Q${x + barW},${y} ${x + barW},${y + r}V${base}Z`;
return (
<path key={i} d={d} className={hover === i ? "fill-accent" : "fill-accent-solid"} />
);
})
) : (
<>
<path
d={`${line}L${W},${PAD_TOP + plotH}L0,${PAD_TOP + plotH}Z`}
className="fill-accent-solid"
opacity={0.12}
/>
<path
d={line}
fill="none"
className="stroke-accent-solid"
strokeWidth={2}
vectorEffect="non-scaling-stroke"
strokeLinejoin="round"
/>
{hover != null && (
<line
x1={xOf(hover)}
x2={xOf(hover)}
y1={PAD_TOP}
y2={PAD_TOP + plotH}
className="stroke-fg-muted"
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/>
)}
</>
)}
</svg>
<span className="pointer-events-none absolute left-0 top-0 text-[10px] text-fg-muted tabular-nums">
{format(top)}
</span>
<div className="pointer-events-none flex justify-between text-[10px] text-fg-muted">
<span>{points[0]!.x}</span>
<span>{points[points.length - 1]!.x}</span>
</div>
{h && hover != null && (
<div
className="pointer-events-none absolute top-0 z-10 -translate-x-1/2 whitespace-nowrap rounded-md border border-border bg-surface-1 px-2 py-1 text-[11px] shadow-lg"
style={{ left: `${Math.min(92, Math.max(8, (xOf(hover) / W) * 100))}%` }}
role="status"
>
<div className="text-fg-muted">{h.x}</div>
<div className="font-medium text-fg-primary tabular-nums">{format(h.y)}</div>
</div>
)}
</div>
);
}
-417
View File
@@ -1,417 +0,0 @@
/**
* @file ServerPanel.tsx
* @description The Administration page's Server tab: the machine AgentHub
* runs on — hostname, OS, uptime, CPU and its load, memory and swap, every
* real disk (HDD/SSD, how full), any NVIDIA GPU — and each user's live share
* of it (`GET /api/host`, server/lib/host-stats.js): CPU % of the whole
* machine, resident memory, process count, the busiest processes by name, and
* the disk their home takes (`GET /api/users/storage`, as a share of the disk
* that holds the homes). Re-read every `POLL_MS` while shown; Refresh re-reads
* at once. Read-only — the only action here is the members' tmux card
* (`MemberTmuxCard`, an admin's restart).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { Fragment, useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
AlertCircle,
ChevronDown,
ChevronRight,
Cpu,
HardDrive,
MemoryStick,
MonitorCog,
RefreshCw,
Server,
} from "lucide-react";
import {
api,
type HostStats,
type HostUsageGroup,
type ServerUserUsage,
type UserStorage,
} from "../../lib/api";
import { formatBytes } from "../../lib/format";
import { useSpin } from "../../hooks/useSpin";
import { StatCard } from "../StatCard";
import { UserAvatar } from "../users/UserAvatar";
import { MemberTmuxCard } from "../users/MemberTmuxCard";
const POLL_MS = 5000;
const pct = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0);
const fmtPct = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`;
/** A thin bar, amber past 75 %, red past 90 %. */
function Meter({ value, label }: { value: number; label: string }) {
const v = Math.max(0, Math.min(100, value));
const tone = v >= 90 ? "bg-status-danger" : v >= 75 ? "bg-status-warning" : "bg-accent-solid";
return (
<div
role="meter"
aria-label={label}
aria-valuenow={Math.round(v)}
aria-valuemin={0}
aria-valuemax={100}
className="h-1.5 w-full rounded-full bg-surface-4 overflow-hidden"
>
<div className={`h-full rounded-full ${tone}`} style={{ width: `${v}%` }} />
</div>
);
}
function uptimeText(t: (k: string, o?: Record<string, unknown>) => string, sec: number) {
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
return d > 0 ? t("server.uptimeDays", { d, h }) : t("server.uptimeHours", { h, m });
}
export function ServerPanel({ isAdmin }: { isAdmin: boolean }) {
const { t } = useTranslation("admin");
const [stats, setStats] = useState<HostStats | null>(null);
const [error, setError] = useState<string | null>(null);
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
const [storage, setStorage] = useState<Record<string, UserStorage> | null>(null);
const [open, setOpen] = useState<Set<string>>(new Set());
const load = useCallback(
() =>
api.host
.stats()
.then((s) => {
setStats(s);
setError(null);
})
.catch((e: Error) => setError(e.message)),
[]
);
const [spinning, refresh] = useSpin(load);
useEffect(() => {
void load();
const id = setInterval(() => {
if (document.visibilityState === "visible") void load();
}, POLL_MS);
return () => clearInterval(id);
}, [load]);
// Names, pictures and home sizes: read once, they change slowly.
useEffect(() => {
api.users
.list()
.then((r) => setUsers(r.users))
.catch(() => setUsers([]));
api.users
.storage()
.then((r) => setStorage(r.storage))
.catch(() => setStorage({}));
}, []);
const byId = new Map((users ?? []).map((u) => [u.id, u]));
const homesDisk = stats?.disks.find((d) => d.mount === stats.homesMount) ?? null;
const loading = !stats && !error;
const mem = stats?.memory;
const toggle = (key: string) =>
setOpen((s) => {
const n = new Set(s);
if (n.has(key)) n.delete(key);
else n.add(key);
return n;
});
const groupName = (g: HostUsageGroup) => {
if (g.kind === "host") return t("server.hostGroup");
if (g.kind === "system") return t("server.systemGroup");
const u = g.userId != null ? byId.get(g.userId) : undefined;
return u?.displayName || g.username || "";
};
const th = "px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
return (
<div className="space-y-6" data-testid="server-panel">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="flex items-center gap-2 text-sm font-semibold text-fg-primary">
<Server className="w-4 h-4 text-accent" />
<span className="truncate">{stats?.hostname ?? t("server.title")}</span>
</h2>
{stats && (
<p className="mt-0.5 text-xs text-fg-muted">
{[stats.os.distro ?? stats.os.platform, stats.os.release, stats.os.arch].join(" · ")}
{" · "}
{t("server.uptime", { v: uptimeText(t, stats.uptime) })}
</p>
)}
</div>
<button
type="button"
onClick={refresh}
disabled={spinning}
aria-busy={spinning}
className="btn-ghost"
>
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />{" "}
{t("common:refresh")}
</button>
</div>
{error && (
<div
role="alert"
className="card flex flex-wrap items-center gap-3 p-3 border-status-danger/40 text-sm text-status-danger"
>
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 min-w-0">{error}</span>
<button type="button" onClick={refresh} className="btn-ghost text-xs">
{t("common:retry")}
</button>
</div>
)}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
label={t("server.cpu")}
value={stats?.usage ? fmtPct(stats.usage.cpu) : "—"}
icon={Cpu}
trend={
stats
? t("server.cores", {
n: stats.cpu.cores,
load: stats.cpu.load.map((l) => l.toFixed(2)).join(" "),
})
: undefined
}
raw={stats?.cpu.model ?? undefined}
help={t("server.cpuHelp")}
loading={loading}
/>
<StatCard
label={t("server.memory")}
value={mem ? fmtPct(pct(mem.used, mem.total)) : "—"}
icon={MemoryStick}
trend={mem ? `${formatBytes(mem.used)} / ${formatBytes(mem.total)}` : undefined}
loading={loading}
/>
<StatCard
label={t("server.swap")}
value={mem && mem.swapTotal ? fmtPct(pct(mem.swapUsed, mem.swapTotal)) : "—"}
icon={MonitorCog}
trend={mem ? `${formatBytes(mem.swapUsed)} / ${formatBytes(mem.swapTotal)}` : undefined}
loading={loading}
/>
<StatCard
label={t("server.homesDisk")}
value={homesDisk ? fmtPct(pct(homesDisk.used, homesDisk.total)) : "—"}
icon={HardDrive}
trend={
homesDisk
? `${formatBytes(homesDisk.free)} ${t("server.free")} · ${homesDisk.mount}`
: undefined
}
help={t("server.homesDiskHelp")}
loading={loading}
/>
</div>
<div className="card overflow-x-auto">
<div className="px-5 pt-4 pb-2">
<h3 className="text-sm font-semibold text-fg-primary">{t("server.usageTitle")}</h3>
<p className="text-xs text-fg-muted">
{t("server.usageHelp", { s: (stats?.usage?.sampleMs ?? 1000) / 1000 })}
</p>
</div>
{stats && !stats.usage ? (
<p className="px-5 pb-4 text-sm text-fg-muted">{t("server.usageUnavailable")}</p>
) : (
<table className="w-full min-w-[760px]" data-testid="server-usage-table">
<thead>
<tr className="border-b border-border text-left">
<th className={th}>{t("server.who")}</th>
<th className={`${th} w-[18%]`}>{t("server.cpu")}</th>
<th className={`${th} w-[22%]`}>{t("server.memory")}</th>
<th className={`${th} text-right`}>{t("server.processes")}</th>
<th className={`${th} w-[18%]`}>{t("server.home")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{loading && (
<tr>
<td colSpan={5} className="px-5 py-6 text-sm text-fg-muted">
{t("server.sampling")}
</td>
</tr>
)}
{stats?.usage?.groups.map((g) => {
const u = g.userId != null ? byId.get(g.userId) : undefined;
const home = g.userId != null && storage ? storage[g.userId] : undefined;
const isOpen = open.has(g.key);
const name = groupName(g);
return (
<Fragment key={g.key}>
<tr className="hover:bg-surface-4/40 transition-colors">
<td className="px-5 py-3">
<button
type="button"
onClick={() => toggle(g.key)}
disabled={!g.top.length}
aria-expanded={isOpen}
className="flex items-center gap-2 text-left disabled:cursor-default"
>
{g.top.length ? (
isOpen ? (
<ChevronDown className="w-3.5 h-3.5 text-fg-muted" />
) : (
<ChevronRight className="w-3.5 h-3.5 text-fg-muted" />
)
) : (
<span className="w-3.5" />
)}
{g.kind === "user" ? (
<UserAvatar
name={name}
avatarAt={u?.avatarAt}
userId={g.userId}
className="h-6 w-6 text-[10px]"
/>
) : (
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-surface-4">
<Server className="w-3.5 h-3.5 text-fg-muted" />
</span>
)}
<span className="min-w-0">
<span className="block text-sm text-fg-primary truncate">{name}</span>
{g.kind === "user" && u?.displayName && (
<span className="block text-[11px] text-fg-muted">{g.username}</span>
)}
</span>
</button>
</td>
<td className="px-5 py-3">
<div className="text-sm text-fg-primary tabular-nums">{fmtPct(g.cpu)}</div>
<Meter value={g.cpu} label={`${name} ${t("server.cpu")}`} />
</td>
<td className="px-5 py-3">
<div className="text-sm text-fg-primary tabular-nums">
{formatBytes(g.memory)}{" "}
<span className="text-fg-muted">({fmtPct(g.memoryPercent)})</span>
</div>
<Meter value={g.memoryPercent} label={`${name} ${t("server.memory")}`} />
</td>
<td className="px-5 py-3 text-right text-sm text-fg-secondary tabular-nums">
{g.processes}
</td>
<td className="px-5 py-3">
{home ? (
<>
<div className="text-sm text-fg-primary tabular-nums">
{formatBytes(home.bytes)}
{homesDisk && (
<span className="text-fg-muted">
{" "}
({fmtPct(pct(home.bytes, homesDisk.total))})
</span>
)}
</div>
{homesDisk && (
<Meter
value={pct(home.bytes, homesDisk.total)}
label={`${name} ${t("server.home")}`}
/>
)}
</>
) : (
<span className="text-sm text-fg-muted">—</span>
)}
</td>
</tr>
{isOpen &&
g.top.map((p) => (
<tr key={`${g.key}:${p.pid}`} className="bg-surface-2/40">
<td className="pl-14 pr-5 py-1.5 text-xs text-fg-secondary font-mono truncate">
{p.name} <span className="text-fg-muted">#{p.pid}</span>
</td>
<td className="px-5 py-1.5 text-xs text-fg-secondary tabular-nums">
{fmtPct(p.cpu)}
</td>
<td className="px-5 py-1.5 text-xs text-fg-secondary tabular-nums">
{formatBytes(p.memory)}
</td>
<td colSpan={2} />
</tr>
))}
</Fragment>
);
})}
</tbody>
</table>
)}
</div>
{stats && stats.disks.length > 0 && (
<div className="card overflow-x-auto">
<h3 className="px-5 pt-4 pb-2 text-sm font-semibold text-fg-primary">
{t("server.disks")}
</h3>
<table className="w-full min-w-[640px]" data-testid="server-disks-table">
<thead>
<tr className="border-b border-border text-left">
<th className={th}>{t("server.mount")}</th>
<th className={th}>{t("server.device")}</th>
<th className={`${th} w-[30%]`}>{t("server.used")}</th>
<th className={`${th} text-right`}>{t("server.free")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{stats.disks.map((d) => (
<tr key={d.device}>
<td className="px-5 py-3 text-sm text-fg-primary">
<span className="font-mono">{d.mount}</span>
{d.mount === stats.homesMount && (
<span className="badge ml-2 py-0 text-[10px]">{t("server.homesBadge")}</span>
)}
</td>
<td className="px-5 py-3 text-xs text-fg-secondary">
<span className="font-mono">{d.device}</span> · {d.type}
{d.kind && <span className="badge ml-2 py-0 text-[10px]">{d.kind}</span>}
</td>
<td className="px-5 py-3">
<div className="text-sm text-fg-primary tabular-nums">
{formatBytes(d.used)} / {formatBytes(d.total)}{" "}
<span className="text-fg-muted">({fmtPct(pct(d.used, d.total))})</span>
</div>
<Meter value={pct(d.used, d.total)} label={d.mount} />
</td>
<td className="px-5 py-3 text-right text-sm text-fg-secondary tabular-nums">
{formatBytes(d.free)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{stats && stats.gpus.length > 0 && (
<div className="card p-5 space-y-3">
<h3 className="text-sm font-semibold text-fg-primary">{t("server.gpus")}</h3>
{stats.gpus.map((g, i) => (
<div key={i} className="space-y-1">
<div className="flex justify-between text-sm">
<span className="text-fg-primary">{g.name}</span>
<span className="text-fg-muted tabular-nums">
{fmtPct(g.utilization)} · {formatBytes(g.memoryUsed)} /{" "}
{formatBytes(g.memoryTotal)}
</span>
</div>
<Meter value={g.utilization} label={g.name} />
</div>
))}
</div>
)}
<MemberTmuxCard isAdmin={isAdmin} />
</div>
);
}
+726
View File
@@ -0,0 +1,726 @@
/**
* @file HostHealth.tsx
* @description The machine AgentHub runs on, as part of the Dashboard's
* System Health tab (`GET /api/host`, server/lib/host-stats.js — every
* signed-in user sees all of it). In the tab's own card style:
* `HostCard` — hostname, OS, uptime, CPU model and load, the AgentHub process
* (pid, uptime, Node RSS) and bars for CPU, memory, swap and the V8 heap;
* `HostSections` — the tab's whole "machine" group: `HostCard` beside the
* CPU / memory history (`GET /api/host/history`, kept in memory since the
* server started); then CPU (every thread as a compact cell, temperatures,
* GPUs), disks (how full, read / write rate) with network interfaces, and the
* busiest processes with who each counts for (history, CPU threads and
* processes are Linux-only reads, so off Linux those cards are left out); and — server mode — each user's
* live CPU / RAM share (a tile per user; click for their busiest processes). Also exports `useHostStats` (a poll every `pollMs` while
* the tab is visible; 0 = read once), `Meter`, `pct` and `fmtPct`, which the
* Administration users table shares for each home's share of the homes disk.
* Read-only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type ComponentType, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
Activity,
ArrowDown,
ArrowUp,
Cpu,
HardDrive,
ListTree,
Network,
Server,
Thermometer,
Users,
} from "lucide-react";
import {
api,
type HostHistory,
type HostHistoryRange,
type HostStats,
type HostUsageGroup,
} from "../../lib/api";
import { formatBytes, formatDateTime } from "../../lib/format";
import { Tip } from "../Tip";
import { MiniChart } from "../admin/MiniChart";
import { UserAvatar } from "../users/UserAvatar";
const POLL_MS = 5000;
const HISTORY_POLL_MS = 30000;
const RANGES: HostHistoryRange[] = ["1h", "6h", "24h"];
export const pct = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0);
export const fmtPct = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`;
/** `GET /api/host`, re-read every `POLL_MS` while the tab is visible. */
export type HostState = {
stats: HostStats | null;
error: string | null;
reload: () => Promise<void>;
};
export function useHostStats(enabled = true, pollMs = POLL_MS): HostState {
const [stats, setStats] = useState<HostStats | null>(null);
const [error, setError] = useState<string | null>(null);
const reload = useCallback(
() =>
api.host
.stats()
.then((s) => {
setStats(s);
setError(null);
})
.catch((e: Error) => setError(e.message)),
[]
);
useEffect(() => {
if (!enabled) return;
void reload();
if (!pollMs) return;
const id = setInterval(() => {
if (document.visibilityState === "visible") void reload();
}, pollMs);
return () => clearInterval(id);
}, [enabled, pollMs, reload]);
return { stats, error, reload };
}
/** A thin bar, amber past 75 %, red past 90 %. */
export function Meter({ value, label }: { value: number; label: string }) {
const v = Math.max(0, Math.min(100, value));
const tone = v >= 90 ? "bg-status-danger" : v >= 75 ? "bg-status-warning" : "bg-accent-solid";
return (
<div
role="meter"
aria-label={label}
aria-valuenow={Math.round(v)}
aria-valuemin={0}
aria-valuemax={100}
className="h-1.5 w-full rounded-full bg-surface-4 overflow-hidden"
>
<div className={`h-full rounded-full ${tone}`} style={{ width: `${v}%` }} />
</div>
);
}
/** The System Health tab's own bar: h-2, green / amber / red by level. */
function HealthBar({
label,
value,
tip,
warn = 70,
bad = 90,
}: {
label: string;
value: number | null;
tip?: string;
warn?: number;
bad?: number;
}) {
const v = value == null ? 0 : Math.max(0, Math.min(100, value));
const tone = v > bad ? "bg-status-danger" : v > warn ? "bg-status-warning" : "bg-status-success";
return (
<Tip block raw={tip}>
<div className="space-y-1">
<div className="flex justify-between text-[10px]">
<span className="text-fg-muted">{label}</span>
<span className="text-fg-secondary font-mono">
{value == null ? "—" : `${Math.round(v)}%`}
</span>
</div>
<div
role="meter"
aria-label={label}
aria-valuenow={Math.round(v)}
aria-valuemin={0}
aria-valuemax={100}
className="w-full bg-surface-3 rounded-full h-2"
>
<div
className={`h-2 rounded-full transition-all duration-700 ${tone}`}
style={{ width: `${v}%` }}
/>
</div>
</div>
</Tip>
);
}
/** One CPU thread in the CPU card's compact grid: its number, % and a thin bar. */
function CoreCell({ n, value, label }: { n: number; value: number; label: string }) {
const v = Math.max(0, Math.min(100, value));
const tone = v > 90 ? "text-status-danger" : v > 70 ? "text-status-warning" : "text-fg-secondary";
return (
<div className="space-y-0.5">
<div className="flex justify-between text-[10px] font-mono">
<span className="text-fg-muted">{n}</span>
<span className={tone}>{Math.round(v)}%</span>
</div>
<Meter value={v} label={label} />
</div>
);
}
function CardHead({
icon: Icon,
tone,
title,
right,
}: {
icon: ComponentType<{ className?: string }>;
tone: string;
title: string;
right?: ReactNode;
}) {
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<Icon className={`w-4 h-4 flex-shrink-0 ${tone}`} />
<span className="text-xs text-fg-muted uppercase tracking-wider truncate">{title}</span>
</div>
{right}
</div>
);
}
function Row({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">{label}</span>
<span className="ml-auto min-w-0 truncate text-right text-xs text-fg-secondary font-mono">
{children}
</span>
</div>
);
}
function uptimeText(t: (k: string, o?: Record<string, unknown>) => string, sec: number) {
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
return d > 0 ? t("host.uptimeDays", { d, h }) : t("host.uptimeHours", { h, m });
}
/** The process's own heap, from `GET /api/settings/info` (the host read has none). */
export type HeapInfo = {
heapUsed: number;
heapTotal: number;
heapLimit?: number;
external: number;
};
/**
* The tab's first card: the machine and the AgentHub process on it.
* `heap` comes from the tab's settings read.
*/
export function HostCard({ host, heap }: { host: HostState; heap?: HeapInfo }) {
const { t } = useTranslation("dashboard");
const s = host.stats;
const mem = s?.memory;
const heapMax = heap ? heap.heapLimit || heap.heapTotal : 0;
const heapPct = heap && heapMax > 0 ? (heap.heapUsed / heapMax) * 100 : null;
return (
<div className="card p-5 flex flex-col gap-4" data-testid="server-facts">
<CardHead
icon={Server}
tone="text-status-success"
title={t("host.title")}
right={
s && (
<span className="text-[10px] font-mono text-fg-muted truncate" title={s.hostname}>
{s.hostname}
</span>
)
}
/>
{!s ? (
<p className="text-xs text-fg-muted">{host.error ?? t("host.sampling")}</p>
) : (
<>
<div className="space-y-2.5">
<Row label={t("host.os")}>
{s.os.distro ?? s.os.platform} · {s.os.release}
</Row>
<Row label={t("host.uptime")}>{uptimeText(t, s.uptime)}</Row>
<Row label={t("host.cpuModel")}>
<Tip raw={s.cpu.model ?? undefined}>
<span>
{s.cpu.physicalCores
? `${t("host.physical", { n: s.cpu.physicalCores })} · `
: ""}
{t("host.threads", { n: s.cpu.cores })} · {s.os.arch}
</span>
</Tip>
</Row>
{s.os.platform !== "win32" && (
<div className="flex items-center gap-3">
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">
{t("host.load")}
</span>
<div className="flex gap-1 ml-auto">
{s.cpu.load.slice(0, 3).map((load, i) => (
<span
key={i}
className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${i === 0 && load > s.cpu.cores ? "bg-status-danger/20 text-status-danger" : "bg-surface-3 text-fg-secondary"}`}
>
{load.toFixed(2)}
</span>
))}
</div>
</div>
)}
{s.process && (
<Row label={t("host.agenthub")}>
<Tip raw={t("host.agenthubTip", { pid: s.process.pid, node: s.process.node })}>
<span>
{uptimeText(t, s.process.uptime)} · {formatBytes(s.process.rss)}
</span>
</Tip>
</Row>
)}
</div>
<div className="border-t border-border/40 pt-3 space-y-3">
{s.usage && (
<HealthBar label={t("host.cpu")} value={s.usage.cpu} tip={t("host.cpuHelp")} />
)}
<HealthBar
label={t("host.memory")}
value={mem ? pct(mem.used, mem.total) : null}
tip={mem ? `${formatBytes(mem.used)} / ${formatBytes(mem.total)}` : undefined}
/>
{mem && mem.swapTotal > 0 && (
<HealthBar
label={t("host.swap")}
value={pct(mem.swapUsed, mem.swapTotal)}
tip={`${formatBytes(mem.swapUsed)} / ${formatBytes(mem.swapTotal)}`}
/>
)}
{heap && (
<HealthBar
label={t("health.heap")}
value={heapPct}
warn={60}
bad={85}
tip={t("health.heapTip", {
pct: (heapPct ?? 0).toFixed(1),
used: formatBytes(heap.heapUsed),
limit: formatBytes(heapMax),
allocated: formatBytes(heap.heapTotal),
external: formatBytes(heap.external),
})}
/>
)}
</div>
</>
)}
</div>
);
}
/** The tab's machine group: host + history, CPU / disks / processes, users. */
export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo }) {
const { t } = useTranslation("dashboard");
const { stats } = host;
const [range, setRange] = useState<HostHistoryRange>("1h");
const [history, setHistory] = useState<HostHistory | null>(null);
// The user tile whose busiest processes are shown.
const [open, setOpen] = useState<string | null>(null);
const loadHistory = useCallback(
() =>
api.host
.history(range)
.then(setHistory)
.catch(() => setHistory(null)),
[range]
);
useEffect(() => {
void loadHistory();
const id = setInterval(() => {
if (document.visibilityState === "visible") void loadHistory();
}, HISTORY_POLL_MS);
return () => clearInterval(id);
}, [loadHistory]);
// Same tree as the full render below, so the card is not remounted once stats arrive.
if (!stats)
return (
<>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<HostCard host={host} heap={heap} />
</div>
</>
);
const groups = stats.usage?.groups ?? [];
const groupName = (g: HostUsageGroup) => {
if (g.kind === "host") return t("host.hostGroup");
if (g.kind === "system") return t("host.systemGroup");
return g.displayName || g.username || "";
};
const groupByKey = new Map(groups.map((g) => [g.key, g]));
const ownerOf = (key: string) => {
const g = groupByKey.get(key);
if (g) return groupName(g);
if (key === "host") return t("host.hostGroup");
if (key === "system") return t("host.systemGroup");
return key;
};
const perSec = (v: number) => t("host.perSecond", { v: formatBytes(v) });
const timeLabel = (iso: string) =>
new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
const histPoints = (key: "cpu" | "memory") =>
(history?.points ?? [])
.filter((p) => p[key] != null)
.map((p) => ({ x: timeLabel(p.at), y: p[key] as number }));
const perCore = stats.cpu.perCore ?? [];
const network = stats.network ?? [];
const temps = stats.temperatures ?? [];
const topProcs = stats.topProcesses ?? [];
const others = groups.filter((g) => g.kind !== "user");
const people = groups.filter((g) => g.kind === "user");
// History, per-thread CPU and processes are read from /proc: on Windows and
// macOS their cards would only ever be empty, so they are left out.
const linux = stats.os.platform === "linux";
const showCores = perCore.length > 0 || temps.length > 0 || stats.gpus.length > 0;
const showProcs = !!stats.usage || others.length > 0;
// A whole disk's I/O goes on the first mounted file system that lives on it
// (`/dev/sda1` → `sda`, `/dev/nvme0n1p2` → `nvme0n1`); a disk with nothing
// mounted gets a row of its own.
const ioLeft = new Map((stats.diskIo ?? []).map((d) => [d.name, d]));
const diskRows = stats.disks.map((d) => {
const base = d.device.split("/").pop() ?? "";
const key = [base, base.replace(/p?\d+$/, "")].find((k) => ioLeft.has(k));
const io = key ? ioLeft.get(key) : undefined;
if (key) ioLeft.delete(key);
return { disk: d, io };
});
const loneIo = [...ioLeft.values()];
const rate = (down?: number, up?: number) => (
<span className="flex items-center gap-2 text-[10px] font-mono text-fg-muted">
<span className="inline-flex items-center gap-0.5">
<ArrowDown className="w-2.5 h-2.5" />
{down == null ? "—" : perSec(down)}
</span>
<span className="inline-flex items-center gap-0.5">
<ArrowUp className="w-2.5 h-2.5" />
{up == null ? "—" : perSec(up)}
</span>
</span>
);
return (
<>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<HostCard host={host} heap={heap} />
{/* CPU and memory over time. */}
{linux && (
<div className="card p-5 flex flex-col gap-4 lg:col-span-2" data-testid="server-history">
<CardHead
icon={Activity}
tone="text-accent"
title={t("host.historyTitle")}
right={
<div
role="group"
aria-label={t("host.range")}
className="flex gap-1 rounded-lg border border-border bg-surface-1 p-0.5"
>
{RANGES.map((r) => (
<button
key={r}
type="button"
onClick={() => setRange(r)}
aria-pressed={range === r}
className={`rounded-md px-2 py-0.5 text-[11px] font-medium transition-colors ${
range === r
? "bg-surface-4 text-fg-secondary"
: "text-fg-muted hover:text-fg-secondary"
}`}
>
{t(`host.range${r}`)}
</button>
))}
</div>
}
/>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<div className="mb-1 text-[10px] text-fg-muted">{t("host.historyCpu")}</div>
<MiniChart
kind="line"
points={histPoints("cpu")}
max={100}
format={fmtPct}
label={t("host.historyCpu")}
emptyText={t("host.historyNone")}
/>
</div>
<div>
<div className="mb-1 text-[10px] text-fg-muted">{t("host.historyMemory")}</div>
<MiniChart
kind="line"
points={histPoints("memory")}
max={100}
format={fmtPct}
label={t("host.historyMemory")}
emptyText={t("host.historyNone")}
/>
</div>
</div>
{history?.startedAt && (
<p className="text-[10px] text-fg-muted">
{t("host.historyHelp", {
s: history.intervalMs / 1000,
at: formatDateTime(history.startedAt),
})}
</p>
)}
</div>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 empty:hidden">
{/* CPU: every thread as a compact cell, then temperatures and GPUs. */}
{showCores && (
<div className="card p-5 flex flex-col gap-4" data-testid="server-cores">
<CardHead
icon={Cpu}
tone="text-syntax-keyword"
title={t("host.cpu")}
right={
<span className="text-[10px] font-mono text-fg-muted">
{t("host.threads", { n: perCore.length || stats.cpu.cores })}
</span>
}
/>
{perCore.length > 0 && (
<div className="grid grid-cols-4 gap-x-3 gap-y-2">
{perCore.map((v, i) => (
<CoreCell key={i} n={i} value={v} label={t("host.coreN", { n: i })} />
))}
</div>
)}
{temps.length > 0 && (
<div
className="border-t border-border/40 pt-3 space-y-2"
data-testid="server-temperatures"
>
<div className="flex items-center gap-1.5 text-[10px] text-fg-muted uppercase tracking-wider">
<Thermometer className="w-3 h-3 text-status-warning" />
{t("host.temperatures")}
</div>
{temps.map((s, i) => (
<div key={i} className="flex items-center gap-3">
<span className="text-xs text-fg-secondary min-w-0 truncate" title={s.chip}>
{s.label} <span className="text-fg-muted">· {s.chip}</span>
</span>
<span
className={`ml-auto text-[10px] font-mono px-1.5 py-0.5 rounded ${
s.celsius >= 90
? "bg-status-danger/20 text-status-danger"
: s.celsius >= 75
? "bg-status-warning/20 text-status-warning"
: "bg-surface-3 text-fg-secondary"
}`}
>
{s.celsius.toFixed(0)} °C
</span>
</div>
))}
</div>
)}
{stats.gpus.length > 0 && (
<div className="border-t border-border/40 pt-3 space-y-3">
{stats.gpus.map((g, i) => (
<HealthBar
key={i}
label={g.name}
value={g.utilization}
tip={`${formatBytes(g.memoryUsed)} / ${formatBytes(g.memoryTotal)}`}
/>
))}
</div>
)}
</div>
)}
{/* Disks (with their I/O) and network interfaces. */}
{(diskRows.length > 0 || loneIo.length > 0 || network.length > 0) && (
<div className="card p-5 flex flex-col gap-4" data-testid="server-disks-table">
<CardHead
icon={HardDrive}
tone="text-syntax-builtin"
title={t("host.storageNetwork")}
/>
<div className="space-y-3" data-testid="server-disk-io">
{diskRows.map(({ disk: d, io }) => (
<div key={d.device} className="space-y-1">
<HealthBar
label={`${d.mount}${d.kind ? ` · ${d.kind}` : ""}${d.mount === stats.homesMount ? ` · ${t("host.homesBadge")}` : ""}`}
value={pct(d.used, d.total)}
warn={75}
tip={`${d.device} · ${d.type}\n${formatBytes(d.used)} / ${formatBytes(d.total)} · ${formatBytes(d.free)} ${t("host.free")}`}
/>
<div className="flex justify-between gap-2">
<span className="text-[10px] font-mono text-fg-muted">
{formatBytes(d.free)} {t("host.free")}
</span>
{io && rate(io.read, io.write)}
</div>
</div>
))}
{loneIo.map((io) => (
<div key={io.name} className="flex items-center gap-3">
<span className="text-xs text-fg-secondary font-mono">{io.name}</span>
<span className="ml-auto">{rate(io.read, io.write)}</span>
</div>
))}
</div>
{network.length > 0 && (
<div
className="border-t border-border/40 pt-3 space-y-2"
data-testid="server-network"
>
{network.map((n) => (
<div key={n.name} className="flex items-center gap-3">
<span className="inline-flex items-center gap-1.5 text-xs text-fg-secondary">
<Network className="w-3 h-3 text-fg-muted" />
<span className="font-mono">{n.name}</span>
</span>
<span className="ml-auto">{rate(n.rx, n.tx)}</span>
</div>
))}
</div>
)}
</div>
)}
{/* The busiest processes, and what belongs to no user. */}
{showProcs && (
<div className="card p-5 flex flex-col gap-4" data-testid="server-top-processes">
<CardHead
icon={ListTree}
tone="text-syntax-function"
title={t("host.topProcesses")}
right={
<span className="text-[10px] font-mono text-fg-muted">
{t("host.sampled", { s: (stats.usage?.sampleMs ?? 1000) / 1000 })}
</span>
}
/>
{!stats.usage ? (
<p className="text-xs text-fg-muted">{t("host.usageUnavailable")}</p>
) : (
<div className="space-y-2">
{topProcs.map((p) => (
<Tip block key={p.pid} raw={`#${p.pid} · ${ownerOf(p.group)}`}>
<div className="flex items-center gap-3">
<span className="min-w-0 flex-1 truncate text-xs">
<span className="font-mono text-fg-secondary">{p.name}</span>
<span className="ml-1.5 text-[10px] text-fg-muted">{ownerOf(p.group)}</span>
</span>
<span className="text-[10px] font-mono text-fg-muted">
{formatBytes(p.memory)}
</span>
<span
className={`w-12 text-right text-[10px] font-mono px-1.5 py-0.5 rounded ${p.cpu >= 50 ? "bg-status-warning/20 text-status-warning" : "bg-surface-3 text-fg-secondary"}`}
>
{fmtPct(p.cpu)}
</span>
</div>
</Tip>
))}
</div>
)}
{others.length > 0 && (
<div
className="border-t border-border/40 pt-3 space-y-1.5"
data-testid="server-usage-table"
>
{others.map((g) => (
<div key={g.key} className="flex items-center gap-3 text-[10px]">
<span className="min-w-0 flex-1 truncate text-fg-muted" title={groupName(g)}>
{groupName(g)}
</span>
<span className="font-mono text-fg-secondary">
{fmtPct(g.cpu)} · {formatBytes(g.memory)} ·{" "}
{t("host.processCount", { n: g.processes })}
</span>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* Each user's live share of the machine (server mode). */}
{people.length > 0 && (
<div className="card p-5 flex flex-col gap-4" data-testid="server-users">
<CardHead
icon={Users}
tone="text-syntax-string"
title={t("host.usersTitle")}
right={
<span className="text-[10px] font-mono text-fg-muted">
{t("host.usersCount", { n: people.length })}
</span>
}
/>
<div className="grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2 xl:grid-cols-3">
{people.map((g) => {
const name = groupName(g);
const isOpen = open === g.key;
return (
<div key={g.key} className="space-y-2" data-testid="server-user">
<button
type="button"
onClick={() => setOpen(isOpen ? null : g.key)}
disabled={!g.top.length}
aria-expanded={isOpen}
className="flex w-full items-center gap-2 text-left disabled:cursor-default"
>
<UserAvatar
name={name}
userId={g.userId}
avatarAt={g.avatarAt}
className="h-6 w-6 text-[10px]"
/>
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
{name}
</span>
<span className="text-[10px] font-mono text-fg-muted">
{t("host.processCount", { n: g.processes })}
</span>
</button>
<HealthBar label="CPU" value={g.cpu} tip={t("host.cpuHelp")} />
<HealthBar label={`RAM · ${formatBytes(g.memory)}`} value={g.memoryPercent} />
{isOpen && (
<ul className="space-y-1 rounded-md bg-surface-2/60 px-2 py-1.5">
{g.top.map((p) => (
<li
key={p.pid}
className="flex justify-between gap-2 text-[10px] font-mono"
>
<span className="truncate text-fg-secondary">
{p.name} <span className="text-fg-muted">#{p.pid}</span>
</span>
<span className="flex-shrink-0 text-fg-muted">
{fmtPct(p.cpu)} · {formatBytes(p.memory)}
</span>
</li>
))}
</ul>
)}
</div>
);
})}
</div>
</div>
)}
</>
);
}
@@ -0,0 +1,213 @@
/**
* @file HostHealth.test.tsx
* @description The machine on the Dashboard's System Health tab
* (`HostHealth.tsx`, `GET /api/host`), its whole machine group: the first card names the host, its OS,
* load and the AgentHub process with bars for CPU, memory, swap and heap; the
* rows under it show every CPU thread, temperatures, each disk with its I/O
* rate and each network interface, the CPU / memory history with its range
* switch (`GET /api/host/history`), and the busiest processes with who each
* counts for, the processes of no user in a short list of their own, and
* each user's live CPU / RAM as a tile (their busiest processes on click);
* off Linux the cards it can never fill are left out.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import type { HostStats } from "../../../lib/api";
const GB = 1024 ** 3;
const stats: HostStats = {
at: "2026-10-15T00:00:00Z",
hostname: "box",
os: { platform: "linux", release: "7.0.0", distro: "Ubuntu 26.04", arch: "x64" },
uptime: 90000,
cpu: { model: "i5-7500", cores: 4, load: [1, 0.5, 0.25] },
memory: { total: 16 * GB, available: 8 * GB, used: 8 * GB, swapTotal: 4 * GB, swapUsed: 0 },
disks: [
{
device: "/dev/sdb2",
mount: "/",
type: "ext4",
kind: "SSD",
total: 200 * GB,
used: 50 * GB,
free: 150 * GB,
},
{
device: "/dev/sda1",
mount: "/data",
type: "ext4",
kind: "HDD",
total: 500 * GB,
used: 0,
free: 500 * GB,
},
],
homesMount: "/",
gpus: [],
usage: {
cpu: 40,
sampleMs: 1000,
groups: [
{
key: "user:2",
kind: "user",
userId: 2,
username: "mia",
cpu: 25,
memory: 2 * GB,
memoryPercent: 12.5,
processes: 3,
top: [{ pid: 77, name: "claude", cpu: 20, memory: GB }],
},
{ key: "host", kind: "host", cpu: 5, memory: GB, memoryPercent: 6.3, processes: 90, top: [] },
],
},
};
const api = vi.hoisted(() => ({
host: { stats: vi.fn(), history: vi.fn() },
me: { avatarUrl: () => null },
users: { avatarUrl: () => null },
}));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, api };
});
import { HostSections, useHostStats } from "../HostHealth";
function Tab() {
const host = useHostStats();
return (
<HostSections
host={host}
heap={{ heapUsed: GB / 4, heapTotal: GB / 2, heapLimit: GB, external: 0 }}
/>
);
}
const full: HostStats = {
...stats,
usage: {
...stats.usage!,
groups: stats.usage!.groups.map((g) => (g.kind === "user" ? { ...g, displayName: "Mia" } : g)),
},
bootTime: "2026-10-14T00:00:00Z",
cpu: { ...stats.cpu, physicalCores: 4, perCore: [10, 20, 30, 40] },
network: [{ name: "enp4s0", rx: 2048, tx: 1024 }],
diskIo: [{ name: "sda", read: 4096, write: 0 }],
temperatures: [{ chip: "coretemp", label: "Package id 0", celsius: 55 }],
topProcesses: [{ pid: 77, name: "claude", cpu: 20, memory: GB, group: "user:2" }],
process: { pid: 9, node: "v24.0.0", uptime: 3600, rss: GB, heapUsed: GB / 2 },
};
beforeEach(() => {
vi.clearAllMocks();
api.host.stats.mockResolvedValue(full);
api.host.history.mockResolvedValue({
range: "1h",
intervalMs: 30000,
running: true,
startedAt: "2026-10-15T00:00:00Z",
points: [
{
at: "2026-10-15T00:00:00Z",
cpu: 30,
memory: 50,
memoryUsed: 0,
swapUsed: 0,
load1: 1,
users: {},
},
{
at: "2026-10-15T00:00:30Z",
cpu: 40,
memory: 52,
memoryUsed: 0,
swapUsed: 0,
load1: 1,
users: {},
},
],
});
});
describe("HostHealth", () => {
it("names the machine and the AgentHub process, with bars", async () => {
render(<Tab />);
const card = await screen.findByTestId("server-facts");
expect(await within(card).findByText("box")).toBeTruthy();
expect(card.textContent).toContain("Ubuntu 26.04");
expect(card.textContent).toContain("1.0 GB");
expect(within(card).getByRole("meter", { name: "CPU" }).getAttribute("aria-valuenow")).toBe(
"40"
);
expect(within(card).getByRole("meter", { name: "Memory" }).getAttribute("aria-valuenow")).toBe(
"50"
);
expect(within(card).getByRole("meter", { name: "V8 Heap" })).toBeTruthy();
});
it("shows every thread, sensors, disks with I/O, network, history and processes", async () => {
render(<Tab />);
expect(await screen.findByTestId("server-history")).toBeTruthy();
expect(await screen.findByRole("img", { name: /CPU/ })).toBeTruthy();
expect(screen.getByTestId("server-cores").textContent).toContain("40%");
expect(screen.getByTestId("server-temperatures").textContent).toContain("55 °C");
const disks = screen.getByTestId("server-disk-io").textContent;
expect(disks).toContain("/data · HDD");
expect(disks).toContain("4.0 KB/s");
expect(screen.getByTestId("server-network").textContent).toContain("enp4s0");
const top = screen.getByTestId("server-top-processes");
expect(await within(top).findByText("Mia")).toBeTruthy();
const other = screen.getByTestId("server-usage-table");
expect(within(other).queryByText("Mia")).toBeNull();
expect(within(other).getByText(/^Host account/)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "24 h" }));
expect(api.host.history).toHaveBeenLastCalledWith("24h");
});
it("shows each user's live CPU and RAM, their processes on click", async () => {
render(<Tab />);
const tile = await screen.findByTestId("server-user");
expect(tile.textContent).toContain("Mia");
expect(within(tile).getByRole("meter", { name: "CPU" }).getAttribute("aria-valuenow")).toBe(
"25"
);
expect(tile.textContent).toContain("2.0 GB");
expect(within(tile).queryByText("claude")).toBeNull();
fireEvent.click(within(tile).getByRole("button", { expanded: false }));
expect(within(tile).getByText("claude")).toBeTruthy();
});
it("leaves out what Windows cannot report instead of empty cards", async () => {
api.host.stats.mockResolvedValue({
...stats,
os: { platform: "win32", release: "10.0.26300", distro: null, arch: "x64" },
cpu: { ...stats.cpu, load: [0, 0, 0], perCore: [] },
disks: [],
usage: null,
});
api.host.history.mockResolvedValue({
range: "1h",
intervalMs: 30000,
running: false,
points: [],
});
render(<Tab />);
const card = await screen.findByTestId("server-facts");
expect(await within(card).findByText(/win32/)).toBeTruthy();
expect(within(card).queryByRole("meter", { name: "CPU" })).toBeNull();
expect(card.textContent).not.toContain("Load");
expect(within(card).getByRole("meter", { name: "Memory" })).toBeTruthy();
for (const id of [
"server-history",
"server-cores",
"server-top-processes",
"server-disks-table",
])
expect(screen.queryByTestId(id)).toBeNull();
});
});
+47 -34
View File
@@ -9,8 +9,11 @@
* manually-typed branch name; "Clone" runs `POST /api/lanes/clone` (an
* https/ssh URL cloned into a folder of the user's, with their OWN SSH key or
* HTTPS login — a 409 `ECREDENTIALS` opens a login row, `ESSHAUTH` points to
* Settings → Git; **Partial clone** makes it blobless, and `CloneProgress`
* shows its queue place, then git's progress). There is no AI tool picker: the lane is created with the
* Settings → Git; **Partial clone** makes it blobless). The clone is a
* background job (`lib/cloneJobs.ts`): the modal closes once the server
* accepted it, the app-wide dock shows its queue place, git's progress and
* outcome, and the new workspace is selected when it ends while the user is
* still on that page. There is no AI tool picker: the lane is created with the
* default AI tool (Settings → AI Tools), and each new session picks its own
* AI on the workspace home. Either
* mode's path field can be filled by typing, by the CwdAutocomplete
@@ -31,7 +34,7 @@ import { useDialog } from "../../hooks/useDialog";
import { CwdAutocomplete } from "../run/RunSetup";
import { PathPickButton } from "../PathPickButton";
import { api } from "../../lib/api";
import { CloneProgress, newCloneToken } from "./CloneProgress";
import { startClone } from "../../lib/cloneJobs";
import type { CwdSuggestion } from "../../lib/api";
import type { Lane, LaneWorktree } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
@@ -119,8 +122,6 @@ export function AddLaneModal({
const [cloneUrl, setCloneUrl] = useState("");
const [cloneName, setCloneName] = useState("");
const [clonePartial, setClonePartial] = useState(false);
/** The running clone's progress token (CloneProgress polls it). */
const [cloneToken, setCloneToken] = useState<string | null>(null);
const [login, setLogin] = useState<{
host: string;
username: string;
@@ -242,28 +243,46 @@ export function AddLaneModal({
const url = cloneUrl.trim();
if (!url) return;
setSshRefused(false);
const progressToken = newCloneToken();
setCloneToken(progressToken);
// The new workspace is selected when the clone ends only while the user
// is still on the page they started it from; elsewhere the dock's
// "Open workspace" does it.
const from = window.location.pathname;
try {
const result = await api.lanes.clone({
progressToken,
url,
parent: repo,
name: cloneName.trim() || undefined,
title: name || undefined,
tool,
...(clonePartial ? { partial: true } : {}),
...(login?.username.trim() && login.token
? {
credentials: {
username: login.username.trim(),
password: login.token,
remember: login.remember,
},
}
: {}),
});
onAdded(result.lane);
await startClone(
{
kind: "workspace",
body: {
url,
parent: repo,
name: cloneName.trim() || undefined,
title: name || undefined,
tool,
...(clonePartial ? { partial: true } : {}),
...(login?.username.trim() && login.token
? {
credentials: {
username: login.username.trim(),
password: login.token,
remember: login.remember,
},
}
: {}),
},
},
{
onDone: (job, lane) => {
if (window.location.pathname !== from) return;
if (lane) return onAdded(lane);
const id = job.result?.laneId;
if (id == null) return;
api.lanes
.get(id)
.then((r) => onAdded(r.lane))
.catch(() => undefined);
},
}
);
// Accepted: the dock carries the clone from here.
reset();
onClose();
} catch (err) {
@@ -284,8 +303,6 @@ export function AddLaneModal({
setError(err instanceof Error ? err.message : String(err));
}
setBusy(false);
} finally {
setCloneToken(null);
}
return;
}
@@ -571,11 +588,7 @@ export function AddLaneModal({
/>
{t("clone.partial")}
</label>
{cloneToken && (
<div className="mt-2">
<CloneProgress token={cloneToken} />
</div>
)}
<p className="mt-1 text-[10px] text-fg-muted">{t("cloneJobs.backgroundHint")}</p>
</div>
)}
@@ -856,7 +869,7 @@ export function AddLaneModal({
{done
? t("addLaneSetupClose")
: busy && mode === "clone"
? t("clone.cloning")
? t("cloneJobs.starting")
: t("add")}
</button>
</div>
+36 -88
View File
@@ -5,12 +5,13 @@
* `POST /api/lanes/clone` as Add workspace → Clone — the server holds the URL
* to its allowlist and the folder to the user's own, and clones as the
* folder's account with that account's own SSH key / HTTPS login — and opens
* a workspace on the new folder. A 409 `ECREDENTIALS` opens a login row (sent
* once, optionally remembered), `ESSHAUTH` points to Settings → Git.
* A running clone shows `CloneProgress` and never holds the page: closing the
* dialog only hides it behind a small progress chip (click to reopen), the
* request keeps going, and its outcome arrives as a toast — a failure is kept
* for the next open so a login row is still there to fill in.
* a workspace on the new folder. The clone is a server-side background job
* (`lib/cloneJobs.ts`): the dialog closes as soon as the server accepted it
* and the app-wide dock (`BackgroundJobsDock`) carries its progress and
* outcome, across pages, F5 and server restarts. A refusal before the clone
* starts (a bad URL, a folder that is not the user's, a 409 `ECREDENTIALS` /
* `ESSHAUTH`) stays in the form: a login row (sent once, optionally
* remembered), or a pointer to Settings → Git.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -18,12 +19,9 @@ import { useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { FolderGit2 } from "lucide-react";
import { api } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { useDialog } from "../../hooks/useDialog";
import { cloneDirName } from "./AddLaneModal";
import { CloneProgress, newCloneToken } from "./CloneProgress";
import { toast } from "../../lib/toast";
import { startClone } from "../../lib/cloneJobs";
type Login = { host: string; username: string; token: string; remember: boolean };
@@ -31,16 +29,11 @@ export function CloneHereDialog({
open,
parent,
onClose,
onCloned,
onReopen,
}: {
open: boolean;
/** The absolute folder the clone goes into. */
parent: string;
onClose: () => void;
onCloned: (lane: Lane, path: string) => void;
/** Show the dialog again from the background chip. */
onReopen: () => void;
}) {
const { t } = useTranslation(["lanes"]);
const panelRef = useRef<HTMLDivElement>(null);
@@ -51,23 +44,10 @@ export function CloneHereDialog({
const [sshRefused, setSshRefused] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [token, setToken] = useState<string | null>(null);
/** Where the running clone goes (the open folder may change meanwhile). */
const [target, setTarget] = useState("");
// Read by the request's own continuation and by the reset below.
const openRef = useRef(open);
openRef.current = open;
const busyRef = useRef(false);
/** A clone failed while hidden: the next open shows that, not a blank form. */
const keepRef = useRef(false);
useDialog(open, panelRef, onClose);
useEffect(() => {
if (!open || busyRef.current) return;
if (keepRef.current) {
keepRef.current = false;
return;
}
if (!open) return;
setUrl("");
setName("");
setLogin(null);
@@ -76,67 +56,42 @@ export function CloneHereDialog({
setError(null);
}, [open, parent]);
const folder = name.trim() || cloneDirName(url);
const into = busy ? target : `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
if (!open) return null;
if (!open) {
if (!busy || !token) return null;
return createPortal(
<button
type="button"
data-testid="fx-clone-chip"
onClick={onReopen}
title={into}
className="fixed bottom-10 right-4 z-[60] w-72 space-y-1 rounded-lg border border-border bg-surface-1 p-2.5 text-left shadow-xl shadow-black/40 hover:bg-surface-2"
>
<span className="flex items-center gap-2 text-xs font-medium text-fg-primary">
<FolderGit2 className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">
{t("clone.cloning")} {into.split("/").pop()}
</span>
</span>
<CloneProgress token={token} />
</button>,
document.body
);
}
const folder = name.trim() || cloneDirName(url);
const into = `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
const submit = async () => {
const u = url.trim();
if (!u || busy) return;
const progressToken = newCloneToken();
busyRef.current = true;
setBusy(true);
setToken(progressToken);
setTarget(into);
setError(null);
setSshRefused(false);
try {
const result = await api.lanes.clone({
url: u,
parent,
name: name.trim() || undefined,
progressToken,
...(login?.username.trim() && login.token
? {
credentials: {
username: login.username.trim(),
password: login.token,
remember: login.remember,
},
}
: {}),
await startClone({
kind: "workspace",
body: {
url: u,
parent,
name: name.trim() || undefined,
...(login?.username.trim() && login.token
? {
credentials: {
username: login.username.trim(),
password: login.token,
remember: login.remember,
},
}
: {}),
},
});
busyRef.current = false;
setBusy(false);
setToken(null);
onCloned(result.lane, result.path);
// Accepted: the dock takes it from here.
onClose();
} catch (err) {
const e = err as { code?: string; auth?: { host?: string }; message?: string };
const host = e.auth?.host || u;
let message: string;
if (e.code === "ECREDENTIALS") {
message = t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host });
setError(t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host }));
setLogin((l) => ({
host,
username: l?.username ?? "",
@@ -145,18 +100,11 @@ export function CloneHereDialog({
}));
} else if (e.code === "ESSHAUTH") {
setSshRefused(true);
message = t("clone.sshRefused", { host });
setError(t("clone.sshRefused", { host }));
} else {
message = err instanceof Error ? err.message : String(err);
setError(err instanceof Error ? err.message : String(err));
}
setError(message);
busyRef.current = false;
setBusy(false);
setToken(null);
if (!openRef.current) {
keepRef.current = true;
toast.error(message);
}
}
};
@@ -258,7 +206,6 @@ export function CloneHereDialog({
</label>
</div>
)}
{busy && token && <CloneProgress token={token} />}
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}{" "}
@@ -269,9 +216,10 @@ export function CloneHereDialog({
)}
</p>
)}
<p className="text-[11px] text-fg-muted">{t("cloneJobs.backgroundHint")}</p>
<div className="flex justify-end gap-2">
<button type="button" onClick={onClose} className="btn-ghost text-xs">
{busy ? t("clone.background") : t("fx.cancel")}
{t("fx.cancel")}
</button>
<button
type="submit"
@@ -279,7 +227,7 @@ export function CloneHereDialog({
className="btn-primary text-xs"
data-testid="fx-clone-submit"
>
{busy ? t("clone.cloning") : t("fx.cloneSubmit")}
{busy ? t("cloneJobs.starting") : t("fx.cloneSubmit")}
</button>
</div>
</form>
+20 -42
View File
@@ -2,59 +2,35 @@
* @file CloneProgress — the live line under a running clone: its place in the
* server's clone queue while it waits (clones run a few at a time machine-wide,
* `server/lib/clone-queue.js`), then git's own phase, percent, size and speed
* with a bar. It polls `GET /api/lanes/clone/progress/:token` every second for
* the token the clone's POST carried (`newCloneToken`); a 404 — the POST not
* yet arrived, or an older server — just shows nothing yet.
* with a bar. It renders the progress fields of a background clone job
* (`lib/cloneJobs.ts`, polled by the store) — it fetches nothing itself.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type CloneProgressView } from "../../lib/api";
import type { CloneJob } from "../../lib/api";
const POLL_MS = 1000;
export type CloneProgressFields = Pick<
CloneJob,
"state" | "position" | "phase" | "label" | "percent" | "detail"
> & { running?: number; waiting?: number; limit?: number };
/** A fresh, unguessable progress token (works on plain http, unlike randomUUID). */
export function newCloneToken(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}
export function CloneProgress({ token }: { token: string }) {
export function CloneProgress({ view }: { view: CloneProgressFields }) {
const { t } = useTranslation("lanes");
const [view, setView] = useState<CloneProgressView | null>(null);
useEffect(() => {
let alive = true;
setView(null);
// Never throws: progress is a courtesy, the clone's own answer decides.
const poll = () =>
Promise.resolve()
.then(() => api.lanes.cloneProgress(token))
.then((v) => {
if (alive) setView(v);
})
.catch(() => {});
void poll();
const timer = setInterval(() => void poll(), POLL_MS);
return () => {
alive = false;
clearInterval(timer);
};
}, [token]);
if (!view || view.state === "done" || view.state === "failed") return null;
if (view.state !== "queued" && view.state !== "running") return null;
if (view.state === "queued") {
return (
<p data-testid="clone-progress" className="text-[11px] text-status-warning">
{t("clone.queued", {
position: view.position ?? 1,
waiting: view.waiting,
running: view.running,
limit: view.limit,
})}
{view.waiting !== undefined && view.running !== undefined && view.limit !== undefined
? t("clone.queued", {
position: view.position ?? 1,
waiting: view.waiting,
running: view.running,
limit: view.limit,
})
: t("cloneJobs.queued", { position: view.position ?? 1 })}
</p>
);
}
@@ -73,7 +49,9 @@ export function CloneProgress({ token }: { token: string }) {
{view.phase ? ` ${percent}%` : ""}
</span>
{view.detail && (
<span className="shrink-0 font-mono text-[10px] text-fg-muted">{view.detail}</span>
<span className="shrink-0 truncate font-mono text-[10px] text-fg-muted">
{view.detail}
</span>
)}
</p>
{view.phase && (
@@ -10,16 +10,19 @@
* sent to Settings → Git first — and an HTTPS host with no saved login offers
* the login row up front (optional: a public repo needs none). The identity is
* read again when the window regains focus, so a key made in another tab
* counts at once. The running clone shows its queue place, then git's
* progress (`CloneProgress`); **Partial clone** sends `partial: true`.
* counts at once. Each clone is a background job (`lib/cloneJobs.ts`): once
* the server accepted it the app-wide dock shows its queue place, git's
* progress and outcome (it survives leaving the page, F5 and a server
* restart), and the folder list is re-read when it ends; **Partial clone**
* sends `partial: true`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, Loader2, X } from "lucide-react";
import { api, type GitIdentity, type LaneSourcesView } from "../../lib/api";
import { CloneProgress, newCloneToken } from "./CloneProgress";
import { api, type CloneJob, type GitIdentity, type LaneSourcesView } from "../../lib/api";
import { CLONE_JOB_EVENT, startClone } from "../../lib/cloneJobs";
type Row = {
url: string;
@@ -27,8 +30,6 @@ type Row = {
note?: string;
/** The note points at Settings → Git (no key, or a key the host refused). */
ssh?: boolean;
/** While busy: the progress token CloneProgress polls. */
token?: string;
};
type Login = { host: string; username: string; token: string; remember: boolean };
@@ -88,6 +89,23 @@ export function CloneSourcesForm({
.catch(() => {}),
[]
);
// A background clone into THIS workspace ended: its folder is now in the
// list (the server added it), so read the list again.
const onAddedRef = useRef(onAdded);
onAddedRef.current = onAdded;
useEffect(() => {
const onJob = (e: Event) => {
const job = (e as CustomEvent<CloneJob>).detail;
if (job?.state !== "done" || job.kind !== "source" || job.laneId !== laneId) return;
Promise.resolve()
.then(() => api.lanes.sources(laneId))
.then((view) => onAddedRef.current(view))
.catch(() => undefined);
};
window.addEventListener(CLONE_JOB_EVENT, onJob);
return () => window.removeEventListener(CLONE_JOB_EVENT, onJob);
}, [laneId]);
useEffect(() => {
void loadIdentity();
const onFocus = () => void loadIdentity();
@@ -121,25 +139,28 @@ export function CloneSourcesForm({
for (const row of next) {
if (row.state === "failed") continue;
row.state = "busy";
row.token = newCloneToken();
show();
try {
const view = await api.lanes.cloneFolder(laneId, {
url: row.url,
progressToken: row.token,
...(partial ? { partial: true } : {}),
...(login?.username.trim() && login.token
? {
credentials: {
username: login.username.trim(),
password: login.token,
remember: login.remember,
},
}
: {}),
await startClone({
kind: "source",
laneId,
body: {
url: row.url,
...(partial ? { partial: true } : {}),
...(login?.username.trim() && login.token
? {
credentials: {
username: login.username.trim(),
password: login.token,
remember: login.remember,
},
}
: {}),
},
});
// Accepted: the dock shows its progress; the folder list is re-read
// when it ends (the CLONE_JOB_EVENT listener above).
row.state = "done";
onAdded(view);
} catch (err) {
const e = err as { code?: string; auth?: { host?: string }; message?: string };
const host = e.auth?.host || row.url;
@@ -159,7 +180,6 @@ export function CloneSourcesForm({
row.note = e.message || String(err);
}
}
row.token = undefined;
show();
}
if (asked) setLogin(asked);
@@ -301,10 +321,8 @@ export function CloneSourcesForm({
{r.url}
</span>
</span>
{r.state === "busy" && r.token && (
<div className="ml-[18px] mt-0.5">
<CloneProgress token={r.token} />
</div>
{r.state === "done" && (
<span className="ml-[18px] block text-fg-muted">{t("cloneJobs.started")}</span>
)}
{r.note && (
<span className="ml-[18px] block text-status-danger">
+134 -56
View File
@@ -15,9 +15,13 @@
* workspace's Files tab. Behaviour: delete → the account's Trash (no confirm:
* restorable 7 days); Copy / Cut then Paste in the same or ANOTHER workspace
* (the clipboard outlives the dialog); upload files or whole folders (picker
* or dropped from the desktop), streamed with progress, ≤ 500 MB a batch;
* or dropped from the desktop), streamed with progress, ≤ 500 MB a batch —
* the progress lives in the app-wide dock (`lib/uploadJobs.ts`), so an
* upload keeps going when the user opens Trash or another page, and can be
* stopped there; New → Clone repository runs as a background clone job;
* drag items onto a folder to move them (Ctrl: copy); download a file as
* itself, anything else as one zip; a taken name asks Replace / Keep both /
* itself, anything else as one zip — except an HTML file, which opens as the
* page (`HtmlViewer`, Download on its header and right-click menu); a taken name asks Replace / Keep both /
* Skip. Deleting forever asks first when it empties the Trash or touches
* another user's. An admin's Trash opens on one folder per user (with its
* item count); each folder is THAT user's Trash, to restore from or purge
@@ -90,12 +94,17 @@ import {
} from "../../lib/api";
import type { Lane } from "../../lib/types";
import { useDialog } from "../../hooks/useDialog";
import { fetchVia } from "../HtmlFrame";
import { HtmlViewerModal } from "../HtmlViewerModal";
import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
import { formatBytes } from "../../lib/format";
import { copyText } from "../../lib/clipboard";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { CloneHereDialog } from "./CloneHereDialog";
import { CLONE_JOB_EVENT } from "../../lib/cloneJobs";
import { beginUpload } from "../../lib/uploadJobs";
import type { CloneJob } from "../../lib/api";
import { isSubmitEnter } from "../../lib/keys";
import { useAuth } from "../AuthGate";
@@ -166,6 +175,7 @@ const join = (dir: string, name: string) => (dir ? `${dir}/${name}` : name);
const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
const errCode = (e: unknown) => (e as { code?: string })?.code;
const isFolder = (e: FsEntry) => e.type === "dir" || e.type === "linkdir";
const isHtml = (e: FsEntry) => !isFolder(e) && /\.html?$/i.test(e.name);
const ext = (name: string) => name.slice(name.lastIndexOf(".") + 1).toLowerCase();
const IMAGE = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico"]);
const CODE = new Set([
@@ -225,6 +235,8 @@ async function walkEntry(
out.push({ file, rel: `${prefix}${entry.name}` });
return;
}
// The server takes nothing into .git: never read one off the disk at all.
if (entry.name === ".git") return;
const reader = (entry as FileSystemDirectoryEntry).createReader();
// readEntries answers in pages until it answers [].
for (;;) {
@@ -321,13 +333,6 @@ export function FileBrowser({
const [cloning, setCloning] = useState(false);
const [ask, setAsk] = useState<Ask | null>(null);
const [askAll, setAskAll] = useState(false);
const [progress, setProgress] = useState<{
name: string;
done: number;
total: number;
sent: number;
bytes: number;
} | null>(null);
const [lanes, setLanes] = useState<Lane[]>([]);
const { state: auth } = useAuth();
const myId = auth.mode === "server" ? (auth.user?.id ?? null) : null;
@@ -363,16 +368,21 @@ export function FileBrowser({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [laneId, initialHome, session, source, initialPath]);
useEffect(() => {
// Re-read after every change here too: deleting a workspace's folder takes
// it out of the places (a workspace whose folder is gone has nothing to show).
const loadLanes = useCallback(() => {
api.lanes
.list()
.then((r) => setLanes(r.lanes))
.catch(() => setLanes([]));
}, []);
useEffect(() => {
loadLanes();
api.files
.homes()
.then((r) => setHomes(r.homes))
.catch(() => setHomes([]));
}, []);
}, [loadLanes]);
// Another user's home (an admin's view) is read-only.
const ro = isHomePlace(target) && !!homes.find((h) => h.id === target.home)?.readOnly;
// Clone into the open folder: the absolute path of a workspace's own folder
@@ -433,6 +443,8 @@ export function FileBrowser({
} else if (view === "trash") setTrash((await api.files.trash(target)).items);
else setListing(await api.files.list(target, path));
} catch (e) {
// Never leave the last folder's entries under another folder's error.
if (view === "files") setListing(null);
// A dashboard that does not know /fs yet (not restarted) answers 404.
setError(
(e as { status?: number }).status === 404 && !errCode(e) ? t("fx.unavailable") : errText(e)
@@ -449,8 +461,20 @@ export function FileBrowser({
const changed = () => {
void load();
loadLanes();
onChanged?.();
};
// A background clone finished (the dock announced it): its folder and
// workspace are new here, wherever it was started from.
const changedRef = useRef(changed);
changedRef.current = changed;
useEffect(() => {
const onJob = (e: Event) => {
if ((e as CustomEvent<CloneJob>).detail?.state === "done") changedRef.current();
};
window.addEventListener(CLONE_JOB_EVENT, onJob);
return () => window.removeEventListener(CLONE_JOB_EVENT, onJob);
}, []);
const entries = useMemo(() => {
const all = listing?.entries ?? [];
@@ -461,13 +485,28 @@ export function FileBrowser({
}, [listing, showHidden, query]);
const selectedEntries = entries.filter((e) => selected.has(e.path));
// An upload outlives this view (its progress is in the dock): once the view
// is gone nobody can answer a name clash, so it keeps both copies.
const mountedRef = useRef(true);
const pendingAsk = useRef<Ask["resolve"] | null>(null);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
pendingAsk.current?.({ choice: "keep", all: true });
pendingAsk.current = null;
};
}, []);
/** A choice for taken names: Replace / Keep both / Skip (null = cancel). */
const askConflict = (names: string[], many: boolean) =>
new Promise<{ choice: FsConflict | null; all: boolean }>((resolve) => {
if (!mountedRef.current) return resolve({ choice: "keep", all: true });
pendingAsk.current = resolve;
setAskAll(false);
setAsk({ names, many, resolve });
});
const answer = (choice: FsConflict | null) => {
pendingAsk.current = null;
ask?.resolve({ choice, all: askAll });
setAsk(null);
};
@@ -498,8 +537,11 @@ export function FileBrowser({
};
// ── Actions ─────────────────────────────────────────────────────────────
// An HTML page opens as itself (`HtmlViewer`); any other file downloads.
const [viewing, setViewing] = useState<FsEntry | null>(null);
const openEntry = (e: FsEntry) => {
if (isFolder(e)) go(e.path);
else if (isHtml(e)) setViewing(e);
else download([e]);
};
const goUp = () => {
@@ -607,8 +649,14 @@ export function FileBrowser({
};
/** Upload `files` (each with its path inside the pick/drop) under `dir`. */
const uploadAll = async (files: { file: File; rel: string }[], dir = path) => {
const uploadAll = async (picked: { file: File; rel: string }[], dir = path) => {
setMenu(null);
// A dropped/picked repo carries its .git, which the server refuses file by
// file — skip it here with ONE notice instead of a toast per object.
const files = picked.filter((f) => !f.rel.split("/").includes(".git"));
if (files.length < picked.length) {
toast.info(t("fx.gitSkipped", { count: picked.length - files.length }));
}
if (!files.length) return;
const bytes = files.reduce((n, f) => n + f.file.size, 0);
if (bytes > FX_BATCH_MAX_BYTES) {
@@ -618,19 +666,29 @@ export function FileBrowser({
return;
}
setBusy(true);
// The place is fixed now: the user may open another folder (or leave the
// page) while this keeps sending; its progress lives in the dock.
const place = target;
const job = beginUpload({
folder: `${placeName(place)}${dir ? `/${dir}` : ""}`,
total: files.length,
bytes,
});
const { signal } = job;
let sent = 0;
let done = 0;
let saved = 0;
let forAll: FsConflict | null = null;
// false once the server says a batch cannot be written here (an old helper).
let batchable = true;
const show = (name: string, extra = 0) =>
setProgress({ name, done, total: files.length, sent: sent + extra, bytes });
const show = (name: string, extra = 0) => job.update({ name, done, sent: sent + extra });
/** One file; false = the user cancelled. */
const one = async ({ file, rel }: { file: File; rel: string }) => {
if (signal.aborted) return false;
const dest = join(dir, rel);
show(rel);
const send = (c: FsConflict) => api.files.upload(target, dest, file, c, (n) => show(rel, n));
const send = (c: FsConflict) =>
api.files.upload(place, dest, file, c, (n) => show(rel, n), signal);
try {
const r = await send(forAll ?? "fail").catch(async (e) => {
if (errCode(e) !== "EEXISTS") throw e;
@@ -641,7 +699,7 @@ export function FileBrowser({
});
if (!r.skipped) saved++;
} catch (e) {
if (errCode(e) === "ECANCEL") return false;
if (errCode(e) === "ECANCEL" || errCode(e) === "EABORT") return false;
toast.error(t("fx.uploadFailed", { name: rel, message: errText(e) }));
}
sent += file.size;
@@ -661,9 +719,10 @@ export function FileBrowser({
if (a.all && a.choice) forAll = a.choice;
return a.choice;
};
if (signal.aborted) return "cancel";
try {
const c = await api.files.uploadCheck(
target,
place,
dir,
b.map((f) => f.rel)
);
@@ -674,7 +733,7 @@ export function FileBrowser({
let policy = await choose(c.taken);
if (!policy) return "cancel";
const send = (p: FsConflict) =>
api.files.uploadBatch(target, dir, b, p, (n) => show(label, n));
api.files.uploadBatch(place, dir, b, p, (n) => show(label, n), signal);
const r = await send(policy).catch(async (e) => {
// Taken meanwhile: ask about those, then once more.
if (errCode(e) !== "EEXISTS") throw e;
@@ -685,7 +744,7 @@ export function FileBrowser({
saved += r.saved;
} catch (e) {
const code = errCode(e);
if (code === "ECANCEL") return "cancel";
if (code === "ECANCEL" || code === "EABORT") return "cancel";
// Refused before a byte was written (a bad name, an old helper…):
// each file then gets its own answer.
if (code === "EUPGRADE") batchable = false;
@@ -709,9 +768,10 @@ export function FileBrowser({
}
for (const f of singles) if (!go || !(await one(f))) break;
} finally {
setProgress(null);
job.end();
setBusy(false);
if (saved) toast.success(t("fx.uploaded", { count: saved }));
if (signal.aborted) toast.info(t("uploads.stopped", { count: saved, total: files.length }));
else if (saved) toast.success(t("fx.uploaded", { count: saved }));
changed();
}
};
@@ -917,11 +977,18 @@ export function FileBrowser({
// owner whose home this viewer may not open (an analyst's view of other
// users) still gets a row, one that only groups. A workspace with no owner
// stays in the flat Workspaces list. Which rows are open is per browser.
// A workspace whose folder was deleted has nothing to browse — it stays a
// place only while it is the one open (so its error has a row to sit on).
const placeLanes = useMemo(
() =>
lanes.filter((l) => !l.folder_missing || (!isHomePlace(target) && laneOf(target) === l.id)),
[lanes, target]
);
const { lanesOfHome, userRows } = useMemo(() => {
const ids = new Set(homes.map((h) => h.id));
const map = new Map<string, Lane[]>();
const extra = new Map<string, UserRow>();
for (const l of lanes) {
for (const l of placeLanes) {
const home =
myId != null && l.owner_id === myId && ids.has("me")
? "me"
@@ -944,9 +1011,9 @@ export function FileBrowser({
...[...extra.values()].sort((a, b) => a.label.localeCompare(b.label)),
];
return { lanesOfHome: map, userRows: rows };
}, [homes, lanes, myId]);
}, [homes, placeLanes, myId]);
const grouped = new Set([...lanesOfHome.values()].flat().map((l) => l.id));
const looseLanes = lanes.filter((l) => !grouped.has(l.id));
const looseLanes = placeLanes.filter((l) => !grouped.has(l.id));
// A home holding the open workspace shows it, whatever was stored.
const homeIsOpen = (id: string, own: Lane[]) =>
openHomes.has(id) ||
@@ -970,20 +1037,17 @@ export function FileBrowser({
onClick={() => setMenu(null)}
data-testid="file-explorer"
>
{cloneParent && (
<CloneHereDialog
open={cloning}
parent={cloneParent}
onClose={() => setCloning(false)}
onReopen={() => setCloning(true)}
onCloned={(lane, at) => {
setCloning(false);
toast.success(t("fx.cloned", { path: at, workspace: lane.title || lane.cwd }));
setLanes((ls) => (ls.some((l) => l.id === lane.id) ? ls : [...ls, lane]));
changed();
}}
{viewing && (
<HtmlViewer
entry={viewing}
place={target}
onDownload={() => download([viewing])}
onClose={() => setViewing(null)}
/>
)}
{cloneParent && (
<CloneHereDialog open={cloning} parent={cloneParent} onClose={() => setCloning(false)} />
)}
{/* ── Places ── */}
<aside className="hidden w-52 shrink-0 flex-col border-r border-border bg-surface-0 sm:flex">
<nav className="min-h-0 flex-1 overflow-y-auto px-2 pb-2" aria-label={t("fx.places")}>
@@ -1630,25 +1694,6 @@ export function FileBrowser({
</div>
)}
{progress && (
<div
data-testid="fx-progress"
className="absolute bottom-12 right-4 w-72 rounded-lg border border-border bg-surface-1 p-3 shadow-xl"
>
<p className="truncate text-xs font-medium">
{t("fx.uploading", { done: progress.done + 1, total: progress.total })}
</p>
<p className="truncate text-[11px] text-fg-muted">{progress.name}</p>
<div className="mt-2 h-1.5 overflow-hidden rounded bg-surface-3">
<div
className="h-full bg-accent-solid transition-[width]"
style={{
width: `${Math.min(100, (progress.sent / Math.max(1, progress.bytes)) * 100)}%`,
}}
/>
</div>
</div>
)}
</div>
{ctx.menu && (
@@ -1656,13 +1701,16 @@ export function FileBrowser({
{menuItem ? (
<>
<MenuItem onSelect={close(() => openEntry(menuItem))}>
{isFolder(menuItem) ? t("fx.open") : t("fx.download")}
{isFolder(menuItem) || isHtml(menuItem) ? t("fx.open") : t("fx.download")}
</MenuItem>
{(isFolder(menuItem) || menuTargets.length > 1) && (
<MenuItem onSelect={close(() => download(menuTargets))}>
{t("fx.downloadZip")}
</MenuItem>
)}
{isHtml(menuItem) && menuTargets.length === 1 && (
<MenuItem onSelect={close(() => download([menuItem]))}>{t("fx.download")}</MenuItem>
)}
<MenuSeparator />
{!ro && (
<MenuItem onSelect={close(() => putClip(menuTargets, true))}>
@@ -1916,6 +1964,36 @@ export function FileExplorer({
);
}
/** An HTML file as the page, in the shared modal viewer (`HtmlViewerModal`):
* its relative CSS, scripts and images read through the same download route. */
function HtmlViewer({
entry,
place,
onDownload,
onClose,
}: {
entry: FsEntry;
place: FsPlace;
onDownload: () => void;
onClose: () => void;
}) {
const placeKey = JSON.stringify(place);
const fetchFile = useMemo(
() => fetchVia((p) => api.files.downloadUrl(place, p)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[placeKey]
);
return (
<HtmlViewerModal
path={entry.path}
fetchFile={fetchFile}
onDownload={onDownload}
onClose={onClose}
testId="fx-html-viewer"
/>
);
}
function HeadButton({
label,
onClick,
+291 -269
View File
@@ -13,9 +13,14 @@
* side, each block with the diff's Undo / Keep while nothing
* is unsaved): Changes folds the unchanged lines away, Whole file
* shows them; any other file opens on its read-only diff; a
* whole markdown file can also be read rendered (Preview), and an
* whole markdown file can also be read rendered (Preview), an HTML
* file's Preview beside Whole file opens the page in a modal
* (`HtmlViewerModal`, its relative CSS, scripts and images read
* like downloads), and an
* image file is shown as the picture (`GET …/git/raw`)
* history — recent commits; opening one shows that commit's diff
* history — the commit graph (`git/HistoryView.tsx`: all branches, search,
* details, multi-select cherry-pick/revert, compare, browse at a
* revision); Show History on a file is `git/FileHistory.tsx`
* browse — quick-open by path, or the repo's tracked file tree (the
* mode switcher's Files button; at full width the sidebar);
* a row dragged onto a prompt box attaches it as `@path`
@@ -51,9 +56,16 @@
* .gitignore (`ignore`, untracked only). While a merge is open the box is
* hidden: a partial commit cannot finish one, Complete does.
*
* History rewrites through its commit menu: Revert (any commit), Undo commit
* (the top one; the server refuses a pushed one) and, while another branch's
* history is shown, Cherry-pick onto this branch. The commit row's Amend box
* **Shelve (n)** beside Discard sets the ticked files aside (`shelve`, with
* an optional one-line note), and the Shelves list (`LaneShelves`) above the
* file list restores or deletes what was shelved — a git stash under the
* hood, its recovery always a button here.
*
* History rewrites through HistoryView's commit menu and selection: Revert and
* Cherry-pick (one or several commits, previewed in `CherryPickDialog`, a
* paused one finished from `PickBanner`), Undo commit (the top one; the
* server refuses a pushed one), Reset branch to here… and Edit commits after
* this one… (`GitTools`). The commit row's Amend box
* folds the ticked files into the last commit (none ticked: rewords it). All
* four ask first (`confirmDialog`).
*
@@ -77,9 +89,19 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import {
Suspense,
lazy,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import {
Archive,
ChevronDown,
ChevronLeft,
ChevronRight,
@@ -127,10 +149,17 @@ import { LaneConflictView } from "./LaneConflictView";
import { FileExplorer } from "./FileExplorer";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
import { fetchVia } from "../HtmlFrame";
import { HtmlViewerModal } from "../HtmlViewerModal";
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
import { LaneShelves, withAgentConfirm } from "./LaneShelves";
import { useGitChanged } from "../../lib/gitChanged";
import { GitTools, GitToolsButton, RebaseSkipButton, type GitTool } from "./git/GitTools";
import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree";
import { dragProjectPath } from "../../lib/promptAttach";
import { commitGraph, type GraphRow } from "../../lib/commitGraph";
import { HistoryView } from "./git/HistoryView";
import { FileHistory } from "./git/FileHistory";
import { PickBanner } from "./git/PickBanner";
import type {
LaneChangeFile,
LaneChangeRange,
@@ -150,6 +179,10 @@ const LAST_CONTEXT = CONTEXT_STEPS[CONTEXT_STEPS.length - 1]!;
type Mode = "overview" | "review" | "file" | "history" | "browse";
/** A cherry-pick or revert: its banner is PickBanner, not the merge one (a
* merge and a rebase keep theirs). */
const isPick = (op: string | null | undefined) => op === "cherry-pick" || op === "revert";
/** Colour per porcelain status letter, matching LaneGitHeader's file panel. */
/** JetBrains' VCS colours: unversioned orange, added green, deleted grey,
* modified (and renamed) blue. */
@@ -185,6 +218,9 @@ function Delta({
);
}
/** An HTML page, which Preview opens as the page (`HtmlViewerModal`). */
const isHtml = (p: string | null | undefined) => !!p && /\.html?$/i.test(p);
/** What the viewer shows as a picture (`readImageAt`'s list on the server). */
const IMAGE_FILE = /\.(png|jpe?g|gif|webp|avif|bmp|ico|svg)$/i;
// CodeMirror is loaded only once a file is edited.
@@ -754,54 +790,6 @@ function ChangeTreeNodes({
);
}
/** History's page size, and the most the server reads (`listCommits`). */
const HISTORY_PAGE = 50;
const HISTORY_MAX = 200;
// The history's commit graph: one fixed-height row per commit so its lanes
// join up row to row. Mid-tone hues that read on dark and light themes alike.
const GRAPH_ROW = 34;
const GRAPH_COL = 12;
const GRAPH_COLORS = [
"#3b82f6",
"#22c55e",
"#f59e0b",
"#a855f7",
"#ef4444",
"#14b8a6",
"#ec4899",
"#84cc16",
];
function GraphCell({ row, width }: { row: GraphRow; width: number }) {
const x = (col: number) => col * GRAPH_COL + GRAPH_COL / 2;
const y = (f: number) => f * GRAPH_ROW;
const hue = (i: number) => GRAPH_COLORS[i % GRAPH_COLORS.length];
return (
<svg
aria-hidden
width={width * GRAPH_COL}
height={GRAPH_ROW}
className="shrink-0"
data-testid="lane-changes-graph"
>
{row.segs.map((g, i) => {
const ym = (y(g.y1) + y(g.y2)) / 2;
return (
<path
key={i}
d={`M${x(g.x1)} ${y(g.y1)} C${x(g.x1)} ${ym} ${x(g.x2)} ${ym} ${x(g.x2)} ${y(g.y2)}`}
fill="none"
stroke={hue(g.color)}
strokeWidth={1.5}
/>
);
})}
<circle cx={x(row.col)} cy={y(0.5)} r={3.5} fill={hue(row.color)} />
</svg>
);
}
export function LaneChangesPanel({
laneId,
session,
@@ -844,6 +832,8 @@ export function LaneChangesPanel({
const opOpen = !!(git?.available && git.merging);
// Every git read and write below goes to the same tree as those facts.
const target = gitTarget(laneId, session, source);
// The open history-rewrite / remote / merge-editor tool (./git/GitTools).
const [gitTool, setGitTool] = useState<GitTool | null>(null);
// A diff line's comment goes into this workspace's pane, as a paste.
const comment = useCallback(
(text: string) => {
@@ -859,6 +849,11 @@ export function LaneChangesPanel({
// Bumped by Refresh and by a git bar action: a pull or a switch moves
// history and the tree too, not just the diff `load` re-reads.
const [reread, setReread] = useState(0);
// Any git write on this workspace — from this panel's bar, another repo's
// panel or the session view — re-reads everything here (`rereadAll`, set
// once it is defined below).
const rereadRef = useRef<(() => void) | null>(null);
useGitChanged(laneId, () => rereadRef.current?.());
const [range, setRange] = useState<LaneChangeRange>("work");
// Local mode: the files are already on this machine — no upload, download
// or File Explorer (the desktop's file manager does all three).
@@ -874,10 +869,13 @@ export function LaneChangesPanel({
const [layout, setLayout] = useState<DiffLayout>(wide ? "split" : "unified");
useEffect(() => setLayout(wide ? "split" : "unified"), [wide]);
const [whole, setWhole] = useState(false);
// A whole markdown file shown rendered instead of as source; per file, each
// one opening as Settings → Appearance says (`lib/markdownView.ts`).
// A whole markdown file shown rendered instead of as source; per file,
// opening as Settings → Appearance says (`lib/markdownView.ts`).
const [mdPreview, setMdPreview] = useState(readMarkdownPreview);
useEffect(() => setMdPreview(readMarkdownPreview()), [openFile]);
// An HTML file's page, open in the modal viewer (Preview beside Whole file).
const [htmlOpen, setHtmlOpen] = useState(false);
useEffect(() => setHtmlOpen(false), [openFile, mode]);
const [data, setData] = useState<LaneChanges | null>(null);
// A folder that is not a repo (an empty new workspace, a parent of clones)
// has no changes, review or history — only its files, which Files reads
@@ -889,7 +887,6 @@ export function LaneChangesPanel({
if (notRepo && (mode === "overview" || mode === "review" || mode === "history"))
setMode(full ? "overview" : "browse");
}, [notRepo, mode, full]);
const [commits, setCommits] = useState<LaneCommit[] | null>(null);
// A folder's children, keyed by its path ("" is the root) — fetched once,
// on first expand, and kept so re-collapsing doesn't re-fetch.
const [treeChildren, setTreeChildren] = useState<Record<string, LaneTreeEntry[]>>({});
@@ -959,6 +956,9 @@ export function LaneChangesPanel({
const returnTo = useRef<{ mode: Mode; scroll: number } | null>(null);
const pendingScroll = useRef<number | null>(null);
const [message, setMessage] = useState("");
// Shelve (n) opened its note row; the note typed there.
const [shelving, setShelving] = useState(false);
const [shelfNote, setShelfNote] = useState("");
// The commit row amends the last commit instead of making a new one.
const [amend, setAmend] = useState(false);
// The last commit is already on its upstream: the server refuses to amend
@@ -1056,30 +1056,8 @@ export function LaneChangesPanel({
}, [load, mode]);
// History and the file tree are their own reads, each fetched when its mode
// is entered rather than alongside the diff nobody is looking at.
// History reads HISTORY_PAGE commits, and Load more asks for the next page
// (up to the server's 200).
const [historyLimit, setHistoryLimit] = useState(HISTORY_PAGE);
const [historyLoading, setHistoryLoading] = useState(false);
useEffect(() => {
if (mode !== "history") return;
// A reply for the previous ref/file/page lands after the next request
// started: drop it, or it overwrites the list the user is looking at.
let live = true;
setHistoryLoading(true);
void api.lanes
.gitLog(target, {
limit: historyLimit,
...(historyRef ? { ref: historyRef } : {}),
...(historyFile ? { file: historyFile } : {}),
})
.then(forLane(laneId, (r) => live && setCommits(r.commits)))
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)))
.finally(() => live && laneNow.current === laneId && setHistoryLoading(false));
return () => {
live = false;
};
}, [mode, laneId, reread, historyLimit, historyRef, historyFile]);
// is entered rather than alongside the diff nobody is looking at. The commit
// graph and a file's history read themselves (git/HistoryView, FileHistory).
useEffect(() => {
if (mode !== "history") return;
let live = true;
@@ -1246,7 +1224,6 @@ export function LaneChangesPanel({
setTreeBusy(false);
setBusy(false);
setError(null);
setCommits(null);
setData(null);
setDraft(null);
setCollapsed(new Set());
@@ -1327,7 +1304,6 @@ export function LaneChangesPanel({
if (!ok) return;
setHistoryFile(path);
setHistoryRef(null);
setCommits(null);
setMode("history");
});
};
@@ -1450,25 +1426,78 @@ export function LaneChangesPanel({
}
};
// Discard all: every non-conflicted file in one `discard`, asked once in a
// dialog that names how many untracked files it will delete.
const discardAll = async () => {
const rows = files.filter((f) => !f.conflicted);
if (!rows.length) return;
// Discard selected: exactly what Commit would take — the ticked files, and
// only the ticked blocks of a partly ticked one (#87: it used to discard
// every file, ticked or not) — in one `discard`, asked once in a dialog
// that names how many untracked files it will delete.
const discardTicked = async () => {
if (!tickedPaths.length) return;
// Tracked and untracked counted apart: an untracked file is deleted, not
// reverted, and counting it in both told "10 changed + 10 new" for 10.
const untracked = files.filter((f) => f.untracked && tickedPaths.includes(f.path)).length;
const tracked = tickedPaths.length - untracked;
const ok = await confirmDialog({
title: t("changes.discardAllTitle"),
message: t("changes.discardAllConfirm", {
count: rows.length,
untracked: rows.filter((f) => f.untracked).length,
}),
confirmLabel: t("changes.discardAll"),
title: t("changes.discardTickedTitle"),
message: t(
untracked === 0
? "changes.discardTickedConfirmTracked"
: tracked === 0
? "changes.discardTickedConfirmNew"
: "changes.discardTickedConfirm",
{ count: tracked, untracked }
),
confirmLabel: t("changes.discardTicked", { count: tickedPaths.length }),
});
if (ok)
await act(
"discard",
rows.map((f) => f.path),
true
if (!ok) return;
const lane = laneId;
setBusy(true);
setError(null);
try {
const { paths, hunks } = await selection();
await api.lanes.gitAction(target, "discard", {
paths,
...(hunks ? { hunks, context: CONTEXT_STEPS[0] } : {}),
confirm: true,
});
if (laneNow.current !== lane) return;
setConfirmDiscard(null);
refreshGit();
await load();
} catch (err) {
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
} finally {
if (laneNow.current === lane) setBusy(false);
}
};
// Shelve (n): the ticked files (whole files — a stash has no hunks) set
// aside with the note; all of them when every file is ticked.
const shelveTicked = async () => {
if (!tickedPaths.length) return;
const lane = laneId;
setBusy(true);
setError(null);
try {
const all = tickedPaths.length >= files.length;
const res = await withAgentConfirm((confirm) =>
api.lanes.gitAction(target, "shelve", {
...(all ? {} : { paths: tickedPaths }),
...(shelfNote.trim() ? { note: shelfNote.trim() } : {}),
...(confirm ? { confirm } : {}),
})
);
if (laneNow.current !== lane || !res) return;
toast.success(t("shelves.shelved", { count: res.shelf?.files.length ?? tickedPaths.length }));
setShelving(false);
setShelfNote("");
refreshGit();
setReread((n) => n + 1);
await load();
} catch (err) {
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
} finally {
if (laneNow.current === lane) setBusy(false);
}
};
// resolve / merge-commit / merge-abort: same shape as `act`, other body.
@@ -1558,7 +1587,6 @@ export function LaneChangesPanel({
const fileCtx = useContextMenu<LaneChangeFile>();
const treeCtx = useContextMenu<LaneTreeEntry>();
const commitCtx = useContextMenu<LaneCommit>();
const viewCommit = (sha: string) => {
setSha(sha);
setRange("commit");
@@ -1734,68 +1762,15 @@ export function LaneChangesPanel({
</MenuItem>
</ContextMenu>
)}
{commitCtx.menu && (
<ContextMenu
x={commitCtx.menu.x}
y={commitCtx.menu.y}
onClose={commitCtx.close}
testId="lane-changes-commit-menu"
>
<MenuItem onSelect={pick(commitCtx.close, () => viewCommit(commitCtx.menu!.item.sha))}>
{t("changes.viewCommit")}
</MenuItem>
<MenuItem onSelect={pick(commitCtx.close, () => copy(commitCtx.menu!.item.sha))}>
{t("changes.copySha")}
</MenuItem>
<MenuItem onSelect={pick(commitCtx.close, () => copy(commitCtx.menu!.item.subject))}>
{t("changes.copySubject")}
</MenuItem>
<MenuSeparator />
{historyRef ? (
<MenuItem
disabled={busy || opOpen || commitCtx.menu.item.parents.length !== 1}
testId="lane-changes-menu-cherry-pick"
onSelect={pick(
commitCtx.close,
() => void historyAct("cherry-pick", commitCtx.menu!.item)
)}
>
{t("changes.cherryPick", { branch: git?.available ? git.branch : "HEAD" })}
</MenuItem>
) : (
<>
<MenuItem
disabled={busy || opOpen || commitCtx.menu.item.parents.length !== 1}
testId="lane-changes-menu-revert"
onSelect={pick(
commitCtx.close,
() => void historyAct("revert", commitCtx.menu!.item)
)}
>
{t("changes.revertCommit")}
</MenuItem>
{commits?.[0]?.sha === commitCtx.menu.item.sha && (
<MenuItem
danger
// Pushed: the server refuses it (EPUSHED), so say why here
// instead of failing after the confirm.
disabled={
busy || opOpen || headPushed || commitCtx.menu.item.parents.length !== 1
}
title={headPushed ? t("changes.undoCommitPushed") : undefined}
testId="lane-changes-menu-undo-commit"
onSelect={pick(
commitCtx.close,
() => void historyAct("undo-commit", commitCtx.menu!.item)
)}
>
{t("changes.undoCommit")}
</MenuItem>
)}
</>
)}
</ContextMenu>
)}
<GitTools
tool={gitTool}
target={target}
branch={git?.available ? git.branch : null}
onClose={() => setGitTool(null)}
onOpen={setGitTool}
onChanged={() => void rereadAll()}
onViewCommit={viewCommit}
/>
</>
);
@@ -1977,6 +1952,7 @@ export function LaneChangesPanel({
setReread((n) => n + 1);
return load();
};
rereadRef.current = () => void rereadAll();
const [rereading, runReread] = useSpin(rereadAll);
// Files tab transfers (issue #78): a download is a plain attachment link; an
@@ -2044,6 +2020,13 @@ export function LaneChangesPanel({
const dirty = draft !== null && draft !== text?.lines.join("\n");
// Rendered only where the toggle is offered: a whole markdown file.
const mdShown = mdPreview && whole && isMarkdown(openFile);
// An HTML page's relative CSS/scripts/images, read like a download — only
// from the working tree: a commit's page would get today's files.
const htmlFetch = useMemo(
() => (range === "commit" ? null : fetchVia((p) => api.lanes.gitDownloadUrl(target, p))),
// eslint-disable-next-line react-hooks/exhaustive-deps
[range, JSON.stringify(target)]
);
dirtyRef.current = dirty;
useUnsavedGuard(dirty);
const dirtyChangeRef = useRef(onDirtyChange);
@@ -2369,10 +2352,7 @@ export function LaneChangesPanel({
aria-pressed={mode === m}
onClick={() => {
// The History button is the branch's; Show History, a file's.
if (m === "history" && historyFile) {
setHistoryFile(null);
setCommits(null);
}
if (m === "history" && historyFile) setHistoryFile(null);
setMode(m);
}}
className={`rounded px-2 py-0.5 text-[11px] font-medium transition-colors ${
@@ -2387,6 +2367,12 @@ export function LaneChangesPanel({
</div>
)}
{!notRepo && (
<GitToolsButton
onPick={setGitTool}
hasRemote={git?.available ? git.hasRemote : undefined}
/>
)}
<button
type="button"
onClick={runReread}
@@ -2560,6 +2546,31 @@ export function LaneChangesPanel({
))}
</div>
)}
{/* An HTML file opens as the page in a modal over the screen;
a commit's page once its text is read (Whole file). */}
{mode === "file" && isHtml(openFile) && (
<button
type="button"
data-testid="lane-file-preview"
disabled={dirty || (range === "commit" && !text)}
onClick={() => setHtmlOpen(true)}
className="flex items-center gap-1 rounded border border-border bg-surface-2 px-2 py-1 text-[11px] text-fg-secondary hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
>
<Eye className="h-3 w-3" />
{t("changes.preview")}
</button>
)}
{htmlOpen && openFile && (
<HtmlViewerModal
path={openFile}
// A commit's page is its text at that commit (no sibling
// files); a working one is read whole from the disk.
html={range === "commit" ? text?.lines.join("\n") : undefined}
fetchFile={htmlFetch}
onDownload={!local && range !== "commit" ? () => download(openFile) : undefined}
onClose={() => setHtmlOpen(false)}
/>
)}
</div>
)}
</div>
@@ -2570,7 +2581,17 @@ export function LaneChangesPanel({
</p>
)}
{git?.available && git.merging && (
{git?.available && git.merging && isPick(git.operation) && (
<PickBanner
target={target}
conflicts={git.conflicts ?? 0}
reread={reread}
busy={busy}
onBusy={setBusy}
onDone={() => void rereadAll()}
/>
)}
{git?.available && git.merging && !isPick(git.operation) && (
<div
data-testid="lane-merge-banner"
className="mb-2 flex flex-wrap items-center gap-1.5 rounded border border-status-warning/40 bg-status-warning/10 px-2 py-1 text-[11px] text-status-warning"
@@ -2588,6 +2609,13 @@ export function LaneChangesPanel({
>
{t("changes.mergeCommit", { op: git.operation ?? "merge" })}
</button>
{git.operation === "rebase" && (
<RebaseSkipButton
target={target}
disabled={busy}
onChanged={() => void rereadAll()}
/>
)}
<button
type="button"
disabled={busy}
@@ -2604,6 +2632,12 @@ export function LaneChangesPanel({
</div>
)}
{/* Changes set aside (git stash entries AgentHub made): Restore and
Delete, never dropped on their own. Absent while there are none. */}
{git?.available && mode !== "file" && (
<LaneShelves target={target} reread={reread} onChanged={rereadAll} />
)}
{/* In single-file mode the DIFF owns the scrolling, so its horizontal
bar sits at the bottom of the visible box instead of at the bottom of
a 2000-line container nobody has scrolled to yet. Every other mode is
@@ -2616,103 +2650,40 @@ export function LaneChangesPanel({
}`}
>
{mode === "history" ? (
<ul data-testid="lane-changes-history">
{historyFile && (
<li
data-testid="lane-changes-history-file"
className="mb-1 flex items-center gap-1.5 px-1 text-[11px] text-fg-secondary"
>
<span className="min-w-0 flex-1 truncate">
{t("changes.historyOfFile", { path: historyFile })}
</span>
<button
type="button"
onClick={() => {
setHistoryFile(null);
setCommits(null);
}}
className="shrink-0 rounded px-1 text-fg-muted hover:bg-surface-2 hover:text-fg-primary"
>
{t("changes.historyWholeBranch")}
</button>
</li>
)}
{historyBranches.length > 1 && (
<li className="mb-1 flex items-center gap-1.5 px-1 text-[11px] text-fg-secondary">
<span className="shrink-0">{t("changes.historyOf")}</span>
<select
data-testid="lane-changes-history-ref"
value={historyRef ?? ""}
onChange={(e) => {
setHistoryRef(e.target.value || null);
setCommits(null);
}}
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-1 py-0.5 text-[11px] text-fg-primary"
>
<option value="">{git?.available ? git.branch : "HEAD"}</option>
{historyBranches
.filter((b) => !(git?.available && b === git.branch))
.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
</li>
)}
{(() => {
const list = commits ?? [];
const graph = commitGraph(list);
return list.map((c, i) => (
<li key={c.sha}>
<button
type="button"
onClick={() =>
historyFile ? openAtCommit(historyFile, c.sha) : viewCommit(c.sha)
}
onContextMenu={(e) => commitCtx.open(e, c)}
style={{ height: GRAPH_ROW }}
className="flex w-full items-center gap-1.5 rounded px-1 text-left hover:bg-surface-2"
>
<GraphCell row={graph.rows[i]!} width={graph.width} />
<span className="min-w-0 flex-1">
<span className="flex items-baseline gap-1.5">
<span className="shrink-0 font-mono text-[10px] text-fg-muted">
{c.short}
</span>
<span className="min-w-0 flex-1 truncate text-[11px] text-fg-primary">
{c.subject}
</span>
</span>
<span className="flex items-baseline gap-1.5 pl-[3.2rem] text-[10px] text-fg-muted">
<span className="truncate">{c.author}</span>
<span>{new Date(c.date).toLocaleString()}</span>
</span>
</span>
</button>
</li>
));
})()}
{commits === null && (
<li className="text-[11px] text-fg-muted">{t("changes.loading")}</li>
)}
{commits && commits.length === 0 && (
<li className="text-[11px] text-fg-muted">{t("changes.noCommits")}</li>
)}
{commits && commits.length >= historyLimit && historyLimit < HISTORY_MAX && (
<li>
<button
type="button"
data-testid="lane-changes-history-more"
disabled={historyLoading}
onClick={() => setHistoryLimit((n) => Math.min(n + HISTORY_PAGE, HISTORY_MAX))}
className="mt-1 w-full rounded px-1 py-1 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:opacity-40"
>
{historyLoading ? t("changes.loading") : t("changes.loadMore")}
</button>
</li>
)}
</ul>
historyFile ? (
<FileHistory
key={`${laneId}:${historyFile}`}
target={target}
path={historyFile}
reread={reread}
onOpenVersion={openAtCommit}
onViewCommit={viewCommit}
onWholeBranch={() => setHistoryFile(null)}
/>
) : (
<HistoryView
key={laneId}
target={target}
branch={git?.available ? git.branch : null}
branches={historyBranches}
historyRef={historyRef}
onHistoryRef={setHistoryRef}
reread={reread}
disabled={busy || opOpen}
headPushed={headPushed}
onViewCommit={viewCommit}
onOpenFileAtCommit={openAtCommit}
onUndoCommit={(c) => void historyAct("undo-commit", c)}
onResetHere={(c) => setGitTool({ kind: "reset", commit: c })}
onEditAfter={(sha) => setGitTool({ kind: "interactive", base: sha })}
onPaused={() => {
setRange("work");
setMode("overview");
void rereadAll();
}}
onChanged={() => void rereadAll()}
/>
)
) : mode === "browse" && !full ? (
browsePane
) : !data ? (
@@ -2810,15 +2781,62 @@ export function LaneChangesPanel({
<button
type="button"
data-testid="lane-changes-discard-all"
disabled={busy}
onClick={() => void discardAll()}
title={t("changes.discardAllTitle")}
disabled={busy || tickedPaths.length === 0}
onClick={() => void discardTicked()}
title={t("changes.discardTickedTitle")}
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-status-danger hover:bg-status-danger/10 disabled:cursor-not-allowed disabled:opacity-40"
>
<Trash2 className="h-3 w-3" aria-hidden />
{t("changes.discardAll")}
{t("changes.discardTicked", { count: tickedPaths.length })}
</button>
<button
type="button"
data-testid="lane-changes-shelve"
aria-expanded={shelving}
disabled={busy || tickedPaths.length === 0}
onClick={() => setShelving((v) => !v)}
title={t("shelves.shelveHint")}
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
>
<Archive className="h-3 w-3" aria-hidden />
{t("shelves.shelveTicked", { count: tickedPaths.length })}
</button>
</div>
{shelving && (
<div
data-testid="lane-changes-shelve-row"
className="flex w-full flex-wrap items-center gap-1.5"
>
<input
value={shelfNote}
autoFocus
maxLength={200}
onChange={(e) => setShelfNote(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e)) void shelveTicked();
if (e.key === "Escape") setShelving(false);
}}
placeholder={t("shelves.notePlaceholder")}
aria-label={t("shelves.notePlaceholder")}
className="min-w-[10rem] flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<button
type="button"
disabled={busy || tickedPaths.length === 0}
onClick={() => void shelveTicked()}
className="shrink-0 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
>
{t("shelves.shelveApply", { count: tickedPaths.length })}
</button>
<button
type="button"
onClick={() => setShelving(false)}
className="shrink-0 rounded px-1.5 py-0.5 text-[11px] text-fg-muted hover:bg-surface-2 hover:text-fg-primary"
>
{t("shelves.cancel")}
</button>
</div>
)}
</div>
)}
{/* The work range splits like JetBrains' Commit window: tracked
@@ -2939,6 +2957,9 @@ export function LaneChangesPanel({
editWanted.current = true;
setWhole(true);
}}
onMergeEditor={() =>
setGitTool({ kind: "merge", path: openFile, onResolved: goBack })
}
/>
) : mode === "file" ? (
current || (whole && text) || openImage ? (
@@ -2969,6 +2990,7 @@ export function LaneChangesPanel({
{t("changes.annotate")}
</button>
)}
{/* An HTML file's Preview sits beside Whole file instead. */}
{whole && text && !dirty && isMarkdown(openFile) && (
<button
type="button"
@@ -8,7 +8,8 @@
* (`gitSaveFile` with its `sha`, 409 ESTALE otherwise) and then runs
* `resolve done`, which re-checks that no marker is left. Take all mine /
* theirs is `resolve ours|theirs` for the whole file; Edit by hand hands the
* file to the panel's plain editor.
* file to the panel's plain editor; Merge editor opens the 3-way editor
* (`./git/MergeEditor`, base/current/incoming side by side).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -42,7 +43,10 @@ export function LaneConflictView({
path,
onResolved,
onEdit,
onMergeEditor,
}: {
/** Open the 3-way merge editor (./git/MergeEditor) on this file. */
onMergeEditor?: () => void;
laneId: number;
/** Work in that session's own worktree (see `gitTarget`). */
session?: string;
@@ -176,6 +180,17 @@ export function LaneConflictView({
<button type="button" className={BTN} disabled={busy} onClick={onEdit}>
{t("conflict.editByHand")}
</button>
{onMergeEditor && (
<button
type="button"
data-testid="conflict-open-merge-editor"
className={BTN}
disabled={busy}
onClick={onMergeEditor}
>
{t("gitTools:merge.open")}
</button>
)}
</div>
{error && (
@@ -179,6 +179,18 @@ export function langFor(filePath: string) {
}
/** Tokenized line text. `emph` marks the span that actually changed. */
/** The tail of a row the server cut down (`LaneDiffLine.elided`). */
function Elided({ n }: { n?: number }) {
const { t, i18n } = useTranslation("lanes");
if (!n) return null;
const size = new Intl.NumberFormat(i18n.language, { notation: "compact" }).format(n);
return (
<span className="ml-1 select-none rounded bg-surface-3 px-1 font-sans text-[10px] text-fg-muted">
{t("changes.lineElided", { size })}
</span>
);
}
function Code({ text, lang, emph }: { text: string; lang: string; emph?: [number, number] }) {
if (emph) {
const [from, to] = emph;
@@ -544,6 +556,7 @@ function UnifiedLine({
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
</span>
<Code text={line.text} lang={lang} emph={emph} />
<Elided n={line.elided} />
</span>
{bar && <HunkBar change={bar} />}
</Row>
@@ -621,6 +634,7 @@ function SplitLine({
/>
<span className="whitespace-pre px-1">
{line ? <Code text={line.text} lang={lang} emph={e} /> : null}
{line ? <Elided n={line.elided} /> : null}
</span>
{bar && <HunkBar change={bar} />}
</Row>
+177 -20
View File
@@ -1,7 +1,9 @@
/**
* @file The lane's git action row: ahead/behind against the upstream, and the
* writes `POST /api/lanes/:id/git/:action` offers that are not per-file —
* fetch, pull, push, switch branch, create/delete a branch, publish a repo
* fetch, pull, push, switch branch (a local one, or one only a remote has —
* listed under "On the remote" and checked out as a new tracking branch; the
* list is re-read after every fetch and pull), create/delete a branch, publish a repo
* that has no remote yet (a URL, then `origin` + `push -u`), and start a merge
* (the picked branch, or the upstream once diverged; resolving it is the
* Changes panel's job). Committing is
@@ -13,7 +15,16 @@
* icons on the same row as the branch line, rather than in the lane card's ⋯ menu: that menu holds the rare and dangerous (`reset
* worktree`), and these are the ordinary moves a lane needs several times an hour.
* Nothing here decides what an action may do; the server does (pull is always
* --ff-only, switch refuses a dirty tree), and this only reports what it said.
* --ff-only, switch refuses a dirty tree unless told what to do with the
* changes), and this only reports what it said. With uncommitted changes,
* Switch branch asks first — **Bring my changes** (`changes: "carry"`; a
* file git would overwrite comes back as ECARRYCONFLICT, listed, with Shelve
* and switch offered instead) or **Shelve and switch** (`changes: "shelve"`)
* — and the new-branch row offers **Create & switch** (`switch: true`, the
* changes come along; the default on Enter while the tree is dirty) beside
* plain Create. A switch onto a branch that has shelves asks whether to
* restore the newest (never automatically), and a write the server answers
* EAGENTRUNNING is confirmed and resent (`withAgentConfirm`'s dialog).
* Failures print git's own text, because "! [rejected] non-fast-forward" tells
* the user more than any sentence this file could write. The one exception
* is `EGITAUTH` (issue #45): the dashboard's git has no terminal to prompt
@@ -21,6 +32,7 @@
* resent with the typed credential — the token is never rendered back.
* Fetch, pull, push and publish say so in a toast when they succeed; a merge,
* a push, a publish and a branch delete ask through the themed confirm first; the rows wrap in a narrow panel.
* The tags icon opens the Branches panel (`git/BranchManager`: every branch and tag, rename, upstream, tags).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -43,6 +55,8 @@ import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys";
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
import { restoreShelf } from "./LaneShelves";
import { BranchesButton } from "./git/BranchesButton";
type Action =
"fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete" | "merge" | "publish";
@@ -114,6 +128,8 @@ export function LaneGitBar({
const [error, setError] = useState<string | null>(null);
const [switching, setSwitching] = useState(false);
const [branches, setBranches] = useState<string[] | null>(null);
// Fetched remote branches with no local one yet (`origin/feature`).
const [remoteBranches, setRemoteBranches] = useState<string[]>([]);
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
const [picked, setPicked] = useState("");
const [newBranch, setNewBranch] = useState("");
@@ -124,6 +140,9 @@ export function LaneGitBar({
const [remember, setRemember] = useState(true);
const [publishing, setPublishing] = useState(false);
const [url, setUrl] = useState("");
// A switch with uncommitted changes asks how to take them: set to the
// branch picked (and the files git refused to carry, after ECARRYCONFLICT).
const [choice, setChoice] = useState<{ branch: string; conflict?: string[] } | null>(null);
// The Changes panel is not keyed by lane, so this bar outlives a lane switch.
// All of the state above describes ONE lane — a branch picked for lane A,
// applied after switching, would move B's HEAD — so it is reset during
@@ -138,6 +157,7 @@ export function LaneGitBar({
setError(null);
setSwitching(false);
setBranches(null);
setRemoteBranches([]);
setWorktrees([]);
setPicked("");
setNewBranch("");
@@ -145,6 +165,7 @@ export function LaneGitBar({
setPassword("");
setPublishing(false);
setUrl("");
setChoice(null);
inFlight.current = false;
}
const laneNow = useRef(laneId);
@@ -152,6 +173,9 @@ export function LaneGitBar({
if (!git.available) return null;
const dirty = git.dirty + git.untracked;
// A remote branch: switching creates its local tracking branch; it cannot
// be deleted here or given a sibling worktree before that.
const pickedRemote = remoteBranches.includes(picked);
const merging = !!git.merging;
const run = async (action: Action, body?: RunBody) => {
@@ -160,6 +184,9 @@ export function LaneGitBar({
const lane = laneId;
setBusy(action);
setError(null);
// EAGENTRUNNING: asked AFTER the request settles, so the resend is not
// refused as a second action in flight.
let askAgent = false;
try {
const res = await api.lanes.gitAction(gitTarget(lane, session, source), action, body || {});
// Landed after a lane switch: every write below belongs to the lane
@@ -182,17 +209,50 @@ export function LaneGitBar({
if (action === "switch") setSwitching(false);
else setNewBranch("");
setPicked("");
setChoice(null);
setBranches(null);
await refreshBranches();
} else if ((action === "fetch" || action === "pull") && branches !== null) {
// A fetch may have brought branches the list does not show yet.
await refreshBranches();
}
if (res?.shelved) toast.success(t("gitBar.shelvedOn", { branch: res.shelved.branch }));
onChanged();
// The branch switched to has changes shelved on it: offer them back —
// asking, never restoring on its own.
const offer = action === "switch" ? res?.shelves?.[0] : undefined;
if (offer) {
const yes = await confirmDialog({
title: t("gitBar.restoreOfferTitle"),
message: t("gitBar.restoreOffer", {
branch: offer.branch,
count: res?.shelves?.length ?? 1,
files: offer.files.length,
}),
confirmLabel: t("shelves.restore"),
destructive: false,
});
if (yes && laneNow.current === lane) {
await restoreShelf(gitTarget(lane, session, source), offer);
if (laneNow.current === lane) onChanged();
}
}
} catch (err) {
// The server appends git's own stderr to the message, so this is the
// rejection text verbatim — hence the <pre> below rather than a line.
// ENOREMOTE is the one refusal with no git text to show: the server's
// sentence is English and says less than a translated one.
const auth = (err as { code?: string; auth?: GitAuthNeeded })?.auth;
if (laneNow.current === lane && (err as { code?: string })?.code === "EGITAUTH" && auth) {
const code = (err as { code?: string })?.code;
if (laneNow.current === lane && code === "EAGENTRUNNING" && body?.confirm !== true) {
askAgent = true;
} else if (laneNow.current === lane && code === "ECARRYCONFLICT" && body?.branch) {
// git would overwrite these files: show them and offer the shelf.
setChoice({
branch: body.branch,
conflict: (err as { files?: string[] }).files ?? [],
});
} else if (laneNow.current === lane && code === "EGITAUTH" && auth) {
setLogin({ action, auth });
setUsername((u) => u || auth.username || "");
setPassword("");
@@ -210,6 +270,15 @@ export function LaneGitBar({
setBusy(null);
}
}
if (askAgent) {
const ok = await confirmDialog({
title: t("shelves.agentTitle"),
message: t("shelves.agentMessage"),
confirmLabel: t("shelves.agentConfirm"),
destructive: false,
});
if (ok && laneNow.current === lane) void run(action, { ...body, confirm: true });
}
};
const refreshBranches = async () => {
@@ -217,6 +286,7 @@ export function LaneGitBar({
const r = await api.lanes.gitBranches(gitTarget(laneId, session, source));
if (laneNow.current !== laneId) return;
setBranches(r.branches);
setRemoteBranches(r.remotes || []);
setWorktrees(r.worktrees || []);
} catch {
if (laneNow.current === laneId) setBranches([]);
@@ -294,6 +364,20 @@ export function LaneGitBar({
if (ok) void run("merge", { branch });
};
// Switch branch: straight away on a clean tree; with changes, ask how.
const switchTo = (branch: string) => {
if (dirty > 0) {
setChoice({ branch });
setError(null);
} else void run("switch", { branch });
};
// Enter in the new-branch box: with changes, Create & switch (they come
// along); on a clean tree, plain Create as before.
const createBranch = (andSwitch: boolean) => {
const branch = newBranch.trim();
if (branch) void run("branch-create", andSwitch ? { branch, switch: true } : { branch });
};
const heldBy = new Map(
worktrees.filter((w) => w.branch).map((w) => [w.branch as string, w.path])
);
@@ -412,17 +496,30 @@ export function LaneGitBar({
label={t("gitBar.switch")}
icon={<GitBranch className="h-3.5 w-3.5" />}
disabled={!!busy || merging}
// A dirty tree is refused server-side; saying so before the click
// beats a red box after it.
// With changes, Switch branch asks whether to bring or shelve
// them; the hint says so before the click.
title={
merging
? t("gitBar.merging", { op: git.operation ?? "merge" })
: dirty > 0
? t("gitBar.switchDirty")
? t("gitBar.switchDirtyHint", { count: dirty })
: t("gitBar.switchHint")
}
onClick={openSwitch}
/>
<BranchesButton
laneId={laneId}
session={session}
source={source}
dirty={dirty}
merging={merging}
disabled={!!busy}
onChanged={() => {
// A rename, delete or new branch makes the switcher's list stale.
if (branches !== null) void refreshBranches();
onChanged();
}}
/>
</div>
)}
</div>
@@ -446,15 +543,26 @@ export function LaneGitBar({
</option>
);
})}
{remoteBranches.length > 0 && (
<optgroup label={t("gitBar.remoteBranches")}>
{remoteBranches.map((b) => (
<option key={`remote:${b}`} value={b}>
{b}
</option>
))}
</optgroup>
)}
</select>
<Btn
label={t("gitBar.switchApply")}
busy={busy === "switch"}
// A branch git already has checked out somewhere else cannot be
// switched to — git allows one worktree per branch.
disabled={!picked || picked === git.branch || !!heldBy.get(picked) || dirty > 0}
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")}
onClick={() => void run("switch", { branch: picked })}
disabled={!picked || picked === git.branch || !!heldBy.get(picked)}
title={
dirty > 0 ? t("gitBar.switchDirtyHint", { count: dirty }) : t("gitBar.switchHint")
}
onClick={() => switchTo(picked)}
/>
<Btn
label={t("gitBar.merge")}
@@ -476,7 +584,7 @@ export function LaneGitBar({
})
: t("gitBar.newLaneHint")
}
disabled={!picked || heldBy.has(picked) || picked === git.branch}
disabled={!picked || heldBy.has(picked) || picked === git.branch || pickedRemote}
// The sibling lane is created through the normal Add-lane flow,
// prefilled — one provisioning path, not a second one here.
onClick={() => onNewLane(picked)}
@@ -486,7 +594,7 @@ export function LaneGitBar({
label={t("gitBar.branchDelete")}
icon={<Trash2 className="h-3 w-3" />}
busy={busy === "branch-delete"}
disabled={!picked || picked === git.branch || !!heldBy.get(picked)}
disabled={!picked || picked === git.branch || !!heldBy.get(picked) || pickedRemote}
title={t("gitBar.branchDeleteHint")}
onClick={() =>
void confirmRun(
@@ -500,34 +608,83 @@ export function LaneGitBar({
)
}
/>
{dirty > 0 && (
<span className="text-[10px] text-status-warning">{t("gitBar.switchDirty")}</span>
</div>
)}
{switching && choice && (
<div
data-testid="lane-git-switch-choice"
role="group"
aria-label={t("gitBar.choiceTitle", { branch: choice.branch })}
className="space-y-1.5 rounded border border-border-light bg-surface-1 p-2 text-[11px]"
>
<p className="break-words text-fg-primary">
{choice.conflict
? t("gitBar.carryConflict", { branch: choice.branch })
: t("gitBar.choiceMessage", { count: dirty, branch: choice.branch })}
</p>
{choice.conflict && choice.conflict.length > 0 && (
<ul
data-testid="lane-git-carry-conflicts"
className="max-h-24 overflow-auto font-mono text-[10px] text-status-warning"
>
{choice.conflict.map((f) => (
<li key={f} className="truncate" title={f}>
{f}
</li>
))}
</ul>
)}
<div className="flex flex-wrap gap-1.5">
{!choice.conflict && (
<Btn
label={t("gitBar.choiceCarry")}
busy={busy === "switch"}
title={t("gitBar.choiceCarryHint")}
onClick={() => void run("switch", { branch: choice.branch, changes: "carry" })}
/>
)}
<Btn
label={t("gitBar.choiceShelve")}
busy={busy === "switch"}
title={t("gitBar.choiceShelveHint")}
onClick={() => void run("switch", { branch: choice.branch, changes: "shelve" })}
/>
<Btn label={t("gitBar.loginCancel")} onClick={() => setChoice(null)} />
</div>
</div>
)}
{switching && (
<div className="flex items-center gap-1.5">
<div className="flex flex-wrap items-center gap-1.5">
<input
value={newBranch}
onChange={(e) => setNewBranch(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e) && newBranch.trim())
void run("branch-create", { branch: newBranch.trim() });
if (isSubmitEnter(e)) createBranch(dirty > 0);
}}
placeholder={t("gitBar.branchNewPlaceholder")}
aria-label={t("gitBar.branchNewPlaceholder")}
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
className="min-w-[8rem] flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<Btn
label={t("gitBar.branchCreateSwitch")}
icon={<GitBranch className="h-3 w-3" />}
busy={busy === "branch-create"}
disabled={!newBranch.trim()}
// The working tree stays as it is: uncommitted changes come along.
title={t("gitBar.branchCreateSwitchHint")}
onClick={() => createBranch(true)}
/>
<Btn
label={t("gitBar.branchCreate")}
icon={<Plus className="h-3 w-3" />}
busy={busy === "branch-create"}
disabled={!newBranch.trim()}
// Creating does not switch: the point is usually a sibling lane,
// and moving this pane's HEAD under a working agent is not.
// Plain Create does not switch: the point is usually a sibling
// lane, and moving this pane's HEAD under a working agent is not.
title={t("gitBar.branchCreateHint")}
onClick={() => void run("branch-create", { branch: newBranch.trim() })}
onClick={() => createBranch(false)}
/>
</div>
)}
@@ -10,7 +10,9 @@
* It used to sit in the lane card, a column away from the diff it describes —
* a pull there changed files the Changes panel only noticed on its next poll.
* Here the two share one read of `GET /api/lanes/:id/git` (`useLaneGitFacts`,
* owned by the panel) and refresh each other after every write.
* owned by the panel) and refresh each other after every write. Every other
* reader of the facts (the rail counts, a second panel) hears a write through
* `agenthub:git-changed` (lib/gitChanged.ts), which `useLaneGitFacts` listens to.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -21,6 +23,7 @@ import { GitBranch } from "lucide-react";
import { LaneGitBar } from "./LaneGitBar";
import { api, gitTarget, type GitSource } from "../../lib/api";
import { keepIfSame } from "../../lib/keepIfSame";
import { useGitChanged } from "../../lib/gitChanged";
import type { LaneGitFacts, LaneGitFile } from "../../lib/types";
/** How often the mounted panel re-reads its working-copy facts. Slow on
@@ -76,6 +79,9 @@ export function useLaneGitFacts(
clearInterval(timer);
};
}, [laneId, session, source, nonce]);
// A git write from ANY view of this workspace (another panel, the session
// view, a second tab of the same page) moves these facts too.
useGitChanged(laneId, () => setNonce((n) => n + 1));
return [facts, () => setNonce((n) => n + 1)];
}
+241
View File
@@ -0,0 +1,241 @@
/**
* @file The Changes panel's Shelves list: changes set aside by AgentHub (git
* stash entries it made — `GET /api/lanes/:id/git/shelves`), each with its
* branch, note, time and files, and the two buttons that make a shelf safe to
* offer from a dashboard: **Restore** (`unshelve`, which drops the shelf only
* when it applied cleanly; a conflicted restore keeps it and leaves the files
* to the conflict list) and **Delete** (`shelf-drop`, two clicks through
* `useArmed`, sent with `confirm: true`). Collapsed to one line until opened,
* and absent while there are none. Also exports `withAgentConfirm`, the
* one place a git write answered `EAGENTRUNNING` asks the user and is resent
* with `confirm: true`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Archive, ChevronDown, ChevronRight, RotateCcw, Trash2 } from "lucide-react";
import i18n from "../../i18n";
import { api, type GitShelf, type GitTarget } from "../../lib/api";
import { confirmDialog } from "../../lib/confirm";
import { toast } from "../../lib/toast";
import { useArmed } from "../../hooks/useArmed";
/**
* Run a git write; when the server answers `EAGENTRUNNING` (an agent of the
* workspace is working and the write would move files under it), ask, and
* resend it with `confirm: true`. Resolves null when the user declined.
*/
export async function withAgentConfirm<T>(
send: (confirm: boolean) => Promise<T>
): Promise<T | null> {
try {
return await send(false);
} catch (err) {
if ((err as { code?: string })?.code !== "EAGENTRUNNING") throw err;
const ok = await confirmDialog({
title: i18n.t("lanes:shelves.agentTitle"),
message: i18n.t("lanes:shelves.agentMessage"),
confirmLabel: i18n.t("lanes:shelves.agentConfirm"),
destructive: false,
});
return ok ? send(true) : null;
}
}
/**
* Restore one shelf (`unshelve`), reporting the outcome in a toast. Shared by
* the list below and the git bar's "restore what you shelved here" prompt.
* @returns true when anything changed in the working tree.
*/
export async function restoreShelf(target: GitTarget, shelf: GitShelf): Promise<boolean> {
try {
const res = await withAgentConfirm((confirm) =>
api.lanes.gitAction(target, "unshelve", { sha: shelf.sha, ...(confirm ? { confirm } : {}) })
);
if (!res) return false;
if (res.conflicts?.length) {
toast.info(i18n.t("lanes:shelves.restoredConflicts", { count: res.conflicts.length }));
} else {
toast.success(i18n.t("lanes:shelves.restored"));
}
return true;
} catch (err) {
toast.error(
(err as { code?: string })?.code === "EDIRTY"
? i18n.t("lanes:shelves.restoreDirty")
: err instanceof Error
? err.message
: String(err)
);
return false;
}
}
/** "M", "A", "D", "??" → a colour, matching the Changes list's letters. */
function statusTone(status: string): string {
if (status === "??" || status.startsWith("A")) return "text-status-success";
if (status.startsWith("D")) return "text-status-danger";
return "text-status-warning";
}
export function LaneShelves({
target,
reread,
onChanged,
}: {
target: GitTarget;
/** Bumped by the panel after every git write: the list is read again. */
reread: number;
/** Called after a restore or a delete changed the tree or the list. */
onChanged: () => void;
}) {
const { t } = useTranslation(["lanes"]);
const [shelves, setShelves] = useState<GitShelf[]>([]);
const [open, setOpen] = useState(false);
const [filesOf, setFilesOf] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const del = useArmed();
const key = JSON.stringify(target);
const keyNow = useRef(key);
keyNow.current = key;
const load = useCallback(async () => {
const asked = key;
try {
const r = await api.lanes.gitShelves(target);
if (keyNow.current === asked) setShelves(r.available ? r.shelves : []);
} catch {
if (keyNow.current === asked) setShelves([]);
}
// `target` is described by `key`; a new object with the same content is
// the same tree.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
useEffect(() => {
void load();
}, [load, reread]);
if (!shelves.length) return null;
const restore = async (s: GitShelf) => {
setBusy(s.sha);
try {
if (await restoreShelf(target, s)) onChanged();
await load();
} finally {
setBusy(null);
}
};
const drop = async (s: GitShelf) => {
if (!del.isArmed(s.sha)) {
del.arm(s.sha);
return;
}
del.disarm();
setBusy(s.sha);
try {
await api.lanes.gitAction(target, "shelf-drop", { sha: s.sha, confirm: true });
toast.success(t("shelves.deleted"));
onChanged();
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
} finally {
setBusy(null);
await load();
}
};
return (
<section data-testid="lane-shelves" className="mb-2 rounded border border-border-light">
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-1 px-2 py-1 text-left text-[11px] font-medium text-fg-secondary hover:bg-surface-2"
>
{open ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
<Archive className="h-3 w-3 text-fg-muted" aria-hidden />
{t("shelves.title", { count: shelves.length })}
</button>
{open && (
<ul className="divide-y divide-border-light border-t border-border-light">
{shelves.map((s) => {
const when = s.createdAt ? new Date(s.createdAt) : null;
const showFiles = filesOf === s.sha;
return (
<li key={s.sha} data-testid="lane-shelf" className="px-2 py-1 text-[11px]">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<span
className="min-w-0 max-w-full truncate font-mono font-semibold text-fg-primary"
title={s.branch}
>
{s.branch}
</span>
{when && !Number.isNaN(when.getTime()) && (
<span className="shrink-0 text-fg-muted" title={s.createdAt ?? undefined}>
{when.toLocaleString()}
</span>
)}
<button
type="button"
aria-expanded={showFiles}
onClick={() => setFilesOf(showFiles ? null : s.sha)}
className="shrink-0 text-fg-muted underline-offset-2 hover:text-fg-primary hover:underline"
>
{t("shelves.files", { count: s.files.length })}
</button>
<div className="ml-auto flex shrink-0 items-center gap-1">
<button
type="button"
disabled={!!busy}
onClick={() => void restore(s)}
title={t("shelves.restoreHint")}
className="flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
>
<RotateCcw className="h-3 w-3" aria-hidden />
{t("shelves.restore")}
</button>
<button
type="button"
disabled={!!busy}
onClick={() => void drop(s)}
title={t("shelves.deleteHint")}
className="flex items-center gap-1 rounded border border-status-danger/40 px-1.5 py-0.5 text-status-danger hover:bg-status-danger/10 disabled:cursor-not-allowed disabled:opacity-40"
>
<Trash2 className="h-3 w-3" aria-hidden />
{del.isArmed(s.sha) ? t("shelves.deleteConfirm") : t("shelves.delete")}
</button>
</div>
</div>
{s.note && (
<p className="mt-0.5 break-words text-fg-secondary" title={s.note}>
{s.note}
</p>
)}
{showFiles && (
<ul data-testid="lane-shelf-files" className="mt-1 space-y-0.5 pl-2">
{s.files.map((f) => (
<li key={f.path} className="flex min-w-0 items-center gap-1.5 font-mono">
<span className={`w-4 shrink-0 text-center ${statusTone(f.status)}`}>
{f.status === "??" ? "U" : f.status.slice(0, 1)}
</span>
<span className="min-w-0 truncate text-fg-secondary" title={f.path}>
{f.path}
</span>
</li>
))}
</ul>
)}
</li>
);
})}
</ul>
)}
</section>
);
}
export default LaneShelves;
@@ -10,7 +10,7 @@
* second `--resume` (the server refuses one too, `ESESSIONLIVE`) — the typed
* message is pasted there (never sent: Enter is the user's), with a toast; while
* it is live the composer is off. Only Claude, Codex and a catalog tool with
* `resumeById` (omp) can resume a session by id — any other tool's session
* `resumeById` (omp, Cursor, Antigravity) can resume a session by id — any other tool's session
* offers a new session from the workspace home
* instead. The header carries the transcript page's Resume/Fork
* (`SessionResumeButton`, behind its warning). The session's tool and folder
+29 -16
View File
@@ -14,7 +14,7 @@
* a catalog tool's bypass — a YOLO chip for Codex) and Local / New worktree — the workspace's Recents — the sessions it
* owns and has not archived (`?lane=&archived=0`, with each one's state dot,
* **Show more** past the first page, and the sidebar row's Rename/Archive in
* its right-click menu), then the archived ones folded with Unarchive; a live session opens its pane,
* its right-click menu — Archive on a live session stops its pane first, `endRuns`), then the archived ones folded with Unarchive; a live session opens its pane,
* any other its conversation (`&session=`, `SessionReplay`), nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and
* Folders (`WorkspaceContext.tsx`, also folded at the bottom of the Changes panel).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -22,6 +22,8 @@
import { isSubmitEnter } from "../../lib/keys";
import { confirmDialog } from "../../lib/confirm";
import { useGitChanged } from "../../lib/gitChanged";
import { endRuns } from "../../lib/stopSession";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
@@ -237,6 +239,17 @@ function HomeBody({
live = false;
};
}, [lane.id]);
// A branch moved from the git bar: the main worktree's branch shown here is
// the server's cached list, so it is refreshed rather than re-read.
useGitChanged(lane.id, (action) => {
if (!["switch", "branch-create", "branch-delete"].includes(action)) return;
api.projects
.refresh()
.then(({ projects }) =>
setProject(projects.find((p) => p.workspaces.includes(lane.id)) ?? null)
)
.catch(() => undefined);
});
// The AI menu's Open terminal: a plain shell in the first free slot, opened
// at once — the same start the session view's tab bar `+` makes.
@@ -307,30 +320,30 @@ function HomeBody({
}
};
const archiveSession = async (s: Session) => {
const live = !!liveRunFor(lane, liveRuns, s.id);
const run = liveRunFor(lane, liveRuns, s.id);
// A live one is stopped first, as the tab's X does (its own confirm):
// archiving only its row left a pane running that no list showed.
if (run) {
if ((await endRuns([run], { name: s.name || t("tree.newSession"), t })).length)
await patchSession(s, { archived: true });
return;
}
const wt = sessionWorktreeName(lane, s);
const ok = await confirmDialog(
wt && !live
wt
? {
title: t("tree.archiveWorktreeTitle"),
message: t("tree.archiveWorktreeBody", { name: wt }),
confirmLabel: t("tree.archiveWorktreeConfirm"),
}
: live
? {
title: t("tree.archiveLiveTitle"),
message: t("tree.archiveLiveBody"),
confirmLabel: t("sessionMenu.archive"),
destructive: false,
}
: {
title: t("tree.archiveSession"),
confirmLabel: t("sessionMenu.archive"),
destructive: false,
}
: {
title: t("tree.archiveSession"),
confirmLabel: t("sessionMenu.archive"),
destructive: false,
}
);
if (!ok) return;
if (wt && !live) {
if (wt) {
try {
await api.lanes.removeSessionWorktree(lane.id, s.id).catch((err: unknown) => {
if ((err as { code?: string }).code !== "ENOWORKTREE") throw err;
+9 -2
View File
@@ -161,12 +161,19 @@ export function WorkspaceRow({
e.preventDefault();
onReorder(index, index + (e.key === "ArrowUp" ? -1 : 1));
}}
title={`${lane.cwd}\n${readOnly ?? t("row.hint")}`}
title={`${lane.cwd}\n${lane.folder_missing ? t("row.folderMissing") : (readOnly ?? t("row.hint"))}`}
className="flex min-w-0 flex-1 items-center gap-2 py-1.5 text-left"
>
<span aria-hidden className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
<span className="sr-only">{t(`row.state.${workspaceDotLabel(lane)}`)}</span>
<span className="truncate text-[13px] font-medium text-fg-primary">{name}</span>
<span
data-folder-missing={lane.folder_missing ? "true" : undefined}
className={`truncate text-[13px] font-medium ${
lane.folder_missing ? "text-fg-muted line-through" : "text-fg-primary"
}`}
>
{name}
</span>
</button>
)}
{readOnly && <Eye aria-label={readOnly} className="mr-1 h-3 w-3 shrink-0 text-fg-muted" />}
@@ -24,7 +24,7 @@
* The list shows `SESSION_LIMIT` rows and a **See all (N more)** under them that
* loads every one, then a **Show less** that goes back to `SESSION_LIMIT`. A
* live tab whose session was archived is not brought back as a New session,
* and archiving a live one asks first. F2 renames the focused row too; each
* and archiving a live one STOPS it first (`endRuns`, the tab X's confirm). F2 renames the focused row too; each
* dot carries its meaning as screen-reader text. `readOnly` (another user's
* workspace, for an analyst): no live tabs, no rename/archive/menu, and a row
* opens the session's analysis page (`/sessions/<id>`) — never a pane or a resume.
@@ -32,7 +32,7 @@
*/
import { useEffect, useState, type ReactNode } from "react";
import { confirmDialog } from "../../lib/confirm";
import { endRuns } from "../../lib/stopSession";
import { isSubmitEnter } from "../../lib/keys";
import { Archive, GitBranch, Pin, PinOff, SquareTerminal } from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -363,17 +363,10 @@ export function WorkspaceSessionList({
};
const archive = async (s: Session) => {
if (sessionWorktreeName(lane, s) && !runFor(s)) return setArchiving(s);
// A live one keeps running in its tab; archiving only hides its row.
if (
runFor(s) &&
!(await confirmDialog({
title: t("tree.archiveLiveTitle"),
message: t("tree.archiveLiveBody"),
confirmLabel: t("sessionMenu.archive"),
destructive: false,
}))
)
return;
// A live one is stopped first, as the tab's X does: archiving only its row
// left a pane running that no list showed any more.
const run = runFor(s);
if (run && !(await endRuns([run], { name: s.name || t("tree.newSession"), t })).length) return;
await patch(s, { archived: true });
};
const archiveWithWorktree = async (s: Session) => {
@@ -16,6 +16,7 @@ import { AddLaneModal } from "../AddLaneModal";
import { api } from "../../../lib/api";
import type { AiTool, AiToolsView, CwdSuggestion } from "../../../lib/api";
import { __resetAiTools } from "../../../hooks/useAiTools";
import { resetCloneJobsForTests } from "../../../lib/cloneJobs";
import type { Lane } from "../../../lib/types";
vi.mock("../../../lib/api", () => ({
@@ -26,6 +27,7 @@ vi.mock("../../../lib/api", () => ({
worktree: vi.fn(),
ensure: vi.fn(),
clone: vi.fn(),
cloneJobs: vi.fn(),
browse: vi.fn(),
pickPath: vi.fn(),
// The folder browser's Places list the user's workspaces.
@@ -158,6 +160,8 @@ beforeEach(() => {
vi.mocked(api.lanes.worktree).mockReset();
vi.mocked(api.lanes.ensure).mockReset();
vi.mocked(api.lanes.clone).mockReset();
vi.mocked(api.lanes.cloneJobs).mockReset().mockResolvedValue({ jobs: [] });
resetCloneJobsForTests();
vi.mocked(api.lanes.browse).mockReset();
vi.mocked(api.lanes.pickPath).mockReset();
// Provisioning finishes instantly by default - tests that care about the
@@ -790,6 +794,54 @@ describe("AddLaneModal — Clone", () => {
await waitFor(() => expect(onAdded).toHaveBeenCalled());
});
it("hands a background clone to the dock and selects the workspace when it ends", async () => {
const onAdded = vi.fn();
const onClose = vi.fn();
const job = {
id: "j1",
kind: "workspace",
state: "queued",
url: "git@github.com:owner/repo.git",
name: "repo",
parent: "/home/an/code",
target: "/home/an/code/repo",
laneId: null,
position: 1,
phase: null,
label: null,
percent: null,
detail: null,
error: null,
result: null,
createdAt: "",
finishedAt: null,
request: { url: "git@github.com:owner/repo.git", parent: "/home/an/code", name: "repo" },
} as const;
vi.mocked(api.lanes.clone).mockResolvedValue({ job: { ...job } });
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
jobs: [{ ...job, state: "done", result: { laneId: 5, path: "/home/an/code/repo" } }],
});
vi.mocked(api.lanes.get).mockResolvedValue({ lane: laneFixture({ id: 5 }) });
const user = userEvent.setup();
renderModal({ onAdded, onClose });
fireEvent.click(screen.getByRole("button", { name: "Clone" }));
const urlField = screen.getByLabelText("Repository URL");
await focusField(user, urlField);
await user.type(urlField, "git@github.com:owner/repo.git");
await user.type(screen.getByLabelText("Clone into folder"), "/home/an/code");
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() =>
expect(api.lanes.clone).toHaveBeenCalledWith(expect.objectContaining({ background: true }))
);
// Accepted: the modal closes at once, before the clone is done.
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(onAdded).not.toHaveBeenCalled();
// The store's next read sees it done: the new workspace is selected.
await waitFor(() => expect(onAdded).toHaveBeenCalledWith(expect.objectContaining({ id: 5 })), {
timeout: 4000,
});
});
it("sends partial only when the box is ticked", async () => {
vi.mocked(api.lanes.clone).mockResolvedValue({
lane: laneFixture({ id: 7 }),
@@ -0,0 +1,296 @@
/**
* @file BranchManager.test.tsx
* @description Pins the git bar's Branches panel: local branches with the
* current one marked and ahead/behind shown, remote branches grouped by
* remote, the search box, arrow-key walking, and each action's request —
* rename, new branch from a remote branch (switch only offered on a clean
* tree), set/unset upstream, merge into current, delete refused with its
* reason — plus the Tags tab: annotated create, push one tag, delete with
* `confirm: true` after the themed confirm, and git's error shown verbatim.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { BranchManager } from "../git/BranchManager";
import { api, type GitRefs } from "../../../lib/api";
import { confirmDialog } from "../../../lib/confirm";
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
vi.mock("../../../lib/toast", () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
vi.mock("../../../lib/api", () => ({
gitTarget: (lane: number) => lane,
api: { lanes: { gitRefs: vi.fn(), gitRefAction: vi.fn() } },
}));
const REFS: GitRefs = {
available: true,
current: "main",
head: "aaaaaaa",
truncated: false,
local: [
{
name: "main",
sha: "a1",
current: true,
upstream: "origin/main",
ahead: 2,
behind: 1,
gone: false,
subject: "latest work",
date: null,
merged: true,
worktree: null,
protected: true,
},
{
name: "feature/very-long-branch-name",
sha: "b2",
current: false,
upstream: null,
ahead: null,
behind: null,
gone: false,
subject: "feature commit",
date: null,
merged: false,
worktree: null,
protected: false,
},
{
name: "held",
sha: "c3",
current: false,
upstream: null,
ahead: null,
behind: null,
gone: false,
subject: "x",
date: null,
merged: true,
worktree: "/tmp/other",
protected: false,
},
],
remotes: [
{
remote: "origin",
branches: [
{ name: "origin/main", branch: "main", sha: "a1", subject: "s", date: null },
{ name: "origin/remote-only", branch: "remote-only", sha: "d4", subject: "r", date: null },
],
},
],
tags: [
{
name: "v1",
sha: "a1b2c3d4",
annotated: true,
message: "release one",
tagger: "T",
date: null,
},
],
};
function setup(over: { dirty?: number } = {}) {
const onChanged = vi.fn();
render(
<BranchManager
laneId={3}
dirty={over.dirty ?? 0}
merging={false}
onClose={vi.fn()}
onChanged={onChanged}
/>
);
return { onChanged };
}
beforeEach(() => {
vi.mocked(api.lanes.gitRefs).mockReset().mockResolvedValue(REFS);
vi.mocked(api.lanes.gitRefAction)
.mockReset()
.mockResolvedValue({ action: "x", ok: true, output: "" });
vi.mocked(confirmDialog).mockClear();
});
const row = (name: RegExp) => screen.getByRole("button", { name });
describe("BranchManager", () => {
it("lists local branches with the current marked and remote ones grouped", async () => {
setup();
await screen.findByText("feature/very-long-branch-name");
const main = row(/^main/);
expect(within(main).getByText("current")).toBeInTheDocument();
expect(main).toHaveTextContent("2");
expect(main).toHaveTextContent("1");
expect(screen.getByText("Remote · origin")).toBeInTheDocument();
expect(screen.getByText("origin/remote-only")).toBeInTheDocument();
// A long name truncates with its full text as the tooltip.
expect(screen.getByText("feature/very-long-branch-name")).toHaveAttribute(
"title",
"feature/very-long-branch-name"
);
});
it("filters by the search box and walks rows with the arrow keys", async () => {
setup();
await screen.findByText("held");
await userEvent.type(screen.getByRole("textbox", { name: "Search branches" }), "remote");
expect(screen.queryByText("held")).toBeNull();
expect(screen.getByText("origin/remote-only")).toBeInTheDocument();
await userEvent.clear(screen.getByRole("textbox", { name: "Search branches" }));
await userEvent.keyboard("{ArrowDown}");
expect(row(/^main/)).toHaveFocus();
await userEvent.keyboard("{ArrowDown}");
expect(row(/^feature\/very-long/)).toHaveFocus();
});
it("renames a branch and re-reads the list", async () => {
const { onChanged } = setup();
await userEvent.click(await screen.findByText("feature/very-long-branch-name"));
await userEvent.click(screen.getByRole("button", { name: /^Rename$/ }));
const input = screen.getByRole("textbox", { name: "New name" });
await userEvent.clear(input);
await userEvent.type(input, "feature/short{Enter}");
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-rename", {
branch: "feature/very-long-branch-name",
name: "feature/short",
});
await waitFor(() => expect(onChanged).toHaveBeenCalled());
expect(api.lanes.gitRefs).toHaveBeenCalledTimes(2);
});
it("refuses rename and delete of a protected current branch, with the reason", async () => {
setup();
await userEvent.click(await screen.findByText("main"));
expect(screen.getByRole("button", { name: /^Rename$/ })).toBeDisabled();
expect(screen.getByRole("button", { name: /^Delete$/ })).toBeDisabled();
expect(screen.getByText(/You are on this branch/)).toBeInTheDocument();
});
it("explains a branch another worktree holds", async () => {
setup();
await userEvent.click(await screen.findByText("held"));
expect(screen.getByRole("button", { name: /^Delete$/ })).toBeDisabled();
expect(screen.getByText(/another worktree: \/tmp\/other/)).toBeInTheDocument();
});
it("deletes an unmerged branch only after the confirm warns", async () => {
setup();
await userEvent.click(await screen.findByText("feature/very-long-branch-name"));
await userEvent.click(screen.getByRole("button", { name: /^Delete$/ }));
expect(vi.mocked(confirmDialog).mock.calls[0]?.[0].message).toMatch(/not merged/);
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-delete", {
branch: "feature/very-long-branch-name",
});
});
it("creates a branch from a remote branch, switch offered only on a clean tree", async () => {
setup({ dirty: 2 });
await userEvent.click(await screen.findByText("origin/remote-only"));
await userEvent.click(screen.getByRole("button", { name: /New branch from here/ }));
expect(screen.getByRole("checkbox", { name: "Switch to it" })).toBeDisabled();
const input = screen.getByRole("textbox", { name: "New branch name" });
expect(input).toHaveValue("remote-only");
await userEvent.type(input, "{Enter}");
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-create", {
branch: "remote-only",
from: "origin/remote-only",
});
});
it("sets and unsets an upstream, and merges into the current branch", async () => {
setup();
await userEvent.click(await screen.findByText("feature/very-long-branch-name"));
await userEvent.click(screen.getByRole("button", { name: /Set upstream/ }));
await userEvent.selectOptions(screen.getByRole("combobox"), "origin/remote-only");
await userEvent.click(screen.getByRole("button", { name: /Apply/ }));
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-upstream", {
branch: "feature/very-long-branch-name",
upstream: "origin/remote-only",
});
await userEvent.click(screen.getByRole("button", { name: /Merge into main/ }));
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "merge", {
branch: "feature/very-long-branch-name",
});
await userEvent.click(screen.getByText("main"));
await userEvent.click(screen.getByRole("button", { name: /Unset upstream/ }));
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-upstream", { branch: "main" });
});
it("shows git's own words when a write is refused", async () => {
vi.mocked(api.lanes.gitRefAction).mockRejectedValueOnce(
Object.assign(new Error("branch x already exists"), { code: "EBRANCHEXISTS" })
);
setup();
await userEvent.click(await screen.findByText("feature/very-long-branch-name"));
await userEvent.click(screen.getByRole("button", { name: /^Rename$/ }));
await userEvent.type(screen.getByRole("textbox", { name: "New name" }), "x{Enter}");
expect(await screen.findByTestId("branch-manager-error")).toHaveTextContent(
"branch x already exists"
);
});
});
describe("TagManager (Tags tab)", () => {
const openTags = async () => {
setup();
await screen.findByText("main");
await userEvent.click(screen.getByRole("tab", { name: /Tags/ }));
};
it("lists tags with their message and creates an annotated one", async () => {
await openTags();
expect(screen.getByText("v1")).toBeInTheDocument();
expect(screen.getByText(/release one/)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /New tag/ }));
await userEvent.type(screen.getByRole("textbox", { name: /Tag name/ }), "v2");
await userEvent.type(
screen.getByRole("textbox", { name: /^On / }),
"feature/very-long-branch-name"
);
await userEvent.type(screen.getByRole("textbox", { name: /Message/ }), "notes");
await userEvent.click(screen.getByRole("button", { name: "Create annotated tag" }));
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "tag-create", {
tag: "v2",
message: "notes",
from: "feature/very-long-branch-name",
});
});
it("pushes one tag and deletes one only with confirm: true", async () => {
await openTags();
await userEvent.click(screen.getByText("v1"));
await userEvent.click(screen.getByRole("button", { name: /Push to origin/ }));
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "tag-push", { tag: "v1" });
await userEvent.click(screen.getByRole("button", { name: /Delete tag/ }));
expect(vi.mocked(confirmDialog).mock.lastCall?.[0].destructive).toBe(true);
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "tag-delete", {
tag: "v1",
confirm: true,
});
});
it("opens from the git bar's button and closes on Escape", async () => {
const { BranchesButton } = await import("../git/BranchesButton");
render(<BranchesButton laneId={3} dirty={0} merging={false} onChanged={vi.fn()} />);
await userEvent.click(screen.getByRole("button", { name: "Manage branches & tags" }));
expect(await screen.findByTestId("branch-manager")).toBeInTheDocument();
await screen.findByText("main");
await userEvent.keyboard("{Escape}");
expect(screen.queryByTestId("branch-manager")).toBeNull();
});
it("does not delete when the confirm is cancelled", async () => {
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
await openTags();
await userEvent.click(screen.getByText("v1"));
await userEvent.click(screen.getByRole("button", { name: /Delete tag/ }));
expect(api.lanes.gitRefAction).not.toHaveBeenCalled();
});
});
@@ -1,28 +1,19 @@
/**
* @file Tests for CloneProgress: a queued clone shows its place in the
* server's clone queue, a running one git's phase, percent and speed with a
* bar, and nothing shows before the server knows the token. Also that
* `newCloneToken` makes a token the server accepts.
* server's clone queue (with the machine-wide counts when it has them), a
* running one git's phase, percent and speed with a bar, and a finished one
* nothing — all rendered from a clone job's fields, no fetching.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import "../../../i18n";
import { CloneProgress, type CloneProgressFields } from "../CloneProgress";
vi.mock("../../../lib/api", () => ({
api: { lanes: { cloneProgress: vi.fn() } },
}));
import { api, type CloneProgressView } from "../../../lib/api";
import { CloneProgress, newCloneToken } from "../CloneProgress";
const base: CloneProgressView = {
const base: CloneProgressFields = {
state: "running",
position: null,
running: 2,
waiting: 1,
limit: 2,
phase: null,
label: null,
percent: null,
@@ -30,50 +21,44 @@ const base: CloneProgressView = {
};
describe("CloneProgress", () => {
// Braces: a function returned from beforeEach is run as its teardown.
beforeEach(() => {
vi.mocked(api.lanes.cloneProgress).mockReset();
it("shows the clone's place in the queue while it waits", () => {
render(<CloneProgress view={{ ...base, state: "queued", position: 2 }} />);
expect(screen.getByTestId("clone-progress")).toHaveTextContent(
"Waiting in the clone queue — #2"
);
});
it("shows the clone's place in the queue while it waits", async () => {
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
...base,
state: "queued",
position: 1,
});
render(<CloneProgress token="t" />);
expect(await screen.findByTestId("clone-progress")).toHaveTextContent(
it("adds the machine-wide counts when the view carries them", () => {
render(
<CloneProgress
view={{ ...base, state: "queued", position: 1, waiting: 1, running: 2, limit: 2 }}
/>
);
expect(screen.getByTestId("clone-progress")).toHaveTextContent(
"Waiting in the clone queue — #1 of 1 (2/2 clones running)"
);
});
it("shows git's phase, percent and speed with a bar while it runs", async () => {
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
...base,
phase: "receiving",
label: "Receiving objects",
percent: 45,
detail: "450/1000, 120.50 MiB | 5.20 MiB/s",
});
render(<CloneProgress token="t" />);
const line = await screen.findByTestId("clone-progress");
it("shows git's phase, percent and speed with a bar while it runs", () => {
render(
<CloneProgress
view={{
...base,
phase: "receiving",
label: "Receiving objects",
percent: 45,
detail: "450/1000, 120.50 MiB | 5.20 MiB/s",
}}
/>
);
const line = screen.getByTestId("clone-progress");
expect(line).toHaveTextContent("Downloading 45%");
expect(line).toHaveTextContent("120.50 MiB | 5.20 MiB/s");
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "45");
});
it("shows nothing while the server does not know the token yet", async () => {
vi.mocked(api.lanes.cloneProgress).mockImplementation(() =>
Promise.reject(Object.assign(new Error("no such clone"), { code: "ENOTFOUND" }))
);
render(<CloneProgress token="t" />);
await new Promise((r) => setTimeout(r, 20));
it("shows nothing once the clone finished", () => {
render(<CloneProgress view={{ ...base, state: "done" }} />);
expect(screen.queryByTestId("clone-progress")).toBeNull();
});
it("makes tokens the server accepts", () => {
const a = newCloneToken();
expect(a).toMatch(/^[A-Za-z0-9_-]{16,64}$/);
expect(newCloneToken()).not.toBe(a);
});
});
@@ -14,17 +14,23 @@ import userEvent from "@testing-library/user-event";
import "../../../i18n";
vi.mock("../../../lib/api", () => ({
api: { lanes: { cloneFolder: vi.fn() }, gitIdentity: { get: vi.fn() } },
api: { lanes: { cloneFolder: vi.fn(), sources: vi.fn(), cloneJobs: vi.fn() }, gitIdentity: { get: vi.fn() } },
}));
import { api, type GitIdentity, type LaneSourcesView } from "../../../lib/api";
import { CloneSourcesForm, cloneTarget } from "../CloneSourcesForm";
import { CLONE_JOB_EVENT, resetCloneJobsForTests } from "../../../lib/cloneJobs";
const view = { sources: [] } as unknown as LaneSourcesView & { path: string };
describe("CloneSourcesForm", () => {
beforeEach(() => {
vi.mocked(api.lanes.cloneFolder).mockReset();
// A finished clone (an older server's answer, or a job the store saw end)
// makes the form read the folder list again.
vi.mocked(api.lanes.sources).mockReset().mockResolvedValue(view);
vi.mocked(api.lanes.cloneJobs).mockReset().mockResolvedValue({ jobs: [] });
resetCloneJobsForTests();
// Unknown identity: nothing is checked up front.
vi.mocked(api.gitIdentity.get).mockReset().mockRejectedValue(new Error("old server"));
});
@@ -49,7 +55,7 @@ describe("CloneSourcesForm", () => {
await user.click(screen.getByTestId("workspace-clone-run"));
await waitFor(() => expect(api.lanes.cloneFolder).toHaveBeenCalledTimes(2));
expect(onAdded).toHaveBeenCalledTimes(1);
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(1));
expect(await screen.findByText(/private\.test needs a login/)).toBeInTheDocument();
// Only what is left stays in the box.
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("https://private.test/b.git");
@@ -60,13 +66,52 @@ describe("CloneSourcesForm", () => {
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(2));
expect(api.lanes.cloneFolder).toHaveBeenLastCalledWith(3, {
progressToken: expect.any(String),
background: true,
url: "https://private.test/b.git",
credentials: { username: "dev", password: "tok", remember: true },
});
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("");
});
it("hands a background clone to the dock and re-reads the folders when it ends", async () => {
const user = userEvent.setup();
const onAdded = vi.fn();
const job = {
id: "s1",
kind: "source",
state: "queued",
url: "https://h/a.git",
name: "a",
parent: "/",
target: "/a",
laneId: 3,
position: 1,
phase: null,
label: null,
percent: null,
detail: null,
error: null,
result: null,
createdAt: "",
finishedAt: null,
request: { url: "https://h/a.git", parent: "/", name: "a" },
} as const;
vi.mocked(api.lanes.cloneFolder).mockResolvedValue({ job: { ...job } });
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={onAdded} />);
await user.type(screen.getByTestId("workspace-clone-input"), "https://h/a.git");
await user.click(screen.getByTestId("workspace-clone-run"));
expect(await screen.findByText(/progress is in the corner/)).toBeInTheDocument();
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("");
expect(onAdded).not.toHaveBeenCalled();
// Another workspace's clone is not ours.
window.dispatchEvent(
new CustomEvent(CLONE_JOB_EVENT, { detail: { ...job, laneId: 4, state: "done" } })
);
window.dispatchEvent(new CustomEvent(CLONE_JOB_EVENT, { detail: { ...job, state: "done" } }));
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(1));
expect(api.lanes.sources).toHaveBeenCalledWith(3);
});
it("asks for partial clones when the box is ticked", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.cloneFolder).mockResolvedValue(view);
@@ -76,7 +121,7 @@ describe("CloneSourcesForm", () => {
await user.click(screen.getByTestId("workspace-clone-run"));
await waitFor(() =>
expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, {
progressToken: expect.any(String),
background: true,
url: "https://h/big.git",
partial: true,
})
@@ -112,9 +157,11 @@ describe("CloneSourcesForm", () => {
await waitFor(() => expect(api.lanes.cloneFolder).toHaveBeenCalledTimes(1));
expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, {
url: "https://h.test/a.git",
progressToken: expect.any(String),
background: true,
});
expect(await screen.findByText(/has no SSH key yet/)).toBeInTheDocument();
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("git@github.com:o/r.git");
await waitFor(() =>
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("git@github.com:o/r.git")
);
});
});
@@ -3,21 +3,26 @@
* opens sub-folders, makes a folder inline, deletes into the Trash without a
* confirm, copies in one workspace and pastes into another (`fromLane`),
* asks Replace / Keep both / Skip when an upload's name is taken, refuses a
* batch over 500 MB before sending anything, restores from the user's own
* batch over 500 MB before sending anything, leaves a repo's .git out of a
* folder upload with one notice, restores from the user's own
* Trash, opens an admin's Trash on one folder per user (counted, an unreadable
* one saying why), opens each user's Trash from its folder (asking before a
* purge there, sending the item's store) and
* shows a Trash that could not be read as an error, never as empty; nests a
* user's workspaces under their home in the sidebar (under a fold-only row
* when the viewer may not open that home).
* when the viewer may not open that home); opens an HTML file as the page
* instead of downloading it; New → Clone repository starts a background
* clone (`background: true`), keeps a pre-start refusal in the form, and a
* finished clone (the `agenthub:clone-job` event) re-reads the folder.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FileExplorer } from "../FileExplorer";
import { api, type FsListing } from "../../../lib/api";
import { api, type CloneJob, type FsListing } from "../../../lib/api";
import { toast } from "../../../lib/toast";
import { CLONE_JOB_EVENT, resetCloneJobsForTests } from "../../../lib/cloneJobs";
import { confirmDialog } from "../../../lib/confirm";
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => false) }));
@@ -30,7 +35,7 @@ vi.mock("../../../lib/api", () => ({
lanes: {
list: vi.fn(),
clone: vi.fn(),
cloneProgress: vi.fn(),
cloneJobs: vi.fn(async () => ({ jobs: [] })),
gitDownloadUrl: (t: number, p: string) => `/api/lanes/${t}/git/download?path=${p}`,
},
files: {
@@ -73,9 +78,30 @@ const src: FsListing = {
truncated: false,
entries: [{ name: "x.js", path: "src/x.js", type: "file", size: 2, mtime: 0 }],
};
const job: CloneJob = {
id: "j1",
kind: "workspace",
state: "queued",
url: "git@host:o/repo.git",
name: "repo",
parent: "/w/one",
target: "/w/one/repo",
laneId: null,
position: 1,
phase: null,
label: null,
percent: null,
detail: null,
error: null,
result: { laneId: 9, path: "/w/one/repo" },
createdAt: "",
finishedAt: null,
request: { url: "git@host:o/repo.git", parent: "/w/one", name: "repo" },
};
beforeEach(() => {
vi.clearAllMocks();
resetCloneJobsForTests();
vi.mocked(api.lanes.list).mockResolvedValue({
lanes: [
{ id: 1, title: "one", cwd: "/w/one" },
@@ -136,6 +162,38 @@ describe("FileExplorer", () => {
expect(api.files.list).toHaveBeenLastCalledWith(1, "src");
});
it("opens an HTML file as the page, its stylesheet read through the download", async () => {
vi.mocked(api.files.list).mockResolvedValue({
...root,
entries: [
...root.entries,
{ name: "deck.html", path: "deck.html", type: "file", size: 9, mtime: 0 },
],
});
const body: Record<string, string> = {
"/dl/1/deck.html": '<link rel="stylesheet" href="s.css"><p>deck</p>',
"/dl/1/s.css": "p{color:red}",
};
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockImplementation(async (u) => new Response(body[String(u)] ?? "", { status: 200 }));
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
open();
await screen.findByText("deck.html");
fireEvent.doubleClick(row("deck.html"));
const viewer = await screen.findByTestId("fx-html-viewer");
await waitFor(() =>
expect(within(viewer).getByTestId("html-frame").getAttribute("srcdoc")).toContain(
"<style>p{color:red}</style>"
)
);
expect(click).not.toHaveBeenCalled();
fireEvent.click(within(viewer).getByRole("button", { name: "Close" }));
expect(screen.queryByTestId("fx-html-viewer")).toBeNull();
fetchSpy.mockRestore();
click.mockRestore();
});
it("makes a folder inline", async () => {
vi.mocked(api.files.mkdir).mockResolvedValue({ path: "docs" });
const user = userEvent.setup();
@@ -196,7 +254,8 @@ describe("FileExplorer", () => {
"a.txt",
file,
"keep",
expect.any(Function)
expect.any(Function),
expect.any(AbortSignal)
)
);
});
@@ -217,7 +276,8 @@ describe("FileExplorer", () => {
"",
files.map((file) => ({ file, rel: file.name })),
"replace",
expect.any(Function)
expect.any(Function),
expect.any(AbortSignal)
)
);
expect(api.files.uploadCheck).toHaveBeenCalledWith(1, "", ["a.txt", "b.txt", "c.txt"]);
@@ -236,6 +296,23 @@ describe("FileExplorer", () => {
expect(api.files.uploadBatch).not.toHaveBeenCalled();
});
it("leaves a picked repo's .git out of a folder upload, with one notice", async () => {
vi.mocked(api.files.upload).mockResolvedValue({ path: "x" });
open();
await screen.findByText("a.txt");
const mk = (rel: string) => {
const f = new File(["hi"], rel.split("/").pop()!);
Object.defineProperty(f, "webkitRelativePath", { value: rel });
return f;
};
const files = ["repo/.git/HEAD", "repo/.git/objects/d4/ab", "repo/src/a.js"].map(mk);
fireEvent.change(screen.getByTestId("fx-upload-folder"), { target: { files } });
await waitFor(() => expect(api.files.upload).toHaveBeenCalledTimes(1));
expect(vi.mocked(api.files.upload).mock.calls[0]![1]).toBe("repo/src/a.js");
expect(toast.info).toHaveBeenCalledTimes(1);
expect(toast.error).not.toHaveBeenCalled();
});
it("refuses a batch over 500 MB before sending anything", async () => {
open();
await screen.findByText("a.txt");
@@ -408,6 +485,33 @@ describe("FileExplorer", () => {
);
});
it("leaves a workspace whose folder was deleted out of the places", async () => {
vi.mocked(api.lanes.list).mockResolvedValue({
lanes: [
{ id: 1, title: "one", cwd: "/w/one" },
{ id: 2, title: "two", cwd: "/w/two", folder_missing: true },
],
counts: {},
} as never);
open();
await screen.findByText("a.txt");
await waitFor(() => expect(api.lanes.list).toHaveBeenCalled());
await screen.findByText("one", { selector: "aside span" });
expect(screen.queryByRole("button", { name: "two" })).toBeNull();
});
it("drops the last folder's entries when the next one cannot be read", async () => {
vi.mocked(api.files.list).mockImplementation(async (_t, p = "") => {
if (p === "src") throw new Error("ENOENT: no such file or directory");
return root;
});
open();
await screen.findByText("a.txt");
fireEvent.doubleClick(row("src"));
await screen.findByText(/ENOENT/);
expect(screen.queryByText("a.txt")).toBeNull();
});
it("right-clicks a sidebar workspace and a path crumb", async () => {
const user = userEvent.setup();
open();
@@ -535,12 +639,8 @@ describe("FileExplorer — New → Clone repository", () => {
return screen.getByTestId("fx-clone-dialog");
};
it("clones into the open folder of the workspace and says where", async () => {
vi.mocked(api.lanes.clone).mockResolvedValue({
lane: { id: 9, title: "repo", cwd: "/w/one/src/repo" },
created: true,
path: "/w/one/src/repo",
} as never);
it("clones into the open folder of the workspace in the background", async () => {
vi.mocked(api.lanes.clone).mockResolvedValue({ job: { ...job, state: "queued" } } as never);
const user = userEvent.setup();
open();
fireEvent.doubleClick(await screen.findByText("src"));
@@ -554,26 +654,28 @@ describe("FileExplorer — New → Clone repository", () => {
url: "git@host:o/repo.git",
parent: "/w/one/src",
name: undefined,
progressToken: expect.stringMatching(/^[0-9a-f]{32}$/),
background: true,
})
);
expect(toast.success).toHaveBeenCalled();
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
// Accepted: the dialog steps aside, the app-wide dock takes over.
await waitFor(() => expect(screen.queryByTestId("fx-clone-dialog")).toBeNull());
});
it("shows progress and lets the page go on while the clone runs", async () => {
let finish: (v: unknown) => void = () => {};
vi.mocked(api.lanes.clone).mockReturnValue(new Promise((r) => (finish = r)) as never);
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
state: "running",
position: null,
running: 1,
waiting: 0,
limit: 2,
phase: "receiving",
label: null,
percent: 45,
detail: "450/1000",
it("re-reads the folder and workspaces when a background clone ends", async () => {
open();
await screen.findByText("a.txt");
const lists = vi.mocked(api.files.list).mock.calls.length;
const laneReads = vi.mocked(api.lanes.list).mock.calls.length;
window.dispatchEvent(new CustomEvent(CLONE_JOB_EVENT, { detail: { ...job, state: "done" } }));
await waitFor(() => expect(vi.mocked(api.files.list).mock.calls.length).toBeGreaterThan(lists));
expect(vi.mocked(api.lanes.list).mock.calls.length).toBeGreaterThan(laneReads);
});
it("treats an older server's synchronous answer as a finished clone", async () => {
vi.mocked(api.lanes.clone).mockResolvedValue({
lane: { id: 9, title: "repo", cwd: "/w/one/repo" },
created: true,
path: "/w/one/repo",
} as never);
const user = userEvent.setup();
open();
@@ -581,43 +683,8 @@ describe("FileExplorer — New → Clone repository", () => {
const dialog = await openClone(user);
await user.type(within(dialog).getByLabelText("Repository URL"), "git@host:o/repo.git");
await user.click(within(dialog).getByTestId("fx-clone-submit"));
expect(await within(dialog).findByRole("progressbar")).toHaveAttribute("aria-valuenow", "45");
await user.click(within(dialog).getByRole("button", { name: "Run in background" }));
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
const chip = await screen.findByTestId("fx-clone-chip");
expect(within(chip).getByText(/repo/)).toBeInTheDocument();
await user.click(chip);
expect(screen.getByTestId("fx-clone-dialog")).toBeInTheDocument();
await user.keyboard("{Escape}");
finish({
lane: { id: 9, title: "repo", cwd: "/w/one/repo" },
created: true,
path: "/w/one/repo",
});
await waitFor(() => expect(toast.success).toHaveBeenCalled());
expect(screen.queryByTestId("fx-clone-chip")).toBeNull();
});
it("toasts a failure that lands while hidden and keeps it for the next open", async () => {
let fail: (e: unknown) => void = () => {};
vi.mocked(api.lanes.clone).mockReturnValue(new Promise((_, r) => (fail = r)) as never);
vi.mocked(api.lanes.cloneProgress).mockRejectedValue(new Error("404"));
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
const dialog = await openClone(user);
await user.type(within(dialog).getByLabelText("Repository URL"), "https://git.example/o/r.git");
await user.click(within(dialog).getByTestId("fx-clone-submit"));
await user.keyboard("{Escape}");
fail(
Object.assign(new Error("login"), { code: "ECREDENTIALS", auth: { host: "git.example" } })
);
await waitFor(() => expect(toast.error).toHaveBeenCalled());
const again = await openClone(user);
expect(within(again).getByTestId("fx-clone-login")).toBeInTheDocument();
expect(within(again).getByLabelText("Repository URL")).toHaveValue(
"https://git.example/o/r.git"
);
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
});
it("asks for a login on ECREDENTIALS and sends it once", async () => {
@@ -625,18 +692,16 @@ describe("FileExplorer — New → Clone repository", () => {
.mockRejectedValueOnce(
Object.assign(new Error("login"), { code: "ECREDENTIALS", auth: { host: "git.example" } })
)
.mockResolvedValueOnce({
lane: { id: 9, title: "r", cwd: "/w/one/r" },
created: true,
path: "/w/one/r",
} as never);
.mockResolvedValueOnce({ job } as never);
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
const dialog = await openClone(user);
await user.type(within(dialog).getByLabelText("Repository URL"), "https://git.example/o/r.git");
await user.click(within(dialog).getByTestId("fx-clone-submit"));
// A refusal before the clone starts stays in the form, with a login row.
const login = await within(dialog).findByTestId("fx-clone-login");
expect(within(dialog).getByRole("alert")).toHaveTextContent("git.example needs a login");
await user.type(within(login).getByLabelText("Username"), "ann");
await user.type(within(login).getByLabelText("Password or access token"), "tok");
await user.click(within(dialog).getByTestId("fx-clone-submit"));
@@ -644,6 +709,7 @@ describe("FileExplorer — New → Clone repository", () => {
expect(api.lanes.clone).toHaveBeenLastCalledWith(
expect.objectContaining({
parent: "/w/one",
background: true,
credentials: { username: "ann", password: "tok", remember: true },
})
)
@@ -20,6 +20,7 @@ import userEvent from "@testing-library/user-event";
import { LaneChangesPanel, gitChangeFor } from "../LaneChangesPanel";
import { confirmDialog, confirmDiscard } from "../../../lib/confirm";
import { api } from "../../../lib/api";
import { emitGitChanged } from "../../../lib/gitChanged";
import type { LaneChanges } from "../../../lib/types";
// The themed confirm needs its host (App); answer it here.
@@ -84,6 +85,19 @@ vi.mock("../../../lib/api", () => ({
gitDownloadUrl: (id: number, path: string) =>
`/api/lanes/${id}/git/download?path=${encodeURIComponent(path)}`,
gitUpload: vi.fn(),
gitHistory: {
commit: vi.fn(),
patch: vi.fn(),
fileHistory: vi.fn(),
blameAt: vi.fn(),
treeAt: vi.fn(),
compare: vi.fn(),
compareFile: vi.fn(),
pickPlan: vi.fn(),
sequence: vi.fn(),
pick: vi.fn(),
act: vi.fn(),
},
},
},
}));
@@ -153,6 +167,44 @@ function discardOf(path: string) {
beforeEach(() => {
perms.local = true;
const h = api.lanes.gitHistory;
vi.mocked(h.commit).mockReset().mockResolvedValue({ available: false });
vi.mocked(h.sequence).mockReset().mockResolvedValue({ available: true, active: false });
vi.mocked(h.pick).mockReset();
vi.mocked(h.pickPlan).mockReset().mockImplementation(async (_t, op, shas) => ({
available: true,
op,
commits: shas.map((sha) => ({
sha,
short: sha.slice(0, 7),
subject: "feat: the commit",
author: "t",
date: "2026-09-22T01:00:00Z",
})),
branch: "feat/thing",
clean: true,
repoRoot: true,
busy: false,
}));
vi.mocked(h.fileHistory)
.mockReset()
.mockResolvedValue({
available: true,
path: "server/lib/git-ops.js",
hasMore: false,
commits: [
{
sha: "a".repeat(40),
short: "aaaaaaa",
subject: "feat: the commit",
author: "t",
date: "2026-09-22T01:00:00Z",
parents: [],
status: "M",
path: "server/lib/git-ops.js",
},
],
});
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
vi.mocked(api.lanes.changes).mockReset();
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
@@ -445,6 +497,84 @@ describe("LaneChangesPanel", () => {
);
});
it("shows the NEW branch everywhere after a switch from the git bar", async () => {
// The repo as the server sees it: which branch, which changes.
let branch = "feat/thing";
vi.mocked(api.lanes.git).mockImplementation(async () => ({
available: true,
branch,
head: "9b3e74a",
subject: "wip",
dirty: 0,
untracked: 0,
files: [],
hasRemote: false,
upstream: null,
ahead: null,
behind: null,
}));
vi.mocked(api.lanes.changes).mockImplementation(async () =>
changes({
files: [
{
path: branch === "other" ? "only-on-other.ts" : "only-on-thing.ts",
status: " M",
untracked: false,
unstaged: true,
added: 1,
deleted: 0,
binary: false,
},
],
})
);
vi.mocked(api.lanes.gitAction).mockImplementation(async (_t, action, body) => {
if (action === "switch") branch = (body as { branch: string }).branch;
return { action, ok: true, output: "", shelves: [] };
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
expect(await screen.findByTestId("lane-git")).toHaveTextContent("feat/thing");
await screen.findByTitle("only-on-thing.ts");
await user.click(screen.getByRole("button", { name: /^Branch$/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "other");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await waitFor(() => expect(screen.getByTestId("lane-git")).toHaveTextContent("other"));
expect(await screen.findByTitle("only-on-other.ts")).toBeInTheDocument();
expect(screen.queryByTitle("only-on-thing.ts")).toBeNull();
});
it("re-reads when ANOTHER view of the lane changed its git state", async () => {
let branch = "feat/thing";
vi.mocked(api.lanes.git).mockImplementation(async () => ({
available: true,
branch,
head: "9b3e74a",
subject: "wip",
dirty: 0,
untracked: 0,
files: [],
hasRemote: false,
upstream: null,
ahead: null,
behind: null,
}));
render(<LaneChangesPanel laneId={7} />);
expect(await screen.findByTestId("lane-git")).toHaveTextContent("feat/thing");
const before = vi.mocked(api.lanes.changes).mock.calls.length;
branch = "other";
act(() => emitGitChanged(7, "switch"));
await waitFor(() => expect(screen.getByTestId("lane-git")).toHaveTextContent("other"));
expect(vi.mocked(api.lanes.changes).mock.calls.length).toBeGreaterThan(before);
// Another workspace's write is not this panel's business.
const now = vi.mocked(api.lanes.git).mock.calls.length;
act(() => emitGitChanged(8, "switch"));
await new Promise((r) => setTimeout(r, 20));
expect(vi.mocked(api.lanes.git).mock.calls.length).toBe(now);
});
it("re-reads history too when Refresh is pressed there", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
@@ -544,6 +674,71 @@ describe("LaneChangesPanel", () => {
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
});
it("opens a whole HTML file as its source, the page in a modal after Preview", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
entries: [{ name: "deck.html", path: "deck.html", dir: false, status: "??" }],
truncated: false,
});
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "deck.html",
rev: "work",
binary: false,
truncated: false,
lines: ["<p>deck</p>"],
sha: "c".repeat(40),
});
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(new Response("<p>deck</p>", { status: 200 }));
try {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />);
const sidebar = await screen.findByTestId("lane-changes-sidebar");
await user.click(await within(sidebar).findByText("deck.html"));
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("<p>deck</p>");
expect(screen.queryByTestId("html-frame")).toBeNull();
await user.click(screen.getByTestId("lane-file-preview"));
const modal = await screen.findByTestId("html-viewer");
const frame = await within(modal).findByTestId("html-frame");
expect(frame.getAttribute("srcdoc")).toContain("<p>deck</p>");
expect(frame.getAttribute("sandbox")).not.toContain("allow-same-origin");
await user.keyboard("{Escape}");
await waitFor(() => expect(screen.queryByTestId("html-viewer")).toBeNull());
// The source stayed where it was, under the modal.
expect(screen.getByTestId("lane-file-editor")).toHaveValue("<p>deck</p>");
} finally {
fetchSpy.mockRestore();
}
});
it("offers Preview on a changed HTML file's Changes, opening the page in a modal", async () => {
vi.mocked(api.lanes.changes).mockResolvedValue(
changes({
files: [
{ path: "deck.html", status: "??", untracked: true, added: 1, deleted: 0, binary: false },
],
})
);
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(new Response("<p>deck</p>", { status: 200 }));
try {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await user.click(await screen.findByTitle("deck.html"));
await screen.findByRole("button", { name: "Changes", pressed: true });
await user.click(await screen.findByTestId("lane-file-preview"));
const modal = await screen.findByTestId("html-viewer");
expect(await within(modal).findByTestId("html-frame")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Changes", pressed: true })).toBeInTheDocument();
} finally {
fetchSpy.mockRestore();
}
});
it("shows an image file as the picture, with no edit control", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
@@ -772,22 +967,37 @@ describe("LaneChangesPanel", () => {
});
});
it("discards every file at once only after the dialog is confirmed", async () => {
it("discards only the ticked files, and only after the dialog is confirmed (#87)", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
// notes.txt (unversioned) starts unticked; untick docs/API.md too.
await user.click(tickOf("docs/API.md"));
vi.mocked(confirmDialog).mockClear();
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
await user.click(screen.getByTestId("lane-changes-discard-all"));
expect(api.lanes.gitAction).not.toHaveBeenCalled();
// The confirm button carries its count, and a tracked-only selection
// says nothing about new files to delete.
expect(confirmDialog).toHaveBeenCalledWith(
expect.objectContaining({
confirmLabel: "Discard (1)",
message: expect.not.stringMatching(/new file|\{\{/),
})
);
await user.click(screen.getByTestId("lane-changes-discard-all"));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
paths: ["server/lib/git-ops.js", "notes.txt", "docs/API.md"],
paths: ["server/lib/git-ops.js"],
confirm: true,
})
);
// Nothing ticked: nothing to discard.
await user.click(tickOf("server/lib/git-ops.js"));
expect(screen.getByTestId("lane-changes-discard-all")).toBeDisabled();
});
it("right-click on a changed file offers Commit File, a two-click discard, copy path and patch", async () => {
@@ -882,10 +1092,11 @@ describe("LaneChangesPanel", () => {
within(screen.getByTestId("lane-changes-file-menu")).getByText("Show History")
);
await waitFor(() =>
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, {
limit: 50,
file: "server/lib/git-ops.js",
})
expect(api.lanes.gitHistory.fileHistory).toHaveBeenLastCalledWith(
7,
"server/lib/git-ops.js",
{ limit: 100 }
)
);
expect(screen.getByTestId("lane-changes-history-file")).toHaveTextContent(
"server/lib/git-ops.js"
@@ -1012,7 +1223,7 @@ describe("LaneChangesPanel", () => {
await user.click(screen.getByRole("button", { name: "History" }));
expect(await screen.findAllByTestId("lane-changes-graph")).toHaveLength(1);
await user.click(await screen.findByText("feat: the commit"));
await user.dblClick(await screen.findByText("feat: the commit"));
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
@@ -1070,8 +1281,21 @@ describe("LaneChangesPanel", () => {
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "History" }));
vi.mocked(api.lanes.gitHistory.pick).mockResolvedValue({
action: "revert",
ok: true,
output: "",
conflicts: [],
paused: false,
from: "b".repeat(40),
to: "c".repeat(40),
commits: [{ sha: "c".repeat(40), short: "ccccccc", subject: 'Revert "x"' }],
});
await user.click(within(await commitMenu(user)).getByText("Revert this commit"));
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "revert", { sha: "a".repeat(40) });
await user.click(await screen.findByTestId("pick-confirm"));
await waitFor(() =>
expect(api.lanes.gitHistory.pick).toHaveBeenCalledWith(7, "revert", ["a".repeat(40)])
);
await user.click(within(await commitMenu(user)).getByText("Undo commit (keep changes)"));
await waitFor(() =>
@@ -1108,10 +1332,22 @@ describe("LaneChangesPanel", () => {
await user.selectOptions(await screen.findByTestId("lane-changes-history-ref"), "other");
await waitFor(() =>
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, { limit: 50, ref: "other" })
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, { limit: 100, ref: "other" })
);
vi.mocked(api.lanes.gitHistory.pick).mockResolvedValue({
action: "cherry-pick",
ok: true,
output: "",
conflicts: [],
paused: false,
});
await user.click(within(await commitMenu(user)).getByText("Cherry-pick onto feat/thing"));
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "cherry-pick", { sha: "a".repeat(40) });
await user.click(await screen.findByTestId("pick-confirm"));
await waitFor(() =>
expect(api.lanes.gitHistory.pick).toHaveBeenCalledWith(7, "cherry-pick", [
"a".repeat(40),
])
);
});
it("asks before a revert and an amend, and a No sends nothing", async () => {
@@ -1127,9 +1363,11 @@ describe("LaneChangesPanel", () => {
expect(confirmDialog).toHaveBeenCalledTimes(1);
await user.click(screen.getByRole("button", { name: "History" }));
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
// A revert previews what it will do; Cancel sends nothing.
await user.click(within(await commitMenu(user)).getByText("Revert this commit"));
await waitFor(() => expect(confirmDialog).toHaveBeenCalledTimes(2));
await screen.findByTestId("pick-plan");
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(api.lanes.gitHistory.pick).not.toHaveBeenCalled();
expect(api.lanes.gitAction).not.toHaveBeenCalled();
});
@@ -1608,6 +1846,25 @@ describe("LaneChangesPanel", () => {
expect(api.lanes.gitAction).not.toHaveBeenCalled();
});
it("discards only the ticked change blocks of a partly ticked file (#87)", async () => {
const user = userEvent.setup();
await openReview(user);
await user.click(
screen.getAllByRole("checkbox", { name: "Include this change in the commit" })[1]!
);
await user.click(screen.getByRole("button", { name: "Changes" }));
await screen.findByTestId("lane-changes-commit");
await user.click(screen.getByTestId("lane-changes-discard-all"));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
paths: [],
hunks: { "server/lib/git-ops.js": [{ hunk: "@@ -1,2 +1,3 @@", blocks: [0] }] },
context: 3,
confirm: true,
})
);
});
it("commits only the ticked change blocks of a partly ticked file (hunks)", async () => {
const user = userEvent.setup();
await openReview(user);
@@ -609,3 +609,26 @@ describe("LaneDiffView — line comments", () => {
expect(screen.queryByTestId("diff-comment-line-1")).toBeNull();
});
});
describe("LaneDiffView — a row the server cut down", () => {
const CUT: LaneChangeFile = {
...FILE,
hunks: [
{
...FILE.hunks![0]!,
lines: [
{ type: "del", oldLine: 1, newLine: null, text: '<video src="AAAA', elided: 3_000_000 },
{ type: "add", oldLine: null, newLine: 1, text: '<video src="BBBB', elided: 3_000_000 },
],
},
],
};
it("says how much of the row it hides, in both layouts", () => {
const { unmount } = render(<LaneDiffView file={CUT} layout="unified" />);
expect(screen.getAllByText(/3M more characters on this line/)).toHaveLength(2);
unmount();
render(<LaneDiffView file={CUT} layout="split" />);
expect(screen.getAllByText(/3M more characters on this line/)).toHaveLength(2);
});
});
@@ -3,8 +3,11 @@
* @description Pins the lane git action row: the ahead/behind readout, which
* buttons a repo with no remote may press (and that it may publish to a typed URL),
* what an unborn repo and a branch with no upstream show, that it offers no commit (the
* Changes panel's staged-only box owns that), that a dirty tree blocks the branch switch client-side (the server
* refuses it too), and that a failed action shows git's own words rather than
* Changes panel's staged-only box owns that), that a dirty tree's switch asks
* whether to bring the changes or shelve them (and offers the shelf when git
* refuses to carry them), Create & switch, the offer to restore a branch's
* shelf after switching to it, the confirm-and-resend of EAGENTRUNNING, that branches only the remote has are offered (and the list
* is re-read after a fetch), and that a failed action shows git's own words rather than
* a generic sentence.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -201,16 +204,114 @@ describe("LaneGitBar", () => {
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "push", {}));
});
it("loads the branch list on demand and refuses to switch while dirty", async () => {
it("loads the branch list on demand and asks how to take the changes while dirty", async () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(3));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
// Picking is harmless; applying is what a dirty tree blocks.
expect(screen.getByRole("button", { name: "Switch branch" })).toBeDisabled();
await user.click(screen.getByRole("button", { name: "Switch branch" }));
// Nothing is sent before the user picks what happens to the changes.
expect(api.lanes.gitAction).not.toHaveBeenCalled();
const choice = screen.getByTestId("lane-git-switch-choice");
expect(choice).toHaveTextContent("3 uncommitted file(s)");
await user.click(screen.getByRole("button", { name: "Bring my changes" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "switch", {
branch: "development",
changes: "carry",
})
);
});
it("offers Shelve and switch when git refuses to carry the changes", async () => {
vi.mocked(api.lanes.gitAction).mockRejectedValueOnce(
Object.assign(new Error("would be overwritten"), {
code: "ECARRYCONFLICT",
files: ["src/a-very-long-path/that/should/truncate.ts"],
})
);
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await user.click(screen.getByRole("button", { name: "Bring my changes" }));
expect(await screen.findByTestId("lane-git-carry-conflicts")).toHaveTextContent(
"src/a-very-long-path/that/should/truncate.ts"
);
expect(screen.queryByRole("button", { name: "Bring my changes" })).toBeNull();
await user.click(screen.getByRole("button", { name: "Shelve and switch" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "switch", {
branch: "development",
changes: "shelve",
})
);
});
it("asks before moving files under a working agent, then resends with confirm", async () => {
vi.mocked(api.lanes.gitAction).mockRejectedValueOnce(
Object.assign(new Error("agent"), { code: "EAGENTRUNNING" })
);
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await user.click(screen.getByRole("button", { name: "Shelve and switch" }));
await waitFor(() =>
expect(confirmDialog).toHaveBeenCalledWith(
expect.objectContaining({ title: "An agent is working here" })
)
);
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "switch", {
branch: "development",
changes: "shelve",
confirm: true,
})
);
});
it("offers to restore the shelf of the branch switched to, and restores it on Yes", async () => {
const shelf = {
ref: "stash@{0}",
sha: "abc1234def",
branch: "development",
note: "",
createdAt: "2026-11-02T00:00:00.000Z",
files: [{ path: "a.txt", status: "M" }],
};
vi.mocked(api.lanes.gitAction)
.mockResolvedValueOnce({ action: "switch", ok: true, output: "", shelves: [shelf] })
.mockResolvedValueOnce({ action: "unshelve", ok: true, output: "", conflicts: [] });
const user = userEvent.setup();
const onChanged = vi.fn();
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={onChanged} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "unshelve", { sha: "abc1234def" })
);
expect(confirmDialog).toHaveBeenCalledWith(
expect.objectContaining({ title: "Restore shelved changes?" })
);
});
it("creates and switches in one step — the default on Enter while dirty", async () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.type(await screen.findByLabelText("New branch name…"), "fix/ac-515{Enter}");
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "branch-create", {
branch: "fix/ac-515",
switch: true,
})
);
});
it("switches branch on a clean tree", async () => {
@@ -236,6 +337,43 @@ describe("LaneGitBar", () => {
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(2));
});
it("lists branches only the remote has, switches to one, and re-reads the list after a fetch", async () => {
vi.mocked(api.lanes.gitBranches).mockResolvedValue({
available: true,
branches: ["development", "feat/thing"],
remotes: ["origin/feat/new"],
current: "feat/thing",
worktrees: [],
});
const user = userEvent.setup();
render(
<LaneGitBar
laneId={3}
git={facts({ dirty: 0, untracked: 0 })}
onChanged={vi.fn()}
onNewLane={vi.fn()}
/>
);
await user.click(screen.getByRole("button", { name: /Branch/ }));
const select = await screen.findByLabelText("Switch branch…");
expect(
screen.getByRole("group", { name: "On the remote — switching creates the local branch" })
).toBeInTheDocument();
await user.selectOptions(select, "origin/feat/new");
// Not a local branch yet: nothing to delete, no worktree to start on it.
expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled();
expect(screen.getByRole("button", { name: /New workspace here/ })).toBeDisabled();
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "switch", { branch: "origin/feat/new" })
);
vi.mocked(api.lanes.gitBranches).mockClear();
await user.click(screen.getByRole("button", { name: /Fetch/ }));
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "fetch", {}));
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(1));
});
it("creates a branch without switching, then offers a sibling lane on it", async () => {
const user = userEvent.setup();
const onNewLane = vi.fn();
@@ -0,0 +1,106 @@
/**
* @file LaneShelves.test.tsx
* @description Pins the Changes panel's Shelves list: hidden while there are
* none, one collapsed line otherwise, each shelf's branch, note and files,
* Restore sending `unshelve` by sha (and the conflict outcome told in a
* toast), Delete needing a second click before `shelf-drop` with
* `confirm: true`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LaneShelves } from "../LaneShelves";
import { api } from "../../../lib/api";
import { toast } from "../../../lib/toast";
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
vi.mock("../../../lib/toast", () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
vi.mock("../../../lib/api", () => ({
api: { lanes: { gitAction: vi.fn(), gitShelves: vi.fn() } },
}));
const shelf = {
ref: "stash@{0}",
sha: "abc1234def5678",
branch: "fix/ac-515-a-rather-long-branch-name",
note: "half way through the login fix",
createdAt: "2026-11-02T03:04:05.000Z",
files: [
{ path: "src/login.ts", status: "M" },
{ path: "src/new.ts", status: "??" },
],
};
beforeEach(() => {
vi.mocked(api.lanes.gitShelves)
.mockReset()
.mockResolvedValue({
available: true,
shelves: [shelf],
});
vi.mocked(api.lanes.gitAction).mockReset().mockResolvedValue({
action: "unshelve",
ok: true,
output: "",
conflicts: [],
});
});
describe("LaneShelves", () => {
it("renders nothing while there are no shelves", async () => {
vi.mocked(api.lanes.gitShelves).mockResolvedValue({ available: true, shelves: [] });
const { container } = render(<LaneShelves target={3} reread={0} onChanged={vi.fn()} />);
await waitFor(() => expect(api.lanes.gitShelves).toHaveBeenCalledWith(3));
expect(container).toBeEmptyDOMElement();
});
it("lists a shelf with its branch, note and files", async () => {
const user = userEvent.setup();
render(<LaneShelves target={3} reread={0} onChanged={vi.fn()} />);
await user.click(await screen.findByRole("button", { name: /Shelves \(1\)/ }));
const row = screen.getByTestId("lane-shelf");
expect(row).toHaveTextContent(shelf.branch);
expect(row).toHaveTextContent(shelf.note);
await user.click(screen.getByRole("button", { name: "2 file(s)" }));
expect(screen.getByTestId("lane-shelf-files")).toHaveTextContent("src/new.ts");
});
it("restores by sha and reports a conflicted restore", async () => {
vi.mocked(api.lanes.gitAction).mockResolvedValueOnce({
action: "unshelve",
ok: true,
output: "",
conflicts: ["src/login.ts"],
kept: true,
});
const user = userEvent.setup();
const onChanged = vi.fn();
render(<LaneShelves target={3} reread={0} onChanged={onChanged} />);
await user.click(await screen.findByRole("button", { name: /Shelves/ }));
await user.click(screen.getByRole("button", { name: "Restore" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "unshelve", { sha: shelf.sha })
);
expect(onChanged).toHaveBeenCalled();
expect(toast.info).toHaveBeenCalledWith(expect.stringContaining("1 conflicted file"));
});
it("deletes only on the second click, with confirm", async () => {
const user = userEvent.setup();
render(<LaneShelves target={3} reread={0} onChanged={vi.fn()} />);
await user.click(await screen.findByRole("button", { name: /Shelves/ }));
await user.click(screen.getByRole("button", { name: "Delete" }));
expect(api.lanes.gitAction).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Delete for good?" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "shelf-drop", {
sha: shelf.sha,
confirm: true,
})
);
});
});
@@ -0,0 +1,156 @@
/**
* @file Blame / annotate at any revision (JetBrains' Annotate, GitLens'
* blame): the file's lines with, on the first line of each run, the commit
* that last changed it — short sha, author, relative date, summary. A commit
* opens its whole change; "Blame before this change" re-reads the file at
* that commit's parent (`previous`). Rows are windowed, so a 20 000-line
* file scrolls as fast as a short one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type GitTarget } from "../../../lib/api";
import type { BlameAt } from "../../../lib/gitHistory";
import { timeAgo } from "../../../lib/format";
import { GitModal } from "./GitModal";
const ROW = 18;
const OVERSCAN = 40;
export function BlameView({
target,
path,
rev,
onClose,
onOpenCommit,
}: {
target: GitTarget;
path: string;
/** A commit, or `work` for the file on disk. */
rev: string;
onClose: () => void;
onOpenCommit: (sha: string) => void;
}) {
const { t } = useTranslation(["gitHistory", "lanes"]);
const [at, setAt] = useState({ path, rev });
const [blame, setBlame] = useState<BlameAt | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let live = true;
setBlame(null);
setError(null);
void api.lanes.gitHistory
.blameAt(target, at.path, at.rev)
.then((r) => live && (r.available ? setBlame(r) : setError(t("noBlame"))))
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- target is stable
}, [at.path, at.rev]);
// Shade alternate commit runs so a run reads as one block.
const runs = useMemo(() => {
const starts: boolean[] = [];
const band: number[] = [];
let b = 0;
blame?.lines.forEach((l, i) => {
const start = i === 0 || blame.lines[i - 1]!.sha !== l.sha;
if (start && i) b++;
starts.push(start);
band.push(b);
});
return { starts, band };
}, [blame]);
const box = useRef<HTMLDivElement>(null);
const [top, setTop] = useState(0);
const height = 800;
const n = blame?.lines.length ?? 0;
const first = Math.max(0, Math.floor(top / ROW) - OVERSCAN);
const last = Math.min(n, Math.ceil((top + height) / ROW) + OVERSCAN);
return (
<GitModal
tall
testId="blame-view"
onClose={onClose}
title={
<span>
{t("blameTitle", { path: at.path })}{" "}
<span className="font-mono text-fg-muted">
@ {at.rev === "work" ? t("workingTree") : at.rev.slice(0, 7)}
</span>
</span>
}
>
{error && <p className="text-[12px] text-status-danger [overflow-wrap:anywhere]">{error}</p>}
{!blame && !error && (
<p className="text-[12px] text-fg-muted">{t("lanes:changes.loading")}</p>
)}
{blame && (
<div
ref={box}
onScroll={(e) => setTop(e.currentTarget.scrollTop)}
className="min-h-0 flex-1 overflow-auto font-mono text-[11px] leading-[18px]"
>
<div style={{ height: n * ROW, position: "relative", minWidth: "100%" }}>
{blame.lines.slice(first, last).map((l, k) => {
const i = first + k;
const c = l.sha ? blame.commits[l.sha] : null;
return (
<div
key={i}
data-testid="blame-line"
style={{ position: "absolute", top: i * ROW, height: ROW, left: 0, right: 0 }}
className={`flex ${runs.band[i]! % 2 ? "bg-surface-2/60" : ""}`}
>
<span className="flex w-[16rem] shrink-0 items-center gap-1 overflow-hidden border-r border-border-light pr-1 text-fg-muted">
{runs.starts[i] &&
(c && l.sha ? (
<>
<button
type="button"
onClick={() => onOpenCommit(l.sha!)}
title={`${c.summary ?? ""}\n${c.author ?? ""} · ${c.date ? new Date(c.date).toLocaleString() : ""}`}
className="shrink-0 text-accent hover:underline"
>
{c.short}
</button>
<span className="min-w-0 truncate font-sans" title={c.author}>
{c.author}
</span>
<span className="shrink-0 font-sans">
{c.date ? timeAgo(c.date, true) : ""}
</span>
{c.previous && (
<button
type="button"
title={t("blameBefore")}
aria-label={t("blameBefore")}
onClick={() => setAt({ path: at.path, rev: c.previous! })}
className="shrink-0 font-sans hover:text-fg-primary"
>
↶
</button>
)}
</>
) : (
<span className="font-sans italic">{t("notCommitted")}</span>
))}
</span>
<span className="w-10 shrink-0 select-none pr-1 text-right text-fg-muted">
{i + 1}
</span>
<span className="whitespace-pre pl-1 text-fg-primary">{l.text}</span>
</div>
);
})}
</div>
</div>
)}
</GitModal>
);
}
@@ -0,0 +1,641 @@
/**
* @file The git bar's Branches panel (JetBrains' branch popup, GitLens'
* Branches view): a dialog over the workspace listing every local branch
* (current marked, upstream with ahead/behind, last commit, why a delete would
* be refused) and the fetched remote branches grouped by remote, filtered by a
* search box and walked with the arrow keys; picking a row opens its actions —
* new branch from it (optionally switching to it on a clean tree), rename,
* set/unset upstream, merge into the current branch, delete. Its Tags tab is
* `TagManager`. Everything it may do is decided by the server
* (`GET …/git/refs`, `POST …/git/:action`, server/lib/git-refs.js); a disabled
* button here is a courtesy, and git's own text is shown when it refuses.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowDown,
ArrowUp,
Check,
GitBranch,
GitMerge,
Link2,
Link2Off,
Pencil,
Plus,
Search,
Tag,
Trash2,
X,
} from "lucide-react";
import {
api,
gitTarget,
type GitLocalBranch,
type GitRefs,
type GitRemoteBranch,
type GitSource,
} from "../../../lib/api";
import { useDialog } from "../../../hooks/useDialog";
import { confirmDialog } from "../../../lib/confirm";
import { toast } from "../../../lib/toast";
import { isSubmitEnter } from "../../../lib/keys";
import { timeAgo } from "../../../lib/format";
import { Checkbox } from "../../Checkbox";
import { TagManager } from "./TagManager";
import { onListKeys, smallBtn, smallInput, type RunRefAction } from "./refUi";
type Mode = "new" | "rename" | "upstream" | null;
export function BranchManager({
laneId,
session,
source,
dirty,
merging,
onClose,
onChanged,
}: {
laneId: number;
session?: string;
source?: GitSource;
/** Uncommitted files: a "switch to it" is refused on a dirty tree. */
dirty: number;
/** A merge, cherry-pick or revert is open: every write here is refused. */
merging: boolean;
onClose: () => void;
/** Called after every write, so the git bar and the Changes panel re-read. */
onChanged: () => void;
}) {
const { t } = useTranslation(["gitRefs"]);
const titleId = useId();
const panelRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
useDialog(true, panelRef, onClose, searchRef);
const target = gitTarget(laneId, session, source);
const [refs, setRefs] = useState<GitRefs | null>(null);
const [tab, setTab] = useState<"branches" | "tags">("branches");
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<string | null>(null);
const [mode, setMode] = useState<Mode>(null);
const [text, setText] = useState("");
const [switchTo, setSwitchTo] = useState(false);
const [upstream, setUpstream] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const inFlight = useRef(false);
const load = useCallback(async () => {
try {
setRefs(await api.lanes.gitRefs(target));
} catch {
setRefs({
available: false,
current: null,
head: null,
local: [],
remotes: [],
tags: [],
truncated: false,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [laneId, session, source]);
useEffect(() => {
void load();
}, [load]);
const run: RunRefAction = async (action, body, done) => {
if (inFlight.current) return false;
inFlight.current = true;
setBusy(true);
setError(null);
try {
await api.lanes.gitRefAction(target, action, body);
if (done) toast.success(done);
setMode(null);
setText("");
await load();
onChanged();
return true;
} catch (err) {
const code = (err as { code?: string })?.code;
setError(
code === "EGITAUTH"
? t("errLogin")
: code === "ESUBDIR"
? t("errSubdir")
: err instanceof Error
? err.message
: String(err)
);
return false;
} finally {
inFlight.current = false;
setBusy(false);
}
};
const q = query.trim().toLowerCase();
const match = (s: string) => !q || s.toLowerCase().includes(q);
const local = useMemo(
() => (refs?.local || []).filter((b) => match(b.name)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[refs, q]
);
const remoteGroups = useMemo(
() =>
(refs?.remotes || [])
.map((g) => ({ ...g, branches: g.branches.filter((b) => match(b.name)) }))
.filter((g) => g.branches.length),
// eslint-disable-next-line react-hooks/exhaustive-deps
[refs, q]
);
const allRemote = useMemo(
() => (refs?.remotes || []).flatMap((g) => g.branches.map((b) => b.name)),
[refs]
);
const current = refs?.current ?? null;
const pick = (key: string) => {
setSelected((s) => (s === key ? null : key));
setMode(null);
setText("");
setError(null);
};
const open = (m: Mode, initial = "") => {
setMode((v) => (v === m ? null : m));
setText(initial);
setSwitchTo(false);
setError(null);
};
const merge = async (branch: string) => {
const ok = await confirmDialog({
title: t("merge"),
message: t("mergeConfirm", { branch, current: current ?? "HEAD" }),
confirmLabel: t("merge"),
destructive: false,
});
if (ok) await run("merge", { branch }, t("done.merge", { branch }));
};
const remove = async (b: GitLocalBranch) => {
const ok = await confirmDialog({
title: t("delete"),
message: b.merged
? t("deleteConfirm", { branch: b.name })
: t("deleteUnmerged", { branch: b.name }),
confirmLabel: t("delete"),
destructive: true,
});
if (ok) {
if (await run("branch-delete", { branch: b.name }, t("done.delete", { branch: b.name })))
setSelected(null);
}
};
/** Why `git branch -d` would refuse this branch, or null. */
const deleteBlock = (b: GitLocalBranch) =>
b.current
? t("why.current")
: b.protected
? t("why.protected")
: b.worktree
? t("why.worktree", { path: b.worktree })
: null;
const newBranchForm = (from: string) => (
<div className="flex flex-wrap items-center gap-1.5">
<input
autoFocus
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e) && text.trim())
void run(
"branch-create",
{ branch: text.trim(), from, ...(switchTo ? { switch: true } : {}) },
t("done.create", { branch: text.trim() })
);
}}
placeholder={t("newBranchName")}
aria-label={t("newBranchName")}
className={smallInput}
/>
<Checkbox
checked={switchTo}
onChange={setSwitchTo}
disabled={dirty > 0 || merging}
label={t("switchToIt")}
/>
<button
type="button"
className={smallBtn}
disabled={busy || !text.trim()}
onClick={() =>
void run(
"branch-create",
{ branch: text.trim(), from, ...(switchTo ? { switch: true } : {}) },
t("done.create", { branch: text.trim() })
)
}
>
<Plus className="h-3 w-3" />
{t("create")}
</button>
{dirty > 0 && <span className="text-[10px] text-fg-muted">{t("switchDirty")}</span>}
</div>
);
const localActions = (b: GitLocalBranch) => {
const block = deleteBlock(b);
return (
<div className="space-y-1.5 border-t border-border-light px-2 py-1.5">
<div className="flex flex-wrap gap-1">
<button type="button" className={smallBtn} disabled={busy} onClick={() => open("new")}>
<Plus className="h-3 w-3" />
{t("newFrom")}
</button>
<button
type="button"
className={smallBtn}
disabled={busy || b.protected || merging}
title={b.protected ? t("why.protected") : t("renameHint")}
onClick={() => open("rename", b.name)}
>
<Pencil className="h-3 w-3" />
{t("rename")}
</button>
<button
type="button"
className={smallBtn}
disabled={busy || merging || !allRemote.length}
title={allRemote.length ? t("upstreamHint") : t("noRemoteBranches")}
onClick={() => {
open("upstream");
setUpstream(b.upstream ?? "");
}}
>
<Link2 className="h-3 w-3" />
{t("setUpstream")}
</button>
{b.upstream && (
<button
type="button"
className={smallBtn}
disabled={busy || merging}
onClick={() =>
void run("branch-upstream", { branch: b.name }, t("done.unsetUpstream"))
}
>
<Link2Off className="h-3 w-3" />
{t("unsetUpstream")}
</button>
)}
{!b.current && (
<button
type="button"
className={smallBtn}
disabled={busy || merging || dirty > 0 || !current}
title={dirty > 0 ? t("switchDirty") : t("mergeHint", { current: current ?? "" })}
onClick={() => void merge(b.name)}
>
<GitMerge className="h-3 w-3" />
{t("mergeInto", { current: current ?? "HEAD" })}
</button>
)}
<button
type="button"
className={`${smallBtn} hover:text-status-danger`}
disabled={busy || !!block}
title={block ?? (b.merged ? t("deleteHint") : t("why.unmerged"))}
onClick={() => void remove(b)}
>
<Trash2 className="h-3 w-3" />
{t("delete")}
</button>
</div>
{block && <p className="text-[10px] text-fg-muted [overflow-wrap:anywhere]">{block}</p>}
{!block && !b.merged && (
<p className="text-[10px] text-status-warning">{t("why.unmerged")}</p>
)}
{mode === "new" && newBranchForm(b.name)}
{mode === "rename" && (
<div className="flex items-center gap-1.5">
<input
autoFocus
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e) && text.trim() && text.trim() !== b.name)
void run(
"branch-rename",
{ branch: b.name, name: text.trim() },
t("done.rename", { branch: text.trim() })
);
}}
aria-label={t("renameTo")}
placeholder={t("renameTo")}
className={smallInput}
/>
<button
type="button"
className={smallBtn}
disabled={busy || !text.trim() || text.trim() === b.name}
onClick={() =>
void run(
"branch-rename",
{ branch: b.name, name: text.trim() },
t("done.rename", { branch: text.trim() })
)
}
>
<Check className="h-3 w-3" />
{t("rename")}
</button>
</div>
)}
{mode === "upstream" && (
<div className="flex items-center gap-1.5">
<select
value={upstream}
onChange={(e) => setUpstream(e.target.value)}
aria-label={t("setUpstream")}
className="select min-w-0 flex-1 !rounded !py-0.5 !pl-2 !pr-6 font-mono !text-[11px]"
>
<option value="">{t("pickUpstream")}</option>
{allRemote.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<button
type="button"
className={smallBtn}
disabled={busy || !upstream || upstream === b.upstream}
onClick={() =>
void run(
"branch-upstream",
{ branch: b.name, upstream },
t("done.setUpstream", { upstream })
)
}
>
<Check className="h-3 w-3" />
{t("apply")}
</button>
</div>
)}
</div>
);
};
const remoteActions = (r: GitRemoteBranch) => (
<div className="space-y-1.5 border-t border-border-light px-2 py-1.5">
<div className="flex flex-wrap gap-1">
<button
type="button"
className={smallBtn}
disabled={busy}
onClick={() => open("new", r.branch)}
>
<Plus className="h-3 w-3" />
{t("newFrom")}
</button>
<button
type="button"
className={smallBtn}
disabled={busy || merging || dirty > 0 || !current}
title={dirty > 0 ? t("switchDirty") : t("mergeHint", { current: current ?? "" })}
onClick={() => void merge(r.name)}
>
<GitMerge className="h-3 w-3" />
{t("mergeInto", { current: current ?? "HEAD" })}
</button>
</div>
{mode === "new" && newBranchForm(r.name)}
</div>
);
const row = (
key: string,
name: string,
sub: React.ReactNode,
badges: React.ReactNode,
actions: () => React.ReactNode
) => (
<li key={key} className={selected === key ? "rounded bg-surface-2" : undefined}>
<button
type="button"
data-ref-row
aria-expanded={selected === key}
onClick={() => pick(key)}
className="flex w-full min-w-0 items-start gap-2 rounded px-2 py-1 text-left hover:bg-surface-3 focus:outline-none focus-visible:ring-1 focus-visible:ring-accent"
>
<GitBranch className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" aria-hidden />
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-1.5">
<span title={name} className="truncate font-mono text-xs text-fg-primary">
{name}
</span>
{badges}
</span>
<span className="block truncate text-[10px] text-fg-muted">{sub}</span>
</span>
</button>
{selected === key && actions()}
</li>
);
const badge = (label: string, tone = "text-fg-muted border-border-light") => (
<span className={`shrink-0 rounded border px-1 text-[9px] uppercase ${tone}`}>{label}</span>
);
const localRow = (b: GitLocalBranch) =>
row(
`l:${b.name}`,
b.name,
<>
{b.subject}
{b.date ? ` · ${timeAgo(b.date)}` : ""}
</>,
<>
{b.current && badge(t("current"), "text-accent border-accent/40")}
{b.protected && badge(t("protected"))}
{b.worktree && (
<span title={b.worktree} className="shrink-0">
{badge(t("worktree"))}
</span>
)}
{b.upstream && (
<span
title={b.upstream}
className="flex shrink-0 items-center gap-0.5 font-mono text-[10px] text-fg-muted"
>
{b.gone ? (
<span className="text-status-warning">{t("gone")}</span>
) : (
<>
{(b.ahead ?? 0) > 0 && (
<span className="flex items-center text-status-warning">
<ArrowUp className="h-3 w-3" aria-label={t("ahead")} />
{b.ahead}
</span>
)}
{(b.behind ?? 0) > 0 && (
<span className="flex items-center text-accent">
<ArrowDown className="h-3 w-3" aria-label={t("behind")} />
{b.behind}
</span>
)}
{!b.ahead && !b.behind && <Check className="h-3 w-3" aria-label={t("inSync")} />}
</>
)}
</span>
)}
</>,
() => localActions(b)
);
const remoteRow = (r: GitRemoteBranch) =>
row(
`r:${r.name}`,
r.name,
<>
{r.subject}
{r.date ? ` · ${timeAgo(r.date)}` : ""}
</>,
null,
() => remoteActions(r)
);
const tabBtn = (id: "branches" | "tags", label: string, icon: React.ReactNode) => (
<button
type="button"
role="tab"
aria-selected={tab === id}
onClick={() => {
setTab(id);
setSelected(null);
setMode(null);
setError(null);
}}
className={`flex items-center gap-1 border-b-2 px-2 py-1 text-xs ${
tab === id
? "border-accent text-fg-primary"
: "border-transparent text-fg-muted hover:text-fg-primary"
}`}
>
{icon}
{label}
</button>
);
return (
<div
className="fixed inset-0 z-[55] flex items-start justify-center bg-black/60 p-4 sm:pt-16"
onClick={onClose}
role="presentation"
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
data-testid="branch-manager"
onClick={(e) => e.stopPropagation()}
className="flex max-h-full w-full max-w-xl min-w-0 flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
>
<div className="flex items-center gap-2 border-b border-border-light px-3 pt-2">
<h3 id={titleId} className="sr-only">
{t("title")}
</h3>
<div role="tablist" className="flex min-w-0 flex-1 gap-1">
{tabBtn("branches", t("branches"), <GitBranch className="h-3.5 w-3.5" />)}
{tabBtn("tags", t("tags"), <Tag className="h-3.5 w-3.5" />)}
</div>
<button
type="button"
className="btn-icon -mt-1"
aria-label={t("close")}
onClick={onClose}
>
<X className="h-4 w-4" aria-hidden />
</button>
</div>
<div className="flex items-center gap-1.5 px-3 py-2">
<Search className="h-3.5 w-3.5 shrink-0 text-fg-muted" aria-hidden />
<input
ref={searchRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "ArrowDown") {
e.preventDefault();
panelRef.current?.querySelector<HTMLElement>("[data-ref-row]")?.focus();
}
}}
placeholder={tab === "tags" ? t("searchTags") : t("searchBranches")}
aria-label={tab === "tags" ? t("searchTags") : t("searchBranches")}
className={`${smallInput} !text-xs`}
/>
</div>
{merging && <p className="px-3 pb-1 text-[11px] text-status-warning">{t("merging")}</p>}
{error && (
<pre
role="alert"
data-testid="branch-manager-error"
className="mx-3 mb-2 max-h-24 overflow-auto whitespace-pre-wrap rounded border border-status-danger/40 bg-status-danger/10 px-2 py-1 text-[10px] text-status-danger [overflow-wrap:anywhere]"
>
{error}
</pre>
)}
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3" onKeyDown={onListKeys}>
{!refs ? (
<p className="px-2 py-4 text-xs text-fg-muted">{t("loading")}</p>
) : !refs.available ? (
<p className="px-2 py-4 text-xs text-fg-muted">{t("unavailable")}</p>
) : tab === "tags" ? (
<TagManager
tags={(refs.tags || []).filter((x) => match(x.name))}
busy={busy}
merging={merging}
dirty={dirty}
run={run}
/>
) : (
<>
<h4 className="px-2 pb-1 pt-1 text-[10px] font-semibold uppercase tracking-wide text-fg-muted">
{t("local")}
</h4>
{local.length ? (
<ul>{local.map(localRow)}</ul>
) : (
<p className="px-2 text-xs text-fg-muted">{t("noMatch")}</p>
)}
{remoteGroups.map((g) => (
<section key={g.remote}>
<h4
title={g.remote}
className="truncate px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wide text-fg-muted"
>
{t("remote", { remote: g.remote })}
</h4>
<ul>{g.branches.map(remoteRow)}</ul>
</section>
))}
{refs.truncated && (
<p className="px-2 pt-2 text-[10px] text-fg-muted">{t("truncated")}</p>
)}
</>
)}
</div>
</div>
</div>
);
}
export default BranchManager;
@@ -0,0 +1,71 @@
/**
* @file The git bar's "Branches & tags" icon button, which owns the Branches
* panel (`BranchManager`) it opens — portalled to `document.body`, so no
* scrolling or transformed ancestor of the Changes panel can clip it. The
* panel closes when the bar starts showing another workspace.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Tags } from "lucide-react";
import type { GitSource } from "../../../lib/api";
import { BranchManager } from "./BranchManager";
export function BranchesButton({
laneId,
session,
source,
dirty,
merging,
disabled,
onChanged,
}: {
laneId: number;
session?: string;
source?: GitSource;
dirty: number;
merging: boolean;
disabled?: boolean;
onChanged: () => void;
}) {
const { t } = useTranslation(["gitRefs"]);
const [open, setOpen] = useState(false);
const [shown, setShown] = useState(`${laneId}|${session ?? ""}|${source ?? ""}`);
const key = `${laneId}|${session ?? ""}|${source ?? ""}`;
if (shown !== key) {
setShown(key);
setOpen(false);
}
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
disabled={disabled}
title={t("openPanelHint")}
aria-label={t("openPanel")}
aria-haspopup="dialog"
className="flex h-6 w-6 items-center justify-center rounded text-[11px] text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
>
<Tags className="h-3.5 w-3.5" />
</button>
{open &&
createPortal(
<BranchManager
laneId={laneId}
session={session}
source={source}
dirty={dirty}
merging={merging}
onClose={() => setOpen(false)}
onChanged={onChanged}
/>,
document.body
)}
</>
);
}
export default BranchesButton;
@@ -0,0 +1,175 @@
/**
* @file Browse files at a revision (GitHub's "Browse files", GitLens' "Open
* files at revision"): a read-only tree of the workspace folder as it was at
* one commit — folders open on a click, read lazily — and the picked file's
* text (or image) beside it, with Blame at that commit. Every read goes
* through the server's own guards: a secret file is neither listed nor
* readable, a big file is capped, a binary one is named, not shown.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, File, Folder } from "lucide-react";
import { api, type GitTarget } from "../../../lib/api";
import type { TreeAtEntry } from "../../../lib/gitHistory";
import type { LaneFileContent } from "../../../lib/types";
import { LaneFileView } from "../LaneDiffView";
import { GitModal } from "./GitModal";
import { BlameView } from "./BlameView";
const IMAGE = /\.(png|jpe?g|gif|webp|avif|bmp|ico)$/i;
export function BrowseAtRevision({
target,
sha,
short,
onClose,
}: {
target: GitTarget;
sha: string;
short: string;
onClose: () => void;
}) {
const { t } = useTranslation(["gitHistory", "lanes"]);
// Folder path → its entries; "" is the workspace folder.
const [tree, setTree] = useState<Record<string, TreeAtEntry[]>>({});
const [open, setOpen] = useState<Set<string>>(new Set([""]));
const [file, setFile] = useState<string | null>(null);
const [content, setContent] = useState<LaneFileContent | null>(null);
const [error, setError] = useState<string | null>(null);
const [blame, setBlame] = useState(false);
const readDir = (dir: string) => {
if (tree[dir]) return;
void api.lanes.gitHistory
.treeAt(target, sha, dir)
.then((r) => r.available && setTree((m) => ({ ...m, [dir]: r.entries })))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
};
useEffect(() => {
readDir("");
// eslint-disable-next-line react-hooks/exhaustive-deps -- once per commit
}, [sha]);
const openFile = (p: string) => {
setFile(p);
setContent(null);
setError(null);
if (IMAGE.test(p)) return;
void api.lanes
.gitFile(target, p, sha)
.then(setContent)
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
};
const toggleDir = (p: string) => {
setOpen((s) => {
const next = new Set(s);
if (next.has(p)) next.delete(p);
else next.add(p);
return next;
});
readDir(p);
};
const rows = (dir: string, depth: number): ReactNode =>
(tree[dir] ?? []).map((e) => (
<li key={e.path}>
<button
type="button"
onClick={() => (e.dir ? toggleDir(e.path) : e.submodule ? undefined : openFile(e.path))}
title={e.path}
style={{ paddingLeft: depth * 12 + 4 }}
className={`flex w-full items-center gap-1 rounded py-0.5 pr-1 text-left text-[11px] hover:bg-surface-2 ${
file === e.path ? "bg-surface-3 text-fg-primary" : "text-fg-secondary"
}`}
>
{e.dir ? (
open.has(e.path) ? (
<ChevronDown className="h-3 w-3 shrink-0" aria-hidden />
) : (
<ChevronRight className="h-3 w-3 shrink-0" aria-hidden />
)
) : (
<span className="w-3 shrink-0" />
)}
{e.dir ? (
<Folder className="h-3 w-3 shrink-0 text-fg-muted" aria-hidden />
) : (
<File className="h-3 w-3 shrink-0 text-fg-muted" aria-hidden />
)}
<span className="min-w-0 truncate">{e.name}</span>
</button>
{e.dir && open.has(e.path) && <ul>{rows(e.path, depth + 1)}</ul>}
</li>
));
return (
<GitModal
tall
testId="browse-at-revision"
onClose={onClose}
title={t("browseTitle", { short })}
>
<div className="flex min-h-0 flex-1 flex-col gap-2 sm:flex-row">
<ul
data-testid="browse-tree"
className="max-h-[35vh] shrink-0 overflow-auto rounded border border-border-light p-1 sm:max-h-none sm:w-64"
>
{tree[""] ? (
rows("", 0)
) : (
<li className="text-[11px] text-fg-muted">{t("lanes:changes.loading")}</li>
)}
</ul>
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
{file && (
<div className="mb-1 flex items-center gap-1.5 text-[11px]">
<span className="min-w-0 flex-1 font-mono text-fg-primary [overflow-wrap:anywhere]">
{file}
</span>
{!IMAGE.test(file) && (
<button
type="button"
onClick={() => setBlame(true)}
className="shrink-0 rounded border border-border-light px-1.5 py-0.5 text-fg-secondary hover:bg-surface-2"
>
{t("blame")}
</button>
)}
</div>
)}
{error && (
<p role="alert" className="text-[12px] text-status-danger [overflow-wrap:anywhere]">
{error}
</p>
)}
{!file && !error && <p className="text-[12px] text-fg-muted">{t("pickAFile")}</p>}
{file && IMAGE.test(file) && (
<img
src={api.lanes.gitRawUrl(target, file, sha)}
alt={file}
className="max-h-full max-w-full self-start object-contain"
/>
)}
{file && content && content.available && content.binary && (
<p className="text-[12px] text-fg-muted">{t("binaryFile")}</p>
)}
{file && content && content.available && !content.binary && (
<LaneFileView path={file} lines={content.lines} truncated={content.truncated} fill />
)}
</div>
</div>
{blame && file && (
<BlameView
target={target}
path={file}
rev={sha}
onClose={() => setBlame(false)}
onOpenCommit={() => setBlame(false)}
/>
)}
</GitModal>
);
}
@@ -0,0 +1,152 @@
/**
* @file The confirmation a cherry-pick or revert opens with, showing exactly
* what will happen before anything does (the reference tools' preview): the
* commits in the order git will apply them (numbered — read from the server,
* which orders them the same way the action will), the branch they land on,
* whether the working tree is clean, and what happens on a conflict. Confirm
* is disabled, with the reason, when the server would refuse (a dirty tree, a
* subfolder workspace, another operation open, a merge commit).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { CheckCircle2, AlertTriangle } from "lucide-react";
import { api, type GitTarget } from "../../../lib/api";
import type { PickPlan, PickResult } from "../../../lib/gitHistory";
import { GitModal, ModalButton } from "./GitModal";
export function CherryPickDialog({
target,
op,
shas,
onClose,
onDone,
}: {
target: GitTarget;
op: "cherry-pick" | "revert";
shas: string[];
onClose: () => void;
onDone: (r: PickResult) => void;
}) {
const { t } = useTranslation(["gitHistory"]);
const [plan, setPlan] = useState<PickPlan | null>(null);
const [error, setError] = useState<string | null>(null);
const [running, setRunning] = useState(false);
useEffect(() => {
let live = true;
void api.lanes.gitHistory
.pickPlan(target, op, shas)
.then((r) => live && (r.available ? setPlan(r) : setError(t("planUnavailable"))))
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- one plan per dialog
}, []);
const branch = plan?.branch ?? "HEAD";
const blocker = !plan
? null
: plan.busy
? t("blockBusy")
: !plan.repoRoot
? t("blockSubdir")
: !plan.clean
? t("blockDirty")
: null;
const n = plan?.commits.length ?? shas.length;
const title =
op === "revert" ? t("revertTitle", { count: n }) : t("pickTitle", { count: n, branch });
const run = async () => {
setRunning(true);
setError(null);
try {
onDone(
await api.lanes.gitHistory.pick(
target,
op,
plan!.commits.map((c) => c.sha)
)
);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setRunning(false);
}
};
return (
<GitModal
title={title}
onClose={onClose}
testId="pick-dialog"
footer={
<>
<ModalButton onClick={onClose}>{t("cancel")}</ModalButton>
<ModalButton
primary
testId="pick-confirm"
disabled={!plan || !!blocker || running}
title={blocker ?? undefined}
onClick={() => void run()}
>
{running
? t("running")
: op === "revert"
? t("revertN", { count: n })
: t("pickN", { count: n, branch })}
</ModalButton>
</>
}
>
<div className="space-y-2 text-[12px] text-fg-secondary">
{!plan && !error && <p className="text-fg-muted">{t("planning")}</p>}
{plan && (
<>
<p className="[overflow-wrap:anywhere]">
{op === "revert"
? t("revertIntro", { count: n, branch })
: t("pickIntro", { count: n, branch })}
</p>
<ol
data-testid="pick-plan"
className="space-y-0.5 rounded border border-border-light bg-surface-0 p-1.5"
>
{plan.commits.map((c, i) => (
<li key={c.sha} className="flex min-w-0 items-baseline gap-1.5">
<span className="w-5 shrink-0 text-right text-fg-muted">{i + 1}.</span>
<span className="shrink-0 font-mono text-[11px] text-fg-muted">{c.short}</span>
<span className="min-w-0 flex-1 truncate text-fg-primary" title={c.subject}>
{c.subject}
</span>
</li>
))}
</ol>
<p className="flex items-center gap-1.5">
{plan.clean && plan.repoRoot && !plan.busy ? (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-status-success" aria-hidden />
) : (
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-status-warning" aria-hidden />
)}
<span data-testid="pick-tree-state" className="[overflow-wrap:anywhere]">
{blocker ?? t("treeClean")}
</span>
</p>
<p className="text-fg-muted">{t(op === "revert" ? "revertResult" : "pickResult")}</p>
<p className="text-fg-muted">{t("onConflict", { op: t(`op.${op}`) })}</p>
</>
)}
{error && (
<p
role="alert"
className="whitespace-pre-wrap text-status-danger [overflow-wrap:anywhere]"
>
{error}
</p>
)}
</div>
</GitModal>
);
}
@@ -0,0 +1,203 @@
/**
* @file The History tab's commit details pane (GitKraken's / JetBrains'
* right-hand commit panel): the full message, author and committer with
* dates, parents (each opens that commit), the commit's refs and the branches
* and tags that contain it, its changed files with +/- counts (a file opens
* that commit's diff of it in the panel's own diff viewer), and Copy SHA,
* Copy patch, Browse files, Compare with current and View all changes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import { api, type GitTarget } from "../../../lib/api";
import type { CommitDetails as Details } from "../../../lib/gitHistory";
import type { LaneChangeFile } from "../../../lib/types";
import { copyText } from "../../../lib/clipboard";
import { RefChip, sortBadges } from "./CommitGraph";
export function CommitDetails({
target,
sha,
onClose,
onOpenCommit,
onViewChanges,
onOpenFile,
onBrowse,
onCompare,
onCopyPatch,
}: {
target: GitTarget;
sha: string;
onClose: () => void;
/** A parent clicked: focus that commit. */
onOpenCommit: (sha: string) => void;
onViewChanges: () => void;
onOpenFile: (path: string) => void;
onBrowse: () => void;
onCompare: () => void;
onCopyPatch: () => void;
}) {
const { t } = useTranslation(["gitHistory", "lanes"]);
const [details, setDetails] = useState<Details | null>(null);
const [files, setFiles] = useState<LaneChangeFile[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let live = true;
void api.lanes.gitHistory
.commit(target, sha)
.then((r) => live && (r.available ? setDetails(r) : setError(t("noDetails"))))
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
void api.lanes
.changes(target, { range: "commit", sha })
.then((r) => live && setFiles(r.available ? r.files : []))
.catch(() => live && setFiles([]));
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on sha by the parent
}, [sha]);
const button =
"shrink-0 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary";
const added = files?.reduce((n, f) => n + (f.added ?? 0), 0) ?? 0;
const deleted = files?.reduce((n, f) => n + (f.deleted ?? 0), 0) ?? 0;
return (
<section
data-testid="commit-details"
aria-label={t("detailsTitle")}
className="max-h-[45%] min-h-[6rem] shrink-0 overflow-y-auto rounded border border-border-light bg-surface-0 p-2 text-[11px]"
>
<div className="mb-1 flex items-start gap-1.5">
<span className="font-mono text-fg-muted">{sha.slice(0, 10)}</span>
<span className="min-w-0 flex-1" />
<button
type="button"
aria-label={t("close")}
onClick={onClose}
className="shrink-0 text-fg-muted hover:text-fg-primary"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
{error && <p className="text-status-danger [overflow-wrap:anywhere]">{error}</p>}
{!details && !error && <p className="text-fg-muted">{t("lanes:changes.loading")}</p>}
{details && (
<>
<p
data-testid="commit-details-message"
className="mb-1.5 whitespace-pre-wrap text-fg-primary [overflow-wrap:anywhere]"
>
{details.message}
</p>
<dl className="mb-1.5 grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 text-fg-secondary">
<dt className="text-fg-muted">{t("author")}</dt>
<dd className="min-w-0 [overflow-wrap:anywhere]">
{details.author} &lt;{details.email}&gt; · {new Date(details.date).toLocaleString()}
</dd>
{(details.committer !== details.author || details.committedAt !== details.date) && (
<>
<dt className="text-fg-muted">{t("committer")}</dt>
<dd className="min-w-0 [overflow-wrap:anywhere]">
{details.committer} · {new Date(details.committedAt).toLocaleString()}
</dd>
</>
)}
<dt className="text-fg-muted">{t("parents", { count: details.parents.length })}</dt>
<dd className="flex min-w-0 flex-wrap gap-1">
{details.parents.length === 0 && <span>{t("rootCommit")}</span>}
{details.parents.map((p) => (
<button
key={p}
type="button"
onClick={() => onOpenCommit(p)}
className="font-mono text-accent hover:underline"
>
{p.slice(0, 7)}
</button>
))}
</dd>
{details.badges.length > 0 && (
<>
<dt className="text-fg-muted">{t("refs")}</dt>
<dd className="flex min-w-0 flex-wrap gap-1">
{sortBadges(details.badges).map((b) => (
<RefChip key={`${b.kind}:${b.name}`} badge={b} />
))}
</dd>
</>
)}
{(details.branches.length > 0 || details.tags.length > 0) && (
<>
<dt className="text-fg-muted">{t("containedIn")}</dt>
<dd className="min-w-0 [overflow-wrap:anywhere]">
{[...details.branches, ...details.tags.map((x) => `tag ${x}`)].join(", ")}
{details.containsTruncated ? " …" : ""}
</dd>
</>
)}
</dl>
<div className="mb-1.5 flex flex-wrap gap-1">
<button type="button" className={button} onClick={onViewChanges}>
{t("viewAllChanges")}
</button>
<button type="button" className={button} onClick={() => void copyText(details.sha)}>
{t("lanes:changes.copySha")}
</button>
<button
type="button"
className={button}
disabled={details.parents.length > 1}
onClick={onCopyPatch}
>
{t("copyPatch")}
</button>
<button type="button" className={button} onClick={onBrowse}>
{t("browseFiles")}
</button>
<button type="button" className={button} onClick={onCompare}>
{t("compareWithCurrent")}
</button>
</div>
</>
)}
{files && (
<>
<p className="mb-0.5 text-fg-muted">
{t("filesChanged", { count: files.length })}{" "}
<span className="text-status-success">+{added}</span>{" "}
<span className="text-status-danger">−{deleted}</span>
</p>
<ul data-testid="commit-details-files">
{files.map((f) => (
<li key={f.path}>
<button
type="button"
onClick={() => onOpenFile(f.path)}
title={f.path}
className="flex w-full items-baseline gap-1.5 rounded px-1 text-left hover:bg-surface-2"
>
<span className="w-3 shrink-0 font-mono text-fg-muted">{f.status.trim()}</span>
<span className="min-w-0 flex-1 text-fg-primary [overflow-wrap:anywhere]">
{f.path}
</span>
{f.binary ? (
<span className="shrink-0 text-fg-muted">{t("binary")}</span>
) : (
<span className="shrink-0 font-mono">
<span className="text-status-success">+{f.added ?? 0}</span>{" "}
<span className="text-status-danger">−{f.deleted ?? 0}</span>
</span>
)}
</button>
</li>
))}
</ul>
</>
)}
</section>
);
}
@@ -0,0 +1,182 @@
/**
* @file The History tab's commit rows, drawn like VS Code's Source Control
* Graph / GitLens: the graph cell (lanes and merge edges from
* `lib/commitGraph`), the ref badges (current branch, local and remote
* branches, tags, a detached HEAD), the subject, author and relative date.
* Colours are theme tokens (CSS variables), so a lane reads in every theme.
* Long subjects and names truncate with the whole text in a tooltip.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { memo } from "react";
import { useTranslation } from "react-i18next";
import { Cloud, GitBranch, Tag, Crosshair } from "lucide-react";
import type { GraphRow } from "../../../lib/commitGraph";
import type { RefBadge } from "../../../lib/gitHistory";
import { timeAgo } from "../../../lib/format";
/** One fixed-height row per commit so the lanes join up row to row. */
export const GRAPH_ROW = 30;
const GRAPH_COL = 12;
/** Columns drawn at most; a wider graph is clipped, not the row. */
const MAX_COLS = 12;
/** Theme tokens, cycled per lane. */
const GRAPH_COLORS = [
"rgb(var(--accent))",
"rgb(var(--status-success))",
"rgb(var(--status-warning))",
"rgb(var(--syn-property))",
"rgb(var(--status-danger))",
"rgb(var(--syn-function))",
"rgb(var(--kept))",
"rgb(var(--diff-mod))",
];
const hue = (i: number) => GRAPH_COLORS[i % GRAPH_COLORS.length];
export function GraphCell({
row,
width,
merge,
head,
}: {
row: GraphRow;
width: number;
/** A merge commit: a hollow node. */
merge?: boolean;
/** HEAD: a ringed node. */
head?: boolean;
}) {
const cols = Math.min(width, MAX_COLS);
const x = (col: number) => col * GRAPH_COL + GRAPH_COL / 2;
const y = (f: number) => f * GRAPH_ROW;
return (
<svg
aria-hidden
width={cols * GRAPH_COL}
height={GRAPH_ROW}
className="shrink-0 overflow-hidden"
data-testid="lane-changes-graph"
>
{row.segs.map((g, i) => {
const ym = (y(g.y1) + y(g.y2)) / 2;
return (
<path
key={i}
d={`M${x(g.x1)} ${y(g.y1)} C${x(g.x1)} ${ym} ${x(g.x2)} ${ym} ${x(g.x2)} ${y(g.y2)}`}
fill="none"
stroke={hue(g.color)}
strokeWidth={1.5}
/>
);
})}
{head && (
<circle
cx={x(row.col)}
cy={y(0.5)}
r={6}
fill="none"
stroke={hue(row.color)}
strokeWidth={1.5}
/>
)}
<circle
cx={x(row.col)}
cy={y(0.5)}
r={3.5}
fill={merge ? "rgb(var(--surface-1))" : hue(row.color)}
stroke={hue(row.color)}
strokeWidth={merge ? 1.5 : 0}
/>
</svg>
);
}
/** One ref badge: the kind's icon, the name truncated, the full name on hover. */
export function RefChip({ badge }: { badge: RefBadge }) {
const { t } = useTranslation(["gitHistory"]);
const Icon =
badge.kind === "tag"
? Tag
: badge.kind === "remote"
? Cloud
: badge.kind === "head"
? Crosshair
: GitBranch;
const tone = badge.current
? "border-accent/60 bg-accent/15 text-accent font-semibold"
: badge.kind === "tag"
? "border-status-warning/50 text-status-warning"
: badge.kind === "remote"
? "border-border text-fg-muted"
: badge.kind === "head"
? "border-status-danger/50 text-status-danger"
: "border-status-success/50 text-status-success";
const label = t(`badge.${badge.kind}`, { name: badge.name });
return (
<span
title={badge.current ? t("badge.current", { name: badge.name }) : label}
data-testid="ref-badge"
data-kind={badge.kind}
className={`inline-flex max-w-[10rem] shrink items-center gap-0.5 rounded border px-1 text-[10px] leading-4 ${tone}`}
>
<Icon className="h-2.5 w-2.5 shrink-0" aria-hidden />
<span className="truncate">{badge.name}</span>
</span>
);
}
/** Badges in the order the reference tools show them: HEAD's branch first. */
export function sortBadges(badges: RefBadge[]): RefBadge[] {
const rank = (b: RefBadge) =>
b.current ? 0 : b.kind === "head" ? 1 : b.kind === "local" ? 2 : b.kind === "remote" ? 3 : 4;
return [...badges].sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name));
}
export interface GraphCommit {
sha: string;
short: string;
subject: string;
author: string;
email?: string;
date: string;
parents: string[];
badges?: RefBadge[];
}
/** The text half of a history row (the graph cell is drawn beside it). */
export const CommitText = memo(function CommitText({ c }: { c: GraphCommit }) {
const badges = sortBadges(c.badges ?? []);
const when = new Date(c.date);
return (
<span className="flex min-w-0 flex-1 flex-col justify-center">
<span className="flex min-w-0 items-center gap-1">
{badges.slice(0, 4).map((b) => (
<RefChip key={`${b.kind}:${b.name}`} badge={b} />
))}
{badges.length > 4 && (
<span
className="shrink-0 text-[10px] text-fg-muted"
title={badges
.slice(4)
.map((b) => b.name)
.join(", ")}
>
+{badges.length - 4}
</span>
)}
<span className="min-w-0 flex-1 truncate text-[11px] text-fg-primary" title={c.subject}>
{c.subject}
</span>
</span>
<span className="flex min-w-0 items-baseline gap-1.5 text-[10px] text-fg-muted">
<span className="shrink-0 font-mono">{c.short}</span>
<span className="min-w-0 truncate" title={c.email ? `${c.author} <${c.email}>` : c.author}>
{c.author}
</span>
<span className="shrink-0" title={when.toLocaleString()}>
{timeAgo(c.date)}
</span>
</span>
</span>
);
});
@@ -0,0 +1,245 @@
/**
* @file Compare any two refs (GitLens' Search & Compare, GitHub's compare
* view): branches, tags or commits A and B; the commits only in A and only in
* B, and the files that differ — "Changes since they diverged" (A...B, what B
* adds; GitHub's default) or "Difference between the two" (A B, the trees as
* they stand). A file opens its diff in the panel's diff viewer; a commit
* opens its whole change. Swap flips the two sides.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ArrowLeftRight } from "lucide-react";
import { api, type GitTarget } from "../../../lib/api";
import type { CompareResult, CompareFile } from "../../../lib/gitHistory";
import { LaneDiffView } from "../LaneDiffView";
import { GitModal } from "./GitModal";
type Tab = "files" | "onlyA" | "onlyB";
export function CompareRefs({
target,
initialA,
initialB,
refs,
onClose,
onOpenCommit,
}: {
target: GitTarget;
initialA: string;
initialB: string;
/** Branch names offered in the inputs' suggestion lists. */
refs: string[];
onClose: () => void;
onOpenCommit: (sha: string) => void;
}) {
const { t } = useTranslation(["gitHistory", "lanes"]);
const [a, setA] = useState(initialA);
const [b, setB] = useState(initialB);
const [asked, setAsked] = useState({ a: initialA, b: initialB });
const [mode, setMode] = useState<"three" | "two">("three");
const [result, setResult] = useState<CompareResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<Tab>("files");
const [file, setFile] = useState<CompareFile["file"] | null>(null);
const [fileLoading, setFileLoading] = useState(false);
useEffect(() => {
let live = true;
setResult(null);
setError(null);
setFile(null);
void api.lanes.gitHistory
.compare(target, asked.a, asked.b, mode)
.then((r) => live && (r.available ? setResult(r) : setError(t("compareUnavailable"))))
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- target is stable
}, [asked, mode]);
const openFile = (path: string, from?: string) => {
if (!result) return;
setFileLoading(true);
void api.lanes.gitHistory
.compareFile(target, result.a, result.b, mode, path, from)
.then((r) => r.available && setFile(r.file))
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
.finally(() => setFileLoading(false));
};
const input =
"min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-1.5 py-0.5 font-mono text-[11px] text-fg-primary";
const tabBtn = (k: Tab, label: string) => (
<button
key={k}
type="button"
aria-pressed={tab === k}
onClick={() => {
setTab(k);
setFile(null);
}}
className={`rounded px-2 py-0.5 text-[11px] ${
tab === k ? "bg-accent-solid text-white" : "text-fg-secondary hover:bg-surface-3"
}`}
>
{label}
</button>
);
const commits = tab === "onlyA" ? result?.onlyA : result?.onlyB;
return (
<GitModal tall testId="compare-refs" onClose={onClose} title={t("compareTitle")}>
<form
className="mb-2 flex flex-wrap items-center gap-1.5"
onSubmit={(e) => {
e.preventDefault();
if (a.trim() && b.trim()) setAsked({ a: a.trim(), b: b.trim() });
}}
>
<datalist id="compare-refs-list">
{refs.map((r) => (
<option key={r} value={r} />
))}
</datalist>
<input
aria-label={t("compareA")}
data-testid="compare-a"
list="compare-refs-list"
value={a}
maxLength={200}
onChange={(e) => setA(e.target.value)}
className={input}
/>
<button
type="button"
aria-label={t("swap")}
title={t("swap")}
onClick={() => {
setA(b);
setB(a);
setAsked({ a: asked.b, b: asked.a });
}}
className="shrink-0 rounded p-1 text-fg-secondary hover:bg-surface-2"
>
<ArrowLeftRight className="h-3.5 w-3.5" />
</button>
<input
aria-label={t("compareB")}
data-testid="compare-b"
list="compare-refs-list"
value={b}
maxLength={200}
onChange={(e) => setB(e.target.value)}
className={input}
/>
<button
type="submit"
className="shrink-0 rounded bg-accent-solid px-2 py-0.5 text-[11px] text-white hover:bg-accent-solid-hover"
>
{t("compare")}
</button>
<select
data-testid="compare-mode"
value={mode}
onChange={(e) => setMode(e.target.value === "two" ? "two" : "three")}
className="min-w-0 rounded border border-border-light bg-surface-0 px-1 py-0.5 text-[11px] text-fg-primary"
>
<option value="three">{t("modeThree")}</option>
<option value="two">{t("modeTwo")}</option>
</select>
</form>
{error && (
<p role="alert" className="text-[12px] text-status-danger [overflow-wrap:anywhere]">
{error}
</p>
)}
{!result && !error && (
<p className="text-[12px] text-fg-muted">{t("lanes:changes.loading")}</p>
)}
{result && (
<>
<div className="mb-1.5 flex flex-wrap gap-1 rounded border border-border bg-surface-2 p-0.5">
{tabBtn("files", t("tabFiles", { count: result.files.length }))}
{tabBtn("onlyA", t("tabOnly", { name: asked.a, count: result.onlyA.length }))}
{tabBtn("onlyB", t("tabOnly", { name: asked.b, count: result.onlyB.length }))}
</div>
{tab === "files" && !file && (
<ul data-testid="compare-files" className="min-h-0 flex-1 overflow-auto">
{result.files.length === 0 && (
<li className="text-[12px] text-fg-muted">{t("noDifference")}</li>
)}
{result.files.map((f) => (
<li key={f.path}>
<button
type="button"
disabled={fileLoading}
onClick={() => openFile(f.path, f.from)}
className="flex w-full items-baseline gap-1.5 rounded px-1 text-left text-[11px] hover:bg-surface-2"
>
<span className="w-3 shrink-0 font-mono text-fg-muted">{f.status.trim()}</span>
<span className="min-w-0 flex-1 text-fg-primary [overflow-wrap:anywhere]">
{f.from ? `${f.from} → ${f.path}` : f.path}
</span>
<span className="shrink-0 font-mono">
<span className="text-status-success">+{f.added}</span>{" "}
<span className="text-status-danger">−{f.deleted}</span>
</span>
</button>
</li>
))}
{result.filesTruncated && (
<li className="text-[11px] text-status-warning">{t("lanes:changes.truncated")}</li>
)}
</ul>
)}
{tab === "files" && file && (
<div className="flex min-h-0 flex-1 flex-col">
<button
type="button"
onClick={() => setFile(null)}
className="mb-1 self-start rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2"
>
{t("backToFiles")}
</button>
{file.secret ? (
<p className="text-[12px] text-fg-muted">{t("secretFile")}</p>
) : file.hunks?.length ? (
<LaneDiffView file={file} layout="unified" fill />
) : (
<p className="text-[12px] text-fg-muted">{t("noTextChange")}</p>
)}
</div>
)}
{tab !== "files" && (
<ul className="min-h-0 flex-1 overflow-auto">
{commits?.length === 0 && (
<li className="text-[12px] text-fg-muted">{t("noCommitsSide")}</li>
)}
{commits?.map((c) => (
<li key={c.sha}>
<button
type="button"
onClick={() => onOpenCommit(c.sha)}
className="flex w-full items-baseline gap-1.5 rounded px-1 text-left text-[11px] hover:bg-surface-2"
>
<span className="shrink-0 font-mono text-fg-muted">{c.short}</span>
<span className="min-w-0 flex-1 truncate text-fg-primary" title={c.subject}>
{c.subject}
</span>
<span className="min-w-0 max-w-[30%] truncate text-fg-muted">{c.author}</span>
</button>
</li>
))}
{result.commitsTruncated && (
<li className="text-[11px] text-status-warning">{t("lanes:changes.truncated")}</li>
)}
</ul>
)}
</>
)}
</GitModal>
);
}
@@ -0,0 +1,128 @@
/**
* @file "Create branch here…" and "Create tag here…" from a History commit's
* menu: a name (and, for a tag, an optional message making it annotated),
* then git-refs.js's `branch-create {branch, from}` / `tag-create {tag,
* message?, from}` with that commit's sha as `from` — the server confirms the
* sha itself. Creating a branch never switches to it here. Announces
* `agenthub:git-changed` so the other views of the workspace read again.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState, type KeyboardEvent } from "react";
import { useTranslation } from "react-i18next";
import { api, type GitTarget } from "../../../lib/api";
import { toast } from "../../../lib/toast";
import { isSubmitEnter } from "../../../lib/keys";
import { emitGitChanged } from "../../../lib/gitChanged";
import { GitModal, ModalButton } from "./GitModal";
export function CreateBranchDialog({
target,
sha,
short,
kind = "branch",
onClose,
onDone,
}: {
target: GitTarget;
sha: string;
short: string;
kind?: "branch" | "tag";
onClose: () => void;
onDone: () => void;
}) {
const { t } = useTranslation(["gitHistory"]);
const [name, setName] = useState("");
const [message, setMessage] = useState("");
const [error, setError] = useState<string | null>(null);
const [running, setRunning] = useState(false);
const tag = kind === "tag";
const run = async () => {
const ref = name.trim();
if (!ref) return;
setRunning(true);
setError(null);
const action = tag ? "tag-create" : "branch-create";
try {
await api.lanes.gitRefAction(
target,
action,
tag
? { tag: ref, from: sha, ...(message.trim() ? { message: message.trim() } : {}) }
: { branch: ref, from: sha }
);
toast.success(t(tag ? "tagCreated" : "branchCreated", { name: ref, branch: ref, short }));
onDone();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setRunning(false);
} finally {
emitGitChanged(typeof target === "number" ? target : target.lane, action);
}
};
const submit = (e: KeyboardEvent) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void run();
}
};
const input =
"rounded border border-border-light bg-surface-0 px-2 py-1 font-mono text-[12px] text-fg-primary";
return (
<GitModal
testId="create-ref"
onClose={onClose}
title={t(tag ? "createTagTitle" : "createBranchTitle", { short })}
footer={
<>
<ModalButton onClick={onClose}>{t("cancel")}</ModalButton>
<ModalButton
primary
testId="create-ref-confirm"
disabled={!name.trim() || running}
onClick={() => void run()}
>
{t(tag ? "createTag" : "createBranch")}
</ModalButton>
</>
}
>
<label className="flex flex-col gap-1 text-[12px] text-fg-secondary">
{t(tag ? "tagName" : "branchName")}
<input
autoFocus
data-testid="create-ref-name"
value={name}
maxLength={200}
onChange={(e) => setName(e.target.value)}
onKeyDown={submit}
className={input}
/>
</label>
{tag && (
<label className="mt-2 flex flex-col gap-1 text-[12px] text-fg-secondary">
{t("tagMessage")}
<input
data-testid="create-ref-message"
value={message}
maxLength={1000}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={submit}
className={input}
/>
</label>
)}
<p className="mt-1.5 text-[11px] text-fg-muted">
{t(tag ? "createTagHint" : "createBranchHint")}
</p>
{error && (
<p role="alert" className="mt-1.5 text-[12px] text-status-danger [overflow-wrap:anywhere]">
{error}
</p>
)}
</GitModal>
);
}
@@ -0,0 +1,194 @@
/**
* @file One file's history (Show History), like GitLens' File History: every
* commit that touched it, newest first, followed across renames (`git log
* --follow`). A version opens that commit's diff of the file under the name
* it had there (the panel's own diff viewer); Blame opens the file as it was
* at that version with each line's commit (BlameView), and a rename says
* where it came from. Paged with Load more.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type GitTarget } from "../../../lib/api";
import type { FileVersion } from "../../../lib/gitHistory";
import { timeAgo } from "../../../lib/format";
import { BlameView } from "./BlameView";
const PAGE = 100;
export function FileHistory({
target,
path,
reread,
onOpenVersion,
onViewCommit,
onWholeBranch,
}: {
target: GitTarget;
path: string;
reread: number;
/** That version's diff of the file: its name THEN, and the commit. */
onOpenVersion: (path: string, sha: string) => void;
onViewCommit: (sha: string) => void;
onWholeBranch: () => void;
}) {
const { t } = useTranslation(["gitHistory", "lanes"]);
const [versions, setVersions] = useState<FileVersion[] | null>(null);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [blame, setBlame] = useState<{ path: string; rev: string } | null>(null);
const seq = useRef(0);
useEffect(() => {
const mine = ++seq.current;
setVersions(null);
setError(null);
setLoading(true);
void api.lanes.gitHistory
.fileHistory(target, path, { limit: PAGE })
.then((r) => {
if (seq.current !== mine) return;
setVersions(r.available ? r.commits : []);
setHasMore(r.available && r.hasMore);
})
.catch(
(err) => seq.current === mine && setError(err instanceof Error ? err.message : String(err))
)
.finally(() => seq.current === mine && setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps -- target is stable per panel
}, [path, reread]);
const more = useCallback(() => {
if (!versions || loading) return;
const mine = ++seq.current;
setLoading(true);
void api.lanes.gitHistory
.fileHistory(target, path, { limit: PAGE, skip: versions.length })
.then((r) => {
if (seq.current !== mine || !r.available) return;
setVersions((v) => [...(v ?? []), ...r.commits]);
setHasMore(r.hasMore);
})
.catch(
(err) => seq.current === mine && setError(err instanceof Error ? err.message : String(err))
)
.finally(() => seq.current === mine && setLoading(false));
}, [versions, loading, target, path]);
return (
<div data-testid="file-history" className="flex min-h-0 flex-col gap-1">
<div
data-testid="lane-changes-history-file"
className="flex items-center gap-1.5 px-1 text-[11px] text-fg-secondary"
>
<span className="min-w-0 flex-1 [overflow-wrap:anywhere]">
{t("lanes:changes.historyOfFile", { path })}
</span>
<button
type="button"
onClick={() => setBlame({ path, rev: "work" })}
className="shrink-0 rounded border border-border-light px-1.5 py-0.5 text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
>
{t("blame")}
</button>
<button
type="button"
onClick={onWholeBranch}
className="shrink-0 rounded px-1 text-fg-muted hover:bg-surface-2 hover:text-fg-primary"
>
{t("lanes:changes.historyWholeBranch")}
</button>
</div>
{error && (
<p role="alert" className="px-1 text-[11px] text-status-danger [overflow-wrap:anywhere]">
{error}
</p>
)}
{versions === null && !error && (
<p className="px-1 text-[11px] text-fg-muted">{t("lanes:changes.loading")}</p>
)}
{versions && versions.length === 0 && (
<p className="px-1 text-[11px] text-fg-muted">{t("lanes:changes.noCommits")}</p>
)}
<ul>
{versions?.map((v) => (
<li key={v.sha} className="group flex items-center gap-1 rounded px-1 hover:bg-surface-2">
<button
type="button"
onClick={() => onOpenVersion(v.path, v.sha)}
className="flex min-w-0 flex-1 flex-col py-0.5 text-left"
>
<span className="flex min-w-0 items-baseline gap-1.5">
<span
className="w-3 shrink-0 font-mono text-[10px] text-fg-muted"
title={t(`status.${v.status}`, { defaultValue: v.status })}
>
{v.status}
</span>
<span className="shrink-0 font-mono text-[10px] text-fg-muted">{v.short}</span>
<span
className="min-w-0 flex-1 truncate text-[11px] text-fg-primary"
title={v.subject}
>
{v.subject}
</span>
</span>
<span className="flex min-w-0 items-baseline gap-1.5 pl-[1.2rem] text-[10px] text-fg-muted">
<span className="min-w-0 truncate">{v.author}</span>
<span className="shrink-0" title={new Date(v.date).toLocaleString()}>
{timeAgo(v.date)}
</span>
{v.from && (
<span className="min-w-0 [overflow-wrap:anywhere]">
{t("renamedFrom", { from: v.from })}
</span>
)}
</span>
</button>
{v.status !== "D" && (
<button
type="button"
onClick={() => setBlame({ path: v.path, rev: v.sha })}
className="shrink-0 rounded px-1 text-[10px] text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
{t("blame")}
</button>
)}
<button
type="button"
onClick={() => onViewCommit(v.sha)}
className="shrink-0 rounded px-1 text-[10px] text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
{t("commit")}
</button>
</li>
))}
</ul>
{hasMore && (
<button
type="button"
data-testid="file-history-more"
disabled={loading}
onClick={more}
className="w-full rounded px-1 py-1 text-[11px] text-fg-secondary hover:bg-surface-2 disabled:opacity-40"
>
{loading ? t("lanes:changes.loading") : t("lanes:changes.loadMore")}
</button>
)}
{blame && (
<BlameView
target={target}
path={blame.path}
rev={blame.rev}
onClose={() => setBlame(null)}
onOpenCommit={(sha) => {
setBlame(null);
onViewCommit(sha);
}}
/>
)}
</div>
);
}
@@ -0,0 +1,119 @@
/**
* @file ForcePushDialog.tsx
* @description "Force push (with lease)" for the CURRENT branch only: shows
* where it goes (remote and branch), the remote commit it will replace (the
* lease — the server refuses with ESTALE if the remote moved since the last
* fetch), how far ahead/behind HEAD is, and refuses a protected branch unless
* the server allows it. Push is a two-click (`useArmed`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type { GitTarget } from "../../../lib/api";
import { errCode, errText, gitRewrite, type PushPlan } from "../../../lib/gitRewrite";
import { toast } from "../../../lib/toast";
import { useArmed } from "../../../hooks/useArmed";
import { BTN, BTN_DANGER, ErrorLine, GitModal, Warn } from "./GitModal";
export function ForcePushDialog({
target,
onClose,
onChanged,
}: {
target: GitTarget;
onClose: () => void;
onChanged: () => void;
}) {
const { t } = useTranslation(["gitTools"]);
const [plan, setPlan] = useState<PushPlan | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const { isArmed, arm, disarm } = useArmed();
const key = JSON.stringify(target);
useEffect(() => {
let live = true;
gitRewrite
.planPush(target)
.then((p) => live && setPlan(p))
.catch((e) => live && setError(errText(e)));
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
const run = async () => {
if (!plan) return;
if (!isArmed("push")) return arm("push");
disarm();
setBusy(true);
setError(null);
try {
await gitRewrite.run(target, "force-push", { expect: plan.remoteSha ?? "", confirm: true });
toast.success(t("forcePush.done", { remote: plan.remote, branch: plan.remoteBranch }));
onChanged();
onClose();
} catch (e) {
setError(errCode(e) === "ESTALE" ? t("forcePush.stale") : errText(e));
} finally {
setBusy(false);
}
};
return (
<GitModal
title={t("forcePush.title")}
onClose={onClose}
testId="force-push-dialog"
footer={
<>
<button type="button" className={BTN} onClick={onClose}>
{t("cancel")}
</button>
<button
type="button"
data-testid="force-push-run"
className={BTN_DANGER}
disabled={busy || !plan || !plan.allowed}
onClick={() => void run()}
>
{isArmed("push") ? t("forcePush.runConfirm") : t("forcePush.run")}
</button>
</>
}
>
{!plan ? (
error ? (
<ErrorLine error={error} />
) : (
<p className="text-fg-muted">{t("loading")}</p>
)
) : (
<div className="space-y-1.5">
<p data-testid="force-push-where" className="break-words">
{t("forcePush.where", {
branch: plan.branch,
remote: plan.remote,
remoteBranch: plan.remoteBranch,
})}
</p>
<p className="break-words">
{plan.remoteSha
? t("forcePush.replaces", {
sha: plan.remoteSha.slice(0, 7),
ahead: plan.ahead,
behind: plan.behind,
})
: t("forcePush.newBranch")}
</p>
{plan.behind > 0 && <Warn>{t("forcePush.dropsRemote", { count: plan.behind })}</Warn>}
{!plan.allowed && <Warn testId="force-push-protected">{t("forcePush.protected")}</Warn>}
<p className="text-[11px] text-fg-muted">{t("forcePush.lease")}</p>
<ErrorLine error={error} />
</div>
)}
</GitModal>
);
}
@@ -0,0 +1,153 @@
/**
* @file GitModal.tsx
* @description The shared modal shell of the Changes panel's git tools (merge
* editor, reflog, rebase, interactive rebase, reset, force push, remotes): a
* portal over the screen with `useDialog`'s focus contract, a title row with a
* close button, a scrolling body and an optional footer. `wide` widens it for
* the three-pane merge editor; `tall` gives a viewer (blame, browse at a
* revision, compare refs) the full height and a column body its own scrolling
* child can fill; on a phone every size is the full width. `ModalButton` is
* the History tools' footer button over the same BTN classes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useId, useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import { useDialog } from "../../../hooks/useDialog";
export const BTN =
"rounded border border-border-light px-2 py-1 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40";
export const BTN_PRIMARY =
"rounded border border-accent-solid bg-accent-solid px-2 py-1 text-[11px] font-medium text-white hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40";
export const BTN_DANGER =
"rounded border border-status-danger/60 px-2 py-1 text-[11px] font-medium text-status-danger hover:bg-status-danger/10 disabled:cursor-not-allowed disabled:opacity-40";
export function GitModal({
title,
onClose,
testId,
wide = false,
tall = false,
footer,
children,
}: {
title: ReactNode;
onClose: () => void;
testId?: string;
wide?: boolean;
/** A viewer: the full height, and a flex-column body. */
tall?: boolean;
footer?: ReactNode;
children: ReactNode;
}) {
const { t } = useTranslation(["gitTools"]);
const panel = useRef<HTMLDivElement>(null);
const titleId = useId();
useDialog(true, panel, onClose);
return createPortal(
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-2 sm:p-3"
onClick={onClose}
onKeyDown={(e) => e.stopPropagation()}
role="presentation"
>
<div
ref={panel}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
data-testid={testId}
onClick={(e) => e.stopPropagation()}
className={`flex max-h-[92vh] w-full flex-col rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/50 ${
wide || tall ? "max-w-6xl" : "max-w-lg"
} ${tall ? "h-[92vh]" : ""}`}
>
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<h2
id={titleId}
className="min-w-0 flex-1 truncate text-sm font-semibold text-fg-primary"
>
{title}
</h2>
<button
type="button"
onClick={onClose}
aria-label={t("close")}
className="shrink-0 rounded p-1 text-fg-muted hover:bg-surface-2 hover:text-fg-primary"
>
<X className="h-4 w-4" />
</button>
</div>
<div
className={`min-h-0 flex-1 px-3 py-2 text-xs text-fg-secondary ${
tall ? "flex flex-col overflow-hidden" : "overflow-y-auto"
}`}
>
{children}
</div>
{footer && (
<div className="flex flex-wrap items-center justify-end gap-1.5 border-t border-border px-3 py-2">
{footer}
</div>
)}
</div>
</div>,
document.body
);
}
/** A red inline error line, or nothing. */
export function ErrorLine({ error }: { error: string | null }) {
if (!error) return null;
return (
<p role="alert" className="my-1 whitespace-pre-wrap break-words text-[11px] text-status-danger">
{error}
</p>
);
}
/** An amber explanation box shown before a rewrite runs. */
export function Warn({ children, testId }: { children: ReactNode; testId?: string }) {
return (
<div
data-testid={testId}
className="my-1.5 rounded border border-status-warning/40 bg-status-warning/10 px-2 py-1.5 text-[11px] text-status-warning"
>
{children}
</div>
);
}
/** A footer button: plain, `primary` (filled) or `danger` (red outline). */
export function ModalButton({
onClick,
children,
primary = false,
danger = false,
disabled = false,
testId,
title,
}: {
onClick: () => void;
children: ReactNode;
primary?: boolean;
danger?: boolean;
disabled?: boolean;
testId?: string;
title?: string;
}) {
return (
<button
type="button"
data-testid={testId}
title={title}
disabled={disabled}
onClick={onClick}
className={primary ? BTN_PRIMARY : danger ? BTN_DANGER : BTN}
>
{children}
</button>
);
}
@@ -0,0 +1,210 @@
/**
* @file GitTools.tsx
* @description The Changes panel's entry points to the history-rewrite and
* remote tools: `GitToolsButton` (a toolbar menu — Rebase onto…, Interactive
* rebase…, Reflog, Force push…, Remotes…), `GitTools` (renders whichever tool
* is open, so the panel holds one `GitTool | null` state), and
* `RebaseSkipButton` (the rebase banner's Skip — Continue and Abort are the
* banner's own Complete/Abort, which the server maps onto the rebase).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { History } from "lucide-react";
import type { GitTarget } from "../../../lib/api";
import { errText, gitRewrite } from "../../../lib/gitRewrite";
import { toast } from "../../../lib/toast";
import { ContextMenu, MenuItem, MenuSeparator } from "../../ContextMenu";
import { ForcePushDialog } from "./ForcePushDialog";
import { InteractiveRebase } from "./InteractiveRebase";
import { MergeEditor } from "./MergeEditor";
import { RebaseDialog } from "./RebaseDialog";
import { ReflogView } from "./ReflogView";
import { RemotesDialog } from "./RemotesDialog";
import { ResetDialog } from "./ResetDialog";
export type GitTool =
| { kind: "rebase" }
| { kind: "interactive"; base?: string }
| { kind: "reset"; commit: { sha: string; short?: string; subject?: string } }
| { kind: "reflog" }
| { kind: "force-push" }
| { kind: "remotes" }
| { kind: "merge"; path: string; onResolved?: () => void };
export function GitToolsButton({
onPick,
disabled,
hasRemote,
}: {
onPick: (tool: GitTool) => void;
disabled?: boolean;
hasRemote?: boolean;
}) {
const { t } = useTranslation(["gitTools"]);
const ref = useRef<HTMLButtonElement>(null);
const [at, setAt] = useState<{ x: number; y: number } | null>(null);
const pick = (tool: GitTool) => () => {
setAt(null);
onPick(tool);
};
return (
<>
<button
ref={ref}
type="button"
data-testid="git-tools-button"
aria-label={t("menu.label")}
title={t("menu.label")}
aria-haspopup="menu"
disabled={disabled}
onClick={() => {
const r = ref.current?.getBoundingClientRect();
setAt(r ? { x: r.left, y: r.bottom + 2 } : { x: 0, y: 0 });
}}
className="shrink-0 rounded p-0.5 text-fg-muted hover:text-fg-primary disabled:opacity-40"
>
<History className="h-3.5 w-3.5" />
</button>
{at && (
<ContextMenu x={at.x} y={at.y} onClose={() => setAt(null)} testId="git-tools-menu">
<MenuItem testId="git-tools-rebase" onSelect={pick({ kind: "rebase" })}>
{t("menu.rebase")}
</MenuItem>
<MenuItem testId="git-tools-interactive" onSelect={pick({ kind: "interactive" })}>
{t("menu.interactive")}
</MenuItem>
<MenuItem testId="git-tools-reflog" onSelect={pick({ kind: "reflog" })}>
{t("menu.reflog")}
</MenuItem>
<MenuSeparator />
<MenuItem
testId="git-tools-force-push"
danger
disabled={hasRemote === false}
onSelect={pick({ kind: "force-push" })}
>
{t("menu.forcePush")}
</MenuItem>
<MenuItem testId="git-tools-remotes" onSelect={pick({ kind: "remotes" })}>
{t("menu.remotes")}
</MenuItem>
</ContextMenu>
)}
</>
);
}
export function GitTools({
tool,
target,
branch,
onClose,
onOpen,
onChanged,
onViewCommit,
}: {
tool: GitTool | null;
target: GitTarget;
/** The current branch, for titles. */
branch: string | null;
onClose: () => void;
/** Open another tool (the reflog's "Reset here"). */
onOpen: (tool: GitTool) => void;
/** Re-read the panel's git facts after a write. */
onChanged: () => void;
onViewCommit?: (sha: string) => void;
}) {
if (!tool) return null;
switch (tool.kind) {
case "rebase":
return (
<RebaseDialog target={target} current={branch} onClose={onClose} onChanged={onChanged} />
);
case "interactive":
return (
<InteractiveRebase
target={target}
base={tool.base}
onClose={onClose}
onChanged={onChanged}
/>
);
case "reset":
return (
<ResetDialog target={target} commit={tool.commit} onClose={onClose} onChanged={onChanged} />
);
case "reflog":
return (
<ReflogView
target={target}
onClose={onClose}
onViewCommit={
onViewCommit &&
((sha) => {
onClose();
onViewCommit(sha);
})
}
onReset={(e) =>
onOpen({ kind: "reset", commit: { sha: e.sha, short: e.short, subject: e.subject } })
}
/>
);
case "force-push":
return <ForcePushDialog target={target} onClose={onClose} onChanged={onChanged} />;
case "remotes":
return <RemotesDialog target={target} onClose={onClose} onChanged={onChanged} />;
case "merge":
return (
<MergeEditor
target={target}
path={tool.path}
onClose={onClose}
onResolved={() => {
onChanged();
tool.onResolved?.();
}}
/>
);
}
}
/** The rebase banner's Skip: drops the commit the rebase stopped on. */
export function RebaseSkipButton({
target,
disabled,
onChanged,
}: {
target: GitTarget;
disabled?: boolean;
onChanged: () => void;
}) {
const { t } = useTranslation(["gitTools"]);
const [busy, setBusy] = useState(false);
return (
<button
type="button"
data-testid="rebase-skip"
title={t("rebaseBanner.skipHint")}
disabled={disabled || busy}
onClick={async () => {
setBusy(true);
try {
const r = await gitRewrite.run(target, "rebase-skip", {});
if (r.conflicts?.length) toast.error(t("rebase.stopped", { count: r.conflicts.length }));
else if (!r.stopped) toast.success(t("rebaseBanner.finished"));
} catch (e) {
toast.error(errText(e));
} finally {
setBusy(false);
onChanged();
}
}}
className="shrink-0 rounded border border-border-light px-1.5 py-0.5 text-fg-secondary hover:bg-surface-2 disabled:opacity-40"
>
{t("rebaseBanner.skip")}
</button>
);
}
@@ -0,0 +1,751 @@
/**
* @file The Changes panel's History tab, shaped like VS Code's Source Control
* Graph, GitLens and the JetBrains git log: a commit graph (lanes and merge
* edges, ref badges) read page by page and drawn windowed, so a repo with
* tens of thousands of commits stays responsive; a search box (message, a SHA
* prefix, `author:`, `path:`) and an all-branches switch; a details pane for
* the focused commit; multi-select (checkbox, Ctrl/⌘-click, Shift-click) with
* "Cherry-pick N" / "Revert N" that open a preview of exactly what will run
* (CherryPickDialog); the reference tools' commit context menu; and, after a
* pick or revert, a result banner with Undo while that is safe (the server
* refuses it once the commits are pushed or HEAD moved on).
*
* Every write still goes through the server's guards (git-pick.js); a
* disabled button here is a courtesy.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
import { useTranslation } from "react-i18next";
import { GitCompare, Search, X } from "lucide-react";
import { api, type GitTarget } from "../../../lib/api";
import type { LaneCommit } from "../../../lib/types";
import { commitGraph } from "../../../lib/commitGraph";
import { parseSearch, inListOrder, type PickResult } from "../../../lib/gitHistory";
import { toast } from "../../../lib/toast";
import { confirmDialog } from "../../../lib/confirm";
import { copyText } from "../../../lib/clipboard";
import { Checkbox } from "../../Checkbox";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../../ContextMenu";
import { GRAPH_ROW, GraphCell, CommitText } from "./CommitGraph";
import { CommitDetails } from "./CommitDetails";
import { CherryPickDialog } from "./CherryPickDialog";
import { BrowseAtRevision } from "./BrowseAtRevision";
import { CompareRefs } from "./CompareRefs";
import { CreateBranchDialog } from "./CreateBranchDialog";
/** Commits one page reads. */
export const PAGE = 100;
/** Rows drawn beyond the visible ones, each side. */
const OVERSCAN = 20;
type Op = "cherry-pick" | "revert";
export function HistoryView({
target,
branch,
branches,
historyRef,
onHistoryRef,
reread,
disabled,
headPushed,
onViewCommit,
onOpenFileAtCommit,
onUndoCommit,
onResetHere,
onEditAfter,
onPaused,
onChanged,
}: {
target: GitTarget;
/** The branch HEAD is on (null = detached / unknown). */
branch: string | null;
/** Local branches, for "History of branch". */
branches: string[];
historyRef: string | null;
onHistoryRef: (ref: string | null) => void;
/** Bumped by the panel's Refresh and after its own writes. */
reread: number;
/** A write is running, or a merge/pick is open: nothing new starts. */
disabled: boolean;
/** The branch's upstream already holds HEAD (Undo commit refused). */
headPushed: boolean;
onViewCommit: (sha: string) => void;
onOpenFileAtCommit: (path: string, sha: string) => void;
onUndoCommit: (c: LaneCommit) => void;
/** "Reset branch to here…" — the rewrite tools' reset dialog (GitTools). */
onResetHere: (c: LaneCommit) => void;
/** "Edit commits after this one…" — the interactive rebase (GitTools). */
onEditAfter: (sha: string) => void;
/** A pick/revert stopped on a conflict: the overview resolves it. */
onPaused: () => void;
/** History changed (a pick, revert or undo): re-read everything. */
onChanged: () => void;
}) {
const { t } = useTranslation(["gitHistory", "lanes", "gitTools"]);
const [search, setSearch] = useState("");
const [filter, setFilter] = useState(() => parseSearch(""));
const [all, setAll] = useState(false);
const [commits, setCommits] = useState<LaneCommit[] | null>(null);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [focused, setFocused] = useState<string | null>(null);
const [picked, setPicked] = useState<Set<string>>(new Set());
const anchor = useRef<string | null>(null);
const [dialog, setDialog] = useState<{ op: Op; shas: string[] } | null>(null);
const [browse, setBrowse] = useState<{ sha: string; short: string } | null>(null);
const [compare, setCompare] = useState<{ a: string; b: string } | null>(null);
const [newRef, setNewRef] = useState<{
sha: string;
short: string;
kind: "branch" | "tag";
} | null>(null);
const [lastPick, setLastPick] = useState<(PickResult & { op: Op }) | null>(null);
const menu = useContextMenu<LaneCommit>();
// The search box applies after a pause in typing, not on every key.
useEffect(() => {
const id = window.setTimeout(() => setFilter(parseSearch(search)), 300);
return () => window.clearTimeout(id);
}, [search]);
const filtered = !!(filter.q || filter.author || filter.path);
const query = useMemo(
() => ({
...(all ? { all: true } : historyRef ? { ref: historyRef } : {}),
...(filter.q ? { q: filter.q } : {}),
...(filter.author ? { author: filter.author } : {}),
...(filter.path ? { path: filter.path } : {}),
}),
[all, historyRef, filter]
);
const queryKey = JSON.stringify(query);
// The first page whenever the question changes; a reply for an older
// question is dropped.
const seq = useRef(0);
useEffect(() => {
const mine = ++seq.current;
setLoading(true);
setError(null);
void api.lanes
.gitLog(target, { limit: PAGE, ...query })
.then((r) => {
if (seq.current !== mine) return;
setCommits(r.available ? r.commits : []);
setHasMore(!!r.hasMore);
})
.catch(
(err) => seq.current === mine && setError(err instanceof Error ? err.message : String(err))
)
.finally(() => seq.current === mine && setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps -- target is stable per panel
}, [queryKey, reread]);
const loadMore = useCallback(() => {
if (loading || !hasMore || !commits) return;
const mine = ++seq.current;
setLoading(true);
void api.lanes
.gitLog(target, { limit: PAGE, skip: commits.length, ...query })
.then((r) => {
if (seq.current !== mine) return;
setCommits((prev) => {
const seen = new Set((prev ?? []).map((c) => c.sha));
return [...(prev ?? []), ...r.commits.filter((c) => !seen.has(c.sha))];
});
setHasMore(!!r.hasMore);
})
.catch(
(err) => seq.current === mine && setError(err instanceof Error ? err.message : String(err))
)
.finally(() => seq.current === mine && setLoading(false));
}, [loading, hasMore, commits, target, query]);
const list = useMemo(() => commits ?? [], [commits]);
// A search result is not a connected history: dots, no lanes.
const graph = useMemo(
() => commitGraph(filtered ? list.map((c) => ({ sha: c.sha, parents: [] })) : list),
[list, filtered]
);
const byShaIndex = useMemo(() => new Map(list.map((c, i) => [c.sha, i])), [list]);
// Which loaded commits HEAD already has: Revert applies to those,
// Cherry-pick to the rest. On HEAD's own history, all of them.
const onHead = useMemo(() => {
if (!all && !historyRef) return null;
const head = list.find((c) => (c.badges ?? []).some((b) => b.current || b.kind === "head"));
const set = new Set<string>();
if (!head) return set;
const stack = [head.sha];
while (stack.length) {
const s = stack.pop()!;
if (set.has(s) || !byShaIndex.has(s)) continue;
set.add(s);
stack.push(...(list[byShaIndex.get(s)!]!.parents ?? []));
}
return set;
}, [all, historyRef, list, byShaIndex]);
// The commit HEAD is on: Edit commits after it has nothing to edit.
const headSha = useMemo(
() =>
list.find((c) => (c.badges ?? []).some((b) => b.current || b.kind === "head"))?.sha ??
(!all && !historyRef ? list[0]?.sha : undefined),
[list, all, historyRef]
);
const isOnHead = useCallback((sha: string) => (onHead ? onHead.has(sha) : true), [onHead]);
// ---- selection -------------------------------------------------------
const clickRow = (e: MouseEvent, c: LaneCommit) => {
setFocused(c.sha);
if (e.shiftKey && anchor.current && byShaIndex.has(anchor.current)) {
const a = byShaIndex.get(anchor.current)!;
const b = byShaIndex.get(c.sha)!;
const [lo, hi] = a < b ? [a, b] : [b, a];
setPicked(new Set(list.slice(lo, hi + 1).map((x) => x.sha)));
return;
}
anchor.current = c.sha;
if (e.ctrlKey || e.metaKey) {
toggle(c.sha);
return;
}
setPicked(new Set());
};
const toggle = (sha: string) =>
setPicked((prev) => {
const next = new Set(prev);
if (next.has(sha)) next.delete(sha);
else next.add(sha);
return next;
});
const selected = inListOrder(list, picked);
const mergeIn = selected.some((c) => c.parents.length > 1);
const allOnHead = selected.length > 0 && selected.every((c) => isOnHead(c.sha));
const noneOnHead = selected.length > 0 && selected.every((c) => !isOnHead(c.sha));
const target_branch = branch ?? "HEAD";
const openPick = (op: Op, shas: string[]) => setDialog({ op, shas });
const afterPick = (op: Op, r: PickResult) => {
setDialog(null);
setPicked(new Set());
if (r.paused) {
toast.error(
r.empty
? t("pausedEmpty", { op: t(`op.${op}`) })
: t("pausedConflicts", { op: t(`op.${op}`), count: r.conflicts.length })
);
onPaused();
return;
}
const n = r.commits?.length ?? 0;
toast.success(
t(op === "revert" ? "revertedN" : "pickedN", { count: n, branch: target_branch })
);
if (r.from && r.to && n) setLastPick({ ...r, op });
onChanged();
};
const undoPick = async () => {
if (!lastPick?.from || !lastPick.to) return;
const n = lastPick.commits?.length ?? 0;
const ok = await confirmDialog({
title: t("undoTitle", { op: t(`op.${lastPick.op}`) }),
message: t("undoBody", { count: n, branch: target_branch }),
confirmLabel: t("undo"),
});
if (!ok) return;
try {
await api.lanes.gitHistory.act(target, "undo-pick", {
from: lastPick.from,
to: lastPick.to,
confirm: true,
});
toast.success(t("undone", { count: n }));
setLastPick(null);
onChanged();
} catch (err) {
toast.error(err);
}
};
const copyPatch = async (c: LaneCommit) => {
try {
const r = await api.lanes.gitHistory.patch(target, c.sha);
if (!r.available) return void toast.error(t("noPatch"));
if (r.omitted.length) toast.info(t("patchOmitted", { count: r.omitted.length }));
await copyText(r.patch);
} catch (err) {
toast.error(err);
}
};
// ---- windowing -------------------------------------------------------
const scroller = useRef<HTMLDivElement>(null);
const [top, setTop] = useState(0);
const [height, setHeight] = useState(600);
useEffect(() => {
const el = scroller.current;
if (!el || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(() => setHeight(el.clientHeight || 600));
ro.observe(el);
return () => ro.disconnect();
}, []);
const first = Math.max(0, Math.floor(top / GRAPH_ROW) - OVERSCAN);
const last = Math.min(list.length, Math.ceil((top + height) / GRAPH_ROW) + OVERSCAN);
const onScroll = () => {
const el = scroller.current;
if (!el) return;
setTop(el.scrollTop);
// Near the end: the next page, like an endless log.
if (el.scrollTop + el.clientHeight > list.length * GRAPH_ROW - GRAPH_ROW * 10) loadMore();
};
const focusedCommit = focused ? list.find((c) => c.sha === focused) : undefined;
const menuItem = menu.menu?.item;
const menuMulti = !!menuItem && picked.has(menuItem.sha) && picked.size > 1;
const pick = (fn: () => void) => () => {
menu.close();
fn();
};
return (
<div data-testid="lane-changes-history" className="flex min-h-0 flex-1 flex-col gap-1.5">
{/* Toolbar: search, all branches, branch, compare. Wraps on a narrow panel. */}
<div className="flex flex-wrap items-center gap-1.5 px-1 text-[11px]">
<label className="flex min-w-[10rem] flex-1 items-center gap-1 rounded border border-border-light bg-surface-0 px-1.5 py-0.5">
<Search className="h-3 w-3 shrink-0 text-fg-muted" aria-hidden />
<input
data-testid="history-search"
value={search}
maxLength={300}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("searchPlaceholder")}
aria-label={t("searchLabel")}
className="min-w-0 flex-1 bg-transparent text-[11px] text-fg-primary outline-none placeholder:text-fg-muted"
/>
{search && (
<button
type="button"
aria-label={t("clearSearch")}
onClick={() => setSearch("")}
className="shrink-0 text-fg-muted hover:text-fg-primary"
>
<X className="h-3 w-3" />
</button>
)}
</label>
<Checkbox
checked={all}
onChange={setAll}
label={t("allBranches")}
labelClassName="text-[11px] text-fg-secondary"
/>
{!all && branches.length > 1 && (
<label className="flex min-w-0 items-center gap-1 text-fg-secondary">
<span className="shrink-0">{t("lanes:changes.historyOf")}</span>
<select
data-testid="lane-changes-history-ref"
value={historyRef ?? ""}
onChange={(e) => onHistoryRef(e.target.value || null)}
className="min-w-0 max-w-[12rem] truncate rounded border border-border-light bg-surface-0 px-1 py-0.5 text-[11px] text-fg-primary"
>
<option value="">{target_branch}</option>
{branches
.filter((b) => b !== branch)
.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
</label>
)}
<button
type="button"
data-testid="history-compare"
onClick={() =>
setCompare({
a: target_branch,
b: historyRef ?? branches.find((b) => b !== branch) ?? target_branch,
})
}
className="flex shrink-0 items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
>
<GitCompare className="h-3 w-3" aria-hidden />
{t("compare")}
</button>
</div>
{lastPick && (
<div
data-testid="pick-result"
className="mx-1 flex flex-wrap items-center gap-1.5 rounded border border-status-success/40 bg-status-success/10 px-2 py-1 text-[11px] text-status-success"
>
<span className="min-w-0 flex-1 [overflow-wrap:anywhere]">
{t(lastPick.op === "revert" ? "revertedN" : "pickedN", {
count: lastPick.commits?.length ?? 0,
branch: target_branch,
})}{" "}
<span className="font-mono">
{(lastPick.commits ?? []).map((c) => c.short).join(", ")}
</span>
</span>
<button
type="button"
data-testid="pick-undo"
disabled={disabled}
title={t("undoHint")}
onClick={() => void undoPick()}
className="shrink-0 rounded border border-status-success/50 px-1.5 py-0.5 hover:bg-status-success/15 disabled:opacity-40"
>
{t("undo")}
</button>
<button
type="button"
aria-label={t("dismiss")}
onClick={() => setLastPick(null)}
className="shrink-0 text-fg-muted hover:text-fg-primary"
>
<X className="h-3 w-3" />
</button>
</div>
)}
{selected.length > 0 && (
<div
data-testid="history-selection"
className="mx-1 flex flex-wrap items-center gap-1.5 rounded border border-accent/40 bg-accent/10 px-2 py-1 text-[11px] text-fg-primary"
>
<span className="min-w-0 flex-1">{t("selectedN", { count: selected.length })}</span>
{noneOnHead && (
<button
type="button"
data-testid="history-pick-selected"
disabled={disabled || mergeIn}
title={mergeIn ? t("mergeCommitRefused") : undefined}
onClick={() =>
openPick(
"cherry-pick",
selected.map((c) => c.sha)
)
}
className="shrink-0 rounded bg-accent-solid px-1.5 py-0.5 text-white hover:bg-accent-solid-hover disabled:opacity-40"
>
{t("pickN", { count: selected.length, branch: target_branch })}
</button>
)}
{allOnHead && (
<button
type="button"
data-testid="history-revert-selected"
disabled={disabled || mergeIn}
title={mergeIn ? t("mergeCommitRefused") : undefined}
onClick={() =>
openPick(
"revert",
selected.map((c) => c.sha)
)
}
className="shrink-0 rounded border border-border px-1.5 py-0.5 hover:bg-surface-2 disabled:opacity-40"
>
{t("revertN", { count: selected.length })}
</button>
)}
{!noneOnHead && !allOnHead && (
<span className="text-fg-muted">{t("mixedSelection")}</span>
)}
<button
type="button"
onClick={() => setPicked(new Set())}
className="shrink-0 rounded px-1 text-fg-muted hover:bg-surface-2 hover:text-fg-primary"
>
{t("clearSelection")}
</button>
</div>
)}
{error && (
<p role="alert" className="px-1 text-[11px] text-status-danger [overflow-wrap:anywhere]">
{error}
</p>
)}
<div
ref={scroller}
onScroll={onScroll}
data-testid="history-list"
className="min-h-[8rem] flex-1 overflow-y-auto"
>
{commits === null ? (
<p className="px-1 text-[11px] text-fg-muted">{t("lanes:changes.loading")}</p>
) : list.length === 0 ? (
<p className="px-1 text-[11px] text-fg-muted">
{filtered ? t("noMatches") : t("lanes:changes.noCommits")}
</p>
) : (
<div role="list" style={{ height: list.length * GRAPH_ROW, position: "relative" }}>
{list.slice(first, last).map((c, k) => {
const i = first + k;
const isPicked = picked.has(c.sha);
const head = (c.badges ?? []).some((b) => b.current || b.kind === "head");
return (
<div
key={c.sha}
role="listitem"
data-sha={c.sha}
aria-selected={isPicked || focused === c.sha}
onContextMenu={(e) => menu.open(e, c)}
style={{
position: "absolute",
top: i * GRAPH_ROW,
height: GRAPH_ROW,
left: 0,
right: 0,
}}
className={`group flex items-center gap-1 rounded px-1 ${
isPicked
? "bg-accent/15"
: focused === c.sha
? "bg-surface-3"
: "hover:bg-surface-2"
}`}
>
<Checkbox
checked={isPicked}
onChange={() => {
// A tick is an anchor too: Shift-click extends from it.
anchor.current = c.sha;
toggle(c.sha);
}}
title={t("selectCommit", { short: c.short })}
className={isPicked || picked.size ? "" : "opacity-40 group-hover:opacity-100"}
/>
<GraphCell
row={graph.rows[i]!}
width={graph.width}
merge={c.parents.length > 1}
head={head}
/>
<button
type="button"
onClick={(e) => clickRow(e, c)}
onDoubleClick={() => onViewCommit(c.sha)}
className="flex h-full min-w-0 flex-1 items-center text-left"
>
<CommitText c={c} />
</button>
</div>
);
})}
</div>
)}
{hasMore && (
<button
type="button"
data-testid="lane-changes-history-more"
disabled={loading}
onClick={loadMore}
className="mt-1 w-full rounded px-1 py-1 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:opacity-40"
>
{loading ? t("lanes:changes.loading") : t("lanes:changes.loadMore")}
</button>
)}
</div>
{focusedCommit && (
<CommitDetails
key={focusedCommit.sha}
target={target}
sha={focusedCommit.sha}
onClose={() => setFocused(null)}
onOpenCommit={(sha) => {
setFocused(sha);
const el = scroller.current?.querySelector(`[data-sha="${sha}"]`);
(el as HTMLElement | null)?.scrollIntoView?.({ block: "nearest" });
}}
onViewChanges={() => onViewCommit(focusedCommit.sha)}
onOpenFile={(path) => onOpenFileAtCommit(path, focusedCommit.sha)}
onBrowse={() => setBrowse({ sha: focusedCommit.sha, short: focusedCommit.short })}
onCompare={() => setCompare({ a: target_branch, b: focusedCommit.sha })}
onCopyPatch={() => void copyPatch(focusedCommit)}
/>
)}
{menu.menu && menuItem && (
<ContextMenu
x={menu.menu.x}
y={menu.menu.y}
onClose={menu.close}
testId="lane-changes-commit-menu"
>
<MenuItem onSelect={pick(() => onViewCommit(menuItem.sha))}>
{t("lanes:changes.viewCommit")}
</MenuItem>
<MenuItem onSelect={pick(() => setFocused(menuItem.sha))}>{t("showDetails")}</MenuItem>
<MenuItem onSelect={pick(() => setBrowse({ sha: menuItem.sha, short: menuItem.short }))}>
{t("browseFiles")}
</MenuItem>
<MenuItem onSelect={pick(() => setCompare({ a: target_branch, b: menuItem.sha }))}>
{t("compareWithCurrent")}
</MenuItem>
<MenuSeparator />
<MenuItem onSelect={pick(() => void copyText(menuItem.sha))}>
{t("lanes:changes.copySha")}
</MenuItem>
<MenuItem onSelect={pick(() => void copyText(menuItem.subject))}>
{t("lanes:changes.copySubject")}
</MenuItem>
<MenuItem
disabled={menuItem.parents.length > 1}
onSelect={pick(() => void copyPatch(menuItem))}
>
{t("copyPatch")}
</MenuItem>
<MenuItem
testId="history-menu-branch"
disabled={disabled}
onSelect={pick(() =>
setNewRef({ sha: menuItem.sha, short: menuItem.short, kind: "branch" })
)}
>
{t("createBranchHere")}
</MenuItem>
<MenuItem
testId="history-menu-tag"
disabled={disabled}
onSelect={pick(() =>
setNewRef({ sha: menuItem.sha, short: menuItem.short, kind: "tag" })
)}
>
{t("createTagHere")}
</MenuItem>
<MenuSeparator />
{menuMulti ? (
<>
{noneOnHead && (
<MenuItem
disabled={disabled || mergeIn}
onSelect={pick(() =>
openPick(
"cherry-pick",
selected.map((c) => c.sha)
)
)}
>
{t("pickN", { count: selected.length, branch: target_branch })}
</MenuItem>
)}
{allOnHead && (
<MenuItem
disabled={disabled || mergeIn}
onSelect={pick(() =>
openPick(
"revert",
selected.map((c) => c.sha)
)
)}
>
{t("revertN", { count: selected.length })}
</MenuItem>
)}
</>
) : isOnHead(menuItem.sha) ? (
<>
<MenuItem
disabled={disabled || menuItem.parents.length !== 1}
title={menuItem.parents.length > 1 ? t("mergeCommitRefused") : undefined}
testId="lane-changes-menu-revert"
onSelect={pick(() => openPick("revert", [menuItem.sha]))}
>
{t("lanes:changes.revertCommit")}
</MenuItem>
{!all && !historyRef && list[0]?.sha === menuItem.sha && (
<MenuItem
danger
disabled={disabled || headPushed || menuItem.parents.length !== 1}
title={headPushed ? t("lanes:changes.undoCommitPushed") : undefined}
testId="lane-changes-menu-undo-commit"
onSelect={pick(() => onUndoCommit(menuItem))}
>
{t("lanes:changes.undoCommit")}
</MenuItem>
)}
{/* History rewrites (GitTools): each opens a dialog that asks first. */}
<MenuItem
disabled={disabled}
testId="lane-changes-menu-reset"
onSelect={pick(() => onResetHere(menuItem))}
>
{t("gitTools:menu.resetHere")}
</MenuItem>
<MenuItem
disabled={disabled || menuItem.sha === headSha}
testId="lane-changes-menu-interactive"
onSelect={pick(() => onEditAfter(menuItem.sha))}
>
{t("gitTools:menu.interactiveHere")}
</MenuItem>
</>
) : (
<MenuItem
disabled={disabled || menuItem.parents.length !== 1}
title={menuItem.parents.length > 1 ? t("mergeCommitRefused") : undefined}
testId="lane-changes-menu-cherry-pick"
onSelect={pick(() => openPick("cherry-pick", [menuItem.sha]))}
>
{t("lanes:changes.cherryPick", { branch: target_branch })}
</MenuItem>
)}
</ContextMenu>
)}
{dialog && (
<CherryPickDialog
target={target}
op={dialog.op}
shas={dialog.shas}
onClose={() => setDialog(null)}
onDone={(r) => afterPick(dialog.op, r)}
/>
)}
{browse && (
<BrowseAtRevision
target={target}
sha={browse.sha}
short={browse.short}
onClose={() => setBrowse(null)}
/>
)}
{compare && (
<CompareRefs
target={target}
initialA={compare.a}
initialB={compare.b}
refs={branches}
onClose={() => setCompare(null)}
onOpenCommit={(sha) => {
setCompare(null);
onViewCommit(sha);
}}
/>
)}
{newRef && (
<CreateBranchDialog
target={target}
sha={newRef.sha}
short={newRef.short}
kind={newRef.kind}
onClose={() => setNewRef(null)}
onDone={() => {
setNewRef(null);
onChanged();
}}
/>
)}
</div>
);
}
@@ -0,0 +1,295 @@
/**
* @file InteractiveRebase.tsx
* @description Interactive rebase as a list (JetBrains' "Interactively Rebase
* from Here"): the branch's commits since a base — by default the ones no
* remote has yet, oldest first — each with Pick / Reword / Squash / Fixup /
* Drop, moved up and down, a new message for a reword or a squash. Before it
* runs it says what it rewrites ("This rewrites 4 commits; 2 are already
* pushed, so a force-push will be needed") and pushed commits need an explicit
* tick; Start is a two-click (`useArmed`). The server builds the todo and runs
* `git rebase -i` with no editor; a clean run stays open with **Undo**.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ArrowDown, ArrowUp } from "lucide-react";
import type { GitTarget } from "../../../lib/api";
import {
errText,
gitRewrite,
type InteractivePlan,
type PlanCommit,
type RewriteResult,
type TodoAction,
} from "../../../lib/gitRewrite";
import { toast } from "../../../lib/toast";
import { useArmed } from "../../../hooks/useArmed";
import { BTN, BTN_DANGER, ErrorLine, GitModal, Warn } from "./GitModal";
import { RewriteDone } from "./RewriteDone";
import { blockedReason } from "./RebaseDialog";
const ACTIONS: TodoAction[] = ["pick", "reword", "squash", "fixup", "drop"];
interface Row {
commit: PlanCommit;
action: TodoAction;
message: string;
}
/** Why the list cannot run as it stands, or null. */
export function todoProblem(rows: Row[]): "first" | "allDropped" | "unchanged" | "reword" | null {
const kept = rows.filter((r) => r.action !== "drop");
if (!kept.length) return "allDropped";
if (kept[0]!.action === "squash" || kept[0]!.action === "fixup") return "first";
if (rows.some((r) => r.action === "reword" && !r.message.trim())) return "reword";
return null;
}
export function InteractiveRebase({
target,
base,
onClose,
onChanged,
}: {
target: GitTarget;
/** Rewrite the commits after this one; absent = the unpushed ones. */
base?: string;
onClose: () => void;
onChanged: () => void;
}) {
const { t } = useTranslation(["gitTools"]);
const [plan, setPlan] = useState<InteractivePlan | null>(null);
const [rows, setRows] = useState<Row[]>([]);
const [force, setForce] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<RewriteResult | null>(null);
const { isArmed, arm, disarm } = useArmed();
const key = JSON.stringify(target);
useEffect(() => {
let live = true;
gitRewrite
.planInteractive(target, base)
.then((p) => {
if (!live) return;
setPlan(p);
setRows(p.commits.map((c) => ({ commit: c, action: "pick", message: c.message })));
})
.catch((e) => live && setError(errText(e)));
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, base]);
const set = (i: number, patch: Partial<Row>) =>
setRows((prev) => prev.map((r, n) => (n === i ? { ...r, ...patch } : r)));
const move = (i: number, step: number) =>
setRows((prev) => {
const j = i + step;
if (j < 0 || j >= prev.length) return prev;
const next = [...prev];
[next[i], next[j]] = [next[j]!, next[i]!];
return next;
});
const blocked = plan ? blockedReason(t, plan) : null;
const problem = todoProblem(rows);
const unchanged =
!!plan && rows.every((r, i) => r.action === "pick" && r.commit.sha === plan.commits[i]?.sha);
const pushed = plan?.pushed ?? 0;
const run = async () => {
if (!plan) return;
if (!isArmed("run")) return arm("run");
disarm();
setBusy(true);
setError(null);
try {
const r = await gitRewrite.run(target, "rebase-interactive", {
head: plan.head,
...(base ? { base: plan.base } : {}),
confirm: true,
...(pushed ? { force: true } : {}),
todo: rows.map((row) => ({
sha: row.commit.sha,
action: row.action,
...((row.action === "reword" || row.action === "squash") &&
row.message.trim() &&
row.message.trim() !== row.commit.message
? { message: row.message }
: row.action === "reword"
? { message: row.message }
: {}),
})),
});
onChanged();
if (r.conflicts?.length || r.stopped) {
toast.error(t("rebase.stopped", { count: r.conflicts?.length ?? 0 }));
onClose();
return;
}
toast.success(t("interactive.done"));
setResult(r);
} catch (e) {
setError(errText(e));
} finally {
setBusy(false);
}
};
return (
<GitModal
title={t("interactive.title")}
onClose={onClose}
testId="interactive-rebase"
footer={
result ? (
<button type="button" className={BTN} onClick={onClose}>
{t("close")}
</button>
) : (
<>
<button type="button" className={BTN} onClick={onClose}>
{t("cancel")}
</button>
<button
type="button"
data-testid="interactive-run"
className={BTN_DANGER}
disabled={
busy || !plan || !!blocked || !!problem || unchanged || (pushed > 0 && !force)
}
onClick={() => void run()}
>
{isArmed("run") ? t("interactive.runConfirm") : t("interactive.run")}
</button>
</>
)
}
>
{result ? (
<RewriteDone target={target} result={result} onChanged={onChanged} onUndone={onClose} />
) : !plan ? (
error ? (
<ErrorLine error={error} />
) : (
<p className="text-fg-muted">{t("loading")}</p>
)
) : (
<>
<p className="mb-1.5 text-[11px] text-fg-muted">{t("interactive.explain")}</p>
{blocked && <Warn>{blocked}</Warn>}
{plan.merges && <Warn>{t("interactive.merges")}</Warn>}
{!rows.length ? (
<p data-testid="interactive-empty">{t("interactive.nothing")}</p>
) : (
<>
<Warn testId="interactive-summary">
{pushed
? t("rewritesPushed", { count: rows.length, pushed })
: t("rewrites", { count: rows.length })}
{pushed > 0 && (
<label className="mt-1 flex items-center gap-1.5">
<input
type="checkbox"
data-testid="interactive-force"
checked={force}
onChange={(e) => setForce(e.target.checked)}
/>
{t("forceAck")}
</label>
)}
</Warn>
<ol data-testid="interactive-list" className="space-y-1">
{rows.map((r, i) => (
<li
key={r.commit.sha}
data-testid="interactive-row"
className={`rounded border border-border-light p-1.5 ${
r.action === "drop" ? "opacity-50" : ""
}`}
>
<div className="flex flex-wrap items-center gap-1.5">
<select
aria-label={t("interactive.action", { short: r.commit.short })}
data-testid="interactive-action"
value={r.action}
onChange={(e) => set(i, { action: e.target.value as TodoAction })}
className="input py-0.5 text-[11px]"
>
{ACTIONS.map((a) => (
<option key={a} value={a}>
{t(`interactive.actions.${a}`)}
</option>
))}
</select>
<span className="shrink-0 font-mono text-[11px] text-fg-muted">
{r.commit.short}
</span>
<span
className={`min-w-0 flex-1 truncate text-[11px] text-fg-primary ${
r.action === "drop" ? "line-through" : ""
}`}
title={r.commit.subject}
>
{r.commit.subject}
</span>
{r.commit.pushed && (
<span className="shrink-0 text-[10px] text-status-warning">
{t("pushed")}
</span>
)}
<button
type="button"
className={BTN}
aria-label={t("interactive.up")}
disabled={i === 0}
onClick={() => move(i, -1)}
>
<ArrowUp className="h-3 w-3" />
</button>
<button
type="button"
className={BTN}
aria-label={t("interactive.down")}
disabled={i === rows.length - 1}
onClick={() => move(i, 1)}
>
<ArrowDown className="h-3 w-3" />
</button>
</div>
{(r.action === "reword" || r.action === "squash") && (
<textarea
data-testid="interactive-message"
aria-label={t("interactive.message")}
value={r.message}
rows={3}
onChange={(e) => set(i, { message: e.target.value })}
placeholder={
r.action === "squash" ? t("interactive.squashHint") : undefined
}
className="input mt-1 w-full font-mono text-[11px]"
/>
)}
</li>
))}
</ol>
{problem && (
<p
data-testid="interactive-problem"
className="mt-1 text-[11px] text-status-danger"
>
{t(`interactive.problem.${problem}`)}
</p>
)}
</>
)}
<ErrorLine error={error} />
</>
)}
</GitModal>
);
}
@@ -0,0 +1,444 @@
/**
* @file MergeEditor.tsx
* @description The 3-way merge editor for ONE conflicted file — a merge,
* cherry-pick, revert or rebase (VS Code's / JetBrains' layout): Incoming
* (theirs, git stage :3) | Result | Current (ours, stage :2), with the common
* base (stage :1) one click away. Each conflict block of the result takes
* Accept current / Accept incoming / Accept both (either order) / a hand edit,
* undoable; next/previous conflict and "N conflicts left"; or the whole result
* edited as text. Save writes the result through the `conflict-save` action —
* the whole-file editor's own guards (baseSha → 409 ESTALE, secret refusal)
* then `git add` — which the server refuses while a marker is left.
* In a rebase git's "ours" is the branch being rebased ONTO and "theirs" the
* commit being replayed, so the labels say that rather than "mine".
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronUp } from "lucide-react";
import type { GitTarget } from "../../../lib/api";
import { parseConflicts, type ConflictPart } from "../../../lib/conflicts";
import { errCode, errText, gitRewrite, type ConflictSides } from "../../../lib/gitRewrite";
import { toast } from "../../../lib/toast";
import { BTN, BTN_PRIMARY, ErrorLine, GitModal } from "./GitModal";
export type BlockChoice =
{ kind: "ours" | "theirs" | "ours-theirs" | "theirs-ours" } | { kind: "custom"; text: string };
type Block = Extract<ConflictPart, { kind: "conflict" }>;
/** The lines a block resolves to, or null while it is unresolved. */
export function blockLines(p: Block, c: BlockChoice | undefined): string[] | null {
if (!c) return null;
if (c.kind === "custom") return c.text === "" ? [] : c.text.split("\n");
if (c.kind === "ours") return p.ours;
if (c.kind === "theirs") return p.theirs;
if (c.kind === "ours-theirs") return [...p.ours, ...p.theirs];
return [...p.theirs, ...p.ours];
}
/** The result file: resolved blocks as chosen, unresolved ones with their markers. */
export function composeResult(parts: ConflictPart[], choices: (BlockChoice | undefined)[]): string {
const out: string[] = [];
let i = 0;
for (const p of parts) {
if (p.kind === "text") {
out.push(...p.lines);
continue;
}
const lines = blockLines(p, choices[i++]);
if (lines) out.push(...lines);
else
out.push(
`<<<<<<< ${p.oursLabel}`.trimEnd(),
...p.ours,
"=======",
...p.theirs,
`>>>>>>> ${p.theirsLabel}`.trimEnd()
);
}
return out.join("\n");
}
function Pane({
title,
text,
testId,
tone,
}: {
title: string;
text: string | null;
testId: string;
tone: "incoming" | "current" | "base";
}) {
const { t } = useTranslation(["gitTools"]);
const head =
tone === "incoming"
? "bg-accent/20 text-accent"
: tone === "current"
? "bg-status-success/20 text-status-success"
: "bg-surface-3 text-fg-secondary";
return (
<section
data-testid={testId}
className="flex min-h-0 min-w-0 flex-col rounded border border-border-light"
>
<h3 className={`truncate px-2 py-1 text-[11px] font-semibold ${head}`} title={title}>
{title}
</h3>
<pre className="max-h-60 min-h-0 flex-1 overflow-auto whitespace-pre bg-surface-0 px-2 py-1 font-mono text-[11px] leading-5 text-fg-primary md:max-h-none">
{text == null ? <span className="italic text-fg-muted">{t("merge.sideGone")}</span> : text}
</pre>
</section>
);
}
export function MergeEditor({
target,
path,
onClose,
onResolved,
}: {
target: GitTarget;
path: string;
onClose: () => void;
/** The file was saved and marked resolved. */
onResolved: () => void;
}) {
const { t } = useTranslation(["gitTools"]);
const [sides, setSides] = useState<ConflictSides | null>(null);
const [choices, setChoices] = useState<(BlockChoice | undefined)[]>([]);
const [editing, setEditing] = useState<{ i: number; text: string } | null>(null);
const [whole, setWhole] = useState<string | null>(null);
const [showBase, setShowBase] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [at, setAt] = useState(0);
const blockRefs = useRef<(HTMLDivElement | null)[]>([]);
const load = useCallback(() => {
let live = true;
setSides(null);
setChoices([]);
setEditing(null);
setWhole(null);
setError(null);
gitRewrite
.conflict(target, path)
.then((s) => live && setSides(s))
.catch((e) => live && setError(errText(e)));
return () => {
live = false;
};
// `target` is rebuilt by the caller on every render; its parts are stable.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(target), path]);
useEffect(load, [load]);
const parts =
sides?.usable && sides.working != null ? parseConflicts(sides.working.split("\n")) : null;
const blocks = (parts ?? []).filter((p): p is Block => p.kind === "conflict");
const total = blocks.length;
const left = blocks.filter((_, i) => !choices[i]).length;
const rebase = sides?.operation === "rebase";
const incomingLabel = sides?.incoming
? t(rebase ? "merge.incomingRebase" : "merge.incoming", {
ref: `${sides.incoming.short} ${sides.incoming.subject}`,
})
: t("merge.incomingPlain");
const currentLabel = t(rebase ? "merge.currentRebase" : "merge.current", {
branch: sides?.branch ?? "HEAD",
});
const choose = (i: number, c: BlockChoice | undefined) =>
setChoices((prev) => {
const next = [...prev];
next[i] = c;
return next;
});
const jump = (step: number) => {
if (!total) return;
const open = blocks.map((_, i) => i).filter((i) => !choices[i]);
const pool = open.length ? open : blocks.map((_, i) => i);
const from = pool.findIndex((i) => i > at);
let i: number;
if (step > 0) i = from === -1 ? pool[0]! : pool[from]!;
else {
const before = pool.filter((x) => x < at);
i = before.length ? before[before.length - 1]! : pool[pool.length - 1]!;
}
setAt(i);
blockRefs.current[i]?.scrollIntoView?.({ block: "center" });
};
const save = async () => {
if (!sides?.sha || !parts) return;
const content = whole ?? composeResult(parts, choices);
setBusy(true);
setError(null);
try {
await gitRewrite.run(target, "conflict-save", { path, content, baseSha: sides.sha });
toast.success(t("merge.saved", { path }));
onResolved();
onClose();
} catch (e) {
const code = errCode(e);
setError(
code === "EMARKERS"
? t("merge.markersLeft")
: code === "ESTALE"
? t("merge.stale")
: errText(e)
);
} finally {
setBusy(false);
}
};
const choiceBtn = (i: number, c: BlockChoice, label: string, testId: string) => (
<button type="button" data-testid={testId} className={BTN} onClick={() => choose(i, c)}>
{label}
</button>
);
let blockIndex = -1;
const result = parts && (
<section
data-testid="merge-result"
className="flex min-h-0 min-w-0 flex-col rounded border border-accent/50"
>
<h3 className="truncate bg-surface-3 px-2 py-1 text-[11px] font-semibold text-fg-primary">
{t("merge.result")}
</h3>
{whole != null ? (
<textarea
data-testid="merge-whole"
aria-label={t("merge.result")}
value={whole}
onChange={(e) => setWhole(e.target.value)}
spellCheck={false}
className="input min-h-60 flex-1 resize-none whitespace-pre rounded-none border-0 font-mono text-[11px] leading-5"
/>
) : (
<div className="max-h-[50vh] min-h-0 flex-1 overflow-auto bg-surface-0 md:max-h-none">
{parts.map((p, n) => {
if (p.kind === "text")
return (
<pre
key={n}
className="whitespace-pre px-2 font-mono text-[11px] leading-5 text-fg-primary"
>
{p.lines.length ? p.lines.join("\n") : " "}
</pre>
);
const i = ++blockIndex;
const c = choices[i];
const lines = blockLines(p, c);
return (
<div
key={n}
ref={(el) => {
blockRefs.current[i] = el;
}}
data-testid="merge-block"
className={`my-1 border-y-2 ${
c ? "border-status-success/60" : "border-status-warning/70"
} ${at === i ? "ring-1 ring-accent" : ""}`}
>
<div className="flex flex-wrap items-center gap-1 bg-surface-2 px-1.5 py-1 text-[11px]">
<span className="mr-auto text-fg-muted">
{t("merge.blockOf", { n: i + 1, total })}
</span>
{editing?.i === i ? (
<button
type="button"
className={BTN}
onClick={() => {
choose(i, { kind: "custom", text: editing.text });
setEditing(null);
}}
>
{t("merge.editDone")}
</button>
) : c ? (
<button type="button" className={BTN} onClick={() => choose(i, undefined)}>
{t("merge.undoChoice")}
</button>
) : (
<>
{choiceBtn(
i,
{ kind: "ours" },
t("merge.acceptCurrent"),
"merge-accept-current"
)}
{choiceBtn(
i,
{ kind: "theirs" },
t("merge.acceptIncoming"),
"merge-accept-incoming"
)}
{choiceBtn(
i,
{ kind: "ours-theirs" },
t("merge.bothCurrentFirst"),
"merge-both-ct"
)}
{choiceBtn(
i,
{ kind: "theirs-ours" },
t("merge.bothIncomingFirst"),
"merge-both-ic"
)}
</>
)}
{editing?.i !== i && (
<button
type="button"
data-testid="merge-edit-block"
className={BTN}
onClick={() =>
setEditing({ i, text: (lines ?? [...p.ours, ...p.theirs]).join("\n") })
}
>
{t("merge.editBlock")}
</button>
)}
</div>
{editing?.i === i ? (
<textarea
data-testid="merge-block-text"
aria-label={t("merge.editBlock")}
value={editing.text}
onChange={(e) => setEditing({ i, text: e.target.value })}
spellCheck={false}
rows={Math.min(14, Math.max(3, editing.text.split("\n").length + 1))}
className="input w-full whitespace-pre rounded-none font-mono text-[11px] leading-5"
/>
) : lines ? (
<pre className="whitespace-pre bg-status-success/10 px-2 font-mono text-[11px] leading-5 text-fg-primary">
{lines.length ? lines.join("\n") : " "}
</pre>
) : (
<>
<pre className="whitespace-pre border-l-4 border-status-success bg-status-success/15 px-2 font-mono text-[11px] leading-5 text-fg-primary">
{p.ours.length ? p.ours.join("\n") : " "}
</pre>
<pre className="whitespace-pre border-l-4 border-accent bg-accent/15 px-2 font-mono text-[11px] leading-5 text-fg-primary">
{p.theirs.length ? p.theirs.join("\n") : " "}
</pre>
</>
)}
</div>
);
})}
</div>
)}
</section>
);
return (
<GitModal
wide
testId="merge-editor"
title={t("merge.title", { path })}
onClose={onClose}
footer={
<>
<ErrorLine error={error} />
<button type="button" className={BTN} onClick={onClose}>
{t("cancel")}
</button>
<button
type="button"
data-testid="merge-save"
className={BTN_PRIMARY}
disabled={busy || !parts || !sides?.sha || (whole == null && left > 0)}
onClick={() => void save()}
>
{t("merge.save")}
</button>
</>
}
>
{!sides ? (
!error && <p className="text-fg-muted">{t("loading")}</p>
) : !sides.usable || !parts ? (
<p data-testid="merge-unusable" className="text-status-warning">
{sides.binary
? t("merge.binary")
: sides.tooBig
? t("merge.tooBig")
: sides.working == null
? t("merge.noWorking")
: t("merge.unbalanced")}
</p>
) : (
<div className="flex min-h-0 flex-col gap-2">
<div className="flex flex-wrap items-center gap-1.5">
<span
data-testid="merge-left"
className={`text-[11px] ${left ? "text-status-warning" : "text-status-success"}`}
>
{whole != null
? t("merge.wholeMode")
: left
? t("merge.left", { count: left, total })
: t("merge.allChosen")}
</span>
<button
type="button"
className={BTN}
aria-label={t("merge.prev")}
disabled={!total || whole != null}
onClick={() => jump(-1)}
>
<ChevronUp className="h-3 w-3" />
</button>
<button
type="button"
className={BTN}
aria-label={t("merge.next")}
disabled={!total || whole != null}
onClick={() => jump(1)}
>
<ChevronDown className="h-3 w-3" />
</button>
<label className="ml-auto flex items-center gap-1 text-[11px]">
<input
type="checkbox"
checked={showBase}
onChange={(e) => setShowBase(e.target.checked)}
/>
{t("merge.showBase")}
</label>
<button
type="button"
data-testid="merge-whole-toggle"
className={BTN}
onClick={() => setWhole(whole == null ? composeResult(parts, choices) : null)}
>
{whole == null ? t("merge.editWhole") : t("merge.backToBlocks")}
</button>
</div>
{showBase && (
<Pane title={t("merge.base")} text={sides.base} testId="merge-base" tone="base" />
)}
<div className="grid min-h-0 grid-cols-1 gap-2 md:h-[60vh] md:grid-cols-3">
<Pane
title={incomingLabel}
text={sides.theirs}
testId="merge-incoming"
tone="incoming"
/>
{result}
<Pane title={currentLabel} text={sides.ours} testId="merge-current" tone="current" />
</div>
</div>
)}
</GitModal>
);
}
@@ -0,0 +1,154 @@
/**
* @file The banner a paused cherry-pick or revert shows above the Changes
* panel (the reference tools' "sequencer" bar): "Cherry-pick paused at commit
* X (2 of 5): resolve the conflicted files, then Continue — or Skip this
* commit, or Abort to go back to where you were", with those three buttons.
* Continue is disabled while a file is still conflicted; Skip and Abort throw
* work away, so each takes a second click (`useArmed`) and sends
* `confirm: true`. Reads GET /git/sequence; acts through git-pick.js.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type GitTarget } from "../../../lib/api";
import type { PickAction, PickSequence } from "../../../lib/gitHistory";
import { toast } from "../../../lib/toast";
import { useArmed } from "../../../hooks/useArmed";
type Active = Extract<PickSequence, { active: true }>;
export function PickBanner({
target,
conflicts,
reread,
busy,
onBusy,
onDone,
}: {
target: GitTarget;
/** The git facts' conflict count: a change re-reads the sequence. */
conflicts: number;
reread: number;
busy: boolean;
onBusy: (busy: boolean) => void;
/** A button ran: re-read the facts and the changes. */
onDone: () => void;
}) {
const { t } = useTranslation(["gitHistory"]);
const [seq, setSeq] = useState<Active | null>(null);
const [error, setError] = useState<string | null>(null);
const { arm, disarm, isArmed } = useArmed();
useEffect(() => {
let live = true;
void api.lanes.gitHistory
.sequence(target)
.then((r) => live && setSeq(r.available && r.active ? r : null))
.catch(() => live && setSeq(null));
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- target is stable per panel
}, [conflicts, reread]);
if (!seq) return null;
const op = t(`op.${seq.operation}`);
const step = seq.done + 1;
const total = seq.done + 1 + seq.remaining.length;
const cur = seq.current;
const left = seq.conflicts.length;
const act = async (action: PickAction) => {
disarm();
onBusy(true);
setError(null);
try {
const r = await api.lanes.gitHistory.act(
target,
action,
action === "pick-continue" ? {} : { confirm: true }
);
if (action === "pick-abort") toast.success(t("aborted", { op }));
else if (r.paused) toast.error(t("pausedConflicts", { op, count: r.conflicts.length }));
else toast.success(t("sequenceDone", { op, count: r.commits?.length ?? 0 }));
onDone();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
onBusy(false);
}
};
const btn =
"shrink-0 rounded border px-1.5 py-0.5 disabled:cursor-not-allowed disabled:opacity-40";
return (
<div
data-testid="pick-banner"
className="mb-2 rounded border border-status-warning/40 bg-status-warning/10 px-2 py-1 text-[11px] text-status-warning"
>
<p className="[overflow-wrap:anywhere]">
<strong>
{cur
? t("pausedAt", { op, short: cur.short, subject: cur.subject, step, total })
: t("pausedBetween", { op, step, total })}
</strong>{" "}
{left
? t("pausedHelpConflicts", { count: left })
: cur
? t("pausedHelpReady")
: t("pausedHelpResume")}
</p>
{seq.remaining.length > 0 && (
<p className="mt-0.5 text-fg-muted [overflow-wrap:anywhere]">
{t("stillToApply", {
list: seq.remaining.map((c) => c.short).join(", "),
count: seq.remaining.length,
})}
</p>
)}
<div className="mt-1 flex flex-wrap items-center gap-1.5">
<button
type="button"
data-testid="pick-continue"
disabled={busy || left > 0}
title={left ? t("continueBlocked") : undefined}
onClick={() => void act("pick-continue")}
className={`${btn} border-accent/40 bg-accent/15 text-accent hover:bg-accent/25`}
>
{t("continue")}
</button>
{cur && (
<button
type="button"
data-testid="pick-skip"
disabled={busy}
title={t("skipHint")}
onClick={() => (isArmed("skip") ? void act("pick-skip") : arm("skip"))}
className={`${btn} border-border text-fg-secondary hover:bg-surface-2`}
>
{isArmed("skip") ? t("skipConfirm") : t("skip")}
</button>
)}
<button
type="button"
data-testid="pick-abort"
disabled={busy}
title={t("abortHint")}
onClick={() => (isArmed("abort") ? void act("pick-abort") : arm("abort"))}
className={`${btn} border-status-danger/40 text-status-danger hover:bg-status-danger/10`}
>
{isArmed("abort") ? t("abortConfirm", { op }) : t("abort")}
</button>
</div>
{error && (
<p
role="alert"
className="mt-1 whitespace-pre-wrap text-status-danger [overflow-wrap:anywhere]"
>
{error}
</p>
)}
</div>
);
}
@@ -0,0 +1,207 @@
/**
* @file RebaseDialog.tsx
* @description "Rebase current branch onto…": pick a local or fetched remote
* branch, see the plan before anything runs (GET /git/rewrite-plan
* kind=rebase — how many commits are replayed, how many are already pushed and
* so will need a force-push, how far behind the branch is), then `rebase`.
* A conflict closes the dialog: the Changes panel's banner then offers
* Continue / Skip / Abort and the merge editor resolves the files. A clean
* rebase stays open on its result with **Undo**.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type GitTarget } from "../../../lib/api";
import { errText, gitRewrite, type RebasePlan, type RewriteResult } from "../../../lib/gitRewrite";
import { toast } from "../../../lib/toast";
import { BTN, BTN_PRIMARY, ErrorLine, GitModal, Warn } from "./GitModal";
import { RewriteDone } from "./RewriteDone";
/** Why a rewrite cannot start, from the plan's common facts — or null. */
export function blockedReason(
t: (k: string, o?: Record<string, unknown>) => string,
p: { subdir: boolean; operation: string | null; dirty: boolean },
needsClean = true
): string | null {
if (p.subdir) return t("blocked.subdir");
if (p.operation) return t("blocked.operation", { op: p.operation });
if (needsClean && p.dirty) return t("blocked.dirty");
return null;
}
export function RebaseDialog({
target,
current,
onClose,
onChanged,
}: {
target: GitTarget;
current: string | null;
onClose: () => void;
onChanged: () => void;
}) {
const { t } = useTranslation(["gitTools"]);
const [refs, setRefs] = useState<string[]>([]);
const [onto, setOnto] = useState("");
const [plan, setPlan] = useState<RebasePlan | null>(null);
const [force, setForce] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<RewriteResult | null>(null);
const key = JSON.stringify(target);
useEffect(() => {
api.lanes
.gitBranches(target)
.then((b) => {
const list = [...(b.branches ?? []), ...(b.remotes ?? [])].filter((x) => x !== current);
setRefs(list);
})
.catch((e) => setError(errText(e)));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
useEffect(() => {
setPlan(null);
setForce(false);
if (!onto) return;
let live = true;
gitRewrite
.planRebase(target, onto)
.then((p) => live && setPlan(p))
.catch((e) => live && setError(errText(e)));
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, onto]);
const blocked = plan ? blockedReason(t, plan) : null;
const run = async () => {
if (!plan) return;
setBusy(true);
setError(null);
try {
const r = await gitRewrite.run(target, "rebase", {
onto,
head: plan.head,
...(plan.pushed ? { force: true } : {}),
});
onChanged();
if (r.conflicts?.length || r.stopped) {
toast.error(t("rebase.stopped", { count: r.conflicts?.length ?? 0 }));
onClose();
return;
}
toast.success(t("rebase.done", { onto }));
setResult(r);
} catch (e) {
setError(errText(e));
} finally {
setBusy(false);
}
};
return (
<GitModal
title={t("rebase.title", { branch: current ?? "HEAD" })}
onClose={onClose}
testId="rebase-dialog"
footer={
result ? (
<button type="button" className={BTN} onClick={onClose}>
{t("close")}
</button>
) : (
<>
<button type="button" className={BTN} onClick={onClose}>
{t("cancel")}
</button>
<button
type="button"
data-testid="rebase-run"
className={BTN_PRIMARY}
disabled={
busy || !plan || !!blocked || !plan.commits.length || (plan.pushed > 0 && !force)
}
onClick={() => void run()}
>
{t("rebase.run")}
</button>
</>
)
}
>
{result ? (
<RewriteDone target={target} result={result} onChanged={onChanged} onUndone={onClose} />
) : (
<>
<label className="mb-1 block text-[11px]" htmlFor="rebase-onto">
{t("rebase.onto")}
</label>
<select
id="rebase-onto"
data-testid="rebase-onto"
value={onto}
onChange={(e) => setOnto(e.target.value)}
className="input mb-2 w-full py-1 text-[11px]"
>
<option value="">{t("rebase.pick")}</option>
{refs.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<p className="mb-1 text-[11px] text-fg-muted">{t("rebase.explain")}</p>
{plan && (
<div data-testid="rebase-plan" className="space-y-1">
{blocked ? (
<Warn>{blocked}</Warn>
) : !plan.commits.length ? (
<p>{t("rebase.nothing", { onto })}</p>
) : (
<>
<p>
{t("rebase.summary", {
count: plan.commits.length,
onto,
behind: plan.behind,
})}
</p>
<ul className="max-h-40 overflow-y-auto rounded border border-border-light">
{plan.commits.map((c) => (
<li key={c.sha} className="flex gap-1.5 px-1.5 py-0.5 text-[11px]">
<span className="shrink-0 font-mono text-fg-muted">{c.short}</span>
<span className="min-w-0 flex-1 truncate text-fg-primary">{c.subject}</span>
{c.pushed && (
<span className="shrink-0 text-status-warning">{t("pushed")}</span>
)}
</li>
))}
</ul>
{plan.pushed > 0 && (
<Warn testId="rebase-pushed">
{t("rewritesPushed", { count: plan.commits.length, pushed: plan.pushed })}
<label className="mt-1 flex items-center gap-1.5">
<input
type="checkbox"
data-testid="rebase-force"
checked={force}
onChange={(e) => setForce(e.target.checked)}
/>
{t("forceAck")}
</label>
</Warn>
)}
</>
)}
</div>
)}
<ErrorLine error={error} />
</>
)}
</GitModal>
);
}
@@ -0,0 +1,157 @@
/**
* @file ReflogView.tsx
* @description The read-only reflog of HEAD or of one local branch, paged
* (GET /git/reflog): each entry's selector, what moved it (commit, rebase,
* reset, checkout…), git's message, when, and the commit it points at — a
* click opens that commit in the Changes panel. "Reset here" hands the entry
* to the reset dialog, which is how a lost commit is brought back.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type GitTarget } from "../../../lib/api";
import { errText, gitRewrite, type ReflogEntry } from "../../../lib/gitRewrite";
import { BTN, ErrorLine, GitModal } from "./GitModal";
const PAGE = 50;
function when(time: number | null, lang: string): string {
if (!time) return "";
try {
return new Date(time * 1000).toLocaleString(lang);
} catch {
return new Date(time * 1000).toISOString();
}
}
export function ReflogView({
target,
onClose,
onViewCommit,
onReset,
}: {
target: GitTarget;
onClose: () => void;
/** Open one commit (the panel's commit view). */
onViewCommit?: (sha: string) => void;
/** Open the reset dialog at this commit. */
onReset?: (entry: ReflogEntry) => void;
}) {
const { t, i18n } = useTranslation(["gitTools"]);
const [ref, setRef] = useState("HEAD");
const [branches, setBranches] = useState<string[]>([]);
const [entries, setEntries] = useState<ReflogEntry[] | null>(null);
const [more, setMore] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const key = JSON.stringify(target);
useEffect(() => {
api.lanes
.gitBranches(target)
.then((b) => setBranches(b.branches ?? []))
.catch(() => setBranches([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
const page = async (skip: number) => {
setBusy(true);
setError(null);
try {
const r = await gitRewrite.reflog(target, { ref, skip, limit: PAGE });
setEntries((prev) => (skip && prev ? [...prev, ...r.entries] : r.entries));
setMore(r.more);
} catch (e) {
setError(errText(e));
if (!skip) setEntries([]);
} finally {
setBusy(false);
}
};
useEffect(() => {
setEntries(null);
void page(0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, ref]);
return (
<GitModal title={t("reflog.title")} onClose={onClose} testId="reflog-view">
<div className="mb-2 flex flex-wrap items-center gap-1.5">
<label className="text-[11px]" htmlFor="reflog-ref">
{t("reflog.of")}
</label>
<select
id="reflog-ref"
data-testid="reflog-ref"
value={ref}
onChange={(e) => setRef(e.target.value)}
className="input min-w-0 max-w-full flex-1 py-0.5 text-[11px]"
>
<option value="HEAD">HEAD</option>
{branches.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
</div>
<p className="mb-1.5 text-[11px] text-fg-muted">{t("reflog.hint")}</p>
<ErrorLine error={error} />
{entries == null ? (
<p className="text-fg-muted">{t("loading")}</p>
) : !entries.length ? (
<p className="text-fg-muted">{t("reflog.empty")}</p>
) : (
<ul data-testid="reflog-list" className="divide-y divide-border">
{entries.map((e) => (
<li key={`${e.index}-${e.sha}`} className="flex flex-wrap items-start gap-1.5 py-1.5">
<span className="shrink-0 font-mono text-[10px] text-fg-muted">{e.selector}</span>
<span className="shrink-0 rounded bg-surface-3 px-1 text-[10px] text-fg-secondary">
{e.action}
</span>
<span className="min-w-0 flex-1 break-words text-[11px] text-fg-primary">
{e.message || e.subject}
<span className="block text-[10px] text-fg-muted">
{when(e.time, i18n.language)}
</span>
</span>
<span className="flex shrink-0 items-center gap-1">
{onViewCommit ? (
<button
type="button"
className="font-mono text-[11px] text-accent hover:underline"
title={e.subject}
onClick={() => onViewCommit(e.sha)}
>
{e.short}
</button>
) : (
<span className="font-mono text-[11px]" title={e.subject}>
{e.short}
</span>
)}
{onReset && (
<button type="button" className={BTN} onClick={() => onReset(e)}>
{t("reflog.resetHere")}
</button>
)}
</span>
</li>
))}
</ul>
)}
{more && (
<button
type="button"
data-testid="reflog-more"
disabled={busy}
className={`${BTN} mt-2 w-full`}
onClick={() => void page(entries?.length ?? 0)}
>
{t("reflog.more")}
</button>
)}
</GitModal>
);
}
@@ -0,0 +1,247 @@
/**
* @file RemotesDialog.tsx
* @description The repo's remotes: each one's URL (no password is ever
* shown — the server strips it) and default branch; add one (name + an
* https:// with no login, ssh:// or user@host:path URL — the server's
* allowlist), rename one, remove one (two clicks), and delete a branch ON a
* remote (two clicks; never its default branch nor main/master/develop —
* the server refuses those whatever this shows).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type { GitTarget } from "../../../lib/api";
import { errText, gitRewrite, type GitRemote } from "../../../lib/gitRewrite";
import { toast } from "../../../lib/toast";
import { useArmed } from "../../../hooks/useArmed";
import { BTN, BTN_DANGER, BTN_PRIMARY, ErrorLine, GitModal } from "./GitModal";
const PROTECTED = new Set(["main", "master", "development", "develop"]);
export function RemotesDialog({
target,
onClose,
onChanged,
}: {
target: GitTarget;
onClose: () => void;
onChanged: () => void;
}) {
const { t } = useTranslation(["gitTools"]);
const [remotes, setRemotes] = useState<GitRemote[] | null>(null);
const [name, setName] = useState("");
const [url, setUrl] = useState("");
const [renaming, setRenaming] = useState<{ from: string; to: string } | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const { isArmed, arm, disarm } = useArmed();
const key = JSON.stringify(target);
const load = useCallback(() => {
gitRewrite
.remotes(target)
.then((r) => setRemotes(r.remotes))
.catch((e) => setError(errText(e)));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
useEffect(load, [load]);
const act = async (fn: () => Promise<unknown>, ok: string) => {
setBusy(true);
setError(null);
try {
await fn();
toast.success(ok);
onChanged();
load();
return true;
} catch (e) {
setError(errText(e));
return false;
} finally {
setBusy(false);
}
};
const add = async () => {
const n = name.trim();
const u = url.trim();
if (
await act(
() => gitRewrite.run(target, "remote-add", { name: n, url: u }),
t("remotes.added", { name: n })
)
) {
setName("");
setUrl("");
}
};
const rename = async () => {
if (!renaming) return;
const { from, to } = renaming;
if (
await act(
() => gitRewrite.run(target, "remote-rename", { name: from, to: to.trim() }),
t("remotes.renamed", { from, to: to.trim() })
)
)
setRenaming(null);
};
const remove = (n: string) => {
if (!isArmed(`rm:${n}`)) return arm(`rm:${n}`);
disarm();
void act(
() => gitRewrite.run(target, "remote-remove", { name: n, confirm: true }),
t("remotes.removed", { name: n })
);
};
const deleteBranch = (ref: string) => {
if (!isArmed(`del:${ref}`)) return arm(`del:${ref}`);
disarm();
void act(
() => gitRewrite.run(target, "remote-branch-delete", { branch: ref, confirm: true }),
t("remotes.branchDeleted", { ref })
);
};
return (
<GitModal title={t("remotes.title")} onClose={onClose} testId="remotes-dialog">
{remotes == null ? (
!error && <p className="text-fg-muted">{t("loading")}</p>
) : !remotes.length ? (
<p className="mb-2">{t("remotes.none")}</p>
) : (
<ul data-testid="remotes-list" className="mb-3 space-y-2">
{remotes.map((r) => (
<li key={r.name} className="rounded border border-border-light p-1.5">
<div className="flex flex-wrap items-center gap-1.5">
{renaming?.from === r.name ? (
<input
aria-label={t("remotes.newName")}
data-testid="remote-rename-input"
value={renaming.to}
onChange={(e) => setRenaming({ from: r.name, to: e.target.value })}
className="input min-w-0 flex-1 py-0.5 font-mono text-[11px]"
/>
) : (
<span className="min-w-0 flex-1 truncate font-mono text-[11px] font-semibold text-fg-primary">
{r.name}
</span>
)}
{renaming?.from === r.name ? (
<>
<button
type="button"
className={BTN_PRIMARY}
disabled={busy || !renaming.to.trim()}
onClick={() => void rename()}
>
{t("remotes.rename")}
</button>
<button type="button" className={BTN} onClick={() => setRenaming(null)}>
{t("cancel")}
</button>
</>
) : (
<>
<button
type="button"
className={BTN}
disabled={busy}
onClick={() => setRenaming({ from: r.name, to: r.name })}
>
{t("remotes.rename")}
</button>
<button
type="button"
data-testid={`remote-remove-${r.name}`}
className={BTN_DANGER}
disabled={busy}
onClick={() => remove(r.name)}
>
{isArmed(`rm:${r.name}`) ? t("remotes.removeConfirm") : t("remotes.remove")}
</button>
</>
)}
</div>
<p className="mt-0.5 break-all font-mono text-[10px] text-fg-muted">{r.url}</p>
{r.defaultBranch && (
<p className="text-[10px] text-fg-muted">
{t("remotes.default", { branch: r.defaultBranch })}
</p>
)}
{!!r.branches?.length && (
<details className="mt-1">
<summary className="cursor-pointer text-[11px]">
{t("remotes.branches", { count: r.branches.length })}
</summary>
<ul className="mt-1 max-h-40 space-y-0.5 overflow-y-auto">
{r.branches.map((b) => {
const ref = `${r.name}/${b}`;
const locked = PROTECTED.has(b) || b === r.defaultBranch;
return (
<li key={b} className="flex items-center gap-1.5">
<span className="min-w-0 flex-1 truncate font-mono text-[11px]">{b}</span>
<button
type="button"
data-testid={`remote-branch-delete-${ref}`}
className={BTN_DANGER}
disabled={busy || locked}
title={locked ? t("remotes.branchLocked") : undefined}
onClick={() => deleteBranch(ref)}
>
{isArmed(`del:${ref}`)
? t("remotes.deleteBranchConfirm")
: t("remotes.deleteBranch")}
</button>
</li>
);
})}
</ul>
</details>
)}
</li>
))}
</ul>
)}
<form
onSubmit={(e) => {
e.preventDefault();
void add();
}}
className="space-y-1.5 rounded border border-border p-1.5"
>
<p className="text-[11px] font-medium text-fg-primary">{t("remotes.add")}</p>
<input
aria-label={t("remotes.name")}
placeholder={t("remotes.name")}
data-testid="remote-add-name"
value={name}
onChange={(e) => setName(e.target.value)}
className="input w-full font-mono text-[11px]"
/>
<input
aria-label={t("remotes.url")}
placeholder="https://host/owner/repo.git"
data-testid="remote-add-url"
value={url}
onChange={(e) => setUrl(e.target.value)}
className="input w-full font-mono text-[11px]"
/>
<p className="text-[10px] text-fg-muted">{t("remotes.urlHint")}</p>
<div className="flex justify-end">
<button
type="submit"
data-testid="remote-add-submit"
className={BTN_PRIMARY}
disabled={busy || !name.trim() || !url.trim()}
>
{t("remotes.addButton")}
</button>
</div>
</form>
<ErrorLine error={error} />
</GitModal>
);
}
@@ -0,0 +1,211 @@
/**
* @file ResetDialog.tsx
* @description "Reset current branch to here": the plan first (GET
* /git/rewrite-plan kind=reset — how many commits leave or join the branch and
* how many of those leaving are already pushed), then one of four modes, each
* explained: soft and mixed never touch the working tree, keep refuses to
* overwrite a local change, and hard runs only on a clean tree with the branch
* name typed back. Pushed commits need an explicit tick (a force-push will be
* needed). A done reset stays open on its result with **Undo**.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type { GitTarget } from "../../../lib/api";
import {
errText,
gitRewrite,
type ResetMode,
type ResetPlan,
type RewriteResult,
} from "../../../lib/gitRewrite";
import { toast } from "../../../lib/toast";
import { BTN, BTN_DANGER, ErrorLine, GitModal, Warn } from "./GitModal";
import { RewriteDone } from "./RewriteDone";
import { blockedReason } from "./RebaseDialog";
const MODES: ResetMode[] = ["soft", "mixed", "keep", "hard"];
export function ResetDialog({
target,
commit,
onClose,
onChanged,
}: {
target: GitTarget;
commit: { sha: string; short?: string; subject?: string };
onClose: () => void;
onChanged: () => void;
}) {
const { t } = useTranslation(["gitTools"]);
const [plan, setPlan] = useState<ResetPlan | null>(null);
const [mode, setMode] = useState<ResetMode>("mixed");
const [force, setForce] = useState(false);
const [typed, setTyped] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<RewriteResult | null>(null);
const key = JSON.stringify(target);
useEffect(() => {
let live = true;
gitRewrite
.planReset(target, commit.sha)
.then((p) => live && setPlan(p))
.catch((e) => live && setError(errText(e)));
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, commit.sha]);
const branch = plan?.branch ?? "HEAD";
const blocked = plan ? blockedReason(t, plan, false) : null;
const hardBlocked = mode === "hard" && !!plan?.dirty;
const ready =
!!plan &&
!blocked &&
plan.related &&
plan.head !== plan.target &&
!(plan.pushed > 0 && !force) &&
!hardBlocked &&
(mode !== "hard" || typed === branch);
const run = async () => {
if (!plan) return;
setBusy(true);
setError(null);
try {
const r = await gitRewrite.run(target, "reset", {
target: plan.target,
mode,
head: plan.head,
confirm: true,
...(plan.pushed ? { force: true } : {}),
...(mode === "hard" ? { confirmBranch: typed } : {}),
});
onChanged();
toast.success(t("reset.done", { short: commit.short ?? plan.target.slice(0, 7) }));
setResult(r);
} catch (e) {
setError(errText(e));
} finally {
setBusy(false);
}
};
return (
<GitModal
title={t("reset.title", { branch, short: commit.short ?? commit.sha.slice(0, 7) })}
onClose={onClose}
testId="reset-dialog"
footer={
result ? (
<button type="button" className={BTN} onClick={onClose}>
{t("close")}
</button>
) : (
<>
<button type="button" className={BTN} onClick={onClose}>
{t("cancel")}
</button>
<button
type="button"
data-testid="reset-run"
className={BTN_DANGER}
disabled={busy || !ready}
onClick={() => void run()}
>
{t("reset.run", { mode })}
</button>
</>
)
}
>
{result ? (
<RewriteDone target={target} result={result} onChanged={onChanged} onUndone={onClose} />
) : !plan ? (
error ? (
<ErrorLine error={error} />
) : (
<p className="text-fg-muted">{t("loading")}</p>
)
) : (
<>
{commit.subject && (
<p className="mb-1 truncate text-[11px] text-fg-primary" title={commit.subject}>
<span className="mr-1 font-mono text-fg-muted">{commit.short}</span>
{commit.subject}
</p>
)}
{blocked && <Warn>{blocked}</Warn>}
{!plan.related ? (
<Warn>{t("reset.unrelated")}</Warn>
) : plan.head === plan.target ? (
<p>{t("reset.already")}</p>
) : (
<p data-testid="reset-summary" className="mb-1.5">
{t("reset.summary", { dropped: plan.dropped, added: plan.added, branch })}
</p>
)}
<fieldset className="space-y-1">
<legend className="mb-1 text-[11px] font-medium text-fg-primary">
{t("reset.mode")}
</legend>
{MODES.map((m) => (
<label key={m} className="flex items-start gap-1.5 text-[11px]">
<input
type="radio"
name="reset-mode"
data-testid={`reset-mode-${m}`}
value={m}
checked={mode === m}
onChange={() => setMode(m)}
className="mt-0.5"
/>
<span className="min-w-0">
<span className="font-medium text-fg-primary">{t(`reset.modes.${m}`)}</span>
<span className="block text-fg-muted">{t(`reset.modesHelp.${m}`)}</span>
</span>
</label>
))}
</fieldset>
{mode === "hard" &&
(plan.dirty ? (
<Warn testId="reset-hard-dirty">{t("reset.hardDirty")}</Warn>
) : (
<div className="mt-1.5">
<label className="mb-0.5 block text-[11px]" htmlFor="reset-confirm-branch">
{t("reset.typeBranch", { branch })}
</label>
<input
id="reset-confirm-branch"
data-testid="reset-confirm-branch"
value={typed}
onChange={(e) => setTyped(e.target.value)}
autoComplete="off"
className="input w-full font-mono text-[11px]"
/>
</div>
))}
{plan.pushed > 0 && (
<Warn testId="reset-pushed">
{t("reset.pushed", { count: plan.pushed })}
<label className="mt-1 flex items-center gap-1.5">
<input
type="checkbox"
data-testid="reset-force"
checked={force}
onChange={(e) => setForce(e.target.checked)}
/>
{t("forceAck")}
</label>
</Warn>
)}
<ErrorLine error={error} />
</>
)}
</GitModal>
);
}
@@ -0,0 +1,90 @@
/**
* @file RewriteDone.tsx
* @description What a finished rebase / interactive rebase / reset leaves on
* screen: the result, a force-push reminder when the rewrite touched pushed
* commits, and the recovery button — **Undo** = `undo-rewrite` (`reset --keep`
* back to the recorded `before`), which the server runs only while HEAD is
* still the `after` this dialog saw (409 ESTALE otherwise).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { CheckCircle2, Undo2 } from "lucide-react";
import type { GitTarget } from "../../../lib/api";
import { errText, gitRewrite, type RewriteResult } from "../../../lib/gitRewrite";
import { toast } from "../../../lib/toast";
import { useArmed } from "../../../hooks/useArmed";
import { BTN, ErrorLine, Warn } from "./GitModal";
export function RewriteDone({
target,
result,
onChanged,
onUndone,
}: {
target: GitTarget;
result: RewriteResult;
onChanged: () => void;
onUndone: () => void;
}) {
const { t } = useTranslation(["gitTools"]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const { isArmed, arm, disarm } = useArmed();
const canUndo = !!result.before && !!result.after && result.before !== result.after;
const undo = async () => {
if (!isArmed("undo")) return arm("undo");
disarm();
setBusy(true);
setError(null);
try {
await gitRewrite.run(target, "undo-rewrite", {
to: result.before,
head: result.after,
confirm: true,
});
toast.success(t("undone"));
onChanged();
onUndone();
} catch (e) {
setError(errText(e));
} finally {
setBusy(false);
}
};
return (
<div data-testid="git-rewrite-done" className="space-y-1.5">
<p className="flex items-center gap-1.5 text-status-success">
<CheckCircle2 className="h-4 w-4 shrink-0" />
{t("doneTitle")}
</p>
{result.output && (
<pre className="max-h-32 overflow-auto whitespace-pre-wrap break-words rounded bg-surface-2 p-1.5 font-mono text-[10px] text-fg-muted">
{result.output}
</pre>
)}
{result.needsForcePush && <Warn>{t("needsForcePushAfter")}</Warn>}
{canUndo && (
<div className="flex flex-wrap items-center gap-1.5">
<button
type="button"
data-testid="git-rewrite-undo"
disabled={busy}
onClick={() => void undo()}
className={`${BTN} flex items-center gap-1`}
>
<Undo2 className="h-3 w-3" />
{isArmed("undo") ? t("undoConfirm") : t("undo")}
</button>
<span className="text-[11px] text-fg-muted">
{t("undoHint", { sha: result.before!.slice(0, 7) })}
</span>
</div>
)}
<ErrorLine error={error} />
</div>
);
}
@@ -0,0 +1,271 @@
/**
* @file The Tags tab of the git bar's Branches panel (`BranchManager`): the
* repo's tags (name, target commit, and for an annotated tag its message,
* tagger and date), a form that creates one — lightweight, or annotated when a
* message is typed — on HEAD or on a branch, tag or commit sha, and per tag:
* new branch from it, push it to origin (one tag, never `--tags`, never
* forced) and delete it locally, which is irreversible and so asks first. The
* server looks every name up (server/lib/git-refs.js); nothing here decides.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, Plus, Tag, Trash2, Upload } from "lucide-react";
import type { GitTag } from "../../../lib/api";
import { confirmDialog } from "../../../lib/confirm";
import { isSubmitEnter } from "../../../lib/keys";
import { timeAgo } from "../../../lib/format";
import { Checkbox } from "../../Checkbox";
import { smallBtn, smallInput, type RunRefAction } from "./refUi";
export function TagManager({
tags,
busy,
merging,
dirty,
run,
}: {
/** Already filtered by the panel's search box. */
tags: GitTag[];
busy: boolean;
merging: boolean;
dirty: number;
run: RunRefAction;
}) {
const { t } = useTranslation(["gitRefs"]);
const [creating, setCreating] = useState(false);
const [name, setName] = useState("");
const [message, setMessage] = useState("");
const [from, setFrom] = useState("");
const [selected, setSelected] = useState<string | null>(null);
const [branchName, setBranchName] = useState<string | null>(null);
const [switchTo, setSwitchTo] = useState(false);
const create = async () => {
const tag = name.trim();
if (!tag) return;
const ok = await run(
"tag-create",
{
tag,
...(message.trim() ? { message: message.trim() } : {}),
...(from.trim() ? { from: from.trim() } : {}),
},
t("done.tagCreate", { tag })
);
if (ok) {
setName("");
setMessage("");
setFrom("");
setCreating(false);
}
};
const push = async (tag: string) => {
const ok = await confirmDialog({
title: t("tagPush"),
message: t("tagPushConfirm", { tag }),
confirmLabel: t("tagPush"),
destructive: false,
});
if (ok) await run("tag-push", { tag }, t("done.tagPush", { tag }));
};
const remove = async (tag: string) => {
const ok = await confirmDialog({
title: t("tagDelete"),
message: t("tagDeleteConfirm", { tag }),
confirmLabel: t("tagDelete"),
destructive: true,
});
if (ok && (await run("tag-delete", { tag, confirm: true }, t("done.tagDelete", { tag }))))
setSelected(null);
};
const branchFrom = (tag: string) => {
const branch = (branchName ?? "").trim();
if (!branch) return;
void run(
"branch-create",
{ branch, from: `refs/tags/${tag}`, ...(switchTo ? { switch: true } : {}) },
t("done.create", { branch })
).then((ok) => ok && setBranchName(null));
};
return (
<div className="space-y-2">
<div className="px-2">
{creating ? (
<div className="space-y-1.5 rounded border border-border-light bg-surface-0/40 p-2">
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e)) void create();
}}
placeholder={t("tagName")}
aria-label={t("tagName")}
className={`${smallInput} w-full`}
/>
<input
value={from}
onChange={(e) => setFrom(e.target.value)}
placeholder={t("tagFrom")}
aria-label={t("tagFrom")}
className={`${smallInput} w-full`}
/>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={2}
placeholder={t("tagMessage")}
aria-label={t("tagMessage")}
className={`${smallInput} w-full resize-y font-sans`}
/>
<div className="flex flex-wrap items-center gap-1.5">
<button
type="button"
className={smallBtn}
disabled={busy || merging || !name.trim()}
onClick={() => void create()}
>
<Check className="h-3 w-3" />
{message.trim() ? t("tagCreateAnnotated") : t("tagCreateLight")}
</button>
<button type="button" className={smallBtn} onClick={() => setCreating(false)}>
{t("cancel")}
</button>
</div>
</div>
) : (
<button
type="button"
className={smallBtn}
disabled={busy || merging}
onClick={() => setCreating(true)}
>
<Plus className="h-3 w-3" />
{t("tagNew")}
</button>
)}
</div>
{tags.length === 0 ? (
<p className="px-2 text-xs text-fg-muted">{t("noTags")}</p>
) : (
<ul>
{tags.map((tag) => {
const open = selected === tag.name;
return (
<li key={tag.name} className={open ? "rounded bg-surface-2" : undefined}>
<button
type="button"
data-ref-row
aria-expanded={open}
onClick={() => {
setSelected(open ? null : tag.name);
setBranchName(null);
}}
className="flex w-full min-w-0 items-start gap-2 rounded px-2 py-1 text-left hover:bg-surface-3 focus:outline-none focus-visible:ring-1 focus-visible:ring-accent"
>
<Tag className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" aria-hidden />
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-1.5">
<span title={tag.name} className="truncate font-mono text-xs text-fg-primary">
{tag.name}
</span>
{tag.sha && (
<span className="shrink-0 font-mono text-[10px] text-fg-muted">
{tag.sha.slice(0, 7)}
</span>
)}
{tag.annotated && (
<span className="shrink-0 rounded border border-border-light px-1 text-[9px] uppercase text-fg-muted">
{t("annotated")}
</span>
)}
</span>
<span className="block truncate text-[10px] text-fg-muted">
{[tag.message, tag.tagger, tag.date ? timeAgo(tag.date) : null]
.filter(Boolean)
.join(" · ")}
</span>
</span>
</button>
{open && (
<div className="space-y-1.5 border-t border-border-light px-2 py-1.5">
<div className="flex flex-wrap gap-1">
<button
type="button"
className={smallBtn}
disabled={busy || !tag.sha}
onClick={() => {
setBranchName((v) => (v === null ? "" : null));
setSwitchTo(false);
}}
>
<Plus className="h-3 w-3" />
{t("newFrom")}
</button>
<button
type="button"
className={smallBtn}
disabled={busy || merging}
title={t("tagPushHint")}
onClick={() => void push(tag.name)}
>
<Upload className="h-3 w-3" />
{t("tagPush")}
</button>
<button
type="button"
className={`${smallBtn} hover:text-status-danger`}
disabled={busy || merging}
title={t("tagDeleteHint")}
onClick={() => void remove(tag.name)}
>
<Trash2 className="h-3 w-3" />
{t("tagDelete")}
</button>
</div>
{branchName !== null && (
<div className="flex flex-wrap items-center gap-1.5">
<input
autoFocus
value={branchName}
onChange={(e) => setBranchName(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e)) branchFrom(tag.name);
}}
placeholder={t("newBranchName")}
aria-label={t("newBranchName")}
className={smallInput}
/>
<Checkbox
checked={switchTo}
onChange={setSwitchTo}
disabled={dirty > 0 || merging}
label={t("switchToIt")}
/>
<button
type="button"
className={smallBtn}
disabled={busy || !branchName.trim()}
onClick={() => branchFrom(tag.name)}
>
<Plus className="h-3 w-3" />
{t("create")}
</button>
</div>
)}
</div>
)}
</li>
);
})}
</ul>
)}
</div>
);
}
export default TagManager;
@@ -0,0 +1,320 @@
/**
* @file Tests for the Changes panel's history tools: the 3-way merge editor
* (three panes, per-block accept/both/edit, "N left", Save only when every
* block is chosen and it sends the composed result with the opened sha), the
* rebase banner's Skip, the interactive-rebase list (pushed warning + tick,
* reorder, squash/reword/drop in the todo, two-click run, Undo after) and the
* reset dialog (mode, hard needs a clean tree and the typed branch name).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MergeEditor, composeResult } from "../MergeEditor";
import { InteractiveRebase, todoProblem } from "../InteractiveRebase";
import { ResetDialog } from "../ResetDialog";
import { RebaseSkipButton } from "../GitTools";
import { gitRewrite } from "../../../../lib/gitRewrite";
import { parseConflicts } from "../../../../lib/conflicts";
vi.mock("../../../../lib/gitRewrite", async (orig) => {
const real = await orig<typeof import("../../../../lib/gitRewrite")>();
return {
...real,
gitRewrite: {
conflict: vi.fn(),
planInteractive: vi.fn(),
planReset: vi.fn(),
run: vi.fn(),
},
};
});
vi.mock("../../../../lib/toast", () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
const WORKING = [
"top",
"<<<<<<< HEAD",
"mine",
"=======",
"theirs",
">>>>>>> feat",
"middle",
"<<<<<<< HEAD",
"a",
"=======",
"b",
">>>>>>> feat",
"",
].join("\n");
const HEAD = "a".repeat(40);
const commit = (n: number, pushed = false) => ({
sha: String(n).repeat(40),
short: String(n).repeat(7),
subject: `c${n}`,
message: `c${n}`,
pushed,
});
beforeEach(() => {
vi.mocked(gitRewrite.conflict)
.mockReset()
.mockResolvedValue({
available: true,
path: "a.txt",
operation: "merge",
branch: "main",
incoming: { sha: "f".repeat(40), short: "fffffff", subject: "feat work" },
base: "top\nbase\n",
ours: "top\nmine\n",
theirs: "top\ntheirs\n",
working: WORKING,
sha: "s1",
usable: true,
binary: false,
tooBig: false,
});
vi.mocked(gitRewrite.run).mockReset().mockResolvedValue({ action: "x", ok: true, output: "" });
});
describe("MergeEditor", () => {
it("shows three panes and saves the composed result once every block is chosen", async () => {
const user = userEvent.setup();
const onResolved = vi.fn();
render(<MergeEditor target={3} path="a.txt" onClose={vi.fn()} onResolved={onResolved} />);
const incoming = await screen.findByTestId("merge-incoming");
expect(incoming).toHaveTextContent("Incoming: fffffff feat work");
expect(incoming).toHaveTextContent("theirs");
expect(screen.getByTestId("merge-current")).toHaveTextContent("Current: main");
expect(screen.getByTestId("merge-left")).toHaveTextContent("2 of 2 conflicts left");
const save = screen.getByTestId("merge-save");
expect(save).toBeDisabled();
const [first, second] = screen.getAllByTestId("merge-block") as [HTMLElement, HTMLElement];
await user.click(within(first).getByTestId("merge-accept-incoming"));
expect(screen.getByTestId("merge-left")).toHaveTextContent("1 of 2 conflicts left");
await user.click(within(second).getByTestId("merge-both-ic"));
expect(screen.getByTestId("merge-left")).toHaveTextContent("Every conflict has a choice");
await user.click(save);
await waitFor(() =>
expect(gitRewrite.run).toHaveBeenCalledWith(3, "conflict-save", {
path: "a.txt",
content: "top\ntheirs\nmiddle\nb\na\n",
baseSha: "s1",
})
);
expect(onResolved).toHaveBeenCalled();
});
it("edits one block by hand, and the base pane is one click away", async () => {
const user = userEvent.setup();
render(<MergeEditor target={3} path="a.txt" onClose={vi.fn()} onResolved={vi.fn()} />);
const [first] = (await screen.findAllByTestId("merge-block")) as [HTMLElement];
await user.click(within(first).getByTestId("merge-edit-block"));
const box = within(first).getByTestId("merge-block-text");
await user.clear(box);
await user.type(box, "hand");
await user.click(within(first).getByRole("button", { name: "Done" }));
expect(first).toHaveTextContent("hand");
expect(screen.queryByTestId("merge-base")).toBeNull();
await user.click(screen.getByLabelText("Show base"));
expect(screen.getByTestId("merge-base")).toHaveTextContent("base");
});
it("labels the sides for a rebase and explains a refused save", async () => {
vi.mocked(gitRewrite.conflict).mockResolvedValueOnce({
...(await vi.mocked(gitRewrite.conflict).getMockImplementation()!(3, "a.txt")),
operation: "rebase",
});
vi.mocked(gitRewrite.run).mockRejectedValueOnce(
Object.assign(new Error("x"), { code: "EMARKERS" })
);
const user = userEvent.setup();
render(<MergeEditor target={3} path="a.txt" onClose={vi.fn()} onResolved={vi.fn()} />);
expect(await screen.findByTestId("merge-current")).toHaveTextContent(
"the branch you are rebasing onto"
);
await user.click(screen.getByTestId("merge-whole-toggle"));
await user.click(screen.getByTestId("merge-save"));
expect(await screen.findByRole("alert")).toHaveTextContent("still has conflict markers");
});
it("composeResult keeps markers on an unresolved block", () => {
const parts = parseConflicts(WORKING.split("\n"))!;
expect(composeResult(parts, [{ kind: "ours" }])).toBe(
"top\nmine\nmiddle\n<<<<<<< HEAD\na\n=======\nb\n>>>>>>> feat\n"
);
});
});
describe("RebaseSkipButton", () => {
it("skips the stopped commit and re-reads", async () => {
const user = userEvent.setup();
const onChanged = vi.fn();
render(<RebaseSkipButton target={3} onChanged={onChanged} />);
await user.click(screen.getByTestId("rebase-skip"));
await waitFor(() => expect(onChanged).toHaveBeenCalled());
expect(gitRewrite.run).toHaveBeenCalledWith(3, "rebase-skip", {});
});
});
describe("InteractiveRebase", () => {
beforeEach(() => {
vi.mocked(gitRewrite.planInteractive)
.mockReset()
.mockResolvedValue({
available: true,
kind: "interactive",
head: HEAD,
branch: "feat",
subdir: false,
operation: null,
dirty: false,
base: "b".repeat(40),
merges: false,
commits: [commit(1, true), commit(2), commit(3)],
pushed: 1,
});
});
it("warns about pushed commits, needs the tick, and sends the edited todo", async () => {
const user = userEvent.setup();
vi.mocked(gitRewrite.run).mockResolvedValueOnce({
action: "rebase-interactive",
ok: true,
output: "",
before: HEAD,
after: "c".repeat(40),
conflicts: [],
stopped: false,
needsForcePush: true,
});
render(<InteractiveRebase target={3} onClose={vi.fn()} onChanged={vi.fn()} />);
expect(await screen.findByTestId("interactive-summary")).toHaveTextContent(
"This rewrites 3 commits; 1 are already pushed, so a force-push will be needed."
);
const run = screen.getByTestId("interactive-run");
expect(run).toBeDisabled(); // unchanged + pushed not acknowledged
const rows = screen.getAllByTestId("interactive-row") as HTMLElement[];
await user.selectOptions(within(rows[1]!).getByTestId("interactive-action"), "squash");
await user.selectOptions(within(rows[2]!).getByTestId("interactive-action"), "reword");
const msg = within(rows[2]!).getByTestId("interactive-message");
await user.clear(msg);
await user.type(msg, "new words");
await user.click(within(rows[2]!).getByRole("button", { name: "Move up" }));
expect(run).toBeDisabled();
await user.click(screen.getByTestId("interactive-force"));
expect(run).toBeEnabled();
await user.click(run);
expect(gitRewrite.run).not.toHaveBeenCalled(); // armed only
await user.click(screen.getByRole("button", { name: "Click again to rewrite" }));
await waitFor(() =>
expect(gitRewrite.run).toHaveBeenCalledWith(3, "rebase-interactive", {
head: HEAD,
confirm: true,
force: true,
todo: [
{ sha: commit(1).sha, action: "pick" },
{ sha: commit(3).sha, action: "reword", message: "new words" },
{ sha: commit(2).sha, action: "squash" },
],
})
);
expect(await screen.findByTestId("git-rewrite-done")).toHaveTextContent(
"Force push (with lease)"
);
expect(screen.getByTestId("git-rewrite-undo")).toBeInTheDocument();
});
it("todoProblem refuses a squash with nothing above and an empty reword", () => {
const c = commit(1);
expect(todoProblem([{ commit: c, action: "squash", message: "" }])).toBe("first");
expect(todoProblem([{ commit: c, action: "drop", message: "" }])).toBe("allDropped");
expect(todoProblem([{ commit: c, action: "reword", message: " " }])).toBe("reword");
expect(todoProblem([{ commit: c, action: "pick", message: "" }])).toBeNull();
});
});
describe("ResetDialog", () => {
const plan = (over = {}) => ({
available: true,
kind: "reset" as const,
head: HEAD,
branch: "feat",
subdir: false,
operation: null,
dirty: false,
target: "d".repeat(40),
related: true,
dropped: 2,
added: 0,
pushed: 0,
...over,
});
it("hard needs the branch name typed back", async () => {
vi.mocked(gitRewrite.planReset).mockReset().mockResolvedValue(plan());
vi.mocked(gitRewrite.run).mockResolvedValueOnce({
action: "reset",
ok: true,
output: "",
before: HEAD,
after: "d".repeat(40),
});
const user = userEvent.setup();
render(
<ResetDialog
target={3}
commit={{ sha: "d".repeat(40), short: "ddddddd", subject: "old" }}
onClose={vi.fn()}
onChanged={vi.fn()}
/>
);
expect(await screen.findByTestId("reset-summary")).toHaveTextContent("2 commit(s) leave feat");
await user.click(screen.getByTestId("reset-mode-hard"));
const run = screen.getByTestId("reset-run");
expect(run).toBeDisabled();
await user.type(screen.getByTestId("reset-confirm-branch"), "feat");
expect(run).toBeEnabled();
await user.click(run);
await waitFor(() =>
expect(gitRewrite.run).toHaveBeenCalledWith(3, "reset", {
target: "d".repeat(40),
mode: "hard",
head: HEAD,
confirm: true,
confirmBranch: "feat",
})
);
expect(await screen.findByTestId("git-rewrite-undo")).toBeInTheDocument();
});
it("refuses hard on a dirty tree and asks for the tick on pushed commits", async () => {
vi.mocked(gitRewrite.planReset)
.mockReset()
.mockResolvedValue(plan({ dirty: true, pushed: 1 }));
const user = userEvent.setup();
render(
<ResetDialog
target={3}
commit={{ sha: "d".repeat(40) }}
onClose={vi.fn()}
onChanged={vi.fn()}
/>
);
await screen.findByTestId("reset-pushed");
const run = screen.getByTestId("reset-run");
expect(run).toBeDisabled();
await user.click(screen.getByTestId("reset-force"));
expect(run).toBeEnabled(); // mixed works on a dirty tree
await user.click(screen.getByTestId("reset-mode-hard"));
expect(screen.getByTestId("reset-hard-dirty")).toBeInTheDocument();
expect(run).toBeDisabled();
});
});
@@ -0,0 +1,308 @@
/**
* @file Pins the History tab's graph view: rows with their graph cell and ref
* badges, the search box turning `author:`/`path:` into the log's filters,
* all branches, multi-select (checkbox and Shift-click) into a cherry-pick
* preview that lists the commits in the server's order and runs only on
* Confirm, a paused result handed to the overview, and Undo after a clean
* pick (asked first, sent with the from/to the pick answered). Also the pure
* `parseSearch` helper, and the commit menu's rewrite tools (Reset branch to
* here, Edit commits after this one) and Create tag here.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, within, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { HistoryView } from "../HistoryView";
import { api } from "../../../../lib/api";
import { confirmDialog } from "../../../../lib/confirm";
import { parseSearch } from "../../../../lib/gitHistory";
import type { LaneCommit } from "../../../../lib/types";
vi.mock("../../../../lib/confirm", () => ({
confirmDialog: vi.fn(async () => true),
}));
vi.mock("../../../../lib/api", () => ({
api: {
lanes: {
gitLog: vi.fn(),
changes: vi.fn(),
gitRefAction: vi.fn(),
gitHistory: {
commit: vi.fn(),
patch: vi.fn(),
pickPlan: vi.fn(),
pick: vi.fn(),
act: vi.fn(),
compare: vi.fn(),
treeAt: vi.fn(),
},
},
},
}));
const sha = (c: string) => c.repeat(40);
const commit = (c: string, over: Partial<LaneCommit> = {}): LaneCommit => ({
sha: sha(c),
short: c.repeat(7),
subject: `commit ${c}`,
author: "Ann",
date: "2026-10-01T00:00:00Z",
refs: [],
parents: [],
badges: [],
...over,
});
// other: c -> b (feature branch), main: a (HEAD)
const LOG = [
commit("c", { parents: [sha("b")], badges: [{ kind: "local", name: "feature" }] }),
commit("b", { parents: [sha("a")] }),
commit("a", {
parents: [],
badges: [
{ kind: "local", name: "main", current: true },
{ kind: "tag", name: "v1" },
],
}),
];
const props = () => ({
target: 7,
branch: "main",
branches: ["main", "feature"],
historyRef: null,
onHistoryRef: vi.fn(),
reread: 0,
disabled: false,
headPushed: false,
onViewCommit: vi.fn(),
onOpenFileAtCommit: vi.fn(),
onUndoCommit: vi.fn(),
onResetHere: vi.fn(),
onEditAfter: vi.fn(),
onPaused: vi.fn(),
onChanged: vi.fn(),
});
beforeEach(() => {
vi.mocked(api.lanes.gitLog).mockReset().mockResolvedValue({ available: true, commits: LOG });
vi.mocked(api.lanes.changes)
.mockReset()
.mockResolvedValue({
available: true,
range: "commit",
base: null,
baseRef: null,
sha: sha("c"),
context: 3,
truncated: false,
files: [],
});
vi.mocked(api.lanes.gitHistory.commit).mockReset().mockResolvedValue({ available: false });
vi.mocked(api.lanes.gitHistory.pick).mockReset();
vi.mocked(api.lanes.gitHistory.act).mockReset();
vi.mocked(api.lanes.gitHistory.pickPlan)
.mockReset()
.mockImplementation(async (_t, op, shas) => ({
available: true,
op,
// The server's order: oldest first for a pick.
commits: [...shas].reverse().map((s) => ({
sha: s,
short: s.slice(0, 7),
subject: `commit ${s[0]}`,
author: "Ann",
date: "",
})),
branch: "main",
clean: true,
repoRoot: true,
busy: false,
}));
vi.mocked(confirmDialog).mockClear();
});
describe("parseSearch", () => {
it("splits author: and path: from the message text", () => {
expect(parseSearch('fix login author:ann path:"src/a b.ts"')).toEqual({
q: "fix login",
author: "ann",
path: "src/a b.ts",
});
expect(parseSearch(" ")).toEqual({});
});
});
describe("HistoryView", () => {
it("draws one graph cell per commit with its ref badges", async () => {
render(<HistoryView {...props()} />);
await screen.findByText("commit c");
expect(screen.getAllByTestId("lane-changes-graph")).toHaveLength(3);
const badges = screen.getAllByTestId("ref-badge");
expect(badges.map((b) => b.textContent)).toEqual(["feature", "main", "v1"]);
expect(badges[1]).toHaveAttribute("title", expect.stringMatching(/branch you are on/));
});
it("searches with filters and reads every branch", async () => {
const user = userEvent.setup();
render(<HistoryView {...props()} />);
await screen.findByText("commit c");
await user.type(screen.getByTestId("history-search"), "fix author:ann");
await waitFor(
() =>
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, {
limit: 100,
q: "fix",
author: "ann",
}),
{ timeout: 2000 }
);
await user.click(screen.getByRole("checkbox", { name: "All branches" }));
await waitFor(() =>
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, {
limit: 100,
all: true,
q: "fix",
author: "ann",
})
);
});
it("multi-selects, previews the cherry-pick in order, and runs it only on Confirm", async () => {
const user = userEvent.setup();
const p = { ...props(), historyRef: "feature" };
vi.mocked(api.lanes.gitHistory.pick).mockResolvedValue({
action: "cherry-pick",
ok: true,
output: "",
conflicts: [],
paused: false,
from: sha("a"),
to: sha("e"),
commits: [
{ sha: sha("d"), short: "ddddddd", subject: "commit b" },
{ sha: sha("e"), short: "eeeeeee", subject: "commit c" },
],
});
vi.mocked(api.lanes.gitHistory.act).mockResolvedValue({
action: "undo-pick",
ok: true,
output: "",
conflicts: [],
paused: false,
});
render(<HistoryView {...p} />);
await screen.findByText("commit c");
await user.click(screen.getByRole("checkbox", { name: "Select ccccccc" }));
// Shift-click extends from the anchor (c) to b.
fireEvent.click(screen.getByText("commit b"), { shiftKey: true });
expect(screen.getByTestId("history-selection")).toHaveTextContent("2 commits selected");
await user.click(screen.getByTestId("history-pick-selected"));
const plan = await screen.findByTestId("pick-plan");
expect(
within(plan)
.getAllByRole("listitem")
.map((li) => li.textContent)
).toEqual(["1.bbbbbbbcommit b", "2.ccccccccommit c"]);
expect(screen.getByTestId("pick-tree-state")).toHaveTextContent("clean");
expect(api.lanes.gitHistory.pick).not.toHaveBeenCalled();
await user.click(screen.getByTestId("pick-confirm"));
await waitFor(() =>
expect(api.lanes.gitHistory.pick).toHaveBeenCalledWith(7, "cherry-pick", [sha("b"), sha("c")])
);
expect(p.onChanged).toHaveBeenCalled();
// Undo is offered, asked first, and sent with what the pick answered.
await user.click(await screen.findByTestId("pick-undo"));
expect(confirmDialog).toHaveBeenCalled();
await waitFor(() =>
expect(api.lanes.gitHistory.act).toHaveBeenCalledWith(7, "undo-pick", {
from: sha("a"),
to: sha("e"),
confirm: true,
})
);
await waitFor(() => expect(screen.queryByTestId("pick-result")).toBeNull());
});
it("refuses to start when the tree is dirty, and hands a paused pick to the overview", async () => {
const user = userEvent.setup();
const p = { ...props(), historyRef: "feature" };
vi.mocked(api.lanes.gitHistory.pickPlan).mockResolvedValueOnce({
available: true,
op: "cherry-pick",
commits: [{ sha: sha("c"), short: "ccccccc", subject: "commit c", author: "", date: "" }],
branch: "main",
clean: false,
repoRoot: true,
busy: false,
});
render(<HistoryView {...p} />);
await screen.findByText("commit c");
await user.click(screen.getByRole("checkbox", { name: "Select ccccccc" }));
await user.click(screen.getByTestId("history-pick-selected"));
await screen.findByTestId("pick-plan");
expect(screen.getByTestId("pick-confirm")).toBeDisabled();
expect(screen.getByTestId("pick-tree-state")).toHaveTextContent(/uncommitted changes/);
await user.click(screen.getByRole("button", { name: "Cancel" }));
vi.mocked(api.lanes.gitHistory.pick).mockResolvedValue({
action: "cherry-pick",
ok: true,
output: "",
conflicts: ["a.txt"],
paused: true,
});
await user.click(screen.getByTestId("history-pick-selected"));
await user.click(await screen.findByTestId("pick-confirm"));
await waitFor(() => expect(p.onPaused).toHaveBeenCalled());
expect(screen.queryByTestId("pick-result")).toBeNull();
});
it("opens the details pane on a click and the full change on a double-click", async () => {
const user = userEvent.setup();
const p = props();
render(<HistoryView {...p} />);
await user.click(await screen.findByText("commit b"));
expect(await screen.findByTestId("commit-details")).toBeInTheDocument();
expect(api.lanes.gitHistory.commit).toHaveBeenCalledWith(7, sha("b"));
await user.dblClick(screen.getByText("commit c"));
expect(p.onViewCommit).toHaveBeenCalledWith(sha("c"));
});
it("keeps the rewrite tools in the commit menu and creates a tag at a commit", async () => {
const user = userEvent.setup();
const p = props();
vi.mocked(api.lanes.gitRefAction).mockReset().mockResolvedValue({
action: "tag-create",
ok: true,
output: "",
});
render(<HistoryView {...p} />);
const menuOn = async (text: string) => {
await user.pointer({ keys: "[MouseRight]", target: await screen.findByText(text) });
return screen.getByTestId("lane-changes-commit-menu");
};
// HEAD's own commit (main, the current branch): nothing after it to edit.
expect(
within(await menuOn("commit a")).getByTestId("lane-changes-menu-interactive")
).toBeDisabled();
await user.keyboard("{Escape}");
await user.click(within(await menuOn("commit b")).getByText("Reset branch to here…"));
expect(p.onResetHere).toHaveBeenCalledWith(expect.objectContaining({ sha: sha("b") }));
await user.click(within(await menuOn("commit b")).getByText("Edit commits after this one…"));
expect(p.onEditAfter).toHaveBeenCalledWith(sha("b"));
await user.click(within(await menuOn("commit a")).getByText("Create tag here…"));
await user.type(screen.getByTestId("create-ref-name"), "v2");
await user.click(screen.getByTestId("create-ref-confirm"));
await waitFor(() =>
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(7, "tag-create", {
tag: "v2",
from: sha("a"),
})
);
expect(p.onChanged).toHaveBeenCalled();
});
});
@@ -0,0 +1,88 @@
/**
* @file Pins the paused cherry-pick/revert banner: it names the commit it
* stopped at and the step ("2 of 3"), Continue waits for every conflict to be
* resolved, Skip and Abort each need a second click and only then send
* `confirm: true`, and it draws nothing when no pick is open.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { PickBanner } from "../PickBanner";
import { api } from "../../../../lib/api";
vi.mock("../../../../lib/api", () => ({
api: { lanes: { gitHistory: { sequence: vi.fn(), act: vi.fn() } } },
}));
const paused = (conflicts: string[]) => ({
available: true as const,
active: true as const,
operation: "cherry-pick" as const,
current: { sha: "b".repeat(40), short: "bbbbbbb", subject: "edit a" },
remaining: [{ sha: "c".repeat(40), short: "ccccccc", subject: "add c" }],
done: 1,
startedFrom: "a".repeat(40),
conflicts,
});
const props = () => ({
target: 7,
conflicts: 1,
reread: 0,
busy: false,
onBusy: vi.fn(),
onDone: vi.fn(),
});
beforeEach(() => {
vi.mocked(api.lanes.gitHistory.act).mockReset().mockResolvedValue({
action: "pick-continue",
ok: true,
output: "",
conflicts: [],
paused: false,
commits: [],
});
});
describe("PickBanner", () => {
it("names the stop and its step, and Continue waits for the conflicts", async () => {
vi.mocked(api.lanes.gitHistory.sequence).mockResolvedValue(paused(["a.txt"]));
render(<PickBanner {...props()} />);
const banner = await screen.findByTestId("pick-banner");
expect(banner).toHaveTextContent('cherry-pick paused at bbbbbbb "edit a" (2 of 3)');
expect(banner).toHaveTextContent("Still to apply: ccccccc");
expect(screen.getByTestId("pick-continue")).toBeDisabled();
});
it("continues once resolved, and skips/aborts only on a second click", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.gitHistory.sequence).mockResolvedValue(paused([]));
const p = props();
render(<PickBanner {...p} />);
await user.click(await screen.findByTestId("pick-continue"));
await waitFor(() =>
expect(api.lanes.gitHistory.act).toHaveBeenCalledWith(7, "pick-continue", {})
);
expect(p.onDone).toHaveBeenCalled();
await user.click(screen.getByTestId("pick-skip"));
expect(api.lanes.gitHistory.act).toHaveBeenCalledTimes(1);
await user.click(screen.getByTestId("pick-skip"));
expect(api.lanes.gitHistory.act).toHaveBeenLastCalledWith(7, "pick-skip", { confirm: true });
await user.click(screen.getByTestId("pick-abort"));
expect(api.lanes.gitHistory.act).toHaveBeenCalledTimes(2);
await user.click(screen.getByTestId("pick-abort"));
expect(api.lanes.gitHistory.act).toHaveBeenLastCalledWith(7, "pick-abort", { confirm: true });
});
it("draws nothing when no pick is open", async () => {
vi.mocked(api.lanes.gitHistory.sequence).mockResolvedValue({ available: true, active: false });
const { container } = render(<PickBanner {...props()} />);
await waitFor(() => expect(api.lanes.gitHistory.sequence).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
});
});
+41
View File
@@ -0,0 +1,41 @@
/**
* @file Shared bits of the Branches panel (`BranchManager`, `TagManager`): the
* small button and input classes, the write runner's type, and the arrow-key
* walk over a list's rows.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { KeyboardEvent } from "react";
import type { GitRefAction, GitRefBody } from "../../../lib/api";
/** Runs one write; resolves true when it succeeded (the list is then re-read). */
export type RunRefAction = (
action: GitRefAction,
body: GitRefBody,
done?: string
) => Promise<boolean>;
export const smallBtn =
"inline-flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40";
export const smallInput =
"min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none";
/** Arrow-key walk over the rows of a list (`[data-ref-row]`), Home/End too. */
export function onListKeys(e: KeyboardEvent<HTMLElement>) {
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) return;
// Inside a text field the arrows and Home/End move the caret.
if ((e.target as HTMLElement).closest("input, textarea, select")) return;
const rows = [...e.currentTarget.querySelectorAll<HTMLElement>("[data-ref-row]")];
if (!rows.length) return;
const at = rows.indexOf(document.activeElement as HTMLElement);
const next =
e.key === "Home"
? 0
: e.key === "End"
? rows.length - 1
: e.key === "ArrowDown"
? Math.min(rows.length - 1, at + 1)
: Math.max(0, at - 1);
e.preventDefault();
rows[next]?.focus();
}
+46 -9
View File
@@ -13,7 +13,8 @@
* puts `/<name>` (`/<plugin>:<name>`) at the front of the message — the same
* text the `/` autocomplete inserts, so the CLI runs it as typed. Both lists
* are read when the menu first opens (`GET /api/cc-config/skills|plugins`),
* and Manage opens the Claude config page on that tab.
* and Manage opens the Claude config page on that tab. The menu opens
* downward when the box sits too near the top to fit it above.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -77,7 +78,36 @@ function loadLists(cwd: string): Promise<Lists> {
}
const PANEL =
"absolute w-60 rounded-xl border border-border bg-surface-1 py-1 shadow-lg animate-fade-in";
"absolute z-40 w-60 rounded-xl border border-border bg-surface-1 py-1 shadow-lg animate-fade-in";
/** Room the menu and its tallest submenu (Project files: search + list) need. */
const MENU_ROOM = 380;
/** The visible band the button sits in: the viewport, narrowed by any
* scrolling/clipping ancestor (the page body scrolls inside the layout). */
function clipBand(el: HTMLElement): { top: number; bottom: number } {
let top = 0;
let bottom = window.innerHeight;
for (let p = el.parentElement; p; p = p.parentElement) {
const o = getComputedStyle(p);
if (/(auto|scroll|hidden|clip)/.test(o.overflowY + o.overflow)) {
const r = p.getBoundingClientRect();
top = Math.max(top, r.top);
bottom = Math.min(bottom, r.bottom);
}
}
return { top, bottom };
}
/** Open downward when there is not room above but more below. */
function opensDown(el: HTMLElement | null): boolean {
if (!el) return false;
const r = el.getBoundingClientRect();
const band = clipBand(el);
const above = r.top - band.top;
const below = band.bottom - r.bottom;
return above < MENU_ROOM && below > above;
}
const ROW =
"flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-3 hover:text-fg-primary";
@@ -195,6 +225,7 @@ export function ChatAddMenu({
const { t } = useTranslation("run");
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [down, setDown] = useState(false);
const [sub, setSub] = useState<Sub | null>(null);
const [context, setContext] = useState<Attachment[] | null>(null);
const [recent, setRecent] = useState<Attachment[]>([]);
@@ -286,6 +317,8 @@ export function ChatAddMenu({
/>
))
);
// A submenu lines up with the menu's edge nearest the button.
const edge = down ? "top-0" : "bottom-0";
const shownPlugin = lists?.plugins.find((p) => p.name === plugin);
return (
@@ -296,13 +329,17 @@ export function ChatAddMenu({
aria-label={t("chat.add")}
title={t("chat.add")}
aria-expanded={open}
onClick={() => (open ? close() : setOpen(true))}
onClick={() => {
if (open) return close();
setDown(opensDown(ref.current));
setOpen(true);
}}
className="flex h-8 w-8 items-center justify-center rounded-full text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"
>
<Plus className="h-4 w-4" />
</button>
{open && (
<div role="menu" data-testid="chat-add-menu" className={`${PANEL} bottom-full left-0 mb-2`}>
<div role="menu" data-testid="chat-add-menu" className={`${PANEL} left-0 ${down ? "top-full mt-2" : "bottom-full mb-2"}`}>
<Row
icon={<Paperclip className="h-4 w-4" />}
label={t("chat.upload")}
@@ -376,13 +413,13 @@ export function ChatAddMenu({
)}
{sub === "project" && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1 w-72 pt-2`}>
<div role="menu" className={`${PANEL} ${edge} left-full ml-1 w-72 pt-2`}>
<ProjectFiles cwd={cwd} onPick={(rel) => attach(projectAttachment(rel))} />
</div>
)}
{(sub === "context" || sub === "recent") && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
<div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
<div className="max-h-72 overflow-y-auto">
{(sub === "context" ? context : recent)?.length ? (
(sub === "context" ? context! : recent).map((it) => (
@@ -405,7 +442,7 @@ export function ChatAddMenu({
)}
{sub === "skills" && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
<div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
<div className="max-h-72 overflow-y-auto">{entries(lists?.skills ?? [], "")}</div>
<div className="my-1 border-t border-border" />
<Row
@@ -417,7 +454,7 @@ export function ChatAddMenu({
)}
{sub === "plugins" && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
<div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
<div className="max-h-72 overflow-y-auto">
{lists && lists.plugins.length === 0 && (
<p className="px-3 py-1.5 text-xs text-fg-muted">{t("chat.none")}</p>
@@ -443,7 +480,7 @@ export function ChatAddMenu({
onClick={() => manage("plugins")}
/>
{shownPlugin && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
<div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
<div className="max-h-72 overflow-y-auto">
{entries(
shownPlugin.items,
+97 -20
View File
@@ -12,6 +12,8 @@
* text file it read, wrote or named (a generated report) anywhere else through
* `GET /api/sessions/:id/file`.
* Anything else (or a file those routes refuse) shows its path only. A Windows path's `\` counts as `/` throughout.
* `FilePreview` with `modal` (an HTML page, from PaneChat) is the same view in
* a modal over the whole screen.
* `SkillListModal` is the same dialog over the session's skills, each one's
* SKILL.md found by name (`plugin:name` from the plugin list, else the user's
* or the project's); a built-in skill has no file and shows by name only.
@@ -39,6 +41,7 @@ import { useDialog } from "../../hooks/useDialog";
import { dragSkill } from "../../lib/promptAttach";
import { readMarkdownPreview } from "../../lib/markdownView";
import { MarkdownPreview } from "../MarkdownPreview";
import { HtmlFrame, fetchVia } from "../HtmlFrame";
import { CodeBlock } from "../conversation/CodeBlock";
import { useTheme } from "../../hooks/useTheme";
import { toast } from "../../lib/toast";
@@ -85,6 +88,18 @@ export const extOf = (p: string) => {
export const displayName = (p: string) =>
DROP_RE.test(p) ? baseName(p).replace(/^[0-9a-f]{16}-?/, "") || baseName(p) : baseName(p);
/** A file inside the pane's folder: the git target that reads it and its path there. */
function workspaceFile(path: string, scope: PreviewScope) {
const file = slashed(path);
const base = slashed(scope.cwd).replace(/\/+$/, "");
if (scope.laneId === null || !base || !file.startsWith(`${base}/`)) return null;
const target =
base === slashed(scope.laneCwd).replace(/\/+$/, "")
? scope.laneId
: gitTarget(scope.laneId, scope.sessionId);
return { target, rel: file.slice(base.length + 1) };
}
type Content =
| { kind: "loading" }
| { kind: "image"; src: string }
@@ -121,8 +136,8 @@ function useContent(path: string, scope: PreviewScope): Content {
};
}
const file = slashed(path);
const base = slashed(scope.cwd).replace(/\/+$/, "");
if (scope.laneId === null || !base || !file.startsWith(`${base}/`)) {
const inside = workspaceFile(path, scope);
if (!inside) {
// Claude Code's own files (memory, a skill): the config pages' guarded read.
if (/\/\.claude\//.test(file) && !IMAGE_RE.test(file)) {
show(
@@ -147,11 +162,7 @@ function useContent(path: string, scope: PreviewScope): Content {
live = false;
};
}
const rel = file.slice(base.length + 1);
const target =
base === slashed(scope.laneCwd).replace(/\/+$/, "")
? scope.laneId
: gitTarget(scope.laneId, scope.sessionId);
const { target, rel } = inside;
if (IMAGE_RE.test(path)) {
setContent({ kind: "image", src: api.lanes.gitRawUrl(target, rel) });
return;
@@ -235,9 +246,28 @@ function defaultView(path: string): View {
return kind === "markdown" && !readMarkdownPreview() ? "code" : "preview";
}
function Body({ path, content, view }: { path: string; content: Content; view: View }) {
function Body({
path,
content,
view,
scope,
}: {
path: string;
content: Content;
view: View;
scope: PreviewScope;
}) {
const { t } = useTranslation("run");
const kind = artifactKind(path);
// A page in the workspace brings its relative CSS, scripts and images along.
const inside = kind === "html" ? workspaceFile(path, scope) : null;
const insideTarget = inside ? JSON.stringify(inside.target) : null;
const page = useMemo(() => {
if (!inside) return null;
const { target, rel } = inside;
return { rel, fetchFile: fetchVia((p) => api.lanes.gitDownloadUrl(target, p)) };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [insideTarget, inside?.rel]);
// An image the route refused (gone, server mode) shows like no preview.
const [broken, setBroken] = useState<string | null>(null);
const shown: Content =
@@ -290,6 +320,18 @@ function Body({ path, content, view }: { path: string; content: Content; view: V
);
}
if (kind === "csv") return <CsvTable text={shown.text} tab={/\.tsv$/i.test(path)} />;
if (page && !shown.truncated) {
return (
<HtmlFrame
key={path}
path={page.rel}
html={shown.text}
fetchFile={page.fetchFile}
title={baseName(path)}
testId="artifact-frame"
/>
);
}
return <ArtifactFrame kind={kind} text={shown.text} title={baseName(path)} />;
}
@@ -317,6 +359,7 @@ export function FilePreview({
onClose,
className = "",
messages,
modal = false,
}: {
path: string;
scope: PreviewScope;
@@ -324,12 +367,16 @@ export function FilePreview({
className?: string;
/** The conversation, for the versions its Write/Edit calls left. */
messages?: TranscriptMessage[];
/** A modal over the whole screen (backdrop, Esc closes) instead of a panel. */
modal?: boolean;
}) {
const { t } = useTranslation("run");
const kind = artifactKind(path);
const loaded = useContent(path, scope);
const [view, setView] = useState<View>(() => defaultView(path));
const [full, setFull] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
useDialog(modal, modalRef, onClose);
const [sharing, setSharing] = useState(false);
const versions = useMemo(
() => (messages ? artifactVersions(messages, path) : []),
@@ -384,7 +431,9 @@ export function FilePreview({
<section
data-testid="chat-file-preview"
className={
full
modal
? "flex h-[92vh] w-[96vw] max-w-7xl min-h-0 min-w-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/50"
: full
? "fixed inset-4 z-[200] flex min-h-0 min-w-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl"
: `flex min-h-0 min-w-0 flex-col overflow-hidden bg-surface-1 ${className}`
}
@@ -482,16 +531,18 @@ export function FilePreview({
<Share2 className="h-3.5 w-3.5" />
)}
</button>
<button
type="button"
data-testid="artifact-expand"
onClick={() => setFull((f) => !f)}
title={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
aria-label={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
className={iconBtn}
>
{full ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
</button>
{!modal && (
<button
type="button"
data-testid="artifact-expand"
onClick={() => setFull((f) => !f)}
title={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
aria-label={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
className={iconBtn}
>
{full ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
</button>
)}
<button
type="button"
onClick={onClose}
@@ -503,9 +554,35 @@ export function FilePreview({
</button>
</div>
</header>
<Body path={path} content={content} view={view} />
<Body path={path} content={content} view={view} scope={scope} />
</section>
);
if (modal) {
return createPortal(
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
onClick={onClose}
onKeyDown={(e) => {
// Nothing reaches the view underneath — so Esc, which useDialog's
// document listener would never see, closes here.
e.stopPropagation();
if (e.key === "Escape") onClose();
}}
role="presentation"
>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label={displayName(path)}
onClick={(e) => e.stopPropagation()}
>
{panel}
</div>
</div>,
document.body
);
}
// Full screen: over the whole page, where a pane's edges cannot clip it.
return full ? createPortal(panel, document.body) : panel;
}
+13 -5
View File
@@ -16,8 +16,9 @@
* keys, Home and End move between tabs (one tab stop, APG). Errors are
* toasts. The `+` menu opens a new tab in the free slot the server picks
* (`slot: "auto"`) — a plain shell (`POST /api/lanes/:id/start {shell: true}`)
* or an installed, enabled AI tool started bare on its default model, no
* prompt (a prompt, proxy or worktree is still the workspace home's prompt
* or an installed, enabled AI tool started bare on its default model and
* the account's Session defaults (permission mode, Codex sandbox/approval/
* bypass), no prompt (a prompt, proxy or worktree is still the workspace home's prompt
* box) — plus a link to Settings → Agents. Ten sessions is the server's limit
* (`MAX_PANE_SLOT`). The bar is the console's only header: Home (the
* workspace's home) at its left end, `actions` (the single pane's split menus)
@@ -72,6 +73,7 @@ import {
type GroupsUpdate,
} from "./PaneGrid";
import { insertionIndex } from "../../lib/laneOrderStorage";
import { readCodexDefaults, readDefaultMode } from "../../lib/sessionDefaults";
import {
type PaneGroups,
MAX_PANES,
@@ -140,15 +142,21 @@ export function newTabTools(lane: Lane, aiTools: AiToolsState): string[] {
];
}
/** The start body of a bare tool on its default model, no prompt. Only a
* different tool is named, as the home does: the workspace's own keeps
* starting even once disabled. */
/** The start body of a bare tool on its default model, no prompt, in the
* account's Session defaults (Claude's permission mode, Codex's sandbox,
* approval and bypass) — the same ones the workspace home's prompt box starts
* from. Only a different tool is named, as the home does: the workspace's own
* keeps starting even once disabled. */
export function newTabBody(id: string, lane: Lane, aiTools: AiToolsState) {
const model = aiTools.byId(id)?.defaultModel;
const codex = id === "codex" ? readCodexDefaults() : null;
return {
prompt: "",
...(id !== lane.tool ? { tool: id } : {}),
...(model ? { model } : {}),
...(id === "claude" ? { permissionMode: readDefaultMode() } : {}),
...(codex ? { sandbox: codex.sandbox, approval: codex.approval } : {}),
...(codex?.bypass && aiTools.byId(id)?.bypass ? { bypass: true } : {}),
};
}
+27 -6
View File
@@ -35,7 +35,9 @@
* of files or the used skills a list with a preview (`FileListModal`,
* `SkillListModal`). Anything there drags into the prompt box. A file in a turn's
* "Edited N files" card opens in the Workspace's Changes panel
* (`openInChanges`) when it lies in the pane's folder. The unsent prompt and
* (`openInChanges`) when it lies in the pane's folder. An HTML file — a card,
* an Outputs row, a path in a reply — opens instead as the page, the same
* preview in a modal over the whole screen. The unsent prompt and
* its attachments are kept per session (`lib/chatDraft.ts`), so a reload
* leaves them as typed; switching to the terminal pastes them into its input
* box and back brings that box's text here (`ChatToggle`). A send scrolls the
@@ -582,6 +584,12 @@ export const PaneChat = memo(function PaneChat({
setPreview(null);
};
const [preview, setPreview] = useState<string | null>(null);
// An HTML file shown as the page, in a modal over the screen.
const [htmlView, setHtmlView] = useState<string | null>(null);
const show = useCallback(
(path: string) => (/\.html?$/i.test(path) ? setHtmlView(path) : setPreview(path)),
[]
);
// The side panel's width as a fraction of the pane: the dragged one, else
// a default per content (a preview wants more room than the panel).
const [dragged, setDragged] = useState(storedSide);
@@ -681,8 +689,11 @@ export const PaneChat = memo(function PaneChat({
// another dashboard on the same database, or the socket dropped) would
// otherwise leave Stop up after the turn ended.
intervalMs: working || waiting ? 5000 : undefined,
// The transcript growing re-reads it too: a hook that reached another
// dashboard on the same database sends this one no agent_updated, and
// without a poll Stop never showed for a turn the chat calls Thinking….
match: (msg) =>
msg.type === "agent_updated" &&
(msg.type === "agent_updated" || msg.type === "transcript_changed") &&
(msg.data as { session_id?: string } | undefined)?.session_id === sessionId,
});
@@ -757,20 +768,21 @@ export const PaneChat = memo(function PaneChat({
// not the workspace's Changes panel a plain file link goes to.
const openArtifact = useCallback(
(named: string) => {
setPreview(resolveIn(cwd, named));
show(resolveIn(cwd, named));
},
[cwd]
[cwd, show]
);
const openFile = useCallback(
(named: string) => {
// A relative path in a reply (`server/lib/x.js`) is relative to this pane's folder.
const path = resolveIn(cwd, named);
if (/\.html?$/i.test(path)) return show(path);
// The workspace's Changes panel when the file is in its folder;
// else this view's own preview.
if (laneId !== null && openInChanges(laneId, cwd, path)) return;
setPreview(path);
},
[laneId, cwd]
[laneId, cwd, show]
);
// Before the session starts the CLI may be asking in the terminal (trust
@@ -984,7 +996,7 @@ export const PaneChat = memo(function PaneChat({
<ChatOutline
outline={activity}
onClose={() => openOutline(false)}
onOpenFile={setPreview}
onOpenFile={show}
onOpenList={setList}
/>
)
@@ -1006,6 +1018,15 @@ export const PaneChat = memo(function PaneChat({
/>
</div>
)}
{htmlView && (
<FilePreview
path={htmlView}
scope={scope}
messages={convMessages}
onClose={() => setHtmlView(null)}
modal
/>
)}
{list === "skills" && (
<SkillListModal
title={t("chat.outline.skills")}
+75 -1
View File
@@ -96,6 +96,36 @@ type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigL
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
/** localStorage flag: the "Shift+drag selects text" hint was dismissed. */
/** The atlas a WebGL addon draws from: @xterm/addon-webgl 0.19 keeps these
* fields unminified in its build. Undefined on any other shape. */
type AtlasPages = { pages: { version: number }[] };
const atlasOf = (addon: WebglAddon): AtlasPages | undefined => {
const atlas = (addon as unknown as { _renderer?: { _charAtlas?: AtlasPages } })._renderer
?._charAtlas;
return Array.isArray(atlas?.pages) ? atlas : undefined;
};
/** Last version handed out by `restampAtlas`, shared by every pane. */
let atlasStamp = 0;
/**
* Gives every page of the addon's glyph atlas a version no texture slot has
* uploaded, so EVERY renderer sharing that atlas re-uploads every page from
* its canvas on its next draw — what addon-webgl 0.20 does with globally
* monotonic page versions. 0.19 counts per page, so after a merge shifts the
* pages down a slot can hold a stale page whose counter happens to match, and
* glyphs are sampled squeezed or foreign. Steps of 2^24 stay clear of the
* per-glyph increments a page makes before the next restamp. False when the
* addon is not the shape this knows.
*/
const restampAtlas = (addon: WebglAddon): boolean => {
const atlas = atlasOf(addon);
if (!atlas) return false;
for (const page of atlas.pages) {
atlasStamp += 2 ** 24;
page.version = atlasStamp;
}
return true;
};
const SELECT_HINT_KEY = "agenthub.terminal.selectHintSeen";
const readFlag = (key: string) => {
try {
@@ -468,7 +498,8 @@ export const TerminalView = memo(function TerminalView({
// A terminal mounted on a hidden tab sits in a 0×0 box (`display:none`):
// fitting it there shrinks xterm to its 12×6 minimum, and that size would
// reach tmux with the attach. It stays unfitted until it is shown.
const hasBox = () => !!containerRef.current?.clientWidth && !!containerRef.current.clientHeight;
const hasBox = () =>
!!containerRef.current?.clientWidth && !!containerRef.current.clientHeight;
if (hasBox()) fit.fit();
// Take the keyboard as soon as the pane is on screen: the terminal IS the
// page here, and having to click it first meant the first thing typed went
@@ -761,6 +792,44 @@ export const TerminalView = memo(function TerminalView({
refit();
};
document.fonts?.addEventListener("loadingdone", onFontLoaded);
// @xterm/addon-webgl 0.19 shares ONE glyph atlas between every terminal
// with the same font. When it fills up it merges four pages into one
// larger page and shifts the rest down, and a GPU texture is re-uploaded
// only when its page's `version` differs from the uploaded one — after a
// shift those per-page counters can coincide, so a slot keeps a stale
// page of another size and every glyph is drawn squeezed or foreign
// while the buffer stays correct (fixed upstream only in 0.20 betas).
// Any change to the page list therefore restamps every page's version
// (`restampAtlas`) synchronously, before the next draw of ANY pane on
// that atlas. `clearTextureAtlas` is no fix: it empties the SHARED atlas
// but redraws only this pane, leaving its siblings slicing reused space.
// An addon of another shape falls back to toggling the family's
// trailing space on the next frame, which makes each renderer re-acquire
// its atlas (as onFontLoaded does).
let atlasFrame = 0;
const rebuildAtlas = () => {
if (gpu && restampAtlas(gpu)) return true;
if (atlasFrame) return false;
// Never inside the render that is rasterizing the merging glyph.
atlasFrame = requestAnimationFrame(() => {
atlasFrame = 0;
const family = term.options.fontFamily ?? "";
term.options.fontFamily = family.endsWith(" ") ? family.trimEnd() : `${family} `;
});
return false;
};
const atlasMerged = gpu?.onRemoveTextureAtlasCanvas(rebuildAtlas);
const atlasGrew = gpu?.onAddTextureAtlasCanvas(rebuildAtlas);
// The browser may also drop the textures without a context-loss event
// (sleep/resume, a long-backgrounded tab): re-upload when the page is
// back, and repaint, since nothing else asks this pane to draw.
const onPageShown = () => {
if (document.visibilityState === "visible" && rebuildAtlas()) {
term.refresh(0, term.rows - 1);
}
};
document.addEventListener("visibilitychange", onPageShown);
window.addEventListener("focus", onPageShown);
// Settings → Appearance changed the terminal's font or size: an open
// pane takes it now, re-measured and re-fitted like a font landing.
const onFontPref = () => {
@@ -779,6 +848,11 @@ export const TerminalView = memo(function TerminalView({
searchRef.current = null;
resizeObserver.disconnect();
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
if (atlasFrame) cancelAnimationFrame(atlasFrame);
atlasMerged?.dispose();
atlasGrew?.dispose();
document.removeEventListener("visibilitychange", onPageShown);
window.removeEventListener("focus", onPageShown);
window.removeEventListener(FONT_SIZE_EVENT, onFontPref);
term.textarea?.removeEventListener("focus", reassert);
detachIme();
@@ -217,6 +217,17 @@ describe("the artifact view", () => {
cleanup();
});
it("shows a page as a modal over the screen, closed by Esc", async () => {
const onClose = vi.fn();
render(<FilePreview path="/w/page.html" scope={scope} onClose={onClose} modal />);
const dialog = await screen.findByRole("dialog", { name: "page.html" });
expect(dialog.parentElement?.parentElement).toBe(document.body);
expect(await screen.findByTestId("artifact-frame")).toBeInTheDocument();
expect(screen.queryByTestId("artifact-expand")).toBeNull();
fireEvent.keyDown(screen.getByTestId("artifact-tab-code"), { key: "Escape" });
expect(onClose).toHaveBeenCalled();
});
it("steps back to an older version the conversation wrote", async () => {
html(["<p>two</p>"]);
const tool = (id: string, input: Record<string, unknown>, name = "Write") => ({
@@ -242,7 +253,9 @@ describe("the artifact view", () => {
((await screen.findByTestId("artifact-frame")) as HTMLIFrameElement).getAttribute("srcdoc")
).toContain("two");
fireEvent.click(versions.querySelector("button")!);
expect(screen.getByTestId("artifact-frame").getAttribute("srcdoc")).toContain("<p>one</p>");
await vi.waitFor(() =>
expect(screen.getByTestId("artifact-frame").getAttribute("srcdoc")).toContain("<p>one</p>")
);
cleanup();
});
@@ -63,6 +63,9 @@ const termState = {
};
const onResizeHandlers: Array<() => void> = [];
const termOptions: { theme?: unknown } = {};
const refreshMock = vi.fn();
/** The atlas the next WebGL addon draws from; null = an addon of another shape. */
const nextAtlas: { pages: { version: number }[] | null } = { pages: null };
const ctorArgs: Array<{ theme?: unknown }> = [];
vi.mock("@xterm/xterm", () => ({
@@ -92,6 +95,7 @@ vi.mock("@xterm/xterm", () => ({
return { dispose: vi.fn() };
},
dispose: disposeMock,
refresh: refreshMock,
loadAddon: loadAddonMock,
attachCustomWheelEventHandler: wheelHandlerMock,
attachCustomKeyEventHandler: keyHandlerMock,
@@ -112,21 +116,44 @@ vi.mock("@xterm/addon-fit", () => ({
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
}));
// The newest WebGL addon, with the context-loss listener the view gave it.
const gpu: { last: { dispose: ReturnType<typeof vi.fn>; lose: () => void } | null } = {
const gpu: {
last: {
dispose: ReturnType<typeof vi.fn>;
lose: () => void;
merge: () => void;
grow: () => void;
} | null;
} = {
last: null,
};
vi.mock("@xterm/addon-webgl", () => ({
WebglAddon: vi.fn().mockImplementation(() => {
let onLoss = () => {};
let onMerge = () => {};
let onGrow = () => {};
const addon = {
__webgl: true,
...(nextAtlas.pages ? { _renderer: { _charAtlas: { pages: nextAtlas.pages } } } : {}),
dispose: vi.fn(),
onContextLoss: (fn: () => void) => {
onLoss = fn;
return { dispose: vi.fn() };
},
onRemoveTextureAtlasCanvas: (fn: () => void) => {
onMerge = fn;
return { dispose: vi.fn() };
},
onAddTextureAtlasCanvas: (fn: () => void) => {
onGrow = fn;
return { dispose: vi.fn() };
},
};
gpu.last = {
dispose: addon.dispose,
lose: () => onLoss(),
merge: () => onMerge(),
grow: () => onGrow(),
};
gpu.last = { dispose: addon.dispose, lose: () => onLoss() };
return addon;
}),
}));
@@ -488,6 +515,50 @@ describe("TerminalView", () => {
expect(disposeMock).not.toHaveBeenCalled(); // the terminal itself lives on
});
it("restamps every atlas page at once when the page list changes", () => {
// addon-webgl 0.19 counts versions per page, so after a merge shifts the
// pages a texture slot can keep a stale page whose counter matches. Every
// page gets a version no slot has uploaded — in every pane on the atlas.
const pages = [{ version: 3 }, { version: 7 }, { version: 1 }];
nextAtlas.pages = pages;
const raf = vi.spyOn(window, "requestAnimationFrame");
try {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
gpu.last!.merge();
const first = pages.map((p) => p.version);
expect(new Set(first).size).toBe(3);
expect(first.every((v) => v > 7)).toBe(true);
gpu.last!.grow();
expect(pages.every((p, i) => p.version > Math.max(...first) && p.version !== first[i])).toBe(
true
);
expect(raf).not.toHaveBeenCalled(); // synchronous, before any pane draws
// The page coming back re-uploads and repaints this pane.
refreshMock.mockClear();
window.dispatchEvent(new Event("focus"));
expect(refreshMock).toHaveBeenCalledWith(0, 27);
} finally {
nextAtlas.pages = null;
raf.mockRestore();
}
});
it("re-acquires the glyph atlas on the next frame after a page merge", () => {
// An addon whose atlas this cannot reach: any option change makes each
// renderer re-acquire its atlas and re-upload every page.
const raf = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 1);
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const opts = termOptions as { fontFamily?: string };
opts.fontFamily = "JetBrains Mono";
gpu.last!.merge();
gpu.last!.merge(); // coalesced into one frame
expect(raf).toHaveBeenCalledTimes(1);
expect(opts.fontFamily).toBe("JetBrains Mono"); // never inside the render that merged
(raf.mock.calls[0]![0] as FrameRequestCallback)(0);
expect(opts.fontFamily).toBe("JetBrains Mono ");
raf.mockRestore();
});
// xterm 6 dropped its own Alt+arrow → word-move mapping; the pane keeps it.
it("sends Alt+arrow as the word-move keys xterm 5 sent", () => {
expect(altArrowSeq("ArrowLeft", false)).toBe("\x1b[1;5D");
@@ -5,7 +5,8 @@
* Default badge or Set default, and Update whenever the catalog names an
* updater — no version lookup, the updater itself knows whether it has work.
* The ones available to install fold behind a disclosure, with Install where
* the catalog knows how. Install/Update/Update all confirm the exact command
* the catalog knows how (an admin's; Update all is anyone's — auth.js
* MEMBER_WRITE_EXCEPTIONS). Install/Update/Update all confirm the exact command
* first, run as ONE server job at a time and show its log under the row
* (polled from GET /api/ai-tools/job; a finished job folds to one line).
* A right-click on an installed row opens its Update / Set default /
@@ -231,7 +232,8 @@ export function AiToolsSettings({
{t("aiTools.installed", { count: installed.length })}
</p>
<div className="flex items-center gap-2">
{isAdmin && updateAllTools.length > 0 && (
{/* Every signed-in user may run Update all (auth.js MEMBER_WRITE_EXCEPTIONS). */}
{updateAllTools.length > 0 && (
<button
type="button"
onClick={() => setConfirm({ kind: "update-all" })}
@@ -41,32 +41,9 @@ const BTN =
"inline-flex items-center gap-1.5 rounded border border-border-light px-2.5 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:cursor-not-allowed disabled:opacity-40";
const CODECOMMIT_RE = /^git-codecommit\.([a-z0-9-]+)\.amazonaws\.com$/;
// The regions AWS CodeCommit runs in.
const AWS_REGIONS = [
"ap-southeast-1",
"ap-southeast-2",
"ap-southeast-3",
"ap-northeast-1",
"ap-northeast-2",
"ap-northeast-3",
"ap-south-1",
"ap-east-1",
"us-east-1",
"us-east-2",
"us-west-1",
"us-west-2",
"ca-central-1",
"eu-west-1",
"eu-west-2",
"eu-west-3",
"eu-central-1",
"eu-north-1",
"eu-south-1",
"sa-east-1",
"me-south-1",
"af-south-1",
"il-central-1",
];
// The host a key ID is saved under when no URL names one: AWS takes the same
// key ID in every region, so the server writes the default login for all.
const AWS_DEFAULT_HOST = "git-codecommit.us-east-1.amazonaws.com";
function Step({ n, children }: { n: number; children: ReactNode }) {
return (
@@ -166,28 +143,25 @@ function KeyCard({
}
/**
* Step 3 of an AWS key: the SSH key ID AWS shows for it, in one region. The
* default key's login is the region's plain host; a named key's is saved
* under an account name (its own name unless another is typed), tied to it.
* Step 3 of an AWS key: the SSH key ID AWS shows for it — nothing else. The
* default key's login is saved for every region; a named key's under its own
* name, tied to it. A clone of any CodeCommit URL tries each saved login.
*/
function AwsLoginForm({
busy,
named,
onSave,
}: {
busy: boolean;
/** The named key's name; undefined for the default key. */
named?: string;
onSave: (v: { keyId: string; region: string; alias: string }) => Promise<boolean>;
onSave: (v: { keyId: string }) => Promise<boolean>;
}) {
const { t } = useTranslation("settings");
const [v, setV] = useState({ keyId: "", region: "ap-southeast-1", alias: "" });
const [v, setV] = useState({ keyId: "" });
return (
<form
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_auto]"
className="mt-1 flex gap-2"
onSubmit={(e) => {
e.preventDefault();
void onSave(v).then((ok) => ok && setV({ ...v, keyId: "", alias: "" }));
void onSave(v).then((ok) => ok && setV({ keyId: "" }));
}}
>
<input
@@ -198,27 +172,6 @@ function AwsLoginForm({
onChange={(e) => setV({ ...v, keyId: e.target.value })}
className={`${INPUT} font-mono`}
/>
<select
aria-label={t("gitIdentity.awsRegion")}
value={v.region}
onChange={(e) => setV({ ...v, region: e.target.value })}
className="select !py-1 !text-xs"
>
{AWS_REGIONS.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<input
aria-label={t("gitIdentity.alias")}
title={t("gitIdentity.aliasHint")}
placeholder={named ?? t("gitIdentity.aliasPlaceholder")}
autoComplete="off"
value={v.alias}
onChange={(e) => setV({ ...v, alias: e.target.value })}
className={`${INPUT} font-mono`}
/>
<button type="submit" className={BTN} disabled={busy || !v.keyId.trim()}>
{t("gitIdentity.awsSave")}
</button>
@@ -413,28 +366,41 @@ export function GitIdentitySettings() {
const namedIds = new Set(named.map((k) => k.id));
const rowsOf = <R extends { key?: string | null }>(rows: R[], k?: Key) =>
k ? rows.filter((r) => r.key === k.id) : rows.filter((r) => !r.key || !namedIds.has(r.key));
const saveAws = (v: { keyId: string; region: string; alias: string }, k?: Key) => {
const saveAws = (v: { keyId: string }, k?: Key) => {
let user = v.keyId.trim();
let host = `git-codecommit.${v.region}.amazonaws.com`;
// A pasted `ssh://APKA…@git-codecommit.<region>…/…` URL fills both.
let host = AWS_DEFAULT_HOST;
// A pasted `ssh://APKA…@git-codecommit.<region>…/…` URL: its key ID. The
// region does not matter — the key ID logs in to every one.
const m = /^ssh:\/\/([^@/]+)@([^/]+)/i.exec(user);
if (m?.[1] && m[2]) {
user = m[1];
host = m[2].toLowerCase();
if (CODECOMMIT_RE.test(m[2].toLowerCase())) host = m[2].toLowerCase();
}
// A named key's account goes by the key's name unless another is typed.
const alias = v.alias.trim().toLowerCase() || (k?.name ?? "");
return run(
() =>
api.gitIdentity.setSshUser({
host,
user,
...(alias ? { alias } : {}),
...(k?.id ? { key: k.id } : {}),
}),
t("gitIdentity.awsSaved", { region: alias || (CODECOMMIT_RE.exec(host)?.[1] ?? host) })
k?.name
? // A named key (another AWS account): its login goes by the key's
// name; a clone of any CodeCommit URL tries it.
api.gitIdentity.setSshUser({ host, user, alias: k.name, key: k.id! })
: api.gitIdentity.setSshUser({ host, user, allRegions: true }),
k?.name ? t("gitIdentity.awsSavedNamed", { name: k.name }) : t("gitIdentity.awsSavedAll")
);
};
// The default key's logins, one row per key ID: "every region" once the
// server's whole list is covered, else the regions saved (an older save) —
// with a button that extends it to every region.
const regionCount = id?.codecommitRegions?.length ?? 0;
const awsDefaultGroups = (rows: typeof awsRows) => {
const out: { user: string; key: string | null; hosts: string[]; regions: string[] }[] = [];
for (const r of rows.filter((x) => !x.alias)) {
const g = out.find((o) => o.user === r.user && o.key === (r.key ?? null));
if (g) {
g.hosts.push(r.host);
g.regions.push(r.region);
} else out.push({ user: r.user, key: r.key ?? null, hosts: [r.host], regions: [r.region] });
}
return out;
};
const saveGitHost = (host: string, k: Key) => {
const h = /^(?:ssh:\/\/)?(?:[^@/]+@)?([^/:]+)/i.exec(host)?.[1]?.toLowerCase() ?? host;
return run(
@@ -689,14 +655,66 @@ export function GitIdentitySettings() {
{id.sshUsers === undefined ? null : (
<>
{loginRows(
rows,
(r) =>
`${r.alias ? `${r.alias} · ` : ""}${r.region} · ${r.user} · ${keyLabel(r.key)}`
rows.filter((r) => !!r.alias),
(r) => `${r.alias} · ${r.user} · ${keyLabel(r.key)}`
)}
{awsDefaultGroups(rows).map((g) => {
const all = regionCount > 0 && g.regions.length >= regionCount;
return (
<div
key={`${g.user}\0${g.key ?? ""}`}
className="mt-1 flex items-center gap-2"
>
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
{all ? t("gitIdentity.awsAllRegions") : g.regions.join(", ")}
{` · ${g.user} · ${keyLabel(g.key)}`}
</span>
{id.writable && !all && regionCount > 0 && (
<button
type="button"
className={BTN}
disabled={busy}
onClick={() =>
void run(
() =>
api.gitIdentity.setSshUser({
host: g.hosts[0]!,
user: g.user,
allRegions: true,
}),
t("gitIdentity.awsSavedAll")
)
}
>
{t("gitIdentity.awsUseAll")}
</button>
)}
{id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
aria-label={`${t("gitIdentity.remove")} ${g.user}`}
onClick={() =>
void run(() =>
g.hosts.length > 1
? api.gitIdentity.removeSshUser(g.hosts[0]!, {
allRegions: true,
user: g.user,
})
: api.gitIdentity.removeSshUser(g.hosts[0]!)
)
}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
);
})}
{id.writable && (
<AwsLoginForm
busy={busy}
named={isNamed ? k!.name! : undefined}
onSave={(v) => saveAws(v, isNamed ? k : undefined)}
/>
)}
@@ -1,6 +1,6 @@
/**
* @file NineRouterUpdate.tsx
* @description **Update 9router** on the AI Proxy page (an admin's; beside
* @description **Update 9router** on the AI Proxy page (every user's; beside
* Config 9router): a button — dotted when npm has a newer 9router — and a
* dialog that runs the server's three-step job (`server/lib/nine-router-update.js`):
* disconnect and shut 9router down, `npm install -g 9router@latest`, start it
@@ -0,0 +1,120 @@
/**
* @file PoolAccountSettings — Settings → AI tools → Pool account: in server
* mode, every user picks which account of the shared account pool their own
* new sessions run on, per tool, among the accounts their pool group may use
* (`GET/PUT /api/me/pool-accounts`, server/lib/account-pins.js `setChoice`).
* "Automatic" leaves it to the balancer; an admin's pin locks the choice. A
* Claude login goes into the user's home at once, so running panes follow it;
* Codex and Cursor take it at the next session. Renders nothing in local mode
* or when the pool holds no account the user may use.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Lock } from "lucide-react";
import { api, type MyPoolTool, type PoolTool } from "../../lib/api";
import { toast } from "../../lib/toast";
import { usePermissions } from "../AuthGate";
import { AiToolIcon } from "../AiToolIcon";
import { SettingsRow, SettingsSection } from "./SettingsRow";
const LABEL: Record<PoolTool, string> = { claude: "Claude Code", codex: "Codex", cursor: "Cursor" };
export function PoolAccountSettings() {
const { t } = useTranslation("settings");
const { local } = usePermissions();
const [tools, setTools] = useState<MyPoolTool[] | null>(null);
const [busy, setBusy] = useState<PoolTool | null>(null);
useEffect(() => {
if (local) return;
api.me
.poolAccounts()
.then((r) => setTools(r.tools))
.catch(() => setTools(null));
}, [local]);
if (local || !tools?.length) return null;
const choose = async (tool: PoolTool, account: string | null) => {
setBusy(tool);
try {
const r = await api.me.choosePoolAccount(tool, account);
setTools((list) =>
(list ?? []).map((x) => (x.tool === tool ? { ...x, choice: r.choice } : x))
);
toast.success(
!r.choice
? t("poolAccount.automaticSaved")
: r.switched === "switched" || r.switched === "kept"
? t("poolAccount.switchedNow", { account: r.choice })
: t("poolAccount.nextSession", { account: r.choice })
);
} catch (err) {
toast.error(err);
} finally {
setBusy(null);
}
};
return (
<SettingsSection
id="pool-account"
title={t("poolAccount.title")}
description={t("poolAccount.description")}
>
{tools.map((x) => (
<SettingsRow
key={x.tool}
id={`pool-account-${x.tool}`}
label={
<span className="inline-flex items-center gap-1.5">
<AiToolIcon tool={{ id: x.tool, label: LABEL[x.tool] }} className="h-4 w-4" />
{LABEL[x.tool]}
</span>
}
description={
x.pin ? (
<span className="inline-flex items-center gap-1">
<Lock className="h-3 w-3" />
{t("poolAccount.pinned", { account: x.pin })}
</span>
) : (
t("poolAccount.count", { count: x.accounts.length })
)
}
disabled={!!x.pin}
control={
<select
aria-label={t("poolAccount.select", { tool: LABEL[x.tool] })}
value={x.pin ?? x.choice ?? ""}
disabled={!!x.pin || busy === x.tool}
onChange={(e) => void choose(x.tool, e.target.value || null)}
className="select !py-1 !text-xs max-w-[16rem]"
>
{!x.pin && <option value="">{t("poolAccount.automatic")}</option>}
{x.accounts.map((a) => (
<option key={a.name} value={a.name} disabled={a.expired && a.name !== x.choice}>
{[
a.name,
a.email && a.email !== a.name ? `(${a.email})` : null,
a.expired
? `— ${t("poolAccount.expired")}`
: a.full
? `— ${t("poolAccount.full")}`
: a.quotaPercent != null
? `— ${Math.round(a.quotaPercent)}%`
: null,
]
.filter(Boolean)
.join(" ")}
</option>
))}
</select>
}
/>
))}
</SettingsSection>
);
}
@@ -262,11 +262,11 @@ function LabelEdit({
function useRunner(load: () => Promise<unknown>) {
const [busy, setBusy] = useState<string | null>(null);
const run = useCallback(
async (key: string, fn: () => Promise<unknown>, success: string) => {
async <T,>(key: string, fn: () => Promise<T>, success: string | ((r: T) => string)) => {
setBusy(key);
try {
await fn();
toast.success(success);
const r = await fn();
toast.success(typeof success === "function" ? success(r) : success);
await load();
} catch (err: unknown) {
toast.error(err);
@@ -399,7 +399,14 @@ function OtherProfiles({
void run(
`switch:${p.name}`,
() => api.toolProfiles.activate(tool, p.name),
t("toolProfiles.switchSuccess", { name: p.label, tool: label })
(r) =>
t(
r.following ? "toolProfiles.switchFollowing" : "toolProfiles.switchSuccess",
{
name: p.label,
tool: label,
}
)
)
}
className={btnCls}
@@ -800,9 +807,7 @@ function ProfileUsageLine({
/** Org / seat / plan line shared by the active card and each profile row. */
function IdentityLine({ identity }: { identity: CcAccountIdentity }) {
const parts = [identity.organization, identity.seatTier, identity.subscriptionType].filter(
Boolean
);
const parts = [identity.organization, identity.plan || identity.subscriptionType].filter(Boolean);
if (parts.length === 0) return null;
return <div className="text-[10px] text-fg-muted mt-0.5">{parts.join(" · ")}</div>;
}
@@ -139,27 +139,64 @@ describe("GitIdentitySettings", () => {
await waitFor(() => expect(api.gitIdentity.removeKey).toHaveBeenCalledWith("rsa"));
});
it("saves the CodeCommit SSH key ID for a region and shows the plain clone", async () => {
it("saves the CodeCommit SSH key ID for every region — no region to pick", async () => {
const RSA = { type: "rsa" as const, publicKey: "ssh-rsa AAAAB3 x", fingerprint: "SHA256:r" };
const H = "git-codecommit.eu-west-1.amazonaws.com";
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ sshKeys: [RSA], sshUsers: [] }));
const REGIONS = ["us-east-1", "eu-west-1", "ap-northeast-1"];
const host = (r: string) => `git-codecommit.${r}.amazonaws.com`;
vi.mocked(api.gitIdentity.get).mockResolvedValue(
view({ sshKeys: [RSA], sshUsers: [], codecommitRegions: REGIONS })
);
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
view({ sshKeys: [RSA], sshUsers: [{ host: H, user: "APKA1" }] })
view({
sshKeys: [RSA],
sshUsers: REGIONS.map((r) => ({ host: host(r), user: "APKA1" })),
codecommitRegions: REGIONS,
})
);
vi.mocked(api.gitIdentity.removeSshUser).mockResolvedValue(
view({ sshKeys: [RSA], sshUsers: [] })
view({ sshKeys: [RSA], sshUsers: [], codecommitRegions: REGIONS })
);
render(<GitIdentitySettings />);
const aws = await screen.findByRole("region", { name: "AWS CodeCommit" });
expect(within(aws).queryByLabelText("Region")).toBeNull();
await userEvent.type(within(aws).getByLabelText("SSH key ID"), " APKA1 ");
await userEvent.selectOptions(within(aws).getByLabelText("Region"), "eu-west-1");
await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({ host: H, user: "APKA1" });
expect(await within(aws).findByText("eu-west-1 · APKA1 · Any key")).toBeInTheDocument();
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
host: "git-codecommit.us-east-1.amazonaws.com",
user: "APKA1",
allRegions: true,
});
// One row for the key ID, not one per region.
expect(await within(aws).findByText("Every region · APKA1 · Any key")).toBeInTheDocument();
// A CodeCommit host never shows up again under "other hosts".
expect(screen.queryByText(`APKA1@${H}`)).toBeNull();
await userEvent.click(within(aws).getByRole("button", { name: `Delete ${H}` }));
expect(api.gitIdentity.removeSshUser).toHaveBeenCalledWith(H);
expect(screen.queryByText(`APKA1@${host("eu-west-1")}`)).toBeNull();
await userEvent.click(within(aws).getByRole("button", { name: "Delete APKA1" }));
expect(api.gitIdentity.removeSshUser).toHaveBeenCalledWith(host("us-east-1"), {
allRegions: true,
user: "APKA1",
});
});
it("offers an older one-region CodeCommit login for every region", async () => {
const RSA = { type: "rsa" as const, publicKey: "ssh-rsa AAAAB3 x", fingerprint: "SHA256:r" };
const H = "git-codecommit.ap-northeast-1.amazonaws.com";
vi.mocked(api.gitIdentity.get).mockResolvedValue(
view({
sshKeys: [RSA],
sshUsers: [{ host: H, user: "APKA9" }],
codecommitRegions: ["us-east-1", "ap-northeast-1"],
})
);
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(view({ sshKeys: [RSA] }));
render(<GitIdentitySettings />);
const aws = await screen.findByRole("region", { name: "AWS CodeCommit" });
expect(await within(aws).findByText("ap-northeast-1 · APKA9 · Any key")).toBeInTheDocument();
await userEvent.click(within(aws).getByRole("button", { name: "Use in every region" }));
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
host: H,
user: "APKA9",
allRegions: true,
});
});
it("saves a user for any other SSH host under the advanced section", async () => {
@@ -247,18 +284,16 @@ describe("GitIdentitySettings", () => {
// Another key can be added below it.
expect(screen.getByRole("button", { name: "Add another key" })).toBeInTheDocument();
// Its own step 3: the account name defaults to the key's name.
// Its own step 3: only the key ID; the login goes by the key's name.
await userEvent.type(within(prod).getByLabelText("SSH key ID"), "APKA2");
await userEvent.click(within(prod).getByRole("button", { name: "Save" }));
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
host: H,
host: "git-codecommit.us-east-1.amazonaws.com",
user: "APKA2",
alias: "aws-2",
key: "id_rsa_aws-2",
});
expect(
await within(prod).findByText("aws-2 · ap-southeast-1 · APKA2 · aws-2")
).toBeInTheDocument();
expect(await within(prod).findByText("aws-2 · APKA2 · aws-2")).toBeInTheDocument();
// The default key's card does not list the named key's login.
const def = screen.getByRole("region", { name: "AWS CodeCommit" });
expect(within(def).queryByText(/APKA2/)).toBeNull();
@@ -298,8 +333,6 @@ describe("GitIdentitySettings", () => {
alias: "git-2",
key: "id_ed25519_git-2",
});
expect(
await within(work).findByText("ssh://git-2/ → git@github.com")
).toBeInTheDocument();
expect(await within(work).findByText("ssh://git-2/ → git@github.com")).toBeInTheDocument();
});
});
@@ -0,0 +1,85 @@
/**
* @file Tests for PoolAccountSettings — Settings → AI tools → Pool account:
* hidden in local mode and with no usable account; one select per tool that
* starts on the user's choice (or Automatic) and saves a pick through
* `PUT /api/me/pool-accounts`; locked when an admin pinned the user.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "../../../i18n";
import { PoolAccountSettings } from "../PoolAccountSettings";
const perms = vi.hoisted(() => ({ local: false }));
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
const m = vi.hoisted(() => ({ poolAccounts: vi.fn(), choosePoolAccount: vi.fn() }));
vi.mock("../../../lib/api", () => ({
api: { me: m, aiTools: { iconUrl: (id: string) => `/icons/${id}.png` } },
}));
const account = (name: string) => ({
name,
kind: "login",
email: `${name}@x.io`,
expired: false,
quotaPercent: 20,
full: false,
});
async function show() {
render(<PoolAccountSettings />);
await act(async () => {
await new Promise((res) => setTimeout(res, 0));
});
}
beforeEach(() => {
perms.local = false;
m.poolAccounts.mockReset();
m.choosePoolAccount.mockReset();
});
describe("PoolAccountSettings", () => {
it("renders nothing in local mode or with no account to choose", async () => {
perms.local = true;
await show();
expect(m.poolAccounts).not.toHaveBeenCalled();
expect(screen.queryByRole("combobox")).toBeNull();
perms.local = false;
m.poolAccounts.mockResolvedValue({ tools: [] });
await show();
expect(screen.queryByRole("combobox")).toBeNull();
});
it("starts on the user's choice and saves a new pick", async () => {
m.poolAccounts.mockResolvedValue({
tools: [
{ tool: "claude", pin: null, choice: "two", accounts: [account("one"), account("two")] },
],
});
m.choosePoolAccount.mockResolvedValue({ tool: "claude", choice: "one", switched: "switched" });
await show();
const select = screen.getByRole("combobox") as HTMLSelectElement;
expect(select.value).toBe("two");
await userEvent.selectOptions(select, "one");
expect(m.choosePoolAccount).toHaveBeenCalledWith("claude", "one");
await userEvent.selectOptions(select, "");
expect(m.choosePoolAccount).toHaveBeenLastCalledWith("claude", null);
});
it("is locked on an admin's pin", async () => {
m.poolAccounts.mockResolvedValue({
tools: [
{ tool: "codex", pin: "one", choice: null, accounts: [account("one"), account("two")] },
],
});
await show();
const select = screen.getByRole("combobox") as HTMLSelectElement;
expect(select.disabled).toBe(true);
expect(select.value).toBe("one");
expect(screen.getByText(/one/, { selector: "span" })).toBeTruthy();
});
});
+156 -289
View File
@@ -1,15 +1,15 @@
/**
* @file AccountPoolCard.tsx
* @description The Users page's account pool card (`GET /api/accounts`), one
* tab per AI tool so each tool's pool is managed on its own: Claude Code,
* Codex and Cursor have a pool — the accounts every user's new sessions of that
* tool are spread over, each with its live panes (and, for Claude/Codex, $ and
* tokens in the last 5 h / 7 d and quota) — so nobody signs in per user;
* Oh My Pi has no pool but can share the host's login (the same switch as
* Settings → AI tools, `GET/PUT /api/ai-tools/omp-sharing`, S3 only); every
* other installed tool says that its users sign in themselves. A tab shows
* how many accounts its pool holds, and an empty pool says so instead of
* vanishing. An admin adds an account to the open tab's tool — for Claude a
* @description The Users page's account pool card (`GET /api/accounts`).
* Claude Code, Codex and Cursor accounts live on the POOLS board
* (`PoolGroups`): one column per pool with its accounts of every tool — each
* a card with its live panes (and, for Claude/Codex, $ and tokens in the last
* 5 h / 7 d and quota) — and its members, so nobody signs in per user. The
* tools with no pool get a tab each below it: Oh My Pi can share the host's
* login (the same switch as Settings → AI tools,
* `GET/PUT /api/ai-tools/omp-sharing`, S3 only); every other installed tool
* says that its users sign in themselves. An admin adds an account from the
* header's Add account, picking its tool — for Claude a
* login MOVED in from a saved profile (Settings → AI tools → Profiles,
* `GET /api/accounts/profiles`), a pasted `.credentials.json`, or a
* `claude setup-token` token; for Codex a saved Codex profile
@@ -17,11 +17,11 @@
* Cursor API key — or removes one, each time with their own password again;
* an analyst only reads. The secret only goes IN: the server never sends it
* back. An account's Edit (pencil) picks the users who run on it
* (`PUT /api/accounts/:name/users`): their sessions skip the balancer, and a
* (`PUT /api/accounts/:name/users`, from a `MemberPicker` filtered by role
* or "no group"): their sessions skip the balancer, and a
* Claude login goes into their home at once — no restart. A profile's
* "Move to pool" link lands here as `?poolAdd=<tool>&poolProfile=<name>`,
* which opens that tool's tab and Add on that profile; the header links back
* to the profiles.
* which opens Add on that profile; the header links back to the profiles.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -29,7 +29,7 @@ import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode }
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { Link, useSearchParams } from "react-router-dom";
import { KeyRound, Pencil, Plus, RefreshCw, Share2, Trash2, UserRound, Users } from "lucide-react";
import { KeyRound, Pencil, Plus, RefreshCw, Share2, Trash2, UserRound } from "lucide-react";
import {
api,
type AccountPin,
@@ -47,10 +47,10 @@ import { isSubmitEnter } from "../../lib/keys";
import { useSpin } from "../../hooks/useSpin";
import { useAiTools } from "../../hooks/useAiTools";
import { ConfirmModal } from "../ConfirmModal";
import { Checkbox } from "../Checkbox";
import { AiToolIcon } from "../AiToolIcon";
import { SettingsSwitchRow } from "../settings/SettingsRow";
import { PoolGroups, groupsOf } from "./PoolGroups";
import { PoolGroups, POOL_TOOL_LABELS } from "./PoolGroups";
import { MemberPicker } from "./MemberPicker";
type Row = {
tool: PoolTool;
@@ -69,18 +69,20 @@ type Row = {
full: boolean;
/** The 5-hour and weekly windows, as the account last reported them. */
windows: QuotaWindow[];
/** "Max 5x", "Team Premium", "Plus"…; null when the account does not say. */
plan?: string | null;
};
/** One quota window of an account: % used and when it rolls over. */
type QuotaWindow = { key: "5h" | "wk"; used: number | null; resetsAt: string | null };
export type QuotaWindow = { key: "5h" | "wk"; used: number | null; resetsAt: string | null };
/**
* The 5-hour and weekly windows of an account, from Claude's `fiveHour` /
* `sevenDay` or Codex's `5h` / `wk` rollout windows. A window whose reset has
* passed is empty again (the reading predates it), as the balancer treats it.
*/
function windowsOf(
a: AccountPool["accounts"][number] | AccountPool["codex"][number]
export function windowsOf(
a: Pick<AccountPool["accounts"][number] | AccountPool["codex"][number], "quota">
): QuotaWindow[] {
const q = a.quota as
| {
@@ -119,13 +121,8 @@ function untilText(iso: string | null): string | null {
/** The tools that have a pool, in tab order (server/routes/accounts.js). */
const POOL_TOOLS: PoolTool[] = ["claude", "codex", "cursor"];
/** Fixed tabs, always shown; any other installed tool follows them. */
const FIXED_TABS: { id: string; label: string }[] = [
{ id: "claude", label: "Claude Code" },
{ id: "codex", label: "Codex" },
{ id: "cursor", label: "Cursor CLI" },
{ id: "omp", label: "Oh My Pi" },
];
/** Tabs for the tools with no pool: Oh My Pi always, any other installed tool after it. */
const FIXED_TABS: { id: string; label: string }[] = [{ id: "omp", label: "Oh My Pi" }];
/** Tools whose pool reports spend and quota (Cursor's CLI exposes neither). */
const METERED = new Set<PoolTool>(["claude", "codex"]);
@@ -166,7 +163,7 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
const profile = params.get("poolProfile");
return tool && isPoolTool(tool) && profile ? { tool, profile } : null;
})();
const [tab, setTab] = useState<string>(preset?.tool ?? "claude");
const [tab, setTab] = useState<string>("omp");
// What Add opens on: a tool, and a saved profile to share into its pool.
const [adding, setAdding] = useState<{ tool: PoolTool; profile?: string } | null>(() =>
isAdmin && preset ? preset : null
@@ -208,15 +205,14 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
const tabs = [
...FIXED_TABS,
...aiTools.tools
.filter((x) => x.installed && !FIXED_TABS.some((f) => f.id === x.id))
.filter((x) => x.installed && !isPoolTool(x.id) && !FIXED_TABS.some((f) => f.id === x.id))
.map((x) => ({ id: x.id, label: x.label })),
];
const current = tabs.find((x) => x.id === tab) ?? FIXED_TABS[0]!;
const badge = (id: string): string | null => {
if (isPoolTool(id)) return pool ? String(rowsOf(pool, id).length) : null;
if (id === "omp") return omp?.available && omp.enabled ? t("pool.omp.badgeOn") : null;
return null;
};
const badge = (id: string): string | null =>
id === "omp" && omp?.available && omp.enabled ? t("pool.omp.badgeOn") : null;
const rowOf = (tool: PoolTool, name: string) =>
pool ? rowsOf(pool, tool).find((r) => r.name === name) : undefined;
return (
<section id="account-pool" className="card scroll-mt-24" data-testid="account-pool">
@@ -229,6 +225,29 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
<Link to="/settings/agents#tool-profiles" className="btn-ghost text-xs">
{t("pool.profilesLink")}
</Link>
{isAdmin && (
<label className="relative">
<span className="btn-primary pointer-events-none text-xs">
<Plus className="h-4 w-4" /> {t("pool.addAccount")}
</span>
<select
className="absolute inset-0 cursor-pointer opacity-0"
value=""
onChange={(e) => {
if (isPoolTool(e.target.value)) setAdding({ tool: e.target.value });
}}
aria-label={t("pool.addAccount")}
data-testid="pool-add-account"
>
<option value="">{t("pool.addAccount")}</option>
{POOL_TOOL_LABELS.map((x) => (
<option key={x.id} value={x.id}>
{x.label}
</option>
))}
</select>
</label>
)}
<button
type="button"
onClick={refresh}
@@ -241,11 +260,37 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
</button>
</div>
{pool?.groups && <PoolGroups pool={pool} isAdmin={isAdmin} onChanged={() => void load()} />}
{error && (
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
{error}
</p>
)}
{pool && (
<PoolGroups
pool={pool}
isAdmin={isAdmin}
onChanged={() => void load()}
renderAccount={(tool, name) => {
const r = rowOf(tool, name);
return r ? (
<AccountCard
row={r}
pool={pool}
isAdmin={isAdmin}
onEdit={() => setEditing(r)}
onRemove={() => setRemoving(r)}
/>
) : null;
}}
/>
)}
<h3 className="border-t border-border px-5 pt-4 text-xs font-semibold uppercase tracking-wider text-fg-muted">
{t("pool.otherTools")}
</h3>
<div
role="tablist"
aria-label={t("pool.title")}
className="flex gap-1 overflow-x-auto px-5 pt-3 border-b border-border"
aria-label={t("pool.otherTools")}
className="flex gap-1 overflow-x-auto px-5 pt-2 border-b border-border"
>
{tabs.map((x) => {
const selected = x.id === current.id;
@@ -280,24 +325,8 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
);
})}
</div>
{error && (
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
{error}
</p>
)}
<div role="tabpanel" id="pool-panel" aria-labelledby={`pool-tab-${current.id}`}>
{isPoolTool(current.id) ? (
<PoolPanel
tool={current.id}
label={current.label}
pool={pool}
isAdmin={isAdmin}
installed={current.id === "claude" || !!aiTools.byId(current.id)?.installed}
onAdd={(profile) => setAdding({ tool: current.id as PoolTool, profile })}
onEdit={setEditing}
onRemove={setRemoving}
/>
) : current.id === "omp" ? (
{current.id === "omp" ? (
<OmpPanel state={omp} isAdmin={isAdmin} onChange={setOmp} />
) : (
<NoPoolPanel id={current.id} label={current.label} />
@@ -306,7 +335,7 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
{adding && (
<AddAccountDialog
tool={adding.tool}
label={tabs.find((x) => x.id === adding.tool)?.label ?? adding.tool}
label={POOL_TOOL_LABELS.find((x) => x.id === adding.tool)?.label ?? adding.tool}
preset={adding.profile ? { tool: adding.tool, profile: adding.profile } : null}
onClose={closeAdd}
onDone={(name) => {
@@ -351,89 +380,16 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
);
}
/** One pool tool's tab: what its pool does, its accounts as cards, the saved profiles to share, and Add. */
function PoolPanel({
tool,
label,
pool,
isAdmin,
installed,
onAdd,
onEdit,
onRemove,
}: {
tool: PoolTool;
label: string;
pool: AccountPool | null;
isAdmin: boolean;
installed: boolean;
/** Add an account; `profile` = share that saved profile into the pool. */
onAdd: (profile?: string) => void;
onEdit: (r: Row) => void;
onRemove: (r: Row) => void;
}) {
const { t } = useTranslation("users");
const rows = pool ? rowsOf(pool, tool) : [];
const grouped = (pool?.groups ?? []).length > 0;
const addButton = isAdmin && (
<button type="button" onClick={() => onAdd()} className="btn-primary">
<Plus className="w-4 h-4" /> {t("pool.addFor", { tool: label })}
</button>
);
return (
<>
<div className="flex flex-wrap items-start gap-3 px-5 py-3">
<p className="flex-1 min-w-[16rem] text-xs text-fg-secondary leading-relaxed">
{t(`pool.about.${tool}`)}
{!installed && (
<span className="ml-1 text-status-warning">
{t("pool.notInstalled", { tool: label })}
</span>
)}
</p>
{rows.length > 0 && addButton}
</div>
{pool && rows.length === 0 && (
<div className="flex flex-col items-center gap-3 px-5 pb-6 pt-2 text-center">
<p className="max-w-md text-sm text-fg-muted">{t("pool.emptyFor", { tool: label })}</p>
{addButton}
</div>
)}
{rows.length > 0 && (
<div className="grid gap-3 px-5 pb-4 sm:grid-cols-2 xl:grid-cols-3">
{rows.map((r) => (
<AccountCard
key={r.name}
row={r}
pool={pool!}
grouped={grouped}
isAdmin={isAdmin}
onEdit={() => onEdit(r)}
onRemove={() => onRemove(r)}
/>
))}
</div>
)}
{isAdmin && SOURCES[tool].includes("profile") && (
<SavedProfiles tool={tool} pool={pool} onShare={(name) => onAdd(name)} />
)}
</>
);
}
/** One pool account: who it is, how loaded, which groups and users run on it. */
/** One pool account: who it is and on which plan, how loaded, who is pinned to it. */
function AccountCard({
row: r,
pool,
grouped,
isAdmin,
onEdit,
onRemove,
}: {
row: Row;
pool: AccountPool;
grouped: boolean;
isAdmin: boolean;
onEdit: () => void;
onRemove: () => void;
@@ -443,39 +399,52 @@ function AccountCard({
const users = pinsOf(pool, r.tool)
.filter((p) => p.account === r.name)
.map((p) => p.username);
const inGroups = groupsOf(pool, r.tool, r.name);
const warn = "bg-status-warning/10 text-status-warning border-status-warning/20";
return (
<div
className={`flex flex-col gap-2 rounded-lg border bg-surface-2 p-3 ${
className={`group flex flex-col gap-2 rounded-lg border bg-surface-1 p-2.5 shadow-sm transition-[border-color,box-shadow] duration-150 hover:border-accent/40 hover:shadow-md ${
r.expired ? "border-status-danger/30 opacity-70" : "border-border"
}`}
title={t("pool.added", { at: formatDateTime(r.addedAt) })}
data-testid="pool-account"
>
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
<div className="truncate text-sm font-medium text-fg-primary">{r.name}</div>
{r.email && <div className="truncate text-[11px] text-fg-secondary">{r.email}</div>}
<div className="flex min-w-0 items-center gap-1.5">
<span className="truncate text-sm font-semibold text-fg-primary">{r.name}</span>
{r.plan && (
<span
className="shrink-0 rounded-full bg-accent/10 px-1.5 text-[10px] font-medium leading-4 text-accent"
data-testid="pool-account-plan"
>
{r.plan}
</span>
)}
</div>
{r.email && <div className="truncate text-[11px] text-fg-muted">{r.email}</div>}
</div>
<span className="badge shrink-0 py-0 text-[10px]">{t(`pool.kindShort.${r.kind}`)}</span>
<span className="shrink-0 rounded border border-border px-1 text-[10px] leading-4 text-fg-muted">
{t(`pool.kindShort.${r.kind}`)}
</span>
{isAdmin && (
<div className="-mr-1 -mt-1 flex shrink-0">
<div className="-mr-1 -mt-1 flex shrink-0 opacity-60 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
<button
type="button"
onClick={onEdit}
title={t("pool.edit", { name: r.name })}
aria-label={t("pool.edit", { name: r.name })}
className="btn-icon"
className="btn-icon p-1"
>
<Pencil className="h-4 w-4" />
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={onRemove}
title={t("pool.remove", { name: r.name })}
aria-label={t("pool.remove", { name: r.name })}
className="btn-icon"
className="btn-icon p-1 hover:text-status-danger"
>
<Trash2 className="h-4 w-4" />
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)}
@@ -488,68 +457,47 @@ function AccountCard({
</span>
)}
{r.refreshError && !r.expired && (
<span
className="badge py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20"
title={r.refreshError}
>
<span className={`badge py-0 text-[10px] ${warn}`} title={r.refreshError}>
{t("pool.refreshFailed")}
</span>
)}
{r.full && (
<span className="badge py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20">
{t("pool.full")}
</span>
)}
{r.full && <span className={`badge py-0 text-[10px] ${warn}`}>{t("pool.full")}</span>}
</div>
)}
<div className="flex items-center gap-3 text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{metered && (
<div className="space-y-1" data-testid="pool-quota">
{r.windows.map((w) => (
<QuotaBar key={w.key} window={w} />
))}
</div>
)}
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] tabular-nums text-fg-muted">
<span className="inline-flex items-center gap-1 text-fg-secondary">
<span
className={`h-1.5 w-1.5 rounded-full ${r.livePanes > 0 ? "bg-status-success" : "bg-fg-muted/40"}`}
aria-hidden
/>
{t("pool.liveCount", { count: r.livePanes })}
</span>
{metered && (
<>
<span aria-hidden>·</span>
<span>
{t("pool.spend")}: <span className="text-fg-primary">{fmtCost(r.spend5h)}</span> ·{" "}
{t("pool.tokens", { tokens: fmt(r.tokens5h) })}
</span>
<span className="basis-full">
{t("pool.spend7d", { cost: fmtCost(r.spend7d), tokens: fmt(r.tokens7d) })}
</span>
</>
)}
</div>
{metered && (
<div className="space-y-1.5" data-testid="pool-quota">
{r.windows.map((w) => (
<QuotaBar key={w.key} window={w} />
))}
</div>
)}
{metered && (
<div className="text-[11px] tabular-nums text-fg-muted">
<div>
{t("pool.spend")}: <span className="text-fg-primary">{fmtCost(r.spend5h)}</span> ·{" "}
{t("pool.tokens", { tokens: fmt(r.tokens5h) })}
</div>
<div>{t("pool.spend7d", { cost: fmtCost(r.spend7d), tokens: fmt(r.tokens7d) })}</div>
</div>
)}
{grouped && (
<div className="flex flex-wrap items-center gap-1 text-[11px]">
<Users className="h-3 w-3 text-fg-muted" aria-hidden />
{inGroups.length === 0 ? (
<span className="text-fg-muted">{t("pool.groups.shared")}</span>
) : (
inGroups.map((g) => (
<span key={g.id} className="badge py-0 text-[10px]">
{g.name}
</span>
))
)}
</div>
)}
{users.length > 0 && (
<div className="inline-flex items-center gap-1 text-[11px] text-fg-secondary">
<UserRound className="h-3 w-3" aria-hidden />
{t("pool.pinned", { users: users.join(", ") })}
</div>
)}
<div className="mt-auto text-[11px] text-fg-muted">
{t("pool.added", { at: formatDateTime(r.addedAt) })}
</div>
</div>
);
}
@@ -558,7 +506,7 @@ function AccountCard({
* One window of an account's quota, shown as what is LEFT (100% = untouched,
* 0% = used up), with when it resets.
*/
function QuotaBar({ window: w }: { window: QuotaWindow }) {
export function QuotaBar({ window: w }: { window: QuotaWindow }) {
const { t } = useTranslation("users");
const left = w.used == null ? null : Math.max(0, Math.min(100, 100 - Math.round(w.used)));
const resets = untilText(w.resetsAt);
@@ -568,7 +516,7 @@ function QuotaBar({ window: w }: { window: QuotaWindow }) {
<span className="w-14 shrink-0 text-fg-muted">{t(`pool.window.${w.key}`)}</span>
<span className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-4">
<span
className={`block h-full ${
className={`block h-full transition-[width] duration-500 ${
left == null
? ""
: left === 0
@@ -596,72 +544,6 @@ function QuotaBar({ window: w }: { window: QuotaWindow }) {
);
}
/**
* The tool's saved login profiles (Settings → AI tools → Profiles) as cards:
* "Share to pool" moves one in (`POST /api/accounts` with `profile`) — it then
* leaves the switcher, since a login's refresh token may have one holder.
*/
function SavedProfiles({
tool,
pool,
onShare,
}: {
tool: PoolTool;
/** Re-read whenever the pool changes: a shared profile is gone from the list. */
pool: AccountPool | null;
onShare: (name: string) => void;
}) {
const { t } = useTranslation("users");
const [profiles, setProfiles] = useState<PoolProfile[] | null>(null);
useEffect(() => {
api.accountPool
.profiles(tool)
.then((r) => setProfiles(r.profiles))
.catch(() => setProfiles([]));
}, [tool, pool]);
return (
<div className="border-t border-border px-5 py-4" data-testid="pool-profiles">
<div className="flex flex-wrap items-center gap-2">
<h3 className="flex-1 text-xs font-semibold uppercase tracking-wider text-fg-muted">
{t("pool.savedProfiles.title")}
</h3>
<Link to="/settings/agents#tool-profiles" className="text-xs text-accent hover:underline">
{t("pool.profilesLink")}
</Link>
</div>
{profiles !== null && profiles.length === 0 && (
<p className="mt-2 text-xs text-fg-muted">{t("pool.savedProfiles.empty")}</p>
)}
{profiles && profiles.length > 0 && (
<div className="mt-2 grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
{profiles.map((p) => (
<div
key={p.name}
className="flex items-center gap-2 rounded-lg border border-dashed border-border px-3 py-2"
>
<div className="flex-1 min-w-0">
<div className="truncate text-sm text-fg-primary">{p.label}</div>
<div className="truncate text-[11px] text-fg-muted">
{[p.email, p.subscriptionType].filter(Boolean).join(" · ")}
{p.active ? ` · ${t("pool.savedProfiles.activeHere")}` : ""}
</div>
</div>
<button
type="button"
onClick={() => onShare(p.name)}
className="btn-ghost shrink-0 text-xs"
aria-label={t("pool.savedProfiles.shareNamed", { name: p.label })}
>
<Share2 className="h-3.5 w-3.5" /> {t("pool.savedProfiles.share")}
</button>
</div>
))}
</div>
)}
</div>
);
}
/**
* Oh My Pi's tab: no pool, but the host's own omp login can be shared with
* every member's pane through omp's gateway (server/lib/omp-share.js) — the
@@ -777,13 +659,8 @@ function EditAccountDialog({
const changed = chosen.size !== initial.length || initial.some((id) => !chosen.has(id));
const ready = changed && current.length > 0;
const toggle = (id: number, on: boolean) =>
setChosen((prev) => {
const next = new Set(prev);
if (on) next.add(id);
else next.delete(id);
return next;
});
const groupOf = (userId: number) =>
(pool.groups ?? []).find((g) => g.users.some((u) => u.userId === userId))?.name ?? null;
const submit = async () => {
if (!ready || busy) return;
@@ -822,38 +699,28 @@ function EditAccountDialog({
>
<div>
<span className="text-xs font-medium text-fg-secondary">{t("pool.users")}</span>
<div className="mt-1 max-h-56 overflow-y-auto rounded-md border border-border divide-y divide-border">
{users === null && (
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.loadingUsers")}</p>
)}
{users?.length === 0 && (
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.noUsers")}</p>
)}
{users?.map((u) => {
const elsewhere = pins.find((p) => p.userId === u.id && p.account !== row.name);
return (
<div key={u.id} className="px-3 py-2">
<Checkbox
checked={chosen.has(u.id)}
onChange={(on) => toggle(u.id, on)}
label={
<span>
{u.username}
{u.disabled && (
<span className="ml-1 text-fg-muted">({t("disabled")})</span>
)}
{elsewhere && (
<span className="ml-1.5 text-[11px] text-fg-muted">
{t("pool.onAccount", { account: elsewhere.account })}
</span>
)}
</span>
}
labelClassName="text-sm text-fg-primary"
/>
</div>
);
})}
<div className="mt-1">
<MemberPicker
users={users}
chosen={chosen}
onChange={setChosen}
groupOf={groupOf}
grouped={(pool.groups ?? []).length > 0}
note={(u) => {
const elsewhere = pins.find((p) => p.userId === u.id && p.account !== row.name);
const group = groupOf(u.id);
return (
<span className="ml-1.5 text-[11px] text-fg-muted">
{[
group && t("pool.groups.inGroup", { group }),
elsewhere && t("pool.onAccount", { account: elsewhere.account }),
]
.filter(Boolean)
.join(" · ")}
</span>
);
}}
/>
</div>
</div>
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
@@ -0,0 +1,194 @@
/**
* @file MemberPicker.tsx
* @description The pool dialogs' member list (an account's Edit, a group's
* Edit): filter chips by role (Admin / Developer / Analyst, each with its
* count) plus "No group" for the users in no pool group, a search box, a
* tick-all for what is shown, and one checkbox per user with their role and
* where they are now. Pure UI: the caller owns the ticked set.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Search } from "lucide-react";
import type { AuthUser, ServerUserUsage } from "../../lib/api";
import { Checkbox } from "../Checkbox";
import { UserAvatar } from "./UserAvatar";
export const ROLES: AuthUser["role"][] = ["admin", "developer", "analyst"];
/** A role, every user, or the users in no pool group. */
export type MemberFilter = "all" | "nogroup" | AuthUser["role"];
/** The chips' filter applied to one user. */
export function matchesFilter(
u: Pick<ServerUserUsage, "id" | "role">,
filter: MemberFilter,
groupOf: (userId: number) => string | null
): boolean {
if (filter === "all") return true;
if (filter === "nogroup") return groupOf(u.id) === null;
return u.role === filter;
}
/** A row of filter chips with counts; `nogroup` only when groups exist. */
export function MemberFilterChips({
users,
filter,
onFilter,
groupOf,
grouped,
}: {
users: Pick<ServerUserUsage, "id" | "role">[];
filter: MemberFilter;
onFilter: (f: MemberFilter) => void;
groupOf: (userId: number) => string | null;
grouped: boolean;
}) {
const { t } = useTranslation("users");
const chips: { id: MemberFilter; label: string }[] = [
{ id: "all", label: t("pool.members.all") },
...ROLES.map((r) => ({ id: r as MemberFilter, label: t(`roles.${r}`) })),
...(grouped ? [{ id: "nogroup" as MemberFilter, label: t("pool.members.noGroup") }] : []),
];
return (
<div role="group" aria-label={t("pool.members.filter")} className="flex flex-wrap gap-1">
{chips.map((c) => {
const n = users.filter((u) => matchesFilter(u, c.id, groupOf)).length;
if (n === 0 && c.id !== "all" && c.id !== filter) return null;
const on = filter === c.id;
return (
<button
key={c.id}
type="button"
aria-pressed={on}
onClick={() => onFilter(c.id)}
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] transition-colors ${
on
? "border-accent/50 bg-accent/15 text-accent"
: "border-border text-fg-muted hover:text-fg-secondary"
}`}
>
{c.label}
<span className="tabular-nums opacity-70">{n}</span>
</button>
);
})}
</div>
);
}
export function MemberPicker({
users,
chosen,
onChange,
groupOf,
grouped,
note,
}: {
users: ServerUserUsage[] | null;
chosen: Set<number>;
onChange: (next: Set<number>) => void;
/** The pool group a user is in now, by name, or null. */
groupOf: (userId: number) => string | null;
/** Whether any pool group exists (shows the "No group" chip). */
grouped: boolean;
/** Extra text after a user's name (where they run now). */
note?: (u: ServerUserUsage) => ReactNode;
}) {
const { t } = useTranslation("users");
const [filter, setFilter] = useState<MemberFilter>("all");
const [query, setQuery] = useState("");
const q = query.trim().toLowerCase();
const shown = (users ?? []).filter(
(u) =>
matchesFilter(u, filter, groupOf) &&
(!q ||
u.username.toLowerCase().includes(q) ||
(u.displayName ?? "").toLowerCase().includes(q))
);
const allShown = shown.length > 0 && shown.every((u) => chosen.has(u.id));
const setMany = (ids: number[], on: boolean) => {
const next = new Set(chosen);
for (const id of ids) {
if (on) next.add(id);
else next.delete(id);
}
onChange(next);
};
return (
<div className="space-y-2" data-testid="member-picker">
{users && users.length > 0 && (
<>
<MemberFilterChips
users={users}
filter={filter}
onFilter={setFilter}
groupOf={groupOf}
grouped={grouped}
/>
<div className="flex items-center gap-2">
<label className="relative flex-1">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-fg-muted" />
<input
className="input w-full py-1 pl-7 text-xs"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("pool.members.search")}
aria-label={t("pool.members.search")}
/>
</label>
<Checkbox
checked={allShown}
disabled={shown.length === 0}
onChange={(on) =>
setMany(
shown.map((u) => u.id),
on
)
}
label={t("pool.members.tickShown", { count: shown.length })}
labelClassName="text-[11px] text-fg-secondary"
/>
</div>
</>
)}
<div className="max-h-56 overflow-y-auto rounded-md border border-border divide-y divide-border">
{users === null && (
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.loadingUsers")}</p>
)}
{users?.length === 0 && (
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.noUsers")}</p>
)}
{users && users.length > 0 && shown.length === 0 && (
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.members.noneShown")}</p>
)}
{shown.map((u) => (
<div key={u.id} className="flex items-center gap-2 px-3 py-1.5">
<Checkbox
checked={chosen.has(u.id)}
onChange={(on) => setMany([u.id], on)}
label={
<span className="inline-flex items-center gap-1.5">
<UserAvatar
name={u.displayName || u.username}
userId={u.id}
avatarAt={u.avatarAt}
letters={1}
className="h-5 w-5 text-[10px]"
/>
{u.username}
{u.disabled && <span className="ml-1 text-fg-muted">({t("disabled")})</span>}
{note?.(u)}
</span>
}
className="min-w-0 flex-1"
labelClassName="text-sm text-fg-primary"
/>
<span className="badge shrink-0 py-0 text-[10px]">{t(`roles.${u.role}`)}</span>
</div>
))}
</div>
</div>
);
}
+220
View File
@@ -0,0 +1,220 @@
/**
* @file MemberTmux.tsx
* @description Each member's own tmux server as part of the Administration
* page's users table (one Linux user per account only, `GET /api/users/tmux`,
* `useMemberTmux`): a `TmuxCell` per row — the version it runs, a badge when
* older than the installed one, its sessions, whether an agent of theirs is
* working — and `MemberTmuxBar` above the table, so an admin sees who still
* runs an old tmux after an upgrade.
* An admin restarts one member's server, or every idle outdated one at once
* (`POST /api/users/:id/tmux-restart`, `/api/users/tmux-restart-idle`), each
* time with their own password again; a restart ends that member's panes,
* and the next start runs the installed tmux. An analyst only reads.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { RotateCcw, TerminalSquare } from "lucide-react";
import { api, type MemberTmux, type MemberTmuxRow } from "../../lib/api";
import { formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
import { isSubmitEnter } from "../../lib/keys";
import { ConfirmModal } from "../ConfirmModal";
function errorText(t: TFunction<"users">, e: unknown): string {
const err = e as Error & { code?: string };
return err.code && t(`tmux.errors.${err.code}`, { defaultValue: "" })
? t(`tmux.errors.${err.code}`)
: err.message;
}
const BADGE = "badge ml-1.5 py-0 text-[10px]";
const WARN = `${BADGE} bg-status-warning/10 text-status-warning border-status-warning/20`;
const DANGER = `${BADGE} bg-status-danger/10 text-status-danger border-status-danger/20`;
/** `GET /api/users/tmux`; `available` false without one Linux user per account. */
export function useMemberTmux(enabled = true) {
const [data, setData] = useState<MemberTmux | null>(null);
const reload = useCallback(
() =>
api.users
.tmux()
.then(setData)
.catch(() => setData(null)),
[]
);
useEffect(() => {
if (enabled) void reload();
}, [enabled, reload]);
return { data, reload };
}
/** One member's tmux server as a users-table cell: version, badges, sessions, agents. */
export function TmuxCell({ row }: { row?: MemberTmuxRow }) {
const { t } = useTranslation("users");
if (!row?.running) return <span className="text-sm text-fg-muted">—</span>;
return (
<div className="text-sm text-fg-secondary">
<span className="font-mono">{row.stuck ? "?" : (row.version ?? "?")}</span>
{row.stuck && <span className={DANGER}>{t("tmux.stuck")}</span>}
{row.outdated && <span className={WARN}>{t("tmux.outdated")}</span>}
<div
className="text-[11px] text-fg-muted"
title={
row.startedAt
? t("tmux.since", { at: formatDateTime(new Date(row.startedAt).toISOString()) })
: undefined
}
>
{t("tmux.sessionsCount", { count: row.sessions ?? 0 })} ·{" "}
{row.busy > 0 ? t("tmux.working", { count: row.busy }) : t("tmux.idle")}
</div>
</div>
);
}
/**
* Above the users table: the installed tmux, the not-yet-installed tmux-member
* warning, and (an admin) "Restart N idle members". Nothing without S3.
*/
export function MemberTmuxBar({
data,
isAdmin,
onRestart,
}: {
data: MemberTmux | null;
isAdmin: boolean;
onRestart: (target: "idle") => void;
}) {
const { t } = useTranslation("users");
if (!data?.available) return null;
const idle = data.users.filter((u) => u.running && u.outdated && u.busy === 0);
return (
<div className="flex flex-wrap items-center gap-3 text-xs" data-testid="member-tmux">
<span className="inline-flex items-center gap-1.5 text-fg-muted">
<TerminalSquare className="w-3.5 h-3.5" />
{t("tmux.description", { version: data.installed ?? "?" })}
</span>
{!data.matched && <span className="text-status-warning">{t("tmux.notMatched")}</span>}
{isAdmin && idle.length > 0 && (
<button type="button" onClick={() => onRestart("idle")} className="btn-ghost text-xs">
<RotateCcw className="w-3.5 h-3.5" /> {t("tmux.restartIdle", { count: idle.length })}
</button>
)}
</div>
);
}
/** An admin's restart of one member's tmux, or of every idle outdated one. */
export function TmuxRestartDialog({
target,
data,
onClose,
onDone,
}: {
target: MemberTmuxRow | "idle";
data: MemberTmux | null;
onClose: () => void;
onDone: (message: string) => void;
}) {
const idle = (data?.users ?? []).filter((u) => u.running && u.outdated && u.busy === 0);
return <RestartDialog target={target} idle={idle} onClose={onClose} onDone={onDone} />;
}
function RestartDialog({
target,
idle,
onClose,
onDone,
}: {
target: MemberTmuxRow | "idle";
idle: MemberTmuxRow[];
onClose: () => void;
onDone: (message: string) => void;
}) {
const { t } = useTranslation("users");
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const one = target === "idle" ? null : target;
const submit = async () => {
if (!current || busy) return;
setBusy(true);
setError(null);
try {
if (one) {
await api.users.tmuxRestart(one.id, current);
onDone(t("tmux.done", { name: one.username }));
} else {
const r = await api.users.tmuxRestartIdle(current);
// A server that would not stop needs root: say which, the rest went.
if (r.failed.length) toast.error(r.failed.map((f) => f.message).join("\n"));
onDone(t("tmux.doneIdle", { names: r.restarted.join(", ") || "—" }));
}
} catch (e) {
setError(errorText(t, e));
} finally {
setBusy(false);
}
};
return (
<ConfirmModal
open
title={one ? t("tmux.restart", { name: one.username }) : t("tmux.restartIdleTitle")}
message={
one
? t(one.busy > 0 ? "tmux.restartHintBusy" : "tmux.restartHint", {
name: one.username,
count: one.sessions ?? 0,
})
: t("tmux.restartIdleHint", { names: idle.map((u) => u.username).join(", ") })
}
confirmLabel={t("tmux.restartButton")}
cancelLabel={t("common:cancel")}
busy={busy}
disabled={!current}
onConfirm={() => void submit()}
onCancel={onClose}
>
<div
className="mt-3 space-y-3"
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
}}
>
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
<input
type="password"
className="input w-full text-sm"
value={current}
onChange={(e) => setCurrent(e.target.value)}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
</Field>
{error && (
<p role="alert" className="whitespace-pre-line text-xs text-status-danger">
{error}
</p>
)}
</div>
</ConfirmModal>
);
}
function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<label className="block">
<span className="text-xs font-medium text-fg-secondary">{label}</span>
<div className="mt-1">{children}</div>
{hint && <span className="mt-0.5 block text-[11px] text-fg-muted">{hint}</span>}
</label>
);
}
@@ -1,267 +0,0 @@
/**
* @file MemberTmuxCard.tsx
* @description The Administration page's Server-tab tmux card (one Linux user per account only,
* `GET /api/users/tmux`): each member's own tmux server — the version it runs
* beside the installed one, its sessions, whether an agent of theirs is
* working — so an admin sees who still runs an old tmux after an upgrade.
* An admin restarts one member's server, or every idle outdated one at once
* (`POST /api/users/:id/tmux-restart`, `/api/users/tmux-restart-idle`), each
* time with their own password again; a restart ends that member's panes,
* and the next start runs the installed tmux. An analyst only reads.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { RefreshCw, RotateCcw, TerminalSquare } from "lucide-react";
import { api, type MemberTmux, type MemberTmuxRow } from "../../lib/api";
import { formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
import { isSubmitEnter } from "../../lib/keys";
import { useSpin } from "../../hooks/useSpin";
import { ConfirmModal } from "../ConfirmModal";
function errorText(t: TFunction<"users">, e: unknown): string {
const err = e as Error & { code?: string };
return err.code && t(`tmux.errors.${err.code}`, { defaultValue: "" })
? t(`tmux.errors.${err.code}`)
: err.message;
}
const BADGE = "badge ml-1.5 py-0 text-[10px]";
const WARN = `${BADGE} bg-status-warning/10 text-status-warning border-status-warning/20`;
const DANGER = `${BADGE} bg-status-danger/10 text-status-danger border-status-danger/20`;
export function MemberTmuxCard({ isAdmin }: { isAdmin: boolean }) {
const { t } = useTranslation("users");
const [data, setData] = useState<MemberTmux | null>(null);
const [error, setError] = useState<string | null>(null);
// A member row = restart that one; "idle" = every idle outdated member.
const [restarting, setRestarting] = useState<MemberTmuxRow | "idle" | null>(null);
const load = useCallback(
() =>
api.users
.tmux()
.then((r) => {
setData(r);
setError(null);
})
.catch((e: Error) => setError(e.message)),
[]
);
const [spinning, refresh] = useSpin(load);
useEffect(() => {
void load();
}, [load]);
if (!data?.available) return null;
const rows = data.users.filter((u) => u.running);
const idleOutdated = rows.filter((u) => u.outdated && u.busy === 0);
const th = "px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
return (
<section className="card" data-testid="member-tmux">
<div className="flex flex-wrap items-center gap-2 px-5 py-4 border-b border-border">
<TerminalSquare className="w-4 h-4 text-fg-muted" />
<div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold text-fg-primary">{t("tmux.title")}</h2>
<p className="text-xs text-fg-muted">
{t("tmux.description", { version: data.installed ?? "?" })}
</p>
</div>
<button
type="button"
onClick={refresh}
disabled={spinning}
aria-busy={spinning}
title={t("tmux.refresh")}
aria-label={t("tmux.refresh")}
className="btn-icon"
>
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
</button>
{isAdmin && idleOutdated.length > 0 && (
<button type="button" onClick={() => setRestarting("idle")} className="btn-primary">
<RotateCcw className="w-4 h-4" />{" "}
{t("tmux.restartIdle", { count: idleOutdated.length })}
</button>
)}
</div>
{!data.matched && (
<p className="px-5 py-2.5 text-xs text-status-warning border-b border-border">
{t("tmux.notMatched")}
</p>
)}
{error && (
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
{error}
</p>
)}
{rows.length === 0 ? (
<p className="px-5 py-8 text-center text-sm text-fg-muted">{t("tmux.empty")}</p>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[560px]">
<thead>
<tr className="border-b border-border text-left">
<th className={th}>{t("tmux.user")}</th>
<th className={th}>{t("tmux.version")}</th>
<th className={`${th} text-right`}>{t("tmux.sessions")}</th>
<th className={th}>{t("tmux.agents")}</th>
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
</tr>
</thead>
<tbody className="divide-y divide-border">
{rows.map((u) => (
<tr key={u.id}>
<td className="px-5 py-2.5 text-sm text-fg-primary">{u.username}</td>
<td className="px-5 py-2.5 text-sm text-fg-secondary">
<span className="font-mono">{u.stuck ? "?" : (u.version ?? "?")}</span>
{u.stuck && <span className={DANGER}>{t("tmux.stuck")}</span>}
{u.outdated && <span className={WARN}>{t("tmux.outdated")}</span>}
{u.startedAt && (
<div className="text-[11px] text-fg-muted">
{t("tmux.since", {
at: formatDateTime(new Date(u.startedAt).toISOString()),
})}
</div>
)}
</td>
<td className="px-5 py-2.5 text-right text-sm tabular-nums text-fg-secondary">
{u.sessions ?? "—"}
</td>
<td className="px-5 py-2.5 text-sm text-fg-secondary">
{u.busy > 0 ? t("tmux.working", { count: u.busy }) : t("tmux.idle")}
</td>
{isAdmin && (
<td className="px-5 py-2 text-right">
<button
type="button"
onClick={() => setRestarting(u)}
title={t("tmux.restart", { name: u.username })}
aria-label={t("tmux.restart", { name: u.username })}
className="btn-icon"
>
<RotateCcw className="h-4 w-4" />
</button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
{restarting && (
<RestartDialog
target={restarting}
idle={idleOutdated}
onClose={() => setRestarting(null)}
onDone={(msg) => {
toast.success(msg);
setRestarting(null);
void load();
}}
/>
)}
</section>
);
}
function RestartDialog({
target,
idle,
onClose,
onDone,
}: {
target: MemberTmuxRow | "idle";
idle: MemberTmuxRow[];
onClose: () => void;
onDone: (message: string) => void;
}) {
const { t } = useTranslation("users");
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const one = target === "idle" ? null : target;
const submit = async () => {
if (!current || busy) return;
setBusy(true);
setError(null);
try {
if (one) {
await api.users.tmuxRestart(one.id, current);
onDone(t("tmux.done", { name: one.username }));
} else {
const r = await api.users.tmuxRestartIdle(current);
// A server that would not stop needs root: say which, the rest went.
if (r.failed.length) toast.error(r.failed.map((f) => f.message).join("\n"));
onDone(t("tmux.doneIdle", { names: r.restarted.join(", ") || "—" }));
}
} catch (e) {
setError(errorText(t, e));
} finally {
setBusy(false);
}
};
return (
<ConfirmModal
open
title={one ? t("tmux.restart", { name: one.username }) : t("tmux.restartIdleTitle")}
message={
one
? t(one.busy > 0 ? "tmux.restartHintBusy" : "tmux.restartHint", {
name: one.username,
count: one.sessions ?? 0,
})
: t("tmux.restartIdleHint", { names: idle.map((u) => u.username).join(", ") })
}
confirmLabel={t("tmux.restartButton")}
cancelLabel={t("common:cancel")}
busy={busy}
disabled={!current}
onConfirm={() => void submit()}
onCancel={onClose}
>
<div
className="mt-3 space-y-3"
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
}}
>
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
<input
type="password"
className="input w-full text-sm"
value={current}
onChange={(e) => setCurrent(e.target.value)}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
</Field>
{error && (
<p role="alert" className="whitespace-pre-line text-xs text-status-danger">
{error}
</p>
)}
</div>
</ConfirmModal>
);
}
function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<label className="block">
<span className="text-xs font-medium text-fg-secondary">{label}</span>
<div className="mt-1">{children}</div>
{hint && <span className="mt-0.5 block text-[11px] text-fg-muted">{hint}</span>}
</label>
);
}
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -4,7 +4,9 @@
* (`GET /api/me/avatar`) or, with `userId`, another's (`GET
* /api/users/:id/avatar`, staff only), versioned by `avatarAt`; without one —
* or when the image fails to load — the first letters of its name. Used by the
* sidebar's account row, its workspace owner groups and Settings → Account.
* sidebar's account row, its workspace owner groups, Settings → Account, the
* Users page and its editor, the pool board and member picker, and System
* Health's per-user CPU/RAM card.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
+333
View File
@@ -0,0 +1,333 @@
/**
* @file UserDetail.tsx
* @description One account, opened IN PLACE under its row of the
* Administration page's users table (`?user=<id>`, `GET /api/users/:id/details`).
* The row already shows identity, sessions, spend, home and tmux, so
* this keeps only what the row cannot: one summary line (last sign-in, last
* activity, active sessions and live panes), then three short columns — spend
* (30-day bars, by AI tool, top models), the account pool (per tool: pin or
* group, each account's quota left beside this user's own 7-day share), and
* what runs now (live panes, owned workspaces) — and the five newest sessions. Read-only; Edit stays on
* the row.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { AlertCircle, Play, RefreshCw } from "lucide-react";
import { api, type PoolTool, type UserDetails } from "../../lib/api";
import { fmtCost, formatDateTime } from "../../lib/format";
import { effectiveSessionStatus, sessionAwaitingReason } from "../../lib/types";
import { useSpin } from "../../hooks/useSpin";
import { useAiTools } from "../../hooks/useAiTools";
import { AiToolIcon } from "../AiToolIcon";
import { SessionStatusBadge } from "../StatusBadge";
import { MiniChart } from "../admin/MiniChart";
import { QuotaBar, windowsOf } from "./AccountPoolCard";
const POOL_LABEL: Record<PoolTool, string> = {
claude: "Claude Code",
codex: "Codex",
cursor: "Cursor CLI",
};
const pctText = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`;
export function UserDetail({ userId }: { userId: number }) {
const { t } = useTranslation("users");
const { label: toolLabel } = useAiTools();
const [data, setData] = useState<UserDetails | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(
() =>
api.users
.details(userId)
.then((d) => {
setData(d);
setError(null);
})
.catch((e: Error) => setError(e.message)),
[userId]
);
const [spinning, refresh] = useSpin(load);
useEffect(() => {
setData(null);
void load();
}, [load]);
if (error)
return (
<div
role="alert"
data-testid="user-detail"
className="flex flex-wrap items-center gap-3 text-sm text-status-danger"
>
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 min-w-0">
{t("detail.failed")}: {error}
</span>
<button type="button" onClick={refresh} className="btn-ghost text-xs">
{t("common:retry")}
</button>
</div>
);
if (!data)
return (
<p data-testid="user-detail" className="py-4 text-center text-sm text-fg-muted">
{t("common:loading")}
</p>
);
const u = data.user;
const total30 = data.usage.last30d.cost;
const pools = (Object.keys(POOL_LABEL) as PoolTool[]).filter(
(tool) => (data.pool[tool]?.accounts.length ?? 0) > 0
);
return (
<div className="space-y-4" data-testid="user-detail">
{/* One line of what the row does not show. */}
<div className="flex flex-wrap items-center gap-x-6 gap-y-1 text-xs">
<Inline label={t("detail.lastLogin")}>
{u.lastLoginAt ? formatDateTime(u.lastLoginAt) : t("detail.never")}
</Inline>
<Inline label={t("detail.lastSession")}>
{data.sessions.lastAt ? formatDateTime(data.sessions.lastAt) : "—"}
</Inline>
<Inline label={t("detail.activeLabel")}>
{t("detail.activeValue", { sessions: data.sessions.active, panes: data.panes.length })}
</Inline>
<button
type="button"
onClick={refresh}
disabled={spinning}
aria-busy={spinning}
className="btn-ghost ml-auto py-1 text-xs"
>
<RefreshCw className={`w-3.5 h-3.5 ${spinning ? "animate-spin" : ""}`} />{" "}
{t("common:refresh")}
</button>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
{/* Spend: the month at a glance, then where it went. */}
<Panel title={t("detail.spendDaily")}>
<MiniChart
kind="bar"
label={t("detail.spendDaily")}
points={data.usage.daily.map((d) => ({ x: d.date.slice(5), y: d.cost }))}
format={(v) => fmtCost(v)}
/>
{data.usage.byTool.length === 0 ? (
<p className="text-xs text-fg-muted">{t("detail.noUsage")}</p>
) : (
<ul className="space-y-1.5 pt-1">
{data.usage.byTool.map((r) => (
<ShareRow
key={r.key}
label={
<span className="flex items-center gap-1.5">
<AiToolIcon
tool={{ id: r.key, label: toolLabel(r.key) }}
className="h-4 w-4"
/>
{toolLabel(r.key)}
</span>
}
cost={r.cost}
total={total30}
/>
))}
{data.usage.byModel.slice(0, 3).map((r) => (
<ShareRow
key={`m:${r.key}`}
label={<span className="font-mono text-[11px] text-fg-muted">{r.key}</span>}
cost={r.cost}
total={total30}
/>
))}
</ul>
)}
</Panel>
{/* Pool: what they may run on, and how much of it is theirs. */}
<Panel title={t("detail.pool")}>
{pools.length === 0 ? (
<p className="text-xs text-fg-muted">{t("detail.poolNone")}</p>
) : (
pools.map((tool) => {
const p = data.pool[tool]!;
return (
<div key={tool} className="space-y-2">
<div className="flex flex-wrap items-center gap-1.5 text-xs">
<AiToolIcon tool={{ id: tool, label: POOL_LABEL[tool] }} className="h-4 w-4" />
<span className="font-medium text-fg-primary">{POOL_LABEL[tool]}</span>
<span className="text-fg-muted">
·{" "}
{p.pin
? t("detail.poolPinned", { account: p.pin })
: p.choice
? t("detail.poolChosen", { account: p.choice })
: p.group
? t("detail.poolGroup", { name: p.group.name })
: t("detail.poolShared")}
</span>
</div>
{p.accounts.map((a) => {
const share = a.spend7d > 0 ? (a.userSpend7d / a.spend7d) * 100 : 0;
return (
<div
key={a.name}
className={`rounded-md border border-border px-2.5 py-2 space-y-1 ${a.allowed ? "" : "opacity-60"}`}
data-testid="user-pool-account"
>
<div className="flex items-center justify-between gap-2 text-xs">
<span className="truncate font-medium text-fg-primary">
{a.name}
{a.expired && (
<span className="ml-1.5 text-status-danger">{t("pool.expired")}</span>
)}
{!a.allowed && (
<span className="ml-1.5 text-fg-muted">
{t("detail.poolNotAllowed")}
</span>
)}
</span>
{tool !== "cursor" && (
<span
className="flex-shrink-0 tabular-nums text-fg-secondary"
title={t("detail.poolTotal7d", { cost: fmtCost(a.spend7d) })}
>
{t("detail.poolTheirs7d", { cost: fmtCost(a.userSpend7d) })}
{a.spend7d > 0 && (
<span className="text-fg-muted"> ({pctText(share)})</span>
)}
</span>
)}
</div>
{tool !== "cursor" &&
windowsOf(a).map((w) => <QuotaBar key={w.key} window={w} />)}
</div>
);
})}
</div>
);
})
)}
</Panel>
{/* Now: live panes and the workspaces they own. */}
<Panel title={t("detail.nowTitle")}>
{data.panes.length === 0 ? (
<p className="text-xs text-fg-muted">{t("detail.noPanes")}</p>
) : (
<ul className="space-y-1.5">
{data.panes.map((p) => (
<li key={p.id} className="text-xs">
<Link
to={`/workspace?lane=${p.laneId}&run=${encodeURIComponent(p.id)}`}
className="inline-flex items-center gap-1.5 text-fg-primary hover:text-accent"
>
<Play className="h-3 w-3 text-status-success" />
{p.laneTitle || p.laneCwd}
</Link>
<span className="text-fg-muted">
{" "}
· {toolLabel(p.tool)}
{p.account ? ` · ${p.account}` : ""}
</span>
</li>
))}
</ul>
)}
{data.workspaces.length > 0 && (
<div className="flex flex-wrap gap-1.5 border-t border-border pt-2">
{data.workspaces.map((w) => (
<Link
key={w.id}
to={`/workspace?lane=${w.id}`}
title={w.cwd}
className="badge py-0.5 text-[11px] hover:text-accent"
>
{w.livePanes > 0 && (
<span className="mr-1 inline-block h-1.5 w-1.5 rounded-full bg-status-success" />
)}
{w.title || w.cwd.split("/").pop()}
<span className="ml-1 text-fg-muted">{w.sessions}</span>
</Link>
))}
</div>
)}
</Panel>
</div>
{data.recentSessions.length > 0 && (
<Panel title={t("detail.recentTitle")}>
<ul className="divide-y divide-border">
{data.recentSessions.slice(0, 5).map((s) => (
<li key={s.id} className="flex items-center gap-3 py-1.5 text-xs">
<SessionStatusBadge
status={effectiveSessionStatus(s)}
reason={sessionAwaitingReason(s)}
/>
<Link
to={`/sessions/${s.id}`}
className="min-w-0 flex-1 truncate text-fg-primary hover:text-accent"
>
{s.name || s.id.slice(0, 8)}
</Link>
<span className="flex-shrink-0 text-fg-muted">{toolLabel(s.tool || "claude")}</span>
<span className="w-32 flex-shrink-0 text-right text-fg-muted">
{formatDateTime(s.last_activity || s.started_at)}
</span>
<span className="w-16 flex-shrink-0 text-right font-mono text-fg-secondary">
{s.cost ? fmtCost(s.cost) : "—"}
</span>
</li>
))}
</ul>
</Panel>
)}
</div>
);
}
function Inline({ label, children }: { label: string; children: ReactNode }) {
return (
<span>
<span className="text-fg-muted">{label}: </span>
<span className="text-fg-primary">{children}</span>
</span>
);
}
function Panel({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="card p-3 space-y-2.5 min-w-0">
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-fg-muted">{title}</h3>
{children}
</section>
);
}
/** One slice of the 30-day spend: label, $, and a thin bar of its share. */
function ShareRow({ label, cost, total }: { label: ReactNode; cost: number; total: number }) {
const share = total > 0 ? (cost / total) * 100 : 0;
return (
<li className="space-y-0.5">
<div className="flex items-center justify-between gap-2 text-xs">
<span className="min-w-0 truncate text-fg-secondary">{label}</span>
<span className="flex-shrink-0 tabular-nums text-fg-primary">
{fmtCost(cost)} <span className="text-fg-muted">{Math.round(share)}%</span>
</span>
</div>
<span className="block h-1 overflow-hidden rounded-full bg-surface-4">
<span
className="block h-full rounded-full bg-accent-solid"
style={{ width: `${Math.min(100, share)}%` }}
/>
</span>
</li>
);
}
+109 -10
View File
@@ -2,10 +2,13 @@
* @file UserEditDialog.tsx
* @description The Users page's ONE account editor (admin only): profile
* (display name, email), access (role, active/disabled — neither for your own
* account), a new password (typed or generated, copyable — someone else's
* account — and, for an admin, whether they own the host's Linux account, so
* its sessions outside every workspace are billed to them), a new password (typed or generated, copyable — someone else's
* may be anything and makes them choose their own at next sign-in; your own
* must pass every rule of `lib/passwordRules.ts`), the workspaces the
* account owns (remove, add from every workspace), and "sign out everywhere".
* account owns (remove, add from every workspace), its spend quota (the
* default, no limit, or its own dollars per day / 5 h — `UserQuota.tsx`), and
* "sign out everywhere".
* Every change is staged and sent as ONE `PATCH /api/users/:id` carrying only
* what changed, confirmed with the admin's OWN password (`currentPassword`) —
* the server validates the whole change before it applies any of it. A
@@ -17,16 +20,25 @@ import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNod
import { useTranslation } from "react-i18next";
import { Copy, Eye, EyeOff, Plus, Sparkles, X } from "lucide-react";
import { confirmDialog } from "../../lib/confirm";
import { api, type AuthUser, type ServerUserUsage, type UserPatch } from "../../lib/api";
import {
api,
type AuthUser,
type ServerUserUsage,
type UserPatch,
type UserQuota,
type UserQuotaSetting,
} from "../../lib/api";
import type { Lane } from "../../lib/types";
import { fmtCost, formatDateTime } from "../../lib/format";
import { copyText } from "../../lib/clipboard";
import { isSubmitEnter } from "../../lib/keys";
import { passwordProblems } from "../../lib/passwordRules";
import { PasswordRules } from "./PasswordRules";
import { UserAvatar } from "./UserAvatar";
import { useDialog } from "../../hooks/useDialog";
import { Switch } from "../Switch";
import { Checkbox } from "../Checkbox";
import { QuotaFields, parseLimit, useQuotaLabel, type QuotaPeriod } from "./UserQuota";
type Role = AuthUser["role"];
const ROLES: Role[] = ["admin", "analyst", "developer"];
@@ -55,10 +67,13 @@ export function UserEditDialog({
user,
self,
users,
quotaDefault = null,
onClose,
onDone,
}: {
user: ServerUserUsage;
/** The default spend quota, named beside the "Default" choice. */
quotaDefault?: UserQuota | null;
/** The account being edited is the signed-in admin's own. */
self: boolean;
users: ServerUserUsage[];
@@ -73,6 +88,7 @@ export function UserEditDialog({
const [email, setEmail] = useState(user.email ?? "");
const [role, setRole] = useState<Role>(user.role);
const [active, setActive] = useState(!user.disabled);
const [hostAccount, setHostAccount] = useState(Boolean(user.hostAccount));
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [owned, setOwned] = useState<OwnedWorkspace[] | null>(null);
@@ -81,6 +97,18 @@ export function UserEditDialog({
const [added, setAdded] = useState<number[]>([]);
const [pick, setPick] = useState("");
const [signOut, setSignOut] = useState(false);
// Spend quota: absent from an older server, which then gets no quota field.
const quotaSetting = user.quotaSetting;
const [quotaMode, setQuotaMode] = useState<UserQuotaSetting["mode"]>(
quotaSetting?.mode ?? "default"
);
const [quotaLimit, setQuotaLimit] = useState(
quotaSetting?.mode === "custom" ? String(quotaSetting.limit_usd) : ""
);
const [quotaPeriod, setQuotaPeriod] = useState<QuotaPeriod>(
quotaSetting?.mode === "custom" ? quotaSetting.period : (quotaDefault?.period ?? "day")
);
const quotaLabel = useQuotaLabel();
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -113,16 +141,38 @@ export function UserEditDialog({
if (norm(email) !== (user.email ?? null)) patch.email = norm(email);
if (!self && role !== user.role) patch.role = role;
if (!self && active === user.disabled) patch.disabled = !active;
if (role === "admin" && hostAccount !== Boolean(user.hostAccount)) patch.hostAccount = hostAccount;
if (password) patch.password = password;
if (added.length) patch.addWorkspaces = added;
if (removed.size) patch.removeWorkspaces = [...removed];
if (signOut) patch.signOut = true;
const quotaValue = parseLimit(quotaLimit);
const quotaBad = quotaMode === "custom" && quotaValue === null;
if (quotaSetting && !quotaBad) {
const same =
quotaMode === quotaSetting.mode &&
(quotaMode !== "custom" ||
(quotaSetting.mode === "custom" &&
quotaSetting.limit_usd === quotaValue &&
quotaSetting.period === quotaPeriod));
if (!same) {
patch.quota =
quotaMode === "custom"
? {
mode: "custom",
limit_usd: quotaValue as number,
period: quotaPeriod,
tz_offset: new Date().getTimezoneOffset(),
}
: { mode: quotaMode };
}
}
const changed = Object.keys(patch).length > 0;
// A blank field keeps the password. Someone else's may be anything (they
// choose their own at next sign-in); your own must pass every rule.
const weakOwn =
self && password.length > 0 && passwordProblems(password, user.username).length > 0;
const ready = changed && current.length > 0 && !weakOwn && !busy;
const ready = changed && current.length > 0 && !weakOwn && !quotaBad && !busy;
const submit = async () => {
if (!ready) return;
@@ -197,12 +247,12 @@ export function UserEditDialog({
className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-xl"
>
<div className="flex items-start gap-3 border-b border-border px-5 py-4">
<span
aria-hidden
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-accent/15 text-sm font-semibold uppercase text-accent"
>
{shown.slice(0, 2)}
</span>
<UserAvatar
name={shown}
userId={user.id}
avatarAt={user.avatarAt}
className="h-10 w-10 text-sm"
/>
<div className="min-w-0 flex-1">
<h2 id="user-edit-title" className="text-base font-semibold text-fg-primary">
{t("edit.title", { name: user.displayName || user.username })}
@@ -317,6 +367,17 @@ export function UserEditDialog({
)}
</div>
</div>
{role === "admin" && (
<div className="mt-3">
<Checkbox
checked={hostAccount}
onChange={setHostAccount}
label={t("edit.hostAccount")}
labelClassName="text-sm text-fg-secondary"
/>
<p className="ml-6 mt-0.5 text-[11px] text-fg-muted">{t("edit.hostAccountHint")}</p>
</div>
)}
</Section>
<Section title={t("edit.password")}>
@@ -479,6 +540,44 @@ export function UserEditDialog({
)}
</Section>
{quotaSetting && (
<Section title={t("quota.title")}>
<div className="space-y-2" data-testid="user-edit-quota">
<select
className="input w-full text-sm"
value={quotaMode}
onChange={(e) => setQuotaMode(e.target.value as UserQuotaSetting["mode"])}
aria-label={t("quota.title")}
>
<option value="default">
{quotaDefault
? t("quota.modes.defaultOf", { quota: quotaLabel(quotaDefault) })
: t("quota.modes.defaultNone")}
</option>
<option value="unlimited">{t("quota.modes.unlimited")}</option>
<option value="custom">{t("quota.modes.custom")}</option>
</select>
{quotaMode === "custom" && (
<QuotaFields
limit={quotaLimit}
period={quotaPeriod}
onLimit={setQuotaLimit}
onPeriod={setQuotaPeriod}
onKeyDown={onKeyDown}
/>
)}
<span className="block text-[11px] text-fg-muted">
{user.quota
? t("quota.now", {
spend: fmtCost(user.quota.spend),
limit: quotaLabel(user.quota),
})
: t("quota.enforceHint")}
</span>
</div>
</Section>
)}
<Section title={t("edit.sessions")}>
<Checkbox
checked={signOut}
+269
View File
@@ -0,0 +1,269 @@
/**
* @file UserQuota.tsx
* @description Spend quotas per user on the Users page (server mode;
* server/lib/user-quota.js): `QuotaSummary`, a row's "spent / limit per day"
* line with its bar — red once used up, with when it opens again;
* `QuotaFields`, a limit in dollars and its period (a day from local midnight,
* or a sliding 5 hours); and `QuotaDefaultCard`, the default every user
* without one of their own gets — an admin edits it with their own password
* again (`PUT /api/users/quota-default`), an analyst only reads. A user's own
* setting is part of the Edit user form (`UserEditDialog`). A user over their
* quota can start no session and their prompts are refused until it resets.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState, type KeyboardEvent } from "react";
import { useTranslation } from "react-i18next";
import { Gauge, Pencil } from "lucide-react";
import { api, type UserQuota, type UserQuotaStatus } from "../../lib/api";
import { fmtCost, formatDateTime } from "../../lib/format";
import { isSubmitEnter } from "../../lib/keys";
import { Checkbox } from "../Checkbox";
import { ConfirmModal } from "../ConfirmModal";
import { Meter } from "../health/HostHealth";
export type QuotaPeriod = UserQuota["period"];
export const QUOTA_PERIODS: QuotaPeriod[] = ["day", "5h"];
/** "$10 / day", "$2.50 / 5 h". */
export function useQuotaLabel() {
const { t } = useTranslation("users");
return (q: Pick<UserQuota, "limit_usd" | "period">) =>
t(`quota.per.${q.period}`, { limit: fmtCost(q.limit_usd) });
}
/** A row's quota: spent of the limit, a bar, and when a used-up one opens again. */
export function QuotaSummary({ quota }: { quota: UserQuotaStatus | null | undefined }) {
const { t } = useTranslation("users");
const label = useQuotaLabel();
if (quota === undefined) return null;
if (quota === null) {
return <div className="text-[11px] text-fg-muted">{t("quota.none")}</div>;
}
return (
<div
className="mt-1 space-y-0.5"
data-testid="user-quota"
title={
quota.exceeded && quota.resetsAt
? t("quota.blockedUntil", { at: formatDateTime(quota.resetsAt) })
: undefined
}
>
<div
className={`whitespace-nowrap text-[11px] ${quota.exceeded ? "text-status-danger" : "text-fg-muted"}`}
>
{t("quota.used", { spend: fmtCost(quota.spend), limit: label(quota) })}
{quota.source === "default" && <span className="ml-1">{t("quota.defaultTag")}</span>}
</div>
<Meter value={quota.pct} label={t("quota.title")} />
</div>
);
}
/** A dollar limit and its period. `limit` is the raw input text. */
export function QuotaFields({
limit,
period,
onLimit,
onPeriod,
onKeyDown,
disabled,
}: {
limit: string;
period: QuotaPeriod;
onLimit: (v: string) => void;
onPeriod: (v: QuotaPeriod) => void;
onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void;
disabled?: boolean;
}) {
const { t } = useTranslation("users");
return (
<div className="flex gap-2">
<div className="relative w-36">
<span className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-sm text-fg-muted">
$
</span>
<input
type="number"
min={0}
step="0.01"
inputMode="decimal"
className="input w-full pl-6 text-sm tabular-nums"
value={limit}
disabled={disabled}
onChange={(e) => onLimit(e.target.value)}
onKeyDown={onKeyDown}
aria-label={t("quota.limit")}
/>
</div>
<select
className="input flex-1 text-sm"
value={period}
disabled={disabled}
onChange={(e) => onPeriod(e.target.value as QuotaPeriod)}
aria-label={t("quota.period")}
>
{QUOTA_PERIODS.map((p) => (
<option key={p} value={p}>
{t(`quota.periods.${p}`)}
</option>
))}
</select>
</div>
);
}
/** A positive dollar amount from the input text, or null. */
export function parseLimit(text: string): number | null {
const n = Number(text);
return text.trim() !== "" && Number.isFinite(n) && n > 0 ? Math.round(n * 100) / 100 : null;
}
/** The default quota: shown to staff, edited by an admin with their own password. */
export function QuotaDefaultCard({
quotaDefault,
isAdmin,
onSaved,
}: {
/** `undefined` while loading, null for none. */
quotaDefault: UserQuota | null | undefined;
isAdmin: boolean;
onSaved: (message: string) => void;
}) {
const { t } = useTranslation("users");
const label = useQuotaLabel();
const [editing, setEditing] = useState(false);
return (
<section className="card" data-testid="quota-default-card">
<div className="flex flex-wrap items-center gap-3 px-5 py-4">
<Gauge className="h-4 w-4 text-fg-muted" />
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold text-fg-primary">{t("quota.cardTitle")}</h2>
<p className="text-xs text-fg-muted">{t("quota.cardDescription")}</p>
</div>
<div className="text-sm tabular-nums text-fg-primary" data-testid="quota-default-value">
{quotaDefault === undefined
? "…"
: quotaDefault
? label(quotaDefault)
: t("quota.noDefault")}
</div>
{isAdmin && quotaDefault !== undefined && (
<button type="button" className="btn-ghost text-xs" onClick={() => setEditing(true)}>
<Pencil className="h-3.5 w-3.5" /> {t("quota.editDefault")}
</button>
)}
</div>
{editing && (
<QuotaDefaultDialog
current={quotaDefault ?? null}
onClose={() => setEditing(false)}
onDone={(message) => {
setEditing(false);
onSaved(message);
}}
/>
)}
</section>
);
}
function QuotaDefaultDialog({
current,
onClose,
onDone,
}: {
current: UserQuota | null;
onClose: () => void;
onDone: (message: string) => void;
}) {
const { t } = useTranslation("users");
const [on, setOn] = useState(current !== null);
const [limit, setLimit] = useState(current ? String(current.limit_usd) : "");
const [period, setPeriod] = useState<QuotaPeriod>(current?.period ?? "day");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const value = parseLimit(limit);
const ready = password.length > 0 && (!on || value !== null) && !busy;
const submit = async () => {
if (!ready) return;
setBusy(true);
setError(null);
try {
await api.users.setQuotaDefault(
on && value !== null
? { limit_usd: value, period, tz_offset: new Date().getTimezoneOffset() }
: null,
password
);
onDone(t("quota.defaultSaved"));
} catch (e) {
const err = e as Error & { code?: string };
setError(
err.code && t(`errors.${err.code}`, { defaultValue: "" })
? t(`errors.${err.code}`)
: err.message
);
} finally {
setBusy(false);
}
};
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
};
return (
<ConfirmModal
open
title={t("quota.defaultDialog")}
confirmLabel={t("save")}
cancelLabel={t("common:cancel")}
destructive={false}
busy={busy}
disabled={!ready}
onConfirm={() => void submit()}
onCancel={onClose}
>
<div className="mt-3 space-y-3">
<Checkbox
checked={on}
onChange={setOn}
label={t("quota.defaultOn")}
labelClassName="text-sm text-fg-secondary"
/>
<QuotaFields
limit={limit}
period={period}
onLimit={setLimit}
onPeriod={setPeriod}
onKeyDown={onKeyDown}
disabled={!on}
/>
<p className="text-[11px] text-fg-muted">{t("quota.enforceHint")}</p>
<label className="block">
<span className="text-xs font-medium text-fg-secondary">{t("yourPassword")}</span>
<input
type="password"
className="input mt-1 w-full text-sm"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={onKeyDown}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
</label>
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}
</p>
)}
</div>
</ConfirmModal>
);
}
@@ -1,19 +1,24 @@
/**
* @file AccountPoolCard.test.tsx
* @description The Users page's account pool card: one tab per AI tool — the
* Claude, Codex and Cursor pools with their accounts' live panes and quota,
* Oh My Pi's login sharing, and a "no pool" note for any other installed
* tool; says what an empty pool means per tool, an
* @description The Users page's account pool card: the pools board with every
* tool's accounts (live panes and quota) and members per pool, then a tab per
* tool with no pool — Oh My Pi's login sharing, and a "no pool" note for any
* other installed tool; an
* admin adds an account only with their own password typed (the token goes
* in once and is not shown back) — a saved login moved in (the server's own
* included), or a setup-token — and removes one the same way, and an analyst
* sees no add or remove control. An account's Edit ticks the users who run on
* it, saved with the admin's own password; the users show on the account.
* The board moves members and accounts between pools (drag, or a column's
* menu) — an account sits in one pool, so a move takes it out of the other —
* splits an account off onto New pool, merges a pool by dragging its only
* account into another, adds a saved profile to a pool, shows each account's
* plan, and saves every change with ONE password.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import type { AccountPool } from "../../../lib/api";
@@ -28,7 +33,7 @@ const accountPool = vi.hoisted(() => ({
updateGroup: vi.fn(),
deleteGroup: vi.fn(),
}));
const users = vi.hoisted(() => ({ list: vi.fn() }));
const users = vi.hoisted(() => ({ list: vi.fn(), avatarUrl: () => null }));
const aiTools = vi.hoisted(() => ({
list: vi.fn(),
ompSharing: vi.fn(),
@@ -129,6 +134,28 @@ const OMP = {
gateway: { running: false },
};
/** The account names a pool column holds (its move menu names others too). */
const held = (col: HTMLElement) =>
within(col)
.queryAllByTestId("pool-group-account")
.map((c) => within(c).queryByText(/^(team|cx)-\d$/)?.textContent);
/** A drag-and-drop between two elements, through one shared DataTransfer. */
function drag(from: Element, to: Element) {
const store: Record<string, string> = {};
const dataTransfer = {
setData: (k: string, v: string) => (store[k] = v),
getData: (k: string) => store[k] ?? "",
get types() {
return Object.keys(store);
},
effectAllowed: "",
};
fireEvent.dragStart(from, { dataTransfer });
fireEvent.dragOver(to, { dataTransfer });
fireEvent.drop(to, { dataTransfer });
}
beforeEach(() => {
for (const fn of Object.values(accountPool)) fn.mockReset();
aiTools.list.mockResolvedValue({
@@ -147,8 +174,8 @@ beforeEach(() => {
accountPool.list.mockResolvedValue(POOL);
users.list.mockResolvedValue({
users: [
{ id: 7, username: "mia", disabled: false },
{ id: 8, username: "leo", disabled: false },
{ id: 7, username: "mia", role: "developer", disabled: false },
{ id: 8, username: "leo", role: "analyst", disabled: false },
],
});
accountPool.profiles.mockResolvedValue({
@@ -166,11 +193,15 @@ beforeEach(() => {
});
describe("AccountPoolCard", () => {
it("shows each tool's pool on its own tab, counted, and explains an empty one", async () => {
it("shows every tool's accounts on the board, and a tab per tool with no pool", async () => {
const user = userEvent.setup();
const { unmount } = render(card(false));
render(card(false));
expect(await screen.findByText("team-1")).toBeInTheDocument();
expect(screen.queryByText("cx-1")).toBeNull();
// With no pool made yet, every account sits in the shared pool, by tool.
const shared = screen.getByTestId("pool-shared");
expect(shared).toHaveTextContent("Claude Code2");
expect(shared).toHaveTextContent("Codex1");
expect(shared).toHaveTextContent("cx-1");
const [account, second] = screen.getAllByTestId("pool-account");
// Every account of the pool has its card; quota is shown as what is LEFT.
expect(second).toHaveTextContent("team-2");
@@ -188,39 +219,25 @@ describe("AccountPoolCard", () => {
expect(account).toHaveTextContent("$12.00 · 3.4M tokens in 7 days");
expect(account).toHaveTextContent("2 live");
expect(screen.getByText("team1@example.com")).toBeInTheDocument();
// Without groups nothing is said about them on an account.
expect(screen.queryByText("Shared pool")).toBeNull();
expect(screen.queryByRole("button", { name: /^add/i })).toBeNull();
// An analyst reads: no add, remove or new pool.
expect(screen.queryByTestId("pool-add-account")).toBeNull();
expect(screen.queryByRole("button", { name: /remove/i })).toBeNull();
const codexTab = screen.getByRole("tab", { name: /Codex/ });
expect(codexTab).toHaveTextContent("1");
expect(screen.getByRole("tab", { name: /Claude Code/ })).toHaveTextContent("2");
await user.click(codexTab);
expect(screen.getByText("cx-1")).toBeInTheDocument();
expect(screen.queryByText("team-1")).toBeNull();
// A pool with no account still has its tab, with what that means.
await user.click(screen.getByRole("tab", { name: /Cursor CLI/ }));
expect(screen.getByText(/No Cursor CLI accounts yet/)).toBeInTheDocument();
// An installed tool without a pool says so; an uninstalled one gets no tab.
expect(screen.queryByRole("button", { name: "New pool" })).toBeNull();
// Pool tools have no tab any more; an installed tool without a pool does.
expect(screen.queryByRole("tab", { name: /Codex/ })).toBeNull();
await user.click(screen.getByRole("tab", { name: /Antigravity/ }));
expect(screen.getByText("Antigravity has no account pool")).toBeInTheDocument();
expect(screen.getByText(/system keyring/)).toBeInTheDocument();
expect(screen.queryByRole("tab", { name: /Goose/ })).toBeNull();
unmount();
accountPool.list.mockResolvedValue({ accounts: [], codex: [], blockPercent: 90 });
render(card(true));
expect(await screen.findByText(/No Claude Code accounts yet/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Add Claude Code account" })).toBeInTheDocument();
});
it("adds a Cursor API key from the Cursor tab", async () => {
it("adds a Cursor API key from Add account", async () => {
accountPool.add.mockResolvedValue({ account: { name: "cu-1" } });
accountPool.list.mockResolvedValue({ ...POOL, cursor: [] });
const user = userEvent.setup();
render(card(true));
await screen.findByText("team-1");
await user.click(screen.getByRole("tab", { name: /Cursor CLI/ }));
await user.click(screen.getByRole("button", { name: "Add Cursor CLI account" }));
await user.selectOptions(screen.getByTestId("pool-add-account"), "cursor");
const dialog = await screen.findByRole("dialog");
expect(screen.queryByLabelText("From")).toBeNull();
await user.type(screen.getByLabelText("Name"), "cu-1");
@@ -238,27 +255,7 @@ describe("AccountPoolCard", () => {
expect(accountPool.profiles).not.toHaveBeenCalledWith("cursor");
});
it("shares a saved profile from its card", async () => {
accountPool.add.mockResolvedValue({ account: { name: "work" } });
const user = userEvent.setup();
render(card(true));
await screen.findByText("team-1");
await user.click(await screen.findByRole("button", { name: "Share Work to the pool" }));
const dialog = await screen.findByRole("dialog");
await waitFor(() => expect(screen.getByLabelText("Profile")).toHaveValue("work"));
await user.type(screen.getByLabelText("Your password"), "boss-pass");
await user.click(
Array.from(dialog.querySelectorAll("button")).find((b) => b.textContent === "Add account")!
);
await waitFor(() =>
expect(accountPool.add).toHaveBeenCalledWith(
{ tool: "claude", name: "work", profile: "work" },
"boss-pass"
)
);
});
it("shows the groups as cards and saves a new one with members and accounts", async () => {
it("builds pools on the board: members, accounts and profiles moved in, one password", async () => {
accountPool.list.mockResolvedValue({
...POOL,
groups: [
@@ -272,28 +269,180 @@ describe("AccountPoolCard", () => {
});
accountPool.createGroup.mockResolvedValue({ group: { id: 2, name: "Team B" } });
accountPool.updateGroup.mockResolvedValue({ group: {} });
accountPool.add.mockResolvedValue({ account: { name: "work" } });
const user = userEvent.setup();
render(card(true));
const groupCard = await screen.findByTestId("pool-group");
expect(groupCard).toHaveTextContent("Team A");
expect(groupCard).toHaveTextContent("leo");
expect(screen.getAllByTestId("pool-account")[0]).toHaveTextContent("Team A");
await user.click(screen.getByRole("button", { name: "New group" }));
await screen.findByRole("dialog");
await user.type(screen.getByLabelText("Group name"), "Team B");
await user.click(await screen.findByRole("checkbox", { name: /leo/ }));
expect(screen.getByText("now in Team A")).toBeInTheDocument();
await user.click(screen.getByRole("checkbox", { name: "cx-1" }));
await user.type(screen.getByLabelText("Your password"), "boss-pass");
await user.click(screen.getByRole("button", { name: "Save" }));
const teamA = await screen.findByTestId("pool-group");
expect(teamA).toHaveTextContent("leo");
expect(teamA).toHaveTextContent("team-1");
await screen.findByText("mia");
await user.click(screen.getByRole("button", { name: "New pool" }));
const names = screen.getAllByLabelText("Pool name");
await user.type(names[names.length - 1]!, "Team B");
const teamB = screen.getAllByTestId("pool-group")[1]!;
drag(screen.getByText("mia").closest("[data-testid=pool-member]")!, teamB);
expect(teamB).toHaveTextContent("mia");
const add = within(teamB).getByLabelText("Move an account into Team B");
await user.selectOptions(add, "codex|cx-1");
await user.selectOptions(add, "claude|profile:work");
expect(held(screen.getByTestId("pool-shared"))).not.toContain("cx-1");
expect(screen.getByTestId("pool-board-save")).toHaveTextContent("2 unsaved changes");
expect(accountPool.updateGroup).not.toHaveBeenCalled();
await user.click(
within(screen.getByTestId("pool-board-save")).getByRole("button", { name: "Save" })
);
const dialog = await screen.findByRole("dialog");
expect(dialog).toHaveTextContent("Move the saved profile Work into the pool");
await user.type(within(dialog).getByLabelText("Your password"), "boss-pass");
await user.click(within(dialog).getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(accountPool.updateGroup).toHaveBeenCalledWith(
2,
{ userIds: [8], accounts: { claude: [], codex: ["cx-1"], cursor: [] } },
{ userIds: [7], accounts: { claude: ["work"], codex: ["cx-1"], cursor: [] } },
"boss-pass"
)
);
expect(accountPool.add).toHaveBeenCalledWith(
{ tool: "claude", name: "work", profile: "work" },
"boss-pass"
);
expect(accountPool.createGroup).toHaveBeenCalledWith("Team B", "boss-pass");
// Team A did not change: nothing is sent for it.
expect(accountPool.updateGroup).toHaveBeenCalledTimes(1);
});
it("drags accounts between pools — one pool each — and a member back to the shared pool", async () => {
accountPool.list.mockResolvedValue({
...POOL,
groups: [
{
id: 1,
name: "Team A",
users: [{ userId: 8, username: "leo" }],
accounts: { claude: ["team-1"], codex: [], cursor: [] },
},
],
});
accountPool.updateGroup.mockResolvedValue({ group: {} });
const user = userEvent.setup();
render(card(true));
const teamA = await screen.findByTestId("pool-group");
const shared = screen.getByTestId("pool-shared");
const item = (col: HTMLElement, name: string) =>
within(col)
.getAllByTestId("pool-group-account")
.find((c) => c.textContent?.includes(name))!;
drag(item(shared, "team-2"), teamA);
expect(held(teamA)).toEqual(["team-1", "team-2"]);
// And back again: an account is in one pool, so it leaves the other.
drag(item(teamA, "team-1"), shared);
expect(held(teamA)).toEqual(["team-2"]);
expect(held(shared)).toContain("team-1");
expect(teamA).toHaveTextContent("team-2");
await user.click(
within(teamA).getByRole("button", { name: "Send leo back to the shared pool" })
);
expect(shared).toHaveTextContent("leo");
await user.click(
within(screen.getByTestId("pool-board-save")).getByRole("button", { name: "Save" })
);
const dialog = await screen.findByRole("dialog");
await user.type(within(dialog).getByLabelText("Your password"), "boss-pass");
await user.click(within(dialog).getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(accountPool.updateGroup).toHaveBeenCalledWith(
1,
{
name: "Team A",
userIds: [],
accounts: { claude: ["team-2"], codex: [], cursor: [] },
},
"boss-pass"
)
);
});
it("shows each account's plan on its card", async () => {
accountPool.list.mockResolvedValue({
...POOL,
accounts: [{ ...POOL.accounts[0]!, plan: "Max 5x" }, POOL.accounts[1]!],
});
render(card(false));
const [first, second] = await screen.findAllByTestId("pool-account");
expect(within(first!).getByTestId("pool-account-plan")).toHaveTextContent("Max 5x");
expect(within(second!).queryByTestId("pool-account-plan")).toBeNull();
});
it("splits an account off onto New pool, and merges a pool by its only account", async () => {
accountPool.list.mockResolvedValue({
...POOL,
groups: [
{
id: 1,
name: "Team A",
users: [{ userId: 8, username: "leo" }],
accounts: { claude: ["team-1"], codex: [], cursor: [] },
},
{
id: 2,
name: "Team B",
users: [{ userId: 7, username: "mia" }],
accounts: { claude: [], codex: ["cx-1"], cursor: [] },
},
],
});
accountPool.createGroup.mockResolvedValue({ group: { id: 3, name: "Solo" } });
accountPool.updateGroup.mockResolvedValue({ group: {} });
accountPool.deleteGroup.mockResolvedValue({ ok: true });
const user = userEvent.setup();
render(card(true));
const [teamA, teamB] = await screen.findAllByTestId("pool-group");
await screen.findByText("mia");
const item = (col: HTMLElement, name: string) =>
within(col)
.getAllByTestId("pool-group-account")
.find((c) => c.textContent?.includes(name))!;
// Split: team-2 leaves the shared pool for a new pool of its own.
drag(item(screen.getByTestId("pool-shared"), "team-2"), screen.getByTestId("pool-new"));
const pools = screen.getAllByTestId("pool-group");
expect(pools).toHaveLength(3);
expect(held(pools[2]!)).toEqual(["team-2"]);
await user.type(within(pools[2]!).getByLabelText("Pool name"), "Solo");
// Merge: Team B's only account into Team A brings mia along; Team B goes.
drag(item(teamB!, "cx-1"), teamA!);
expect(screen.getAllByTestId("pool-group")).toHaveLength(2);
expect(held(teamA!)).toEqual(["team-1", "cx-1"]);
expect(teamA).toHaveTextContent("mia");
await user.click(
within(screen.getByTestId("pool-board-save")).getByRole("button", { name: "Save" })
);
const dialog = await screen.findByRole("dialog");
expect(dialog).toHaveTextContent("Merge Team B into Team A: pool Team B is deleted");
await user.type(within(dialog).getByLabelText("Your password"), "boss-pass");
await user.click(within(dialog).getByRole("button", { name: "Save" }));
await waitFor(() => expect(accountPool.deleteGroup).toHaveBeenCalledWith(2, "boss-pass"));
expect(accountPool.updateGroup).toHaveBeenCalledWith(
1,
{
name: "Team A",
userIds: [8, 7],
accounts: { claude: ["team-1"], codex: ["cx-1"], cursor: [] },
},
"boss-pass"
);
expect(accountPool.createGroup).toHaveBeenCalledWith("Solo", "boss-pass");
expect(accountPool.updateGroup).toHaveBeenCalledWith(
3,
{ userIds: [], accounts: { claude: ["team-2"], codex: [], cursor: [] } },
"boss-pass"
);
// The members moved before the emptied pool was deleted.
const order = (fn: { mock: { invocationCallOrder: number[] } }) =>
fn.mock.invocationCallOrder[0]!;
expect(order(accountPool.updateGroup)).toBeLessThan(order(accountPool.deleteGroup));
});
it("turns Oh My Pi login sharing on from its tab", async () => {
@@ -312,7 +461,7 @@ describe("AccountPoolCard", () => {
const user = userEvent.setup();
render(card(true));
await screen.findByText("team-1");
await user.click(screen.getByRole("button", { name: "Add Claude Code account" }));
await user.selectOptions(screen.getByTestId("pool-add-account"), "claude");
const dialog = await screen.findByRole("dialog");
const select = await screen.findByLabelText("Profile");
await waitFor(() => expect(screen.getByRole("option", { name: /Work/ })).toBeInTheDocument());
@@ -337,7 +486,7 @@ describe("AccountPoolCard", () => {
const user = userEvent.setup();
render(card(true));
await screen.findByText("team-1");
await user.click(screen.getByRole("button", { name: "Add Claude Code account" }));
await user.selectOptions(screen.getByTestId("pool-add-account"), "claude");
const dialog = await screen.findByRole("dialog");
await user.selectOptions(screen.getByLabelText("From"), "token");
await user.type(screen.getByLabelText("Name"), "team-2");
@@ -362,7 +511,6 @@ describe("AccountPoolCard", () => {
const user = userEvent.setup();
render(card(true));
await screen.findByText("team-1");
await user.click(screen.getByRole("tab", { name: /Codex/ }));
await user.click(await screen.findByRole("button", { name: "Remove cx-1" }));
await user.type(await screen.findByLabelText("Your password"), "boss-pass");
await user.click(screen.getByRole("button", { name: "Remove" }));
@@ -425,7 +573,6 @@ describe("AccountPoolCard", () => {
render(card(true, "/admin?poolAdd=codex&poolProfile=cx"));
const dialog = await screen.findByRole("dialog");
await waitFor(() => expect(accountPool.profiles).toHaveBeenCalledWith("codex"));
expect(screen.getByRole("tab", { name: /Codex/ })).toHaveAttribute("aria-selected", "true");
await waitFor(() => expect(screen.getByLabelText("Profile")).toHaveValue("cx"));
await user.type(screen.getByLabelText("Your password"), "boss-pass");
const confirm = Array.from(dialog.querySelectorAll("button")).find(
@@ -1,18 +1,20 @@
/**
* @file MemberTmuxCard.test.tsx
* @description The Users page's members' tmux card: hidden without one Linux
* user per account, lists each running member server with its version and a
* badge when it is older than the installed tmux, warns until tmux-member is
* installed, and an admin restarts one member — or every idle outdated one,
* the working member left out — only with their own password typed; an
* analyst sees no restart control.
* @file MemberTmux.test.tsx
* @description Each member's tmux server as part of the users table
* (`MemberTmux.tsx`): the bar above the table is hidden without one Linux
* user per account, names the installed tmux and warns until tmux-member is
* installed; a row's cell shows the version with a badge when it is older than
* the installed tmux, and "—" for a member with no server; an admin restarts
* one member — or every idle outdated one, the working member left out — only
* with their own password typed; an analyst gets no "Restart idle".
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { MemberTmux } from "../../../lib/api";
import type { MemberTmux, MemberTmuxRow } from "../../../lib/api";
const users = vi.hoisted(() => ({
tmux: vi.fn(),
@@ -25,7 +27,41 @@ vi.mock("../../../lib/api", async (importOriginal) => {
});
vi.mock("../../../lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { MemberTmuxCard } from "../MemberTmuxCard";
import { MemberTmuxBar, TmuxCell, TmuxRestartDialog, useMemberTmux } from "../MemberTmux";
/** The users table's use of it, reduced: the bar, one cell and restart per row. */
function Harness({ isAdmin }: { isAdmin: boolean }) {
const { data, reload } = useMemberTmux();
const [target, setTarget] = useState<MemberTmuxRow | "idle" | null>(null);
return (
<>
<MemberTmuxBar data={data} isAdmin={isAdmin} onRestart={setTarget} />
{data?.available &&
data.users.map((u) => (
<div key={u.id} data-testid={`row-${u.username}`}>
<span>{u.username}</span>
<TmuxCell row={u} />
{isAdmin && u.running && (
<button type="button" onClick={() => setTarget(u)}>
Restart {u.username}'s tmux
</button>
)}
</div>
))}
{target && (
<TmuxRestartDialog
target={target}
data={data}
onClose={() => setTarget(null)}
onDone={() => {
setTarget(null);
void reload();
}}
/>
)}
</>
);
}
const DATA: MemberTmux = {
available: true,
@@ -52,33 +88,33 @@ beforeEach(() => {
users.tmux.mockResolvedValue(DATA);
});
describe("MemberTmuxCard", () => {
it("renders nothing without one Linux user per account", async () => {
describe("MemberTmux", () => {
it("shows no bar without one Linux user per account", async () => {
users.tmux.mockResolvedValue({ available: false, users: [] });
const { container } = render(<MemberTmuxCard isAdmin />);
render(<Harness isAdmin />);
await waitFor(() => expect(users.tmux).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
expect(screen.queryByTestId("member-tmux")).toBeNull();
});
it("lists running servers and marks the ones older than the installed tmux", async () => {
render(<MemberTmuxCard isAdmin={false} />);
expect(await screen.findByText("mia")).toBeInTheDocument();
expect(screen.queryByText("bob")).toBeNull();
it("marks the servers older than the installed tmux, and a member with none", async () => {
render(<Harness isAdmin={false} />);
expect(await screen.findByText(/Installed: tmux 3\.8/)).toBeInTheDocument();
expect(screen.getAllByText("older than installed")).toHaveLength(2);
expect(screen.getByText(/Installed: tmux 3\.8/)).toBeInTheDocument();
expect(screen.getByTestId("row-mia").textContent).toContain("2 sessions");
expect(screen.getByTestId("row-bob").textContent).toContain("—");
// An analyst reads only.
expect(screen.queryByRole("button", { name: /Restart/ })).toBeNull();
});
it("warns until tmux-member is installed", async () => {
users.tmux.mockResolvedValue({ ...DATA, matched: false });
render(<MemberTmuxCard isAdmin />);
render(<Harness isAdmin />);
expect(await screen.findByText(/install-system/)).toBeInTheDocument();
});
it("restarts one member only with the admin's password", async () => {
users.tmuxRestart.mockResolvedValue({});
render(<MemberTmuxCard isAdmin />);
render(<Harness isAdmin />);
await userEvent.click(await screen.findByRole("button", { name: "Restart mia's tmux" }));
expect(screen.getByText(/Every pane of mia closes \(2 sessions\)/)).toBeInTheDocument();
const go = screen.getByRole("button", { name: "Restart" });
@@ -90,7 +126,7 @@ describe("MemberTmuxCard", () => {
it("restarts every idle outdated member at once, the working one left out", async () => {
users.tmuxRestartIdle.mockResolvedValue({ restarted: ["mia"], skipped: ["zed"], failed: [] });
render(<MemberTmuxCard isAdmin />);
render(<Harness isAdmin />);
await userEvent.click(await screen.findByRole("button", { name: "Restart 1 idle member" }));
expect(screen.getByText(/no agent working: mia\./)).toBeInTheDocument();
await userEvent.type(screen.getByLabelText("Your password"), "pw");
@@ -13,7 +13,12 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ServerUserUsage } from "../../../lib/api";
const users = vi.hoisted(() => ({ get: vi.fn(), update: vi.fn(), remove: vi.fn() }));
const users = vi.hoisted(() => ({
get: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
avatarUrl: () => null,
}));
const confirmDialog = vi.hoisted(() => vi.fn());
vi.mock("../../../lib/confirm", () => ({ confirmDialog }));
const lanes = vi.hoisted(() => ({ list: vi.fn() }));
@@ -0,0 +1,158 @@
/**
* @file UserQuota.test.tsx
* @description Spend quotas on the Users page: a row's "spent of limit" line
* (red once used up, "(default)" when it is the default), the default quota
* card (an admin edits it with their own password, an analyst only reads),
* and the Edit user form sending a user's own quota as part of its ONE PATCH.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ServerUserUsage } from "../../../lib/api";
const users = vi.hoisted(() => ({
setQuotaDefault: vi.fn(),
update: vi.fn(),
get: vi.fn(),
avatarUrl: () => null,
}));
const lanes = vi.hoisted(() => ({ list: vi.fn() }));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, api: { users, lanes } };
});
import { QuotaDefaultCard, QuotaSummary } from "../UserQuota";
import { UserEditDialog } from "../UserEditDialog";
beforeEach(() => {
vi.clearAllMocks();
users.setQuotaDefault.mockResolvedValue({ user: {}, quotaDefault: null });
users.update.mockResolvedValue({ user: {} });
users.get.mockResolvedValue({ user: {}, workspaces: [] });
lanes.list.mockResolvedValue({ lanes: [] });
});
describe("QuotaSummary", () => {
it("shows spent of the limit, red and marked default once used up", () => {
render(
<QuotaSummary
quota={{
limit_usd: 10,
period: "day",
source: "default",
spend: 12.5,
pct: 125,
exceeded: true,
resetsAt: "2026-10-29T17:00:00.000Z",
since: "2026-10-28T17:00:00.000Z",
}}
/>
);
const line = screen.getByText(/of \$10\.00 \/ day/);
expect(line.textContent).toContain("$12.50");
expect(line.textContent).toContain("(default)");
expect(line.className).toContain("text-status-danger");
expect(screen.getByRole("meter")).toHaveAttribute("aria-valuenow", "100");
});
it("says No quota without one, and nothing for an older server", () => {
const { container, rerender } = render(<QuotaSummary quota={null} />);
expect(screen.getByText("No quota")).toBeInTheDocument();
rerender(<QuotaSummary quota={undefined} />);
expect(container.textContent).toBe("");
});
});
describe("QuotaDefaultCard", () => {
it("an admin sets the default with their own password", async () => {
const user = userEvent.setup();
const onSaved = vi.fn();
render(<QuotaDefaultCard quotaDefault={null} isAdmin onSaved={onSaved} />);
expect(screen.getByTestId("quota-default-value").textContent).toBe("No default — no limit");
await user.click(screen.getByRole("button", { name: /Edit/ }));
await user.click(screen.getByRole("checkbox", { name: "Limit what each user may spend" }));
await user.type(screen.getByLabelText("Limit in dollars"), "25");
await user.selectOptions(screen.getByLabelText("Period"), "5h");
const save = screen.getByRole("button", { name: "Save" });
expect(save).toBeDisabled(); // no password yet
await user.type(screen.getByLabelText("Your password"), "pw");
await user.click(save);
await waitFor(() => expect(onSaved).toHaveBeenCalled());
expect(users.setQuotaDefault).toHaveBeenCalledWith(
expect.objectContaining({ limit_usd: 25, period: "5h" }),
"pw"
);
});
it("an analyst only reads it", () => {
render(
<QuotaDefaultCard
quotaDefault={{ limit_usd: 5, period: "day" }}
isAdmin={false}
onSaved={() => {}}
/>
);
expect(screen.getByTestId("quota-default-value").textContent).toBe("$5.00 / day");
expect(screen.queryByRole("button", { name: /Edit/ })).toBeNull();
});
});
describe("UserEditDialog quota", () => {
const mia: ServerUserUsage = {
id: 7,
username: "mia",
role: "developer",
disabled: false,
workspaces: 0,
sessions: 0,
last7d: { cost: 0, tokens: 0 },
last30d: { cost: 0, tokens: 0 },
quotaSetting: { mode: "default" },
quota: null,
} as ServerUserUsage;
it("sends the user's own limit in the one PATCH", async () => {
const user = userEvent.setup();
const onDone = vi.fn();
render(
<UserEditDialog
user={mia}
self={false}
users={[mia]}
quotaDefault={{ limit_usd: 10, period: "day" }}
onClose={() => {}}
onDone={onDone}
/>
);
const select = screen.getByLabelText("Spend quota");
expect(screen.getByRole("option", { name: "Default ($10.00 / day)" })).toBeInTheDocument();
await user.selectOptions(select, "custom");
await user.type(screen.getByLabelText("Limit in dollars"), "40");
await user.type(screen.getByLabelText("Your password"), "pw");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(onDone).toHaveBeenCalled());
expect(users.update).toHaveBeenCalledWith(
7,
{ quota: expect.objectContaining({ mode: "custom", limit_usd: 40, period: "day" }) },
"pw"
);
});
it("No limit is a change; an empty custom limit keeps Save off", async () => {
const user = userEvent.setup();
render(
<UserEditDialog user={mia} self={false} users={[mia]} onClose={() => {}} onDone={() => {}} />
);
await user.type(screen.getByLabelText("Your password"), "pw");
await user.selectOptions(screen.getByLabelText("Spend quota"), "custom");
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
await user.selectOptions(screen.getByLabelText("Spend quota"), "unlimited");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(users.update).toHaveBeenCalledWith(7, { quota: { mode: "unlimited" } }, "pw")
);
});
});
+3
View File
@@ -118,6 +118,9 @@ export const i18nReady = i18n
"admin",
"onboarding",
"artifacts",
"gitRefs",
"gitTools",
"gitHistory",
],
defaultNS: "common",
interpolation: { escapeValue: false },
+1 -34
View File
@@ -1,37 +1,4 @@
{
"title": "Administration",
"description": "Accounts and the server AgentHub runs on.",
"tabs": {
"users": "Users",
"server": "Server"
},
"server": {
"title": "Server",
"uptime": "up {{v}}",
"uptimeDays": "{{d}} d {{h}} h",
"uptimeHours": "{{h}} h {{m}} min",
"cpu": "CPU",
"cores": "{{n}} cores · load {{load}}",
"cpuHelp": "Share of the whole machine: 100% means every core is busy.",
"memory": "Memory",
"swap": "Swap",
"homesDisk": "Homes disk",
"homesDiskHelp": "The disk that holds the users' home folders.",
"free": "free",
"usageTitle": "Usage by user",
"usageHelp": "Live, sampled over {{s}} s. A process counts for a user when it runs as their Linux account or inside one of their workspaces' sessions. Memory is resident (RSS); shared pages count once per process. Click a row for its busiest processes.",
"usageUnavailable": "Per-user usage is read from /proc and is only available on Linux.",
"who": "User",
"processes": "Processes",
"home": "Home",
"sampling": "Sampling…",
"hostGroup": "Host account (AgentHub and other host processes)",
"systemGroup": "System (root and other accounts)",
"disks": "Disks",
"mount": "Mount",
"device": "Device",
"used": "Used",
"homesBadge": "homes",
"gpus": "GPUs"
}
"description": "Accounts and the server AgentHub runs on."
}
+12 -1
View File
@@ -28,6 +28,12 @@
"unshare": "Unshare",
"shareDone": "Shared to the Hub",
"unshareDone": "Removed from the Hub",
"reshare": "Share again",
"reshareDone": "New version shared — people who installed it are offered Update",
"reshareSame": "Already the newest version — shared",
"reshareAskTitle": "Choose the {{name}} folder again",
"reshareAskBody": "This server has no copy of {{name}}. Choose the skill's folder (the one holding SKILL.md): it replaces this version, and people who installed it are offered Update. For a .zip, upload it through Add skill → Upload: a skill with the same name updates your existing one.",
"resharePick": "Choose folder",
"open": "Open",
"copyLink": "Copy link",
"preview": "Preview",
@@ -136,6 +142,11 @@
"installsYou": "You",
"installsUnknown": "A removed user",
"installsReplaced": "replaced an older copy",
"versionAt": "Updated {{ago}}",
"versionAtFull": "Last updated {{date}}",
"installState.latest": "Latest",
"installState.outdated": "Old version",
"installState.removed": "Removed",
"installsPluginWhere": "claude plugin install --scope user",
"installsUnrecorded": "{{count}} earlier installs were counted before details were kept",
"installsNoneOfYours": "You have not installed it — only your own installs are listed.",
@@ -179,7 +190,7 @@
"stat.installs": "Installs",
"stat.files": "Files",
"stat.size": "Size",
"stat.updated": "Updated",
"stat.updated": "Last updated",
"detail.runAs": "Run it as",
"detail.market": "Marketplace",
"detail.added": "Added {{date}}",

Some files were not shown because too many files have changed in this diff Show More