Compare commits

..

91 Commits

Author SHA1 Message Date
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
nntrivi2001 df15789982 fix(9router): a sudo update gives root's npm its own cache
The AgentHub unit makes /root inaccessible (InaccessiblePaths=), so root's
npm failed at mkdir /root/.npm with ENOENT (exit 254). A sudo run now passes
--cache /var/cache/agenthub-9router-npm; the sudoers rule names that argv.
2026-10-26 17:12:02 +07:00
nntrivi2001 272a3e7d6e chore: v2.8.27 — Update 9router from Settings → AI Proxy 2026-10-26 17:08:09 +07:00
nntrivi2001 569b68ec98 feat(ai-proxy): Update 9router button — shut down, npm update, relaunch hidden
Settings → AI Proxy gains an admin-only Update 9router button beside
Config 9router. One in-memory job (server/lib/nine-router-update.js) stops
9router (its user unit, else this account's own 9router processes), runs
npm install -g --prefix <its prefix> 9router@latest (sudo -n only when
sudoers already allows it, probed before anything stops), then relaunches
it hidden only if it was running. A failed update still relaunches the old
version. Nothing in the request names a command, path or package.
2026-10-26 17:00:53 +07:00
nntrivi2001 631ea96f19 chore: v2.8.26 — React 19 / Express 5 / xterm 6, bigger file drops, hidden-tab terminal size fix 2026-10-26 16:36:49 +07:00
nntrivi2001 dce32b96b7 fix(drop): stream every dropped file to disk; no local size cap
- POST /api/run/drop-file pipes the body to its file as it arrives in every
  case — a member's (S3) through `write-as … replace` with the bytes on
  stdin — instead of buffering up to 20 MB in memory for a remote browser.
- Remote limit (server mode, a LAN browser) 20 MB → 500 MB, as a Files upload.
- A loopback browser in local mode has no cap any more, only the data dir's
  free space less a 1 GB reserve; ETOOBIG's max_bytes is what still fits, so
  the UI's "at most N MB" stays true.
- drop-image keeps its 20 MB in-memory limit, now checked on its own.
- fake-sudo gains a spawn for run-as.__setSpawnImpl.
2026-10-26 16:36:29 +07:00
nntrivi2001 a0cd808456 fix(terminal): a pane on a hidden tab never shrinks its tmux window
A terminal mounted on a hidden tab sits in a 0x0 box; its first fit shrank
xterm to 12x6 and the attach carried that size to tmux, so every background
pane was squeezed (Claude's TUI redrawn at 12 columns). It now neither fits
nor reports a size until shown, attaches with no size, and the server spawns
such an attach at the window's own size (tmux.windowSize). A shown tab
reports its size even when the fit did not change xterm's.

Settings > Agents shows the host's login profiles to staff only: a developer
got two 403s from reads that were never theirs.
2026-10-26 14:53:29 +07:00
nntrivi2001 e3745d7504 fix(build): target ES2021 so esbuild keeps xterm 6's requestMode variable
Vite's es2020 default made esbuild 0.25 lower `let r; …(r||={})` to
`void 0||(i={})` without the declaration, so every DECRQM query tmux sends
threw "i is not defined" inside the terminal's parser. Found by a headless
smoke run of the preview build.
2026-10-26 14:43:01 +07:00
nntrivi2001 8546748759 chore: maintenance — dependency majors, rename step 4, member Claude config, OpenAPI coverage
Dependencies
- Security: body-parser and multer DoS advisories fixed (npm audit: 0).
- Server: Express 5 (query parser kept "extended", req.body defaults to {},
  SPA catch-all /{*splat}, send-file helper restores Express 4's dot-folder
  rule for sendFile/download, alerts ack route without inline regex),
  better-sqlite3 13, js-yaml 5 (server/lib/yaml.js keeps js-yaml 4's default
  schema), uuid dropped for crypto.randomUUID, node-pty beta.18, multer 2.5.
- Client: React 19, xterm 6 (WebGL renderer replaces the removed canvas one,
  DOM fallback on context loss; Alt+arrow word moves re-mapped), lucide-react 1,
  TypeScript 7, i18next/react-i18next/codemirror minors.

Workspace rename step 4
- `agenthub lanes` and `--lane` exit 1 with "renamed: use …"; client api.ts,
  the CLI and the MCP server call /api/workspaces; /api/lanes stays mounted.

Server mode (S3): members manage their own Claude Code config
- server/lib/cc-target.js resolves whose config a /cc-config request touches
  and gates the router by allowlist; /profiles* and /plugins/update-all stay
  admin-only. Member writes go through write-as, CLI calls through sudo as the
  member (never -y), reads through openSafe; cc_config_changed reaches only
  its owner. Not verified live on an S3 install yet.

Other
- Chat: an HTML file is an artifact only when written whole and not a project
  page (index.html, or under src/, public/, templates/, …).
- OpenAPI covers every /api route; openapi-coverage.test.js guards it.
- read-pool test keeps its event loop alive (its worker is unref'd).
- Docs: plan statuses, checklists, CLI.md, LANES.md, SERVER-MODE.md, API.md.
2026-10-26 14:22:41 +07:00
nntrivi2001 cc2ece94c3 fix(ui): drop the git clone hints from Git identity and the background toast on closing the update dialog 2026-10-26 14:22:18 +07:00
nntrivi2001 6b602c76b8 chore: v2.8.25 — per-user Today, Alt+V image paste on Windows, local paste/drop by path 2026-10-26 11:18:05 +07:00
nntrivi2001 f2f1cd9b6c fix: per-user Today, native paste and drop on a local machine
- Today's spend in the status bar and the Claude Code statusline is the
  caller's own (`GET /api/pricing/cost?mine=1`, scope.ownSessionWhere), not
  every user's sum for an admin or analyst.
- A screenshot pasted into a local claude pane is sent as Claude Code's own
  image-paste key: Alt+V on Windows (Ctrl+V did nothing there), ^V elsewhere,
  and only when the machine can read its clipboard (wl-paste/xclip + a
  display on Linux), else it is uploaded (GET /api/run/native-paste {key}).
- A file manager's files pasted or dropped on a local browser go in by their
  own paths, read from the OS clipboard or the file manager's selection
  (server/lib/file-manager.js, POST /api/run/clipboard-files and
  /selected-files): no upload, no size limit. A copied image file is pasted
  by path, text that comes with a picture of it (spreadsheet cells) is
  pasted as text, Windows paths go in as `C:/…` mentions.
- Tab titles for Snapshot, Artifacts, Files and Administration.
- An npm tool AgentHub installed under ~/.local is updated there, not by its
  own updater into npm's global prefix.
2026-10-26 11:18:05 +07:00
nntrivi2001 c59e196e41 chore: v2.8.24 — sub-agent edits in Edited N files, OpenCode/Grok dropped, Artifacts list view and skill sync
Edited N files (#86): a turn's card counts the files its subagents wrote
or edited (read from their own transcripts, nested ones folded in), and a
turn longer than the chat's first page keeps the edits made before the
page cut. A non-Claude transcript page honours its limit again.

AI tools: OpenCode and Grok Build left the catalog (opencode-usage.js
gone; a Grok payload on the Claude hook route is dropped), and so did the
leftover Kimi Code quota reader.

Artifacts: a list view, skill packs and badges only by exception; a
shared skill follows its owner's folder, and an installer whose copy
differs is offered Update.
2026-10-26 09:46:45 +07:00
nntrivi2001 72aef2426c feat(artifacts): a shared skill follows its owner's folder; installers get Update
A skill in the Hub was a snapshot taken once: the owner changing
~/.claude/skills/<name> changed nothing there, and an installer had to
uninstall and install again to get a newer version.

- syncSkillSources (on GET /api/artifacts, at most every 15 s): a skill row
  taken from a folder — the owner's ~/.claude/skills/<name>, or the
  workspace's .claude/skills/<name> — is brought up to it. Only a folder
  whose names/sizes/mtimes changed is read again, with a pick's checks (no
  symlink, no secret, <= 5 MB, a member's files as theirs); a change is a
  NEW snapshot, the old one dropped, and an artifact_changed broadcast.
- artifacts.version (snapshot content hash) and source_hash (the folder's
  when last taken, additive columns): an upload or a Hub edit records the
  folder as it is then, so only a LATER folder change replaces it.
- A list row's updateAvailable: installed, and the viewer's copy differs
  from version. The page shows Update (a pack: Update N); install with
  {update: true} replaces the copies (old ones moved aside) and is not
  counted as another install.
2026-10-26 09:34:21 +07:00
nntrivi2001 244f999538 feat(artifacts): list view, skill packs, badges by exception
The page showed every artifact as a tall card with a blue Install and four
text actions, a Shared badge on nearly all of them and 12 cards per section,
so 26 artifacts filled ~1900px (5000px on a phone).

- List view by default (Grid kept, a per-browser choice): one row per
  artifact — kind icon, name, one line of description, owner and installs,
  ONE primary action, everything else in its ⋯ menu / right-click.
- Three or more skills of one owner sharing a name prefix (sg-*, vp-*) are
  one pack row: open it for its skills, or Install all / Install N more
  after one confirmation (a name already there is left alone).
- Badges only for the exception: Private (not shared), Hub only, Auto.
- No per-section paging: 30 entries, then Show more.
- Scope tabs under the title; category chips scroll on a phone.
- Another user's private row (seen by an admin) gets its own section
  instead of sitting under Shared by others.
- "1 installs" → "1 install".
2026-10-26 09:34:10 +07:00
nntrivi2001 127da516ac chore(quota): drop the Kimi Code quota reader (Kimi already left the catalog)
Redone on v2.8.22 from 3179d25 (feat/grok-usage, never merged).
2026-10-26 09:31:37 +07:00
nntrivi2001 c0832182f2 feat(ai-tools): drop OpenCode and Grok Build
- opencode.json / grok.json leave the built-in catalog; opencode-usage.js,
  its poller, transcript branch and table creation go with it (existing
  tables and session rows stay as history).
- A Grok payload reaching the Claude hook route (Grok runs the Claude hooks
  itself) is dropped instead of recorded as tool = 'grok'.
- quota.js no longer reads Grok or OpenCode Go logins.
- Redone on v2.8.22 from c6b2892 (feat/grok-usage, never merged).
2026-10-26 09:31:30 +07:00
nntrivi2001 9ea7758abb fix(transcript): a non-Claude page honours its limit again (#86)
`carry?.drop(messages.shift())` skips its argument when `carry` is null —
optional chaining short-circuits the whole call — so a Codex, omp, Cursor
or Antigravity page never shifted its sliding window and returned the
whole journal (codex-transcript.test.js: 8 messages for ?limit=2). Shift
first, then hand the dropped message to the carry.
2026-10-26 08:35:39 +07:00
nntrivi2001 f51a6a895e fix(chat): keep a long turn's Edited N files card whole across the page cut (#86)
The reported turn (/vp-auto, ~120 messages) showed "Edited 1 file": the
chat loads the newest 50 messages, and the turn's other Write
(rbac_routes_test.go, +176) was in the part before the page.

- turnCarry (agent-edits.js): every message the route's sliding window
  drops goes through it; it keeps the file changes since the last prompt it
  dropped (a dropped call whose result is on the page, and dropped
  subagents, included).
- A Claude page that starts mid-turn answers them as `turn_edits`; the
  chat keeps the one from the page that holds its oldest message and adds
  it to that turn's card.

Files a turn changed through Bash (python/sed writing a file) are still not
listed: nothing in the transcript says which files a command wrote.
2026-10-26 08:32:45 +07:00
nntrivi2001 5ccbaf5c23 fix(chat): count a subagent's edits in the turn's Edited N files card (#86)
A skill that hands its work to subagents (vp-auto) showed "Edited 1 file"
for a turn that changed 4: a subagent's Write/Edit calls are in its own
agent-<id>.jsonl, so the card only saw the parent's.

- server/lib/agent-edits.js reads a subagent's transcript (readLines, a
  member's only as theirs) for the files it wrote or edited with their +/-
  lines, nested subagents folded in (3 levels), cached by size + mtime.
- The transcript route puts them on each Claude Agent/Task result as
  `edits`; the card adds them to the turn's own edits. A file only a
  subagent wrote is listed, never an artifact card (its versions are
  rebuilt from this transcript's inputs, which a subagent's are not).
2026-10-26 08:32:45 +07:00
nntrivi2001 c8bc4a95a9 chore: v2.8.23 — workspace groups, inner repos in Changes (opt-in), sub-agent tokens counted
Workspace groups: named, foldable, ordered sidebar sections kept per
account (agenthub.workspace.groups); a workspace joins one from its menu,
a drop on the header, a drag under a row, or Alt+Up/Down past a group's
edge; headers drag to reorder; a folded header shows its most urgent
state; New workspace in this group; fold/unfold all.

Inner repos: GET /api/lanes/:id/repos + ?repo= on git/fs routes; the
Changes panel shows a parent folder's repos only when Settings -> Git ->
Changes panel turns it on (agenthub.changes.innerRepos, off by default).

Cost: the hook writer adds a session's sub-agent transcripts to its
tokens and hours (server/lib/subagent-usage.js) — a sub-agent on the
session's own model was never counted live (~8% of a week here); an
imported session is re-raised on every pass, so a claude -p run first
seen with an empty transcript no longer stays at $0 (~10%). A one-time
raise-only catch-up (user_version 5) re-reads ~/.claude/projects.

Also: omp install script may redirect to its exact install.redirect URL
(#85, repo-links test exempts it), native paste in the terminal.
2026-10-23 16:41:45 +07:00
nntrivi2001 65a001dad4 fix(lanes): cloning into a deleted workspace's folder re-attaches it instead of failing UNIQUE
A workspace record outlives its folder (deleted from the File Explorer or a
shell). Cloning the same repo into that path again ran `git clone`, then
createLane hit idx_lanes_cwd_tool and answered 500 "UNIQUE constraint failed:
lanes.cwd, lanes.tool", leaving the new clone with no workspace on it.

POST /api/lanes/clone now looks for the caller's own workspace at exactly
that folder and tool and answers 200 { lane, created: false, path }, like
POST /ensure. Another user's workspace there is still refused before the
clone (folderTakenByOther), and a nested path still makes its own workspace.
2026-10-23 16:28:18 +07:00
nntrivi2001 572d615d4e chore: v2.8.22 — Users today/7/30-day spend counts every tool, update chip follows a running update
- Users: today / 7 / 30-day spend read only token_usage_hourly, which holds
  Claude transcript hours only — omp/Codex/Antigravity/Cursor sessions,
  sub-agent files, baselines and long-context pricing were missing. Now
  booked by hourlyTokenRows + calculateCost like the cost routes.
- Update: a page adopts a plugin update the server reports as running
  (after a reload, another tab, or one started from an older bundle), and
  the progress chip shows whenever an update runs and the modal is not
  visible — also when the status stops offering it mid-run.
2026-10-23 11:26:12 +07:00
nntrivi2001 9dd97a369c chore: v2.8.21 — update progress steps, clone in background, extra SSH keys inline, today's spend
- Plugin update reports its running step (plugin-update-progress.json →
  plugin_update_progress); a hidden update or clone leaves a progress chip.
- Clone here can run in the background.
- Git identity: extra SSH keys are added inline per host (no More keys tab).
- Users: spend today (viewer's local day, ?tz_offset=).
- Prettier on the files touched since v2.8.20.
2026-10-23 10:44:11 +07:00
nntrivi2001 62b4520507 feat(artifacts): capture Claude Docs docs and session documents, claim unowned rows
- A Claude Docs `batch` that creates a doc is captured like an `Artifact`
  publish, titled by the doc's name and linked by its own frame URL.
- At Stop/SessionEnd, each pptx/docx/xlsx/pdf/html a workspace session
  created or changed is snapshotted into its owner's library (`auto`);
  an unchanged file is skipped, a changed one refreshes its snapshot.
- Server mode: a page with no owner carries a suggested owner (the
  workspace or home its session ran in). The suggested user claims or
  declines it; an admin claims or assigns any. New tab Unowned /
  Is this yours? on the Artifacts page; all three actions audited.
2026-10-23 08:33:55 +07:00
396 changed files with 49380 additions and 8242 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": ""
}
}
+8 -3
View File
@@ -7,7 +7,7 @@ Architectural overview and technical reference for the Agent Dashboard system, c
![Model Context Protocol](https://img.shields.io/badge/Model_Context_Protocol-1.0-0f766e?style=flat-square&logo=modelcontextprotocol&logoColor=white)
![Node.js](https://img.shields.io/badge/Node.js-%3E%3D20-339933?style=flat-square&logo=node.js&logoColor=white)
![Python](https://img.shields.io/badge/Python-%3E%3D3.6-3776AB?style=flat-square&logo=python&logoColor=white)
![Express](https://img.shields.io/badge/Express-4.21-000000?style=flat-square&logo=express&logoColor=white)
![Express](https://img.shields.io/badge/Express-5-000000?style=flat-square&logo=express&logoColor=white)
![ws](https://img.shields.io/badge/ws-WebSocket_server-010101?style=flat-square&logo=socketdotio&logoColor=white)
![web-push](https://img.shields.io/badge/web--push-VAPID-3b82f6?style=flat-square&logo=javascript&logoColor=white)
![swagger-ui-express](https://img.shields.io/badge/swagger--ui--express-5.0-85EA2D?style=flat-square&logo=swagger&logoColor=white)
@@ -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 |
@@ -2557,7 +2562,7 @@ Components that consume this: SessionDetail, Analytics (donut chart + breakdown)
| **SQLite** (via `better-sqlite3` or built-in `node:sqlite`) | Zero-config, embedded, no server process. WAL mode gives concurrent reads. Synchronous API is simpler than async alternatives for this use case. Falls back to Node.js built-in `node:sqlite` when `better-sqlite3` cannot be compiled |
| **Express** | Battle-tested, minimal, well-understood. Overkill would be Fastify for this scale; underkill would be raw `http` module |
| **ws** | Fastest, most lightweight WebSocket library for Node. No Socket.IO overhead needed since we only push JSON messages |
| **React 18** | Stable, widely known, strong TypeScript support. No need for Server Components or RSC given this is a client-rendered SPA |
| **React 19** | Stable, widely known, strong TypeScript support. No need for Server Components or RSC given this is a client-rendered SPA |
| **Vite** | Fast builds, native ESM, excellent dev experience. Proxy config handles the dev server split cleanly |
| **Tailwind CSS** | Utility-first approach keeps styles colocated with markup. No CSS module boilerplate. Colors are CSS-variable-backed tokens (`darkMode: "class"`) so the Dark/Light/System switch re-themes the app with one class flip rather than per-component `dark:` variants, and a named theme (Settings → Appearance) is one `data-theme` attribute selecting an override block of the same variables |
| **React Router 6** | Standard routing for React SPAs. Layout routes with `<Outlet>` give clean shell composition |
+17 -16
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
+67 -69
View File
@@ -1281,7 +1281,7 @@ async function cmdDoctor() {
// Asked of the SERVER, not of this shell: a run is a tmux session the server
// spawns, so the only PATH that decides whether Start Run works is its own —
// a systemd --user service never reads your shell profile. Missing tmux is a
// warning, not a failure: it costs the run console and `lanes shell`, nothing
// warning, not a failure: it costs the run console and `workspace shell`, nothing
// else.
try {
const { available, upgrade } = await get("/api/run/tmux");
@@ -1693,7 +1693,7 @@ function cmdOpen() {
}
/**
* `agenthub lanes add` — adopt a working directory or provision a managed worktree.
* `agenthub workspace add` — adopt a working directory or provision a managed worktree.
*/
async function cmdLanesAdd(args) {
const flag = (name) => {
@@ -1719,12 +1719,12 @@ async function cmdLanesAdd(args) {
if (base) body.base = base;
if (slug) body.slug = slug;
if (tool) body.tool = tool;
const { lane: provisioning } = await post("/api/lanes/worktree", body);
const { lane: provisioning } = await post("/api/workspaces/worktree", body);
const deadline = Date.now() + 30_000;
let lane = provisioning;
while (lane.status === "provisioning" && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 250));
({ lane } = await get(`/api/lanes/${provisioning.id}`));
({ lane } = await get(`/api/workspaces/${provisioning.id}`));
}
if (lane.status === "provisioning") {
console.error(
@@ -1755,12 +1755,12 @@ async function cmdLanesAdd(args) {
const body = { cwd, title };
if (pipeline) body.pipeline = pipeline;
if (tool) body.tool = tool;
const { lane } = await post("/api/lanes", body);
const { lane } = await post("/api/workspaces", body);
console.log(`${c.green("✔")} Created workspace #${lane.id}: ${lane.title}`);
}
/**
* `agenthub lanes gc [--dry-run]` — reap orphaned Playwright MCP processes
* `agenthub workspace gc [--dry-run]` — reap orphaned Playwright MCP processes
* (owning session died) and cap oversized hook logs, across every lane on
* this machine. Machine-wide, not one lane's; pure local process/file
* action, no HTTP round-trip.
@@ -1790,7 +1790,7 @@ function cmdLanesGc(args) {
}
/**
* `agenthub lanes profile init <repo>` — detect a Node.js project and scaffold
* `agenthub workspace profile init <repo>` — detect a Node.js project and scaffold
* `.agenthub/profile/`. Pure filesystem action against the SOURCE repo; does not
* talk to the dashboard server at all.
*/
@@ -1844,9 +1844,9 @@ function cmdLanesProfileInit(args) {
}
/**
* `agenthub lanes profile check [<path>]` — validate a profile without needing a
* `agenthub workspace profile check [<path>]` — validate a profile without needing a
* lane to exist for it yet. Defaults to the current directory, NOT lane-id
* resolution (unlike every other `lanes` subcommand) — this is meant to run
* resolution (unlike every other `workspace` subcommand) — this is meant to run
* against a bare repo right after `profile init`.
*/
async function cmdLanesProfileCheck(args) {
@@ -1883,7 +1883,7 @@ function printLaneFacts(title, facts, fields) {
}
}
/** `agenthub lanes reset|remove|purge` — destructive work only after a fact check. */
/** `agenthub workspace reset|remove|purge` — destructive work only after a fact check. */
async function cmdLanesLifecycle(action, args) {
const id = args.find((arg) => !arg.startsWith("--"));
const fields = LANE_PREFLIGHT_FIELDS[action];
@@ -1894,7 +1894,7 @@ async function cmdLanesLifecycle(action, args) {
return;
}
const preflight = await get(`/api/lanes/${id}/preflight?action=${action}`);
const preflight = await get(`/api/workspaces/${id}/preflight?action=${action}`);
printLaneFacts(`Preflight for ${action} workspace #${id}:`, preflight, fields);
const keepDb = action === "reset" && args.includes("--keep-db");
if (preflight.database) {
@@ -1938,7 +1938,7 @@ async function cmdLanesLifecycle(action, args) {
const body = { confirm: true, expect };
if (args.includes("--force")) body.force = true;
if (keepDb) body.keepDb = true;
const result = await post(`/api/lanes/${id}/${action}`, body, { allowError: true });
const result = await post(`/api/workspaces/${id}/${action}`, body, { allowError: true });
if (result.status) {
const error = result.data?.error || {};
console.error(`✖ ${action} workspace #${id} → ${error.message || `HTTP ${result.status}`}`);
@@ -1961,7 +1961,7 @@ async function cmdLanesLifecycle(action, args) {
}
/**
* `agenthub lanes` — one row per lane: what it is, where it is in its pipeline, and
* `agenthub workspaces` — one row per lane: what it is, where it is in its pipeline, and
* whether the driving session is still breathing.
*/
/**
@@ -1993,7 +1993,7 @@ function detectionLeadsDeclaration(l) {
}
async function cmdLanes() {
const { lanes, counts } = await get("/api/lanes");
const { lanes, counts } = await get("/api/workspaces");
if (!lanes.length) {
console.log(
"no workspaces yet — create one with: agenthub workspace add --cwd <path> --title <text>"
@@ -2040,7 +2040,7 @@ function toolFromEnv() {
*
* Only the FIRST positional counts as an id, and only when it is all digits.
* Scanning the whole argv for a number would swallow flag values —
* `agenthub lanes logs web --tail 4096` would have addressed lane 4096.
* `agenthub workspace logs web --tail 4096` would have addressed lane 4096.
*
* @param {string[]} args - Arguments after the subcommand.
* @returns {Promise<{laneId: string|number, rest: string[]}|null>} null once an error is printed.
@@ -2051,13 +2051,13 @@ async function resolveLaneArg(args) {
return i > -1 ? args[i + 1] : undefined;
};
const explicit = flagValue("lane") || flagValue("workspace");
const explicit = flagValue("workspace");
if (explicit) return { laneId: explicit, rest: args };
if (args.length && /^\d+$/.test(args[0])) return { laneId: args[0], rest: args.slice(1) };
const tool = flagValue("tool") || toolFromEnv();
const cwd = require("path").resolve(flagValue("cwd") || process.cwd());
const { lanes } = await get("/api/lanes");
const { lanes } = await get("/api/workspaces");
// A pane's AGENTHUB_LANE_ID beats cwd matching, but only after it is checked
// against the live lane list AND the directory: a pane outlives the lane it
@@ -2146,7 +2146,7 @@ function printRuntime(runtime) {
}
/**
* `agenthub lanes up|down|runtime|logs|hook` — a lane's own application stack, as
* `agenthub workspace up|down|runtime|logs|hook` — a lane's own application stack, as
* opposed to `start`/`stop`, which drive its Claude run. Two lifecycles, one
* lane id.
*
@@ -2160,7 +2160,7 @@ async function cmdLanesRuntime(sub, args) {
const { laneId, rest: laneArgs } = resolved;
if (sub === "runtime") {
printRuntime(await get(`/api/lanes/${laneId}/runtime`));
printRuntime(await get(`/api/workspaces/${laneId}/runtime`));
return;
}
@@ -2168,7 +2168,7 @@ async function cmdLanesRuntime(sub, args) {
const body = {};
if (laneArgs.includes("--no-build")) body.build = false;
if (laneArgs.includes("--qc")) body.qc = true;
const result = await post(`/api/lanes/${laneId}/up`, body, { allowError: true });
const result = await post(`/api/workspaces/${laneId}/up`, body, { allowError: true });
if (result.status) {
console.error(`✖ up workspace #${laneId} → ${result.data?.error?.message || result.status}`);
process.exitCode = 1;
@@ -2181,7 +2181,7 @@ async function cmdLanesRuntime(sub, args) {
const deadline = Date.now() + 15 * 60 * 1000;
for (;;) {
await new Promise((r) => setTimeout(r, 2000));
const runtime = await get(`/api/lanes/${laneId}/runtime`);
const runtime = await get(`/api/workspaces/${laneId}/runtime`);
if (runtime.healthy) {
printRuntime(runtime);
return;
@@ -2202,7 +2202,7 @@ async function cmdLanesRuntime(sub, args) {
}
if (sub === "down") {
const result = await post(`/api/lanes/${laneId}/down`, {}, { allowError: true });
const result = await post(`/api/workspaces/${laneId}/down`, {}, { allowError: true });
if (result.status) {
console.error(
`✖ down workspace #${laneId} → ${result.data?.error?.message || result.status}`
@@ -2223,7 +2223,7 @@ async function cmdLanesRuntime(sub, args) {
}
const i = laneArgs.indexOf("--tail");
const query = i > -1 && laneArgs[i + 1] ? `?tail=${encodeURIComponent(laneArgs[i + 1])}` : "";
const log = await get(`/api/lanes/${laneId}/logs/${encodeURIComponent(svc)}${query}`);
const log = await get(`/api/workspaces/${laneId}/logs/${encodeURIComponent(svc)}${query}`);
if (!log.available) {
console.log("no logs for this workspace yet.");
return;
@@ -2241,7 +2241,7 @@ async function cmdLanesRuntime(sub, args) {
return;
}
const result = await post(
`/api/lanes/${laneId}/hook/${encodeURIComponent(name)}`,
`/api/workspaces/${laneId}/hook/${encodeURIComponent(name)}`,
{ args: laneArgs.slice(1) },
{ allowError: true }
);
@@ -2263,7 +2263,7 @@ async function cmdLanesRuntime(sub, args) {
: "merge";
const branch = laneArgs.find((arg) => !arg.startsWith("--"));
const result = await post(
`/api/lanes/${laneId}/sync-base`,
`/api/workspaces/${laneId}/sync-base`,
{ mode, branch },
{ allowError: true }
);
@@ -2342,7 +2342,7 @@ async function defaultHolder(argsAfterName) {
const resolved = await resolveLaneArg(argsAfterName);
if (!resolved) return null;
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
return lane.slot ? `lane${lane.slot}` : `lane${lane.id}`;
}
@@ -2474,7 +2474,7 @@ function fmtFeatureRow(f) {
async function cmdFeatureList(args) {
const resolved = await resolveLaneArg(args);
if (!resolved) return;
const { features } = await get(`/api/lanes/${resolved.laneId}/features`);
const { features } = await get(`/api/workspaces/${resolved.laneId}/features`);
if (!features.length) {
console.log("no features activated yet — start one with: agenthub feature activate <slug>");
return;
@@ -2496,7 +2496,7 @@ async function cmdFeatureActivate(args) {
};
const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
if (!resolved) return;
const { lane, feature } = await post(`/api/lanes/${resolved.laneId}/features/activate`, {
const { lane, feature } = await post(`/api/workspaces/${resolved.laneId}/features/activate`, {
slug,
title: flag("title"),
});
@@ -2506,13 +2506,13 @@ async function cmdFeatureActivate(args) {
}
/** `agenthub feature show <slug> [<id>] [--cwd path]` — one feature's saved pipeline. */
/** `agenthub lanes proof-link [<id>] [--cwd path]` — converge the clone-root
/** `agenthub workspace proof-link [<id>] [--cwd path]` — converge the clone-root
* `proof/` onto `.playwright-mcp/proof` (idempotent). Never run automatically
* by anything else in this codebase; a session or hook calls it explicitly. */
async function cmdLanesProofLink(args) {
const resolved = await resolveLaneArg(args);
if (!resolved) return;
const { linked } = await post(`/api/lanes/${resolved.laneId}/proof-link`);
const { linked } = await post(`/api/workspaces/${resolved.laneId}/proof-link`);
console.log(
linked
? `${c.green("✔")} linked proof/ -> .playwright-mcp/proof`
@@ -2520,13 +2520,13 @@ async function cmdLanesProofLink(args) {
);
}
/** `agenthub lanes mcp sync [<id>] [--cwd path]` — relocate the source repo's
/** `agenthub workspace mcp sync [<id>] [--cwd path]` — relocate the source repo's
* already-configured MCP servers into <lane>/.mcp.json and seed Chromium
* profiles. Never run automatically; a session calls it explicitly. */
async function cmdLanesMcpSync(args) {
const resolved = await resolveLaneArg(args);
if (!resolved) return;
const result = await post(`/api/lanes/${resolved.laneId}/mcp/sync`, {}, { allowError: true });
const result = await post(`/api/workspaces/${resolved.laneId}/mcp/sync`, {}, { allowError: true });
if (result.status) {
console.error(`✖ mcp sync → ${result.data?.error?.message || result.status}`);
process.exitCode = 1;
@@ -2538,7 +2538,7 @@ async function cmdLanesMcpSync(args) {
}
}
/** `agenthub lanes integration <name> [<id>]` — check whether a named
/** `agenthub workspace integration <name> [<id>]` — check whether a named
* integration (tracker, dev_qc, ci_wait) is turned on for this lane, per
* its .agenthub/profile/integrations.env. Exit 0 = on, 1 = off. */
async function cmdLanesIntegration(args) {
@@ -2551,13 +2551,13 @@ async function cmdLanesIntegration(args) {
const resolved = await resolveLaneArg(args.filter((a) => a !== name));
if (!resolved) return;
const { enabled } = await get(
`/api/lanes/${resolved.laneId}/integrations/${encodeURIComponent(name)}`
`/api/workspaces/${resolved.laneId}/integrations/${encodeURIComponent(name)}`
);
console.log(enabled ? "on" : "off");
process.exitCode = enabled ? 0 : 1;
}
/** `agenthub lanes ci status <branch-or-sha> [<lane-id>]` — one-shot GitHub
/** `agenthub workspace ci status <branch-or-sha> [<lane-id>]` — one-shot GitHub
* Actions status snapshot for a ref, via GET /:id/ci/status. Never polls;
* the caller (ship-feature-lane Stage 13) owns the wait loop. Exit code
* mirrors the derived state: 0 success, 1 failure or any error, 3 pending. */
@@ -2576,7 +2576,7 @@ async function cmdLanesCi(args) {
const resolved = await resolveLaneArg(args.slice(2));
if (!resolved) return;
const result = await get(
`/api/lanes/${resolved.laneId}/ci/status?ref=${encodeURIComponent(ref)}`,
`/api/workspaces/${resolved.laneId}/ci/status?ref=${encodeURIComponent(ref)}`,
undefined,
{ allowError: true }
);
@@ -2590,7 +2590,7 @@ async function cmdLanesCi(args) {
}
/**
* `agenthub lanes node [<node-id>] [extra…] [<id>] [--print]` — run ONE node of the
* `agenthub workspace node [<node-id>] [extra…] [<id>] [--print]` — run ONE node of the
* lane's pipeline instead of the whole thing.
*
* Every node already carries the one command that drives it (`command`, added
@@ -2599,7 +2599,7 @@ async function cmdLanesCi(args) {
* pasted it into a session by hand. This runs it, in the lane's own cwd:
*
* - a slash command starts `claude` with it as the prompt;
* - anything else (`npm test`, `agenthub lanes hook ci-gate`) is spawned directly.
* - anything else (`npm test`, `agenthub workspace hook ci-gate`) is spawned directly.
*
* With no node id it lists the nodes and their commands, which is also the
* error path's answer. `--print` shows the argv and runs nothing.
@@ -2621,10 +2621,10 @@ async function cmdLanesNode(args) {
const { flags, positional } = parseArgs(args);
const resolved = await resolveLaneArg(args);
if (!resolved) return;
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
// The lane payload's `pipeline_nodes` carries render state but not `aliases`;
// the template does. Merge, so the listing can still mark the current node.
const { pipelines } = await get("/api/lanes/pipelines");
const { pipelines } = await get("/api/workspaces/pipelines");
const stateOf = new Map((lane.pipeline_nodes || []).map((n) => [n.id, n.state]));
const nodes = (pipelines.find((p) => p.id === lane.pipeline)?.nodes || []).map((n) => ({
...n,
@@ -2699,8 +2699,8 @@ async function cmdLanesNode(args) {
}
/**
* `agenthub lanes pipeline [<template-id>] [<id>]` — show or switch which pipeline
* template a lane renders against. `agenthub lanes add --pipeline` could only set
* `agenthub workspace pipeline [<template-id>] [<id>]` — show or switch which pipeline
* template a lane renders against. `agenthub workspace add --pipeline` could only set
* this at creation time, so every lane added from the dashboard's "+ Add lane"
* was stuck on `default` with no way back to a 16-node template.
*/
@@ -2710,15 +2710,15 @@ async function cmdLanesPipeline(args) {
if (!resolved) return;
if (!target) {
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
const { pipelines } = await get("/api/lanes/pipelines");
const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
const { pipelines } = await get("/api/workspaces/pipelines");
console.log(`workspace #${lane.id} → ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`);
if (lane.pipeline_skill) console.log(`driver: ${lane.pipeline_skill} <requirement>`);
console.log(`available: ${pipelines.map((p) => `${p.id} (${p.nodes.length})`).join(", ")}`);
return;
}
const { lane } = await patch(`/api/lanes/${resolved.laneId}`, { pipeline: target });
const { lane } = await patch(`/api/workspaces/${resolved.laneId}`, { pipeline: target });
console.log(
`${c.green("✔")} workspace #${lane.id} → pipeline ${lane.pipeline} ` +
`(${lane.pipeline_nodes.length} nodes, stage ${lane.stage}, ${lane.progress}%)`
@@ -2746,7 +2746,7 @@ async function cmdLanesPipeline(args) {
async function cmdLanesShell(args) {
const resolved = await resolveLaneArg(args);
if (!resolved) return;
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
const sessionName = `agenthub-lane-${lane.id}`;
const child = spawn("tmux", ["new-session", "-A", "-s", sessionName, "-c", lane.cwd], {
stdio: "inherit",
@@ -2771,7 +2771,7 @@ async function cmdFeatureShow(args) {
const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
if (!resolved) return;
const result = await get(
`/api/lanes/${resolved.laneId}/features/${encodeURIComponent(slug)}`,
`/api/workspaces/${resolved.laneId}/features/${encodeURIComponent(slug)}`,
undefined,
{ allowError: true }
);
@@ -2806,7 +2806,7 @@ async function cmdStage(args) {
if (!resolved) return;
const laneId = resolved.laneId;
const { lane } = await post(`/api/lanes/${laneId}/stage`, {
const { lane } = await post(`/api/workspaces/${laneId}/stage`, {
stage,
status: flag("status"),
evidence: flag("evidence"),
@@ -3101,8 +3101,8 @@ falling back to http://127.0.0.1:4820.
${c.bold("Output:")} colors auto-enable on a TTY and turn off when piped.
Disable with --no-color or NO_COLOR=1; force with FORCE_COLOR=1 / AGENTHUB_COLOR=1.
${c.bold("Old name:")} ${c.bold("agenthub lanes …")} and ${c.bold("--lane")} still work — the same
commands as ${c.bold("agenthub workspace …")} and ${c.bold("--workspace")}.
${c.bold("Old name:")} ${c.bold("agenthub lanes …")} and ${c.bold("--lane")} were removed — use
${c.bold("agenthub workspace …")} and ${c.bold("--workspace")}.
${c.bold("Note:")} agenthub talks to the local dashboard server — API-backed commands
require it to be running (${c.bold("agenthub start")} brings one up in the background).
@@ -3512,8 +3512,8 @@ function replHelp() {
/** Detailed help for one command (or command prefix): every catalog entry
* whose invocation starts with the token. Returns false if none matched. */
function commandHelp(token) {
// `lanes`, `workspace` and `workspaces` are one command (COMMAND_ALIASES),
// so `help lanes` finds the `workspace …` rows and vice versa.
// `workspace` and `workspaces` are one command (COMMAND_ALIASES), so
// `help workspaces` finds the `workspace …` rows and vice versa.
const canon = (words) => {
const [head, ...tail] = words.split(" ");
return [COMMAND_ALIASES[head] || head, ...tail].join(" ");
@@ -3774,23 +3774,22 @@ async function cmdRepl() {
// prompt. Handlers that need the server throw ServerDownError; the caller
// decides whether to fall back to an offline reader or report the refusal.
/** Command names that are another command under the user-facing word.
* `workspace` is the name to use; `lanes` (the handler's own name) and
* `--lane` are the OLD names, still working but deprecated. Removing them is
* step 4 of docs/plans/workspace-rename/README.md — not before the third
* release after the first one that shipped these aliases. */
const COMMAND_ALIASES = { workspace: "lanes", workspaces: "lanes" };
/** Command names that are another command under the user-facing word. */
const COMMAND_ALIASES = { workspaces: "workspace" };
const canonicalArgv = (argv) =>
COMMAND_ALIASES[argv[0]] ? [COMMAND_ALIASES[argv[0]], ...argv.slice(1)] : argv;
/** One stderr line when an old name is typed, so people and agents move over
* before step 4 removes it. stderr only: scripts reading stdout see nothing. */
function warnOldNames(argv) {
const old = [];
if (argv[0] === "lanes") old.push("`agenthub lanes` → `agenthub workspace`");
if (argv.includes("--lane")) old.push("`--lane` → `--workspace`");
if (old.length) {
console.error(c.dim(`note: old name, still works for now — use ${old.join(", ")}`));
/** The old names `agenthub workspace` and `--lane` were removed in step 4 of
* docs/plans/workspace-rename/README.md. They exit 1 naming the new spelling
* — never "Unknown command" or help, so a script written before says why. */
function refuseOldNames(argv) {
if (argv[0] === "lanes") {
console.error(c.red("✖ renamed: use agenthub workspace …"));
process.exit(1);
}
if (argv.includes("--lane")) {
console.error(c.red("✖ renamed: use --workspace <id>"));
process.exit(1);
}
}
@@ -3803,7 +3802,6 @@ const COMMANDS = (() => {
for (const [name] of items) set.add(name.split(" ")[0]);
}
set.add("help");
// The old names stay typeable (and completable) for scripts written before.
for (const [alias, canonical] of Object.entries(COMMAND_ALIASES)) set.add(alias).add(canonical);
return [...set];
})();
@@ -4498,7 +4496,7 @@ async function cmdAccounts(sub, positional, flags = {}) {
* for usage/validation failures. `cmd === "repl"` is handled by the entry
* points, not here, to avoid recursive REPLs. */
async function runCommand(argv) {
warnOldNames(argv);
refuseOldNames(argv);
const [cmd, ...rest] = canonicalArgv(argv);
const { flags, positional } = parseArgs(rest);
switch (cmd) {
@@ -4571,7 +4569,7 @@ async function runCommand(argv) {
return cmdReinstallHooks();
case "update-check":
return cmdUpdateCheck();
case "lanes":
case "workspace":
if (rest[0] === "add") {
return cmdLanesAdd(rest.slice(1));
}
+3 -3
View File
@@ -49,8 +49,8 @@ Enterprise-grade React + TypeScript dashboard for real-time Claude Code agent mo
The client is a single-page application (SPA) built with modern web technologies:
- **React 18.3** - Component-based UI with hooks and concurrent features
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
- **React 19** - Component-based UI with hooks and concurrent features
- **TypeScript 7** - Full type safety across components, utilities, and API contracts
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount. The workspace terminal's font is the same kind of preference: `agenthub.terminal.font` = `jetbrains|maple|system` (`lib/terminalFont.ts`, default `jetbrains`) and `agenthub.terminal.fontSize` = 11|12|13|14|15|16|18|20 px (`lib/fontSize.ts`, default 13), picked in Settings → Appearance; a change fires `agenthub-font-size` on `window`, so an open terminal re-fits at once. Settings → Appearance → **Text size** (`#text-size`) sets `agenthub.ui.fontSize` = 14|16|17|18 (Small/Default/Large/Larger), the ROOT font size in px (`lib/fontSize.ts`; the index.html script applies it before first paint too); Tailwind's sizes are rem and `index.css` re-states every `text-[Npx]` class in rem, so all text scales with it. All of these keys are synced to the account like the theme (`PREF_KEYS` in `lib/userPrefs.ts` and `server/routes/me.js`)
@@ -1215,7 +1215,7 @@ graph TB
The client is a production-ready React application with:
- 🚀 **Modern Stack** - React 18, TypeScript, Vite, Tailwind
- 🚀 **Modern Stack** - React 19, TypeScript, Vite, Tailwind
- ⚡ **Real-time** - WebSocket integration for live updates
- 🧪 **Tested** - Vitest + React Testing Library
- 📦 **Optimized** - Code splitting, tree shaking, lazy loading
+427 -139
View File
@@ -15,18 +15,18 @@
"@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/maple-mono": "^5.3.0",
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"@xterm/addon-clipboard": "^0.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"d3": "^7.9.0",
"i18next": "^26.0.8",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.474.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"lucide-react": "^1.61.0",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"react-i18next": "^17.0.4",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.18.4",
@@ -34,17 +34,17 @@
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.1",
"@types/d3": "^7.4.3",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"jsdom": "^27.0.1",
"postcss": "^8.5.1",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"typescript": "^7.0.2",
"vite": "^6.1.0",
"vitest": "^3.2.4"
}
@@ -450,10 +450,9 @@
}
},
"node_modules/@codemirror/merge": {
"version": "6.12.2",
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.2.tgz",
"integrity": "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==",
"license": "MIT",
"version": "6.12.3",
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.3.tgz",
"integrity": "sha512-Wq9wXrp41cW/nIpi1CQPyNwD+36kg8KIX676ym0Hyfn3Dpr+XQHNAIsZYVNlaivuVB4SAASbreG3V0VXinbqZA==",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
@@ -478,10 +477,9 @@
"license": "MIT"
},
"node_modules/@codemirror/view": {
"version": "6.43.14",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.14.tgz",
"integrity": "sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==",
"license": "MIT",
"version": "6.43.15",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.15.tgz",
"integrity": "sha512-HHsfpqFeHyXLs983pSRe38Xajs0vFWZQ2BuX0m7Ki8w3jZ5iD8cuR3lBz2/5/X53Dhf32SBHKh6rtSTB6tKeGw==",
"dependencies": {
"@codemirror/state": "^6.7.0",
"crelt": "^1.0.6",
@@ -1661,7 +1659,6 @@
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz",
"integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5"
},
@@ -2108,30 +2105,21 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.31",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
"license": "MIT",
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
"integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
"@types/react": "^19.3.0"
}
},
"node_modules/@types/unist": {
@@ -2140,6 +2128,326 @@
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
"node_modules/@typescript/typescript-aix-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-loong64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-mips64el": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
"cpu": [
"mips64el"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-riscv64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-s390x": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-sunos-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@ungap/structured-clone": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz",
@@ -2282,59 +2590,38 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@xterm/addon-canvas": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-canvas/-/addon-canvas-0.7.0.tgz",
"integrity": "sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/addon-clipboard": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.1.0.tgz",
"integrity": "sha512-zdoM7p53T5sv/HbRTyp4hY0kKmEQ3MZvAvEtiXqNIHc/JdpqwByCtsTaQF5DX2n4hYdXRPO4P/eOS0QEhX1nPw==",
"license": "MIT",
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.2.0.tgz",
"integrity": "sha512-Dl31BCtBhLaUEECUbEiVcCLvLBbaeGYdT7NofB8OJkGTD3MWgBsaLjXvfGAD4tQNHhm6mbKyYkR7XD8kiZsdNg==",
"dependencies": {
"js-base64": "^3.7.5"
},
"peerDependencies": {
"@xterm/xterm": "^5.4.0"
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="
},
"node_modules/@xterm/addon-search": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.15.0.tgz",
"integrity": "sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz",
"integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA=="
},
"node_modules/@xterm/addon-web-links": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz",
"integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw=="
},
"node_modules/@xterm/addon-webgl": {
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz",
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A=="
},
"node_modules/@xterm/xterm": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT"
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="
},
"node_modules/agent-base": {
"version": "7.1.4",
@@ -3706,9 +3993,9 @@
}
},
"node_modules/i18next": {
"version": "26.4.2",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz",
"integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==",
"version": "26.5.11",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.5.11.tgz",
"integrity": "sha512-Wc0klBPKxqZ2tL2qdAnF8xvvnWgr1IMGSY2ikTiAvaFVbz7xiFARtkoU4RidBDw1SyziGROp0qxu6qnJ7l2Wug==",
"funding": [
{
"type": "individual",
@@ -3723,7 +4010,6 @@
"url": "https://www.locize.com"
}
],
"license": "MIT",
"peerDependencies": {
"typescript": "^5 || ^6 || ^7"
},
@@ -3734,12 +4020,11 @@
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
"license": "MIT",
"version": "8.2.3",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.3.tgz",
"integrity": "sha512-0jSTK3ncpXj9MFdxRz+TVTKDkSvYH8ifB/OCmUdEMprvXC3vy7kEvdKpj0yYB72B0UKEHJLPMEeRVC/UXroWnw==",
"dependencies": {
"@babel/runtime": "^7.23.2"
"@babel/runtime": "^7.29.2"
}
},
"node_modules/iconv-lite": {
@@ -3924,6 +4209,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": {
@@ -4022,18 +4308,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
@@ -4052,12 +4326,17 @@
}
},
"node_modules/lucide-react": {
"version": "0.474.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.474.0.tgz",
"integrity": "sha512-CmghgHkh0OJNmxGKWc0qfPJCYHASPMVSyGY8fj3xgk4v84ItqDg64JNKFZn5hC6E0vHi6gxnbCgwhyVB09wQtA==",
"license": "ISC",
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.61.0.tgz",
"integrity": "sha512-v2SQPPPNjnr+BlhL9Spuzmo5yR+hLkftfuE4E3jaxdNPgSH0hJKPcLJBP7yAta87jQNPsSERS59PJjCJ/CorBA==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/lz-string": {
@@ -5378,35 +5657,28 @@
"license": "MIT"
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"version": "19.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz",
"integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"version": "19.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz",
"integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==",
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
"scheduler": "^0.28.0"
},
"peerDependencies": {
"react": "^18.3.1"
"react": "^19.3.1"
}
},
"node_modules/react-i18next": {
"version": "17.0.16",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.16.tgz",
"integrity": "sha512-7uImVD9hG5yUOaP5KHVied1x0A004Di/KU031ULR35Ma+9e9H5GdnNZHgQEIByyAz+IAXBkRDTedNVzjPUH8ZA==",
"license": "MIT",
"version": "17.1.9",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.9.tgz",
"integrity": "sha512-OgYqIH13XHPlWTaaI795Dz+k2AdKnXcOxNQVnf750T8Ij0TYWp1nYvXRpoJBulPqlyk2OAbn1PM4tFKiIj/byA==",
"dependencies": {
"@babel/runtime": "^7.29.7",
"html-parse-stringify": "^4.0.1",
@@ -5755,13 +6027,9 @@
}
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="
},
"node_modules/semver": {
"version": "6.3.1",
@@ -5774,9 +6042,9 @@
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
"version": "2.7.3",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.3.tgz",
"integrity": "sha512-irZHtTqurLna57KQG/s+fzOwv/J6XSH4cs8M/vsrY3uCaQ3S6+QJguAH0WdN47SXlk82voMZ4naWMQf8fdEoIg=="
},
"node_modules/siginfo": {
"version": "2.0.0",
@@ -6183,17 +6451,37 @@
"license": "Apache-2.0"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
"tsc": "bin/tsc"
},
"engines": {
"node": ">=14.17"
"node": ">=16.20.0"
},
"optionalDependencies": {
"@typescript/typescript-aix-ppc64": "7.0.2",
"@typescript/typescript-darwin-arm64": "7.0.2",
"@typescript/typescript-darwin-x64": "7.0.2",
"@typescript/typescript-freebsd-arm64": "7.0.2",
"@typescript/typescript-freebsd-x64": "7.0.2",
"@typescript/typescript-linux-arm": "7.0.2",
"@typescript/typescript-linux-arm64": "7.0.2",
"@typescript/typescript-linux-loong64": "7.0.2",
"@typescript/typescript-linux-mips64el": "7.0.2",
"@typescript/typescript-linux-ppc64": "7.0.2",
"@typescript/typescript-linux-riscv64": "7.0.2",
"@typescript/typescript-linux-s390x": "7.0.2",
"@typescript/typescript-linux-x64": "7.0.2",
"@typescript/typescript-netbsd-arm64": "7.0.2",
"@typescript/typescript-netbsd-x64": "7.0.2",
"@typescript/typescript-openbsd-arm64": "7.0.2",
"@typescript/typescript-openbsd-x64": "7.0.2",
"@typescript/typescript-sunos-x64": "7.0.2",
"@typescript/typescript-win32-arm64": "7.0.2",
"@typescript/typescript-win32-x64": "7.0.2"
}
},
"node_modules/unified": {
+13 -13
View File
@@ -18,18 +18,18 @@
"@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/maple-mono": "^5.3.0",
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"@xterm/addon-clipboard": "^0.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"d3": "^7.9.0",
"i18next": "^26.0.8",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.474.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"lucide-react": "^1.61.0",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"react-i18next": "^17.0.4",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.18.4",
@@ -37,17 +37,17 @@
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.1",
"@types/d3": "^7.4.3",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"jsdom": "^27.0.1",
"postcss": "^8.5.1",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"typescript": "^7.0.2",
"vite": "^6.1.0",
"vitest": "^3.2.4"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 411 KiB

After

Width:  |  Height:  |  Size: 465 KiB

+1 -1
View File
@@ -49,7 +49,7 @@
<g transform="translate(20,22)"><rect width="48" height="48" rx="12" fill="#4cc3f5" fill-opacity="0.12"/><rect x="11" y="11" width="11" height="11" rx="3" fill="#4cc3f5"/><rect x="26" y="11" width="11" height="11" rx="3" fill="none" stroke="#4cc3f5" stroke-width="2.5"/><rect x="11" y="26" width="11" height="11" rx="3" fill="none" stroke="#4cc3f5" stroke-width="2.5"/><rect x="26" y="26" width="11" height="11" rx="3" fill="#4cc3f5"/></g>
<text x="84" y="44" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="21" font-weight="700" fill="#f1f5ff">Every AI coding agent</text>
<text x="84" y="72" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="16" fill="#9aa6c4">Claude Code, Codex, Antigravity,</text>
<text x="84" y="94" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="16" fill="#9aa6c4">Cursor, Kimi, Grok and more</text>
<text x="84" y="94" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="16" fill="#9aa6c4">Cursor, omp and more</text>
</g>
<g transform="translate(792,300)">
<rect width="344" height="116" rx="16" fill="#ffffff" fill-opacity="0.045" stroke="#ffffff" stroke-opacity="0.10"/>

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

@@ -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>
);
}
+8
View File
@@ -24,10 +24,18 @@ export function DocumentTitle() {
title = `${t("sessions")} · ${sessionId.slice(0, 8)}`;
} else if (path.startsWith("/sessions")) {
title = t("sessions");
} else if (path.startsWith("/snapshot/")) {
title = t("snapshot");
} else if (path.startsWith("/workspace")) {
title = t("workspace");
} else if (path.startsWith("/settings")) {
title = t("settings");
} else if (path.startsWith("/artifacts")) {
title = t("artifacts");
} else if (path.startsWith("/files")) {
title = t("files");
} else if (path.startsWith("/admin")) {
title = t("admin");
} else if (path !== "/") {
title = t("notFound");
}
+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 && (
+1 -1
View File
@@ -93,7 +93,7 @@ export const SagiAvatar = memo(function SagiAvatar({
moment = null,
}: SagiAvatarProps) {
const rootRef = useRef<SVGSVGElement | null>(null);
const rafRef = useRef<number>();
const rafRef = useRef<number | undefined>(undefined);
// Pupils follow the cursor whenever motion is allowed and the eyes are open.
// (Only `sleeping` closes them by intent; `disconnected` hides the open-eye
+3 -3
View File
@@ -197,11 +197,11 @@ export function useSagiBrain(): SagiBrain {
const [bubble, setBubbleState] = useState<SagiBubble | null>(null);
const [moment, setMoment] = useState<SagiBrain["moment"]>(null);
const [reaction, setReaction] = useState<SagiBrain["reaction"]>(null);
const reactionTimer = useRef<ReturnType<typeof setTimeout>>();
const reactionTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const [muted, setMuted] = useState<boolean>(() => sagiPrefs.getMuted());
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>();
const momentTimer = useRef<ReturnType<typeof setTimeout>>();
const bubbleTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const momentTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const lastBubbleAt = useRef(0);
const burst = useRef<Burst | null>(null);
const momentKey = useRef(0);
+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>
*/
+35 -8
View File
@@ -5,7 +5,8 @@
* — with a green dot while a newer release is out —, how many SESSIONS are
* running and how many need the user (`GET /api/sessions?status=active` and
* `?awaiting=1` — the Dashboard's Needs-you rule —, re-read on session
* messages), and today's spend — `GET /api/pricing/cost?date=<local date>` — and, on the
* messages), and today's spend — `GET /api/pricing/cost?date=<local date>&mine=1`, the
* user's OWN spend even for an admin — and, on the
* Workspace page, the open workspace's total (`?lane=` on the same call: the
* sessions it owns) and the shown session's (`GET /api/pricing/cost/:id` for
* the URL's `session`, or the focused pane's session from the page's run poll,
@@ -21,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>
*/
@@ -29,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";
@@ -50,9 +54,6 @@ const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = {
codex: "Codex",
antigravity: "Antigravity",
cursor: "Cursor",
grok: "Grok",
kimi: "Kimi",
opencode: "OpenCode Go",
};
function localDate(d = new Date()): string {
@@ -281,7 +282,7 @@ function QuotaChips() {
const [open, setOpen] = useState(false);
// Leaving the chips closes the panel only after a beat, and entering the
// panel cancels that: the pointer crosses the gap to read it.
const closeTimer = useRef<number>();
const closeTimer = useRef<number | undefined>(undefined);
const show = () => {
window.clearTimeout(closeTimer.current);
setOpen(true);
@@ -371,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;
@@ -406,7 +409,7 @@ export function StatusBar({
const gen = costGen.current;
const live = () => gen === costGen.current;
api.pricing
.totalCost({ date: localDate() })
.totalCost({ date: localDate(), mine: true })
.then((r) => live() && setToday(r))
.catch(() => undefined);
api.budgets
@@ -416,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 })
@@ -526,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} />
+85 -8
View File
@@ -19,7 +19,12 @@
* background process can do for it. Escape, the backdrop and × close it for
* this page load only — also while an update applies: it keeps running in the
* background (the page still reloads itself once the new server is up), and a
* failure re-opens the modal with its error; "Skip this version" is the one
* failure re-opens the modal with its error. While hidden — or once the status
* stops offering the update mid-run — a small chip in the corner keeps showing
* it, with the plugin chain's current step from `plugin_update_progress`, and a
* click on it shows the modal again. A chain the server reports as running
* (started before a reload or from another tab) is adopted the same way;
* "Skip this version" is the one
* persistent, per-version dismiss (never while applying). After a failed update, "Auto fix" hands the
* error to an AI agent: it opens (or adopts) a workspace on the install folder
* and starts a Claude Code session there with the error as its prompt — the
@@ -184,6 +189,8 @@ export function UpdateNotifier() {
const [applying, setApplying] = useState(false);
const [restarting, setRestarting] = useState(false);
const [fixing, setFixing] = useState(false);
/** The running plugin chain's step, from the polls while it runs. */
const [progress, setProgress] = useState<UpdateStatusPayload["plugin_update_progress"]>(null);
const navigate = useNavigate();
const syncFromPayload = useCallback((s: UpdateStatusPayload) => {
@@ -238,13 +245,14 @@ export function UpdateNotifier() {
status?.latest_release ||
status?.remote_sha ||
(installed ? `installed:${installed.root}` : null);
// Updating replaces the host's install: only an admin is offered it.
// Updating replaces the host's install: only an admin is offered it. A
// running update stays shown (unless closed) even once the status stops
// offering it — the new version may already be on disk before it restarts.
const show = Boolean(
isAdmin &&
status?.update_available &&
dismissKey &&
dismissedSha !== dismissKey &&
closedKey !== dismissKey
closedKey !== dismissKey &&
(applying || restarting || (status?.update_available && dismissedSha !== dismissKey))
);
const busy = applying || restarting || fixing;
@@ -252,9 +260,8 @@ export function UpdateNotifier() {
// poll that reloads the page keep running, and a failure re-opens it.
const closeForNow = useCallback(() => {
if (!dismissKey) return;
if (applying || restarting) toast.info(t("backgroundNote"));
setClosedKey(dismissKey);
}, [applying, restarting, dismissKey, t]);
}, [dismissKey]);
const dismiss = useCallback(() => {
if (busy || !dismissKey) return;
@@ -312,6 +319,7 @@ export function UpdateNotifier() {
api.updates
.status()
.then((s) => {
setProgress(s.plugin_update_progress ?? null);
const v = s.last_plugin_update;
const mine = !!v?.finished_at && Date.parse(v.finished_at) >= since;
// A failed step, or nothing to pull, leaves the OLD server up: it
@@ -339,9 +347,23 @@ export function UpdateNotifier() {
[syncFromPayload, t]
);
// A chain already running on the server (started before a reload, in another
// tab, or from the old bundle) is adopted: this tab shows its steps, then
// reloads onto the new server like the tab that started it.
const running = status?.plugin_update_progress ?? null;
const adoptedRef = useRef(false);
useEffect(() => {
if (!running || !isAdmin || applying || restarting || adoptedRef.current) return;
adoptedRef.current = true;
setProgress(running);
setRestarting(true);
pollUntilBack(true, status?.local_version ?? null);
}, [running, isAdmin, applying, restarting, pollUntilBack, status?.local_version]);
const applyNow = async () => {
if (applying || restarting) return;
setError(null);
setProgress(null);
setApplying(true);
try {
const result = await api.updates.apply();
@@ -392,6 +414,54 @@ export function UpdateNotifier() {
}
};
const stepText = progress?.step
? t(`pluginStep.${progress.step}`, {
index: progress.index,
total: progress.total,
defaultValue: progress.step,
})
: null;
// Hidden while it runs — closed, or the status no longer offers the update
// (the new version is already on disk): a chip keeps it in sight.
if (!show && isAdmin && (applying || restarting)) {
return (
<button
type="button"
data-testid="update-chip"
onClick={() => setClosedKey(null)}
title={t("chipHint")}
className="fixed bottom-24 right-4 z-[60] w-72 rounded-lg border border-accent/30 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">
<RefreshCw className="h-3.5 w-3.5 shrink-0 animate-spin text-accent" aria-hidden />
<span className="truncate">
{status?.latest_release
? t("chipTitle", { version: status.latest_release })
: t("updating")}
</span>
</span>
<span className="mt-1 block truncate text-[11px] text-fg-muted">
{stepText || (applying ? t("updating") : t("restartingShort"))}
</span>
{progress && progress.total > 0 ? (
<span
className="mt-1.5 block h-1 overflow-hidden rounded bg-surface-2"
role="progressbar"
aria-valuenow={progress.index}
aria-valuemin={0}
aria-valuemax={progress.total}
>
<span
className="block h-full bg-accent-solid transition-all"
style={{ width: `${(Math.max(progress.index - 1, 0) / progress.total) * 100}%` }}
/>
</span>
) : null}
</button>
);
}
if (!show || !status) return null;
const refLabel = status.remote_ref || "origin";
@@ -520,7 +590,14 @@ export function UpdateNotifier() {
{/* The plugin chain pulls a new cache directory and reinstalls
dependencies before it restarts, so it takes minutes, not
seconds — saying "restarting" there would look hung. */}
{pluginManaged ? t("pluginUpdating") : t("restarting")}
<span>
{pluginManaged ? t("pluginUpdating") : t("restarting")}
{stepText ? (
<span className="block mt-0.5 font-medium" data-testid="update-step">
{stepText}
</span>
) : null}
</span>
</div>
) : null}
+415 -10
View File
@@ -15,6 +15,16 @@
* first, no owner last, groups foldable — `collapsedOwners`); one this user
* may not change (`canEditLane`) is read only — no menu, no new session, and a
* session opens its analysis page (`/sessions/<id>`), never a pane.
* The user's own GROUPS (`lib/workspaceGroups.ts`, kept per account on the
* server like the other preferences) arrange the list under foldable headers —
* each group in its order, then "Ungrouped" — inside each owner group when
* there are owner groups. A workspace joins a group from its menu, by a drop
* on the group's header, by a drag that lands it below a row of that
* group, or by Alt+↑/↓ past the group's edge; a header is dragged onto
* another to reorder the groups, and its menu renames, moves, folds every
* group, adds a new workspace straight into it or deletes it (its
* workspaces stay, ungrouped). A folded header still shows the worst state
* of its workspaces (needs you, error, running) and marks the selected one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -30,7 +40,7 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import { ChevronDown, ChevronRight, Plus, Search } from "lucide-react";
import { ChevronDown, ChevronRight, FolderPlus, MoreHorizontal, Plus, Search } from "lucide-react";
import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
import type { Lane } from "../lib/types";
import { useLanes } from "../hooks/useLanes";
@@ -46,7 +56,31 @@ import {
writeLaneOrder,
} from "../lib/laneOrderStorage";
import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePrefs";
import { LANE_DRAG_TYPE, WorkspaceRow, isLaneDrag, workspaceName } from "./lanes/WorkspaceRow";
import {
arrangeByGroup,
assignGroup,
createGroup,
deleteGroup,
groupOf,
moveGroup,
moveGroupBefore,
pruneWorkspaceGroups,
renameGroup,
setAllFolded,
setGroupFolded,
useWorkspaceGroups,
type WorkspaceGroup,
} from "../lib/workspaceGroups";
import { confirmDialog } from "../lib/confirm";
import { ContextMenu, MenuItem, MenuSeparator } from "./ContextMenu";
import {
LANE_DRAG_TYPE,
WorkspaceRow,
isLaneDrag,
workspaceDot,
workspaceDotLabel,
workspaceName,
} from "./lanes/WorkspaceRow";
import { WorkspaceSessionList } from "./lanes/WorkspaceSessionList";
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu";
import { UserAvatar } from "./users/UserAvatar";
@@ -98,8 +132,23 @@ export function WorkspaceTree() {
);
const [addOpen, setAddOpen] = useState(false);
const [addPrefill, setAddPrefill] = useState<{ sourceRepo?: string } | null>(null);
// "New workspace in this group": the group the added workspace joins.
const [addToGroup, setAddToGroup] = useState<string | null>(null);
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
const [orderTick, setOrderTick] = useState(0);
const groups = useWorkspaceGroups();
const hasGroups = groups.groups.length > 0;
const [renamingGroup, setRenamingGroup] = useState<string | null>(null);
const [groupMenu, setGroupMenu] = useState<{
group: WorkspaceGroup;
x: number;
y: number;
} | null>(null);
// A removed workspace leaves the stored groups (they hold ids only).
const laneIdsKey = lanes.map((l) => l.id).join(",");
useEffect(() => {
if (laneIdsKey) pruneWorkspaceGroups(laneIdsKey.split(",").map(Number));
}, [laneIdsKey]);
// A slot-0 pane starts, ends or learns its session id exactly when a lane's
// status or session moves, so that is when the live-pane list is re-read;
@@ -126,8 +175,9 @@ export function WorkspaceTree() {
return () => window.removeEventListener(LIVE_RUNS_EVENT, take);
}, []);
const openAdd = (sourceRepo?: string) => {
const openAdd = (sourceRepo?: string, groupId: string | null = null) => {
setAddPrefill(sourceRepo ? { sourceRepo } : null);
setAddToGroup(groupId);
setAddOpen(true);
api.run
.cwds()
@@ -148,8 +198,9 @@ export function WorkspaceTree() {
const toolFilter =
tools.length > 1 && prefs.tool && tools.includes(prefs.tool) ? prefs.tool : null;
const sorted = useMemo(() => {
const list = sortByStoredOrder(lanes, order).filter(
(l) => !toolFilter || l.tool === toolFilter
const list = arrangeByGroup(
sortByStoredOrder(lanes, order).filter((l) => !toolFilter || l.tool === toolFilter),
groups
);
if (!grouped) return list;
// Stable: each owner's workspaces keep the stored order inside the group.
@@ -157,7 +208,7 @@ export function WorkspaceTree() {
return [...list].sort(
(a, b) => rank(a) - rank(b) || ownerShown(a).localeCompare(ownerShown(b))
);
}, [lanes, order, toolFilter, grouped, meId]);
}, [lanes, order, toolFilter, grouped, meId, groups]);
const q = query.trim().toLowerCase();
const nameHit = (l: Lane) =>
workspaceName(l).toLowerCase().includes(q) || l.cwd.toLowerCase().includes(q);
@@ -190,6 +241,26 @@ export function WorkspaceTree() {
setOrderTick((n) => n + 1);
};
// Alt+↑/↓. Past the edge of its group the workspace stays where it is on
// screen and joins the next group instead (the arrangement would otherwise
// put it straight back): last of the group above, first of the one below —
// opened if folded, so the focused row stays shown. The shown order is
// written as it stands, so the store agrees. Never across an owner's section.
const nudge = (from: number, to: number) => {
const lane = sorted[from];
const next = sorted[to];
if (lane && next && hasGroups && ownerKey(lane) === ownerKey(next)) {
const target = groupOf(groups, next.id);
if ((target?.id ?? null) !== (groupOf(groups, lane.id)?.id ?? null)) {
assignGroup(lane.id, target?.id ?? null);
if (target?.folded) setGroupFolded(target.id, false);
reorder((ids) => [...ids]);
return;
}
}
reorder((ids) => moveLane(ids, from, to));
};
// A row only starts a drag; the whole section takes the drop, so a gap — a
// workspace's session list, the space above or below a row — lands too.
// `dropAt` is the insertion point in `sorted`, drawn as a line.
@@ -223,11 +294,74 @@ export function WorkspaceTree() {
const raw = e.dataTransfer.getData(LANE_DRAG_TYPE);
// Number("") is 0: an empty payload must not move the first row.
const from = raw === "" ? NaN : Number(raw);
if (!Number.isInteger(from)) return;
const lane = sorted[from];
// Dropped on a group's header: into that group, at its top.
const header = (e.target as HTMLElement).closest<HTMLElement>("[data-group-drop]");
if (header && lane) {
const gid = header.dataset.groupDrop || null;
assignGroup(lane.id, gid);
const first = sorted.findIndex(
(l) => (groups.of[l.id] ?? null) === gid && l.id !== lane.id
);
if (first >= 0) reorder((ids) => moveBefore(ids, from, first));
return;
}
const before = dropIndexAt(e.clientY);
if (Number.isInteger(from)) reorder((ids) => moveBefore(ids, from, before));
// Elsewhere it joins the group of the row it now sits under (the line
// drawn above a header closes the section before it).
if (lane && hasGroups) {
const rest = sorted.filter((l) => l.id !== lane.id);
const at = sorted.slice(0, before).filter((l) => l.id !== lane.id).length;
const neighbour = rest[at - 1] ?? rest[0];
if (neighbour) assignGroup(lane.id, groups.of[neighbour.id] ?? null);
}
reorder((ids) => moveBefore(ids, from, before));
},
};
// A group no workspace is in yet still shows (to be renamed, or dropped on),
// just above the ungrouped ones; a search hides it.
const emptyGroups = q
? []
: groups.groups.filter((g) => !sorted.some((l) => groups.of[l.id] === g.id));
const firstUngrouped = hasGroups ? sorted.findIndex((l) => !groupOf(groups, l.id)) : -1;
const groupHeader = (g: WorkspaceGroup | null, members: Lane[], first: boolean) => (
<GroupHeader
key={g?.id ?? "none"}
group={g}
count={members.length}
// What a folded group hides: the most urgent of its workspaces, and
// whether the open one is among them.
worst={worstOf(members)}
holdsSelected={members.some((l) => l.id === selected.lane)}
onGroupDrop={(dragged) => moveGroupBefore(dragged, g?.id ?? null)}
folded={!!g?.folded && !q}
renaming={!!g && renamingGroup === g.id}
first={first}
onToggle={() => g && setGroupFolded(g.id, !g.folded)}
onMenu={(x, y) => g && setGroupMenu({ group: g, x, y })}
onRenameEnd={(name) => {
setRenamingGroup(null);
if (g && name !== null) renameGroup(g.id, name);
}}
/>
);
const newGroup = (laneIds: number[] = []) => {
const id = createGroup(t("tree.groupDefaultName"), laneIds);
setRenamingGroup(id);
};
const removeGroup = async (g: WorkspaceGroup) => {
const ok = await confirmDialog({
title: t("tree.groupDelete"),
message: t("tree.groupDeleteConfirm", { name: g.name }),
confirmLabel: t("tree.groupDelete"),
});
if (!ok) return;
deleteGroup(g.id);
};
const rename = async (lane: Lane, title: string | null) => {
setRenamingId(null);
if (title === null || title === (lane.title || "")) return;
@@ -304,6 +438,16 @@ export function WorkspaceTree() {
>
<Search className="h-3.5 w-3.5" />
</button>
<button
type="button"
data-testid="workspace-tree-new-group"
onClick={() => newGroup()}
aria-label={t("tree.newGroup")}
title={t("tree.newGroup")}
className={headBtn}
>
<FolderPlus className="h-3.5 w-3.5" />
</button>
<button
type="button"
data-testid="workspace-tree-add"
@@ -347,6 +491,16 @@ export function WorkspaceTree() {
: ownerShown(lane) || `#${lane.owner_id}`;
const groupStart = grouped && (i === 0 || ownerKey(sorted[i - 1] ?? lane) !== owner);
const groupShown = sorted.some((l) => ownerKey(l) === owner && visible(l));
// The user's own group, inside the owner's.
const ug = groupOf(groups, lane.id);
const ugKey = ug?.id ?? "";
const sameSection = (l: Lane) =>
ownerKey(l) === owner && (groupOf(groups, l.id)?.id ?? "") === ugKey;
const prev = sorted[i - 1];
const sectionStart = hasGroups && (groupStart || !prev || !sameSection(prev));
const sectionShown = sorted.some((l) => sameSection(l) && visible(l));
const sectionFolded = !!ug?.folded && !q;
const hiddenRow = folded || sectionFolded;
return (
<div key={lane.id} className="relative">
{groupStart && groupShown && (
@@ -381,8 +535,13 @@ export function WorkspaceTree() {
</span>
</button>
)}
{i === firstUngrouped && emptyGroups.map((g) => groupHeader(g, [], false))}
{sectionStart &&
sectionShown &&
!folded &&
groupHeader(ug, sorted.filter(sameSection), i === 0 || !!groupStart)}
{dropAt === i && <DropLine />}
{visible(lane) && !folded && (
{visible(lane) && !hiddenRow && (
<WorkspaceRow
lane={lane}
index={i}
@@ -398,13 +557,13 @@ export function WorkspaceTree() {
}}
onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
onNewSession={() => select(lane.id)}
onReorder={(from, to) => reorder((ids) => moveLane(ids, from, to))}
onReorder={nudge}
onMenu={(x, y) => setMenu({ lane, x, y })}
onRenameStart={() => setRenamingId(lane.id)}
onRenameEnd={(title) => void rename(lane, title)}
/>
)}
{expanded(lane.id) && !folded && (
{expanded(lane.id) && !hiddenRow && (
<WorkspaceSessionList
lane={lane}
liveRuns={liveRuns}
@@ -420,6 +579,8 @@ export function WorkspaceTree() {
</div>
);
})}
{firstUngrouped === -1 &&
emptyGroups.map((g, k) => groupHeader(g, [], k === 0 && sorted.length === 0))}
{dropAt === sorted.length && (
<div className="relative">
<DropLine />
@@ -440,8 +601,92 @@ export function WorkspaceTree() {
y={menu.y}
onClose={() => setMenu(null)}
onAction={(a) => onMenuAction(menu.lane, a)}
groups={groups.groups}
groupId={groups.of[menu.lane.id] ?? null}
onGroup={(target) => {
const id = menu.lane.id;
setMenu(null);
if (target === "new") newGroup([id]);
else assignGroup(id, target);
}}
/>
)}
{groupMenu && (
<ContextMenu
x={groupMenu.x}
y={groupMenu.y}
onClose={() => setGroupMenu(null)}
testId={`workspace-group-menu-${groupMenu.group.id}`}
>
<MenuItem
onSelect={() => {
setRenamingGroup(groupMenu.group.id);
setGroupMenu(null);
}}
>
{t("tree.groupRename")}
</MenuItem>
<MenuItem
disabled={groups.groups[0]?.id === groupMenu.group.id}
onSelect={() => {
moveGroup(groupMenu.group.id, -1);
setGroupMenu(null);
}}
>
{t("tree.groupMoveUp")}
</MenuItem>
<MenuItem
disabled={groups.groups[groups.groups.length - 1]?.id === groupMenu.group.id}
onSelect={() => {
moveGroup(groupMenu.group.id, 1);
setGroupMenu(null);
}}
>
{t("tree.groupMoveDown")}
</MenuItem>
<MenuSeparator />
<MenuItem
testId="workspace-group-add"
onSelect={() => {
const g = groupMenu.group;
setGroupMenu(null);
openAdd(undefined, g.id);
}}
>
{t("tree.groupAddWorkspace")}
</MenuItem>
<MenuItem
testId="workspace-group-fold-all"
onSelect={() => {
setGroupMenu(null);
setAllFolded(true);
}}
>
{t("tree.groupFoldAll")}
</MenuItem>
<MenuItem
testId="workspace-group-unfold-all"
onSelect={() => {
setGroupMenu(null);
setAllFolded(false);
}}
>
{t("tree.groupUnfoldAll")}
</MenuItem>
<MenuSeparator />
<MenuItem
danger
testId="workspace-group-delete"
onSelect={() => {
const g = groupMenu.group;
setGroupMenu(null);
void removeGroup(g);
}}
>
{t("tree.groupDelete")}
</MenuItem>
</ContextMenu>
)}
<Suspense fallback={null}>
{destructive && (
<DestructiveLaneModal
@@ -460,8 +705,10 @@ export function WorkspaceTree() {
onClose={() => {
setAddOpen(false);
setAddPrefill(null);
setAddToGroup(null);
}}
onAdded={(lane) => {
if (addToGroup) assignGroup(lane.id, addToGroup);
void refreshLanes();
select(lane.id);
}}
@@ -472,9 +719,167 @@ export function WorkspaceTree() {
);
}
/** What a group header's drag carries: the group's id. */
const GROUP_DRAG_TYPE = "application/x-agenthub-workspace-group";
const isGroupDrag = (e: { dataTransfer: DataTransfer | null }) =>
!!e.dataTransfer?.types?.includes(GROUP_DRAG_TYPE);
// needsYou outranks an error, an error a running agent; idle/active say nothing.
const URGENCY: Record<string, number> = { needsYou: 3, error: 2, running: 1 };
/** The most urgent of `lanes` by its dot, or null when all are at rest. */
function worstOf(lanes: Lane[]): Lane | null {
let best: Lane | null = null;
let top = 0;
for (const l of lanes) {
const u = URGENCY[workspaceDotLabel(l)] ?? 0;
if (u > top) [best, top] = [l, u];
}
return best;
}
/** A workspace's owner group: its user id, or "none". */
const ownerKey = (l: Lane) => (l.owner_id == null ? "none" : String(l.owner_id));
/**
* A section's header: a user group (foldable, menu, inline rename) or, with
* `group` null, "Ungrouped". Both take a dropped workspace (`data-group-drop`).
*/
function GroupHeader({
group,
count,
worst,
holdsSelected,
folded,
renaming,
first,
onToggle,
onMenu,
onRenameEnd,
onGroupDrop,
}: {
group: WorkspaceGroup | null;
count: number;
/** The most urgent workspace inside, drawn as its dot while folded. */
worst: Lane | null;
/** The open workspace is in this group (marked while folded). */
holdsSelected: boolean;
folded: boolean;
renaming: boolean;
first: boolean;
onToggle: () => void;
onMenu: (x: number, y: number) => void;
/** The typed name, or null when the edit was cancelled. */
onRenameEnd: (name: string | null) => void;
/** A group header (by id) dropped here: it goes before this group, or last on "Ungrouped". */
onGroupDrop: (draggedId: string) => void;
}) {
const { t } = useTranslation("lanes");
const [draft, setDraft] = useState(group?.name ?? "");
const [dropOver, setDropOver] = useState(false);
useEffect(() => {
if (renaming) setDraft(group?.name ?? "");
}, [renaming, group?.name]);
const label = group ? group.name : t("tree.ungrouped");
return (
<div
data-group-drop={group?.id ?? ""}
data-testid={`workspace-group-${group?.id ?? "none"}`}
onContextMenu={(e) => {
if (!group) return;
e.preventDefault();
onMenu(e.clientX, e.clientY);
}}
// A group (never "Ungrouped") is dragged by its header to reorder.
draggable={!!group && !renaming}
onDragStart={(e) => {
if (!group) return;
e.dataTransfer.setData(GROUP_DRAG_TYPE, group.id);
e.dataTransfer.effectAllowed = "move";
}}
onDragOver={(e) => {
if (!isGroupDrag(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDropOver(true);
}}
onDragLeave={() => setDropOver(false)}
onDrop={(e) => {
setDropOver(false);
if (!isGroupDrag(e)) return;
e.preventDefault();
const id = e.dataTransfer.getData(GROUP_DRAG_TYPE);
if (id) onGroupDrop(id);
}}
className={`group/gh flex items-center gap-1 rounded px-1 py-0.5 text-[11px] font-semibold text-fg-muted ${first ? "" : "mt-1.5"} ${dropOver ? "shadow-[inset_0_2px_0_rgb(var(--accent))]" : ""}`}
>
{renaming && group ? (
<input
autoFocus
value={draft}
maxLength={60}
aria-label={t("tree.groupNameLabel")}
onChange={(e) => setDraft(e.target.value)}
onFocus={(e) => e.currentTarget.select()}
onBlur={() => onRenameEnd(draft)}
onKeyDown={(e) => {
if (e.key === "Enter") onRenameEnd(draft);
else if (e.key === "Escape") onRenameEnd(null);
}}
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-1 py-0.5 text-[11px] font-normal text-fg-primary"
/>
) : (
<button
type="button"
aria-expanded={group ? !folded : undefined}
disabled={!group}
title={group ? t("tree.groupToggle", { name: label }) : undefined}
onClick={onToggle}
className="flex min-w-0 flex-1 items-center gap-1 text-left hover:text-fg-primary disabled:hover:text-fg-muted"
>
{group ? (
folded ? (
<ChevronRight className="h-3 w-3 shrink-0" />
) : (
<ChevronDown className="h-3 w-3 shrink-0" />
)
) : (
<span className="w-3 shrink-0" />
)}
<span
className={`truncate uppercase tracking-wide ${folded && holdsSelected ? "text-accent" : ""}`}
>
{label}
</span>
<span className="ml-1 shrink-0 font-normal tabular-nums">{count}</span>
{folded && worst && (
<span
data-testid={`workspace-group-state-${group?.id ?? "none"}`}
className="ml-1 flex shrink-0 items-center"
>
<span aria-hidden className={`h-1.5 w-1.5 rounded-full ${workspaceDot(worst)}`} />
<span className="sr-only">{t(`row.state.${workspaceDotLabel(worst)}`)}</span>
</span>
)}
</button>
)}
{group && !renaming && (
<button
type="button"
aria-label={t("tree.groupMenu")}
title={t("tree.groupMenu")}
onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
onMenu(r.left, r.bottom);
}}
className="rounded p-0.5 opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover/gh:opacity-100"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
)}
</div>
);
}
/** Where a dragged workspace would land: a line on its parent's top edge. */
function DropLine() {
return (
@@ -13,7 +13,7 @@ import { AgentCard } from "../AgentCard";
import type { Agent } from "../../lib/types";
import { formatModelName, fmtCost } from "../../lib/format";
function renderCard(element: JSX.Element) {
function renderCard(element: React.JSX.Element) {
return render(<MemoryRouter>{element}</MemoryRouter>);
}
@@ -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("")
);
});
});
@@ -34,5 +34,13 @@ describe("DocumentTitle", () => {
expect(titleFor("/")).toMatch(/^Dashboard · /);
expect(titleFor("/sessions")).toMatch(/^Sessions · /);
expect(titleFor("/settings")).toMatch(/^Settings · /);
expect(titleFor("/artifacts")).toMatch(/^Artifacts · /);
expect(titleFor("/files")).toMatch(/^Files · /);
expect(titleFor("/admin")).toMatch(/^Administration · /);
expect(titleFor("/snapshot/abc")).toMatch(/^Snapshot · /);
});
it("titles an unknown route as not found", () => {
expect(titleFor("/nope")).toMatch(/^Not found · /);
});
});
@@ -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: [
@@ -151,7 +165,7 @@ import { api } from "../../lib/api";
import { StatusBar } from "../StatusBar";
import { LIVE_RUNS_EVENT } from "../WorkspaceTree";
const at = (url: string, ui: JSX.Element) =>
const at = (url: string, ui: React.JSX.Element) =>
render(<MemoryRouter initialEntries={[url]}>{ui}</MemoryRouter>);
describe("StatusBar", () => {
@@ -162,7 +176,7 @@ describe("StatusBar", () => {
const conn = screen.getByRole("button", { name: /Live/ });
expect(conn).toHaveTextContent(`v${__APP_VERSION__}`);
const cost = await screen.findByText("Today $1.23 (12.3M Tokens)");
expect(api.pricing.totalCost).toHaveBeenCalledWith({ date: today() });
expect(api.pricing.totalCost).toHaveBeenCalledWith({ date: today(), mine: true });
expect(cost).toHaveAttribute(
"title",
"Claude Opus 4.8: 12.0M · $1.20\nClaude Haiku 4.5: 300.0K · $0.03"
@@ -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();
@@ -312,6 +312,91 @@ describe("UpdateNotifier — closing vs skipping", () => {
});
});
describe("UpdateNotifier — hidden while it runs", () => {
it("keeps a chip with the chain's step in sight and re-opens from it", async () => {
const PLUGIN: UpdateStatusPayload = {
...BASE_STATUS,
situation: "plugin_managed",
install_kind: "plugin",
plugin_marketplace: "sg",
plugin_name: "agenthub",
commits_behind: 0,
local_version: "1.5.0",
release_check: "ok",
latest_release: "v1.6.0",
};
statusMock.mockResolvedValue(PLUGIN);
applyMock.mockResolvedValue({ ...PLUGIN, applied: true, mode: "plugin" });
render(<UpdateNotifier />);
await userEvent.click(await screen.findByText(i18n.t("updates:updateNow")));
await screen.findAllByText(i18n.t("updates:pluginUpdating"));
statusMock.mockResolvedValue({
...PLUGIN,
plugin_update_progress: {
step: "plugin_update",
index: 2,
total: 3,
started_at: new Date().toISOString(),
},
});
const step = i18n.t("updates:pluginStep.plugin_update", { index: 2, total: 3 });
expect(await screen.findByTestId("update-step", {}, { timeout: 4000 })).toHaveTextContent(step);
await userEvent.keyboard("{Escape}");
const chip = await screen.findByTestId("update-chip");
expect(chip).toHaveTextContent(i18n.t("updates:chipTitle", { version: "v1.6.0" }));
expect(chip).toHaveTextContent(step);
await userEvent.click(chip);
expect(
await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" }))
).toBeInTheDocument();
expect(screen.queryByTestId("update-chip")).toBeNull();
});
it("adopts a chain the server reports as running, and keeps it once the update is no longer offered", async () => {
const running: UpdateStatusPayload = {
...BASE_STATUS,
situation: "plugin_managed",
install_kind: "plugin",
plugin_marketplace: "sg",
plugin_name: "agenthub",
commits_behind: 0,
local_version: "1.5.0",
release_check: "ok",
latest_release: "v1.6.0",
// The new version is already installed: the status no longer offers it.
update_available: false,
plugin_update_progress: {
step: "bootstrap",
index: 3,
total: 3,
started_at: new Date().toISOString(),
},
};
statusMock.mockResolvedValue(running);
render(<UpdateNotifier />);
const step = i18n.t("updates:pluginStep.bootstrap", { index: 3, total: 3 });
expect(await screen.findByTestId("update-step")).toHaveTextContent(step);
expect(screen.queryByText(i18n.t("updates:updateNow"))).toBeNull();
await userEvent.keyboard("{Escape}");
const chip = await screen.findByTestId("update-chip");
expect(chip).toHaveTextContent(step);
await userEvent.click(chip);
expect(await screen.findByTestId("update-step")).toHaveTextContent(step);
});
it("shows no chip when nothing is running", async () => {
render(<UpdateNotifier />);
await screen.findByText(i18n.t("updates:title"));
await userEvent.keyboard("{Escape}");
await waitFor(() =>
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
);
expect(screen.queryByTestId("update-chip")).toBeNull();
});
});
describe("UpdateNotifier — Auto fix", () => {
it("offers no Not now button, and Auto fix only once an update failed", async () => {
applyMock.mockRejectedValue(new Error("Command failed: git merge --ff-only v2.8.0"));
@@ -3,10 +3,13 @@
* @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, and the status dot.
* search, the row menu's items by kind and tool, the status dot, and the
* user's own groups (menu, Alt+arrow past a group's edge, a folded group's
* state dot, dragging a header to reorder, fold all).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -91,6 +94,20 @@ vi.mock("../../lib/api", async (importOriginal) => {
};
});
// The Add modal reports the workspace it made (id 2 here); the tree does the rest.
vi.mock("../lanes/AddLaneModal", () => ({
AddLaneModal: ({ onAdded, onClose }: { onAdded: (l: Lane) => void; onClose: () => void }) => (
<button
type="button"
data-testid="stub-add-lane"
onClick={() => {
onAdded(lane(2, "/r/sub"));
onClose();
}}
/>
),
}));
import { api } from "../../lib/api";
import { WorkspaceTree } from "../WorkspaceTree";
import { workspaceDot } from "../lanes/WorkspaceRow";
@@ -209,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 });
});
@@ -576,6 +595,185 @@ describe("WorkspaceTree", () => {
menu = screen.getByTestId("workspace-row-menu-3");
expect(within(menu).getByText("Reset worktree…")).toBeInTheDocument();
});
it("groups workspaces under named, foldable headers, ungrouped last", async () => {
const user = userEvent.setup();
renderTree();
await screen.findByTestId("workspace-row-1");
// No group yet: no headers at all.
expect(screen.queryByTestId("workspace-group-none")).toBeNull();
// A new group from the row menu holds that workspace and asks for a name.
await user.click(screen.getByTestId("workspace-menu-3"));
await user.click(screen.getByTestId("workspace-move-group-new"));
const name = await screen.findByRole("textbox", { name: "Group name" });
await user.clear(name);
await user.type(name, "Backend{Enter}");
const stored = JSON.parse(localStorage.getItem("agenthub.workspace.groups")!);
expect(stored.groups).toEqual([expect.objectContaining({ name: "Backend" })]);
const gid = stored.groups[0].id as string;
expect(stored.of).toEqual({ 3: gid });
await waitFor(() =>
expect(rowIds()).toEqual(["workspace-row-3", "workspace-row-1", "workspace-row-2"])
);
expect(screen.getByTestId(`workspace-group-${gid}`)).toHaveTextContent("Backend1");
expect(screen.getByTestId("workspace-group-none")).toHaveTextContent("Ungrouped2");
// Another workspace joins it from its menu.
await user.click(screen.getByTestId("workspace-menu-2"));
await user.click(screen.getByTestId(`workspace-move-group-${gid}`));
await waitFor(() =>
expect(rowIds()).toEqual(["workspace-row-2", "workspace-row-3", "workspace-row-1"])
);
// Folding hides its rows; the ungrouped one stays.
await user.click(
within(screen.getByTestId(`workspace-group-${gid}`)).getByRole("button", {
expanded: true,
})
);
expect(rowIds()).toEqual(["workspace-row-1"]);
// Deleting the group only ungroups: every workspace is still listed.
fireEvent.contextMenu(screen.getByTestId(`workspace-group-${gid}`));
await user.click(screen.getByTestId("workspace-group-delete"));
await waitFor(() => expect(rowIds()).toHaveLength(3));
expect(screen.queryByTestId("workspace-group-none")).toBeNull();
expect(api.lanes.update).not.toHaveBeenCalled();
});
it("shows a new, empty group to be named, above the ungrouped workspaces", async () => {
const user = userEvent.setup();
renderTree();
await screen.findByTestId("workspace-row-1");
await user.click(screen.getByTestId("workspace-tree-new-group"));
const name = await screen.findByRole("textbox", { name: "Group name" });
await user.clear(name);
await user.type(name, "Later{Enter}");
const gid = JSON.parse(localStorage.getItem("agenthub.workspace.groups")!).groups[0].id;
const header = screen.getByTestId(`workspace-group-${gid}`);
expect(header).toHaveTextContent("Later0");
expect(
header.compareDocumentPosition(screen.getByTestId("workspace-group-none")) &
Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
expect(rowIds()).toHaveLength(3);
});
});
describe("WorkspaceTree groups", () => {
// Groups A = [1], B = [3]; 2 ungrouped. Stored order 1, 2, 3.
const seed = (extra: { folded?: boolean } = {}) =>
localStorage.setItem(
"agenthub.workspace.groups",
JSON.stringify({
groups: [
{ id: "ga", name: "Alpha", ...extra },
{ id: "gb", name: "Beta" },
],
of: { 1: "ga", 3: "gb" },
})
);
const stored = () => JSON.parse(localStorage.getItem("agenthub.workspace.groups")!);
it("moves a workspace into the next group with Alt+arrow past its edge", async () => {
seed();
const user = userEvent.setup();
renderTree();
await waitFor(() =>
expect(rowIds()).toEqual(["workspace-row-1", "workspace-row-3", "workspace-row-2"])
);
// The last of Alpha, Alt+↓: the first of Beta.
screen.getByTestId("workspace-open-1").focus();
await user.keyboard("{Alt>}{ArrowDown}{/Alt}");
expect(stored().of).toEqual({ 1: "gb", 3: "gb" });
await waitFor(() =>
expect(rowIds()).toEqual(["workspace-row-1", "workspace-row-3", "workspace-row-2"])
);
expect(screen.getByTestId("workspace-group-gb")).toHaveTextContent("Beta2");
// The last of Beta, Alt+↓: out into the ungrouped ones.
screen.getByTestId("workspace-open-3").focus();
await user.keyboard("{Alt>}{ArrowDown}{/Alt}");
expect(stored().of).toEqual({ 1: "gb" });
expect(api.lanes.update).not.toHaveBeenCalled();
});
it("shows a folded group's most urgent state and marks the open workspace", async () => {
lanes[0] = lane(1, "/r", { title: "Repo", status: "running" });
seed({ folded: true });
renderTree("/workspace?lane=1");
await waitFor(() => expect(rowIds()).toEqual(["workspace-row-3", "workspace-row-2"]));
const state = screen.getByTestId("workspace-group-state-ga");
expect(state).toHaveTextContent("running");
expect(within(screen.getByTestId("workspace-group-ga")).getByText("Alpha")).toHaveClass(
"text-accent"
);
// An open group shows its rows instead.
expect(screen.queryByTestId("workspace-group-state-gb")).toBeNull();
});
it("reorders groups by dragging a header onto another, last onto Ungrouped", async () => {
seed();
renderTree();
await screen.findByTestId("workspace-group-gb");
const data = new Map<string, string>();
const dataTransfer = {
get types() {
return [...data.keys()];
},
setData: (k: string, v: string) => data.set(k, v),
getData: (k: string) => data.get(k) ?? "",
dropEffect: "move",
effectAllowed: "move",
};
fireEvent.dragStart(screen.getByTestId("workspace-group-gb"), { dataTransfer });
fireEvent.dragOver(screen.getByTestId("workspace-group-ga"), { dataTransfer });
fireEvent.drop(screen.getByTestId("workspace-group-ga"), { dataTransfer });
expect(stored().groups.map((g: { id: string }) => g.id)).toEqual(["gb", "ga"]);
await waitFor(() =>
expect(rowIds()).toEqual(["workspace-row-3", "workspace-row-1", "workspace-row-2"])
);
data.clear();
fireEvent.dragStart(screen.getByTestId("workspace-group-gb"), { dataTransfer });
fireEvent.drop(screen.getByTestId("workspace-group-none"), { dataTransfer });
expect(stored().groups.map((g: { id: string }) => g.id)).toEqual(["ga", "gb"]);
// A header drag never moves a workspace.
expect(stored().of).toEqual({ 1: "ga", 3: "gb" });
});
it("puts a workspace added from a group's menu into that group", async () => {
seed();
const user = userEvent.setup();
renderTree();
await screen.findByTestId("workspace-group-gb");
fireEvent.contextMenu(screen.getByTestId("workspace-group-gb"));
await user.click(screen.getByTestId("workspace-group-add"));
await user.click(await screen.findByTestId("stub-add-lane"));
expect(stored().of).toEqual({ 1: "ga", 2: "gb", 3: "gb" });
// The header's own Add belongs to no group: what it adds stays where it is.
act(() =>
localStorage.setItem(
"agenthub.workspace.groups",
JSON.stringify({ ...stored(), of: { 1: "ga" } })
)
);
await user.click(screen.getByTestId("workspace-tree-add"));
await user.click(await screen.findByTestId("stub-add-lane"));
expect(stored().of).toEqual({ 1: "ga" });
});
it("folds and unfolds every group from a header's menu", async () => {
seed();
const user = userEvent.setup();
renderTree();
await screen.findByTestId("workspace-group-ga");
fireEvent.contextMenu(screen.getByTestId("workspace-group-ga"));
await user.click(screen.getByTestId("workspace-group-fold-all"));
await waitFor(() => expect(rowIds()).toEqual(["workspace-row-2"]));
fireEvent.contextMenu(screen.getByTestId("workspace-group-gb"));
await user.click(screen.getByTestId("workspace-group-unfold-all"));
await waitFor(() => expect(rowIds()).toHaveLength(3));
});
});
describe("workspaceDot", () => {
+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>
);
}
@@ -78,7 +78,12 @@ import { useSpin } from "../../hooks/useSpin";
import { useOnScreen, isShown } from "../../hooks/useOnScreen";
import { throttle } from "../../lib/throttle";
import { useTranslation } from "react-i18next";
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
import type {
TranscriptMessage,
TranscriptInfo,
TranscriptResult,
WSMessage,
} from "../../lib/types";
// Catch-up poll interval. Claude Code only fires hooks on PreToolUse /
// PostToolUse / Stop, which means a user-typed message (no hook) and any
@@ -175,6 +180,8 @@ function ConversationViewPane({
initialTranscriptId ?? null
);
const [hasMore, setHasMore] = useState(false);
// The edits of the turn the oldest loaded page starts inside of (`turn_edits`).
const [leadEdits, setLeadEdits] = useState<TranscriptResult["turn_edits"]>(undefined);
const [error, setError] = useState<string | null>(null);
const [transcripts, setTranscripts] = useState<TranscriptInfo[]>([]);
const [showNewMsg, setShowNewMsg] = useState(false);
@@ -283,6 +290,7 @@ function ConversationViewPane({
if (cancelled) return;
setNotStarted(false);
setMessages(result.messages);
setLeadEdits(result.turn_edits);
setTotal(result.total);
setHasMore(result.has_more);
lastLineRef.current = result.last_line;
@@ -343,6 +351,7 @@ function ConversationViewPane({
// Seed the view in a single render so the user sees the whole
// catch-up batch instead of a blank panel followed by a partial one.
setMessages(result.messages);
setLeadEdits(result.turn_edits);
firstLineRef.current = result.first_line;
setHasMore(result.has_more);
} else {
@@ -487,6 +496,7 @@ function ConversationViewPane({
firstLineRef.current = result.first_line;
setMessages((prev) => [...result.messages, ...prev]);
setLeadEdits(result.turn_edits);
setHasMore(result.has_more);
// Preserve scroll position (don't jump to top)
@@ -686,6 +696,7 @@ function ConversationViewPane({
stopped={stopped}
agentLinks={agentLinks}
compact={compact}
leadEdits={leadEdits}
sessionId={
selectedTranscript && selectedTranscript !== "main" ? undefined : sessionId
}
@@ -172,6 +172,9 @@ interface MessageListProps {
/** A subagent's conversation inside its step: tight spacing, and the task
* the orchestrator gave it reads as the prompt it is. */
compact?: boolean;
/** The files the first, cut-off turn changed before `messages` begins (the
* transcript page's `turn_edits`): counted in that turn's card. */
leadEdits?: TranscriptContent["edits"];
}
/** Build a map from tool_use id → tool_result for matching */
@@ -491,6 +494,10 @@ type Item =
| { kind: "command"; key: string; name: string | null; output: string | null; error: boolean };
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
// A subagent's result carries the files IT changed (`edits`): they count too.
const AGENT_TOOLS = new Set(["Agent", "Task"]);
// The stand-in for the part of a turn the page cut off: its `edits` count.
const LEAD = "__lead_edits";
/** A line that is only Claude Code's local-command plumbing — the caveat it
* writes first, the command, what the command printed — or null for
@@ -515,11 +522,18 @@ export function localCommand(
return { name, output, error };
}
export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[] {
export function chatItems(
messages: TranscriptMessage[],
keys: string[],
leadEdits?: TranscriptContent["edits"]
): Item[] {
const items: Item[] = [];
// The file-changing calls of the turn so far, closed into one card when the
// human speaks again (or the list ends).
let edits: TranscriptContent[] = [];
// human speaks again (or the list ends) — starting with what the page's
// cut-off first turn changed before it.
let edits: TranscriptContent[] = leadEdits?.length
? [{ type: "tool_use", name: LEAD, edits: leadEdits }]
: [];
// The turn's prompt time, its newest assistant line and its output so far,
// for the "Done in …" line under a turn that ended.
let start: string | null = null;
@@ -592,7 +606,11 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
if (msg.usage) output.add(msg.usage);
msg.content.forEach((block, b) => {
const last = items[items.length - 1];
if (block.type === "tool_use" && EDIT_TOOLS.has(block.name ?? "")) edits.push(block);
if (
block.type === "tool_use" &&
(EDIT_TOOLS.has(block.name ?? "") || AGENT_TOOLS.has(block.name ?? ""))
)
edits.push(block);
// A question to the user is a turn of the conversation, not hidden work.
if (block.type === "tool_use" && block.name === "AskUserQuestion") {
items.push({ kind: "ask", key: `${key}:${b}`, block });
@@ -672,19 +690,38 @@ function EditedFilesCard({
const files = new Map<string, { added: number; removed: number; counted: boolean }>();
// Written whole in this turn (a Write): a component made here is an artifact.
const created = new Set<string>();
// Changed only by a subagent: listed, never an artifact card — the chat
// rebuilds an artifact from this transcript's Write/Edit inputs, which a
// subagent's are not.
const byAgent = new Set<string>();
for (const b of blocks) {
const r0 = b.id ? toolResultMap.get(b.id) : undefined;
if (AGENT_TOOLS.has(b.name ?? "") || b.name === LEAD) {
for (const e of (b.name === LEAD ? b.edits : r0?.edits) ?? []) {
if (!files.has(e.path)) byAgent.add(e.path);
const f = files.get(e.path) ?? { added: 0, removed: 0, counted: false };
f.added += e.added;
f.removed += e.removed;
f.counted ||= e.counted;
files.set(e.path, f);
}
continue;
}
const input = (b.input ?? {}) as Record<string, unknown>;
const p = input.file_path ?? input.notebook_path;
const r = b.id ? toolResultMap.get(b.id) : undefined;
if (typeof p !== "string" || !r || r.is_error) continue;
if (b.name === "Write") created.add(p);
byAgent.delete(p);
const f = files.get(p) ?? { added: 0, removed: 0, counted: false };
f.added += r.diff?.added ?? 0;
f.removed += r.diff?.removed ?? 0;
f.counted ||= !!r.diff;
files.set(p, f);
}
const artifacts = [...files.keys()].filter((p) => isArtifact(p, created.has(p)));
const artifacts = [...files.keys()].filter(
(p) => !byAgent.has(p) && isArtifact(p, created.has(p))
);
const cards = artifacts.length > 0 && (
<div data-testid="artifact-cards" className="flex flex-col gap-2">
{artifacts.map((p) => (
@@ -773,6 +810,7 @@ function MessageListView({
sessionId,
agentLinks,
compact = false,
leadEdits,
}: MessageListProps) {
const { t } = useTranslation("sessions");
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
@@ -798,9 +836,10 @@ function MessageListView({
m.sender === "orchestrator" ? { ...m, sender: "user" as const } : m
)
: messages,
keys
keys,
leadEdits
),
[messages, keys, compact]
[messages, keys, compact, leadEdits]
);
const agents = useMemo(
() => (live ? backgroundAgents(messages, toolResultMap) : []),
@@ -4,7 +4,10 @@
* Desktop's "Edited N files" card: one row per file with the lines added and
* removed summed over the turn, a failed call left out, a row click handed
* to the caller, and a row (or the header, every file) dragged as `@path`
* attachments for a prompt box.
* attachments for a prompt box. Files a subagent changed (its Agent
* result's `edits`, read from its own transcript) are counted in the same
* card, never as an artifact; so are the files a turn changed before the
* loaded page began (`leadEdits`, the page's `turn_edits`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
@@ -127,4 +130,71 @@ describe("the Edited N files card", () => {
JSON.parse(all["application/x-agenthub-attach"]!).map((a: { path: string }) => a.path)
).toEqual(["@/w/a.ts", "@/w/b.json"]);
});
it("counts the files a subagent changed, beside the turn's own edits", () => {
render(
<MessageList
loading={false}
messages={[
user("run the task"),
call("1", "Edit", "/w/a.go"),
result("1", { added: 1, removed: 0 }),
{
type: "assistant",
sender: "assistant",
timestamp: at,
content: [{ type: "tool_use", id: "2", name: "Agent", input: { prompt: "do it" } }],
} as TranscriptMessage,
{
type: "user",
sender: "tool",
timestamp: at,
content: [
{
type: "tool_result",
id: "2",
output: "done",
agent: { id: "x1" },
edits: [
{ path: "/w/a.go", added: 4, removed: 2, counted: true },
{ path: "/w/b_test.go", added: 9, removed: 0, counted: true },
{ path: "/w/report.html", added: 5, removed: 0, counted: true },
],
},
],
} as TranscriptMessage,
user("thanks"),
]}
/>
);
const card = screen.getByTestId("edited-files");
expect(card.textContent).toContain("Edited 3 files");
expect(within(card).getByText("b_test.go")).toBeTruthy();
expect(within(card).getByTitle("/w/a.go").textContent).toContain("+5"); // a.go: 1 + 4
// A page only a subagent wrote is listed, not shown as an artifact card.
expect(within(card).getByText("report.html")).toBeTruthy();
expect(screen.queryByTestId("artifact-card")).toBeNull();
});
it("adds what the cut-off first turn changed before the page to that turn's card", () => {
render(
<MessageList
loading={false}
leadEdits={[{ path: "/w/rbac_test.go", added: 176, removed: 0, counted: true }]}
messages={[
call("1", "Write", "/w/handler_test.go"),
result("1", { added: 113, removed: 0 }),
user("what changed?"),
call("2", "Edit", "/w/x.go"),
result("2", { added: 1, removed: 0 }),
]}
/>
);
const [first, second] = screen.getAllByTestId("edited-files");
expect(first!.textContent).toContain("Edited 2 files");
expect(within(first!).getByText("rbac_test.go")).toBeTruthy();
expect(within(first!).getByText("handler_test.go")).toBeTruthy();
// The next turn's card is its own.
expect(second!.textContent).toContain("Edited 1 file");
});
});
+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>
+40 -26
View File
@@ -5,8 +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 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>
*/
@@ -14,10 +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 { startClone } from "../../lib/cloneJobs";
type Login = { host: string; username: string; token: string; remember: boolean };
@@ -25,13 +29,11 @@ export function CloneHereDialog({
open,
parent,
onClose,
onCloned,
}: {
open: boolean;
/** The absolute folder the clone goes into. */
parent: string;
onClose: () => void;
onCloned: (lane: Lane, path: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
const panelRef = useRef<HTMLDivElement>(null);
@@ -42,7 +44,7 @@ export function CloneHereDialog({
const [sshRefused, setSshRefused] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useDialog(open, panelRef, busy ? () => {} : onClose);
useDialog(open, panelRef, onClose);
useEffect(() => {
if (!open) return;
@@ -55,6 +57,7 @@ export function CloneHereDialog({
}, [open, parent]);
if (!open) return null;
const folder = name.trim() || cloneDirName(url);
const into = `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
@@ -65,21 +68,25 @@ export function CloneHereDialog({
setError(null);
setSshRefused(false);
try {
const result = await api.lanes.clone({
url: u,
parent,
name: name.trim() || undefined,
...(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,
},
}
: {}),
},
});
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;
@@ -104,7 +111,7 @@ export function CloneHereDialog({
return createPortal(
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
onClick={busy ? undefined : onClose}
onClick={onClose}
// A portal still bubbles through React: keep keys away from the File
// Explorer's shortcuts (Backspace = up a folder, Delete = delete).
onKeyDown={(e) => e.stopPropagation()}
@@ -119,7 +126,10 @@ export function CloneHereDialog({
className="w-full max-w-lg rounded-xl border border-border bg-surface-1 p-4 shadow-2xl shadow-black/50"
onClick={(e) => e.stopPropagation()}
>
<h2 id={titleId} className="mb-3 flex items-center gap-2 text-sm font-semibold text-fg-primary">
<h2
id={titleId}
className="mb-3 flex items-center gap-2 text-sm font-semibold text-fg-primary"
>
<FolderGit2 className="h-4 w-4" />
{t("fx.cloneRepo")}
</h2>
@@ -162,7 +172,10 @@ export function CloneHereDialog({
</p>
</div>
{login && (
<div data-testid="fx-clone-login" className="space-y-2 rounded-md border border-border p-2">
<div
data-testid="fx-clone-login"
className="space-y-2 rounded-md border border-border p-2"
>
<input
aria-label={t("clone.username")}
placeholder={t("clone.username")}
@@ -203,8 +216,9 @@ 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} disabled={busy} className="btn-ghost text-xs">
<button type="button" onClick={onClose} className="btn-ghost text-xs">
{t("fx.cancel")}
</button>
<button
@@ -213,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">
+137 -57
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
@@ -85,16 +89,22 @@ import {
type FsTrashItem,
type FsHome,
type FsPlace,
type GitSource,
isHomePlace,
} 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";
@@ -165,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([
@@ -224,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 (;;) {
@@ -283,7 +296,7 @@ export function FileBrowser({
/** A home to open on (`me`, or another user's for an admin). */
initialHome?: string | null;
session?: string | null;
source?: number | null;
source?: GitSource | null;
initialPath?: string;
/** Called after anything changed files, so a tree elsewhere can re-read. */
onChanged?: () => void;
@@ -320,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;
@@ -362,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
@@ -432,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)
@@ -448,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 ?? [];
@@ -460,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);
};
@@ -497,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 = () => {
@@ -606,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) {
@@ -617,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;
@@ -640,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;
@@ -660,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)
);
@@ -673,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;
@@ -684,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;
@@ -708,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();
}
};
@@ -916,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"
@@ -943,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) ||
@@ -969,19 +1037,17 @@ export function FileBrowser({
onClick={() => setMenu(null)}
data-testid="file-explorer"
>
{cloneParent && (
<CloneHereDialog
open={cloning}
parent={cloneParent}
onClose={() => setCloning(false)}
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")}>
@@ -1628,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 && (
@@ -1654,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))}>
@@ -1881,7 +1931,7 @@ export function FileExplorer({
onClose: () => void;
laneId: number;
session?: string | null;
source?: number | null;
source?: GitSource | null;
initialPath?: string;
onChanged?: () => void;
}) {
@@ -1914,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,
+293 -271
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,
@@ -103,7 +125,7 @@ import {
Trash2,
UnfoldVertical,
} from "lucide-react";
import { api, gitTarget } from "../../lib/api";
import { api, gitTarget, type GitSource } from "../../lib/api";
import { usePermissions } from "../AuthGate";
import { pasteToLane } from "../../lib/panePaste";
import { readMarkdownPreview } from "../../lib/markdownView";
@@ -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,
@@ -827,7 +815,7 @@ export function LaneChangesPanel({
session?: string;
/** Show and act on that extra folder's repo instead (`lane_sources` id);
* wins over `session`, and is keyed on like it. */
source?: number;
source?: GitSource;
/** Open the Add-lane flow for a sibling worktree; see `LaneGitBar`. */
onNewLane?: (branch?: string) => void;
/** The panel has the whole width: the file tree becomes a sidebar and a
@@ -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,14 +8,15 @@
* (`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>
*/
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronUp } from "lucide-react";
import { api, gitTarget } from "../../lib/api";
import { api, gitTarget, type GitSource } from "../../lib/api";
import {
applyChoices,
parseConflicts,
@@ -42,12 +43,15 @@ 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;
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
source?: number;
source?: GitSource;
path: string;
/** The file left the conflict (resolved, or a whole side taken). */
onResolved: () => void;
@@ -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>
+179 -22
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>
*/
@@ -38,11 +50,13 @@ import {
Trash2,
Upload,
} from "lucide-react";
import { api, gitTarget, type GitAuthNeeded } from "../../lib/api";
import { api, gitTarget, type GitSource, type GitAuthNeeded } from "../../lib/api";
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";
@@ -98,7 +112,7 @@ export function LaneGitBar({
/** Work in that session's own worktree (see `gitTarget`). */
session?: string;
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
source?: number;
source?: GitSource;
git: LaneGitFacts;
/** Rendered at the start of the action row: the header's branch line, so
* the facts and the buttons that act on them read as one row. */
@@ -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>
)}
+12 -6
View File
@@ -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>
*/
@@ -19,8 +21,9 @@ import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { GitBranch } from "lucide-react";
import { LaneGitBar } from "./LaneGitBar";
import { api, gitTarget } from "../../lib/api";
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
@@ -37,8 +40,8 @@ export function useLaneGitFacts(
laneId: number,
/** Read that session's own worktree instead (`?session=`). */
session?: string,
/** Read that extra folder's repo instead (`?source=`). */
source?: number
/** Read that extra folder's repo (`?source=`, a number) or that inner repo (`?repo=`, a string) instead. */
source?: GitSource
): [LaneGitFacts | null, () => void] {
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
// Bumped by the git action bar: a commit or a pull changes every fact 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)];
}
@@ -329,7 +335,7 @@ export function LaneGitRailCounts({
}: {
laneId: number;
session?: string;
source?: number;
source?: GitSource;
/** Opens the Changes panel from the file list's "… more files". */
onShowAll?: () => void;
}) {
@@ -349,7 +355,7 @@ export function LaneGitHeader({
/** Work in that session's own worktree (see `gitTarget`). */
session?: string;
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
source?: number;
source?: GitSource;
git: LaneGitFacts | null;
/** Called after a git bar action: the facts AND the panel's diff moved. */
onChanged: () => void;
+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" />}
@@ -3,7 +3,9 @@
* the pointer (right-click) or under the row's `⋯` button. Every item is an
* existing call: Rename (the row's inline field), New workspace here (the
* Add modal prefilled), Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
* managed lane. The server's guards are the real ones; the menu only hides
* managed lane — plus Move to group (the sidebar's own groups,
* `lib/workspaceGroups.ts`: one of them, a new one, or out of its group). The
* server's guards are the real ones; the menu only hides
* what the lane's kind or tool makes meaningless. The shell (position,
* Escape / outside-click close) is `ContextMenu`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -11,7 +13,9 @@
import { useTranslation } from "react-i18next";
import type { Lane } from "../../lib/types";
import { Check } from "lucide-react";
import { ContextMenu, MenuItem, MenuSeparator } from "../ContextMenu";
import type { WorkspaceGroup } from "../../lib/workspaceGroups";
import { copyText } from "../../lib/clipboard";
export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove";
@@ -22,6 +26,9 @@ export function WorkspaceRowMenu({
y,
onClose,
onAction,
groups = [],
groupId = null,
onGroup,
}: {
lane: Lane;
x: number;
@@ -29,6 +36,11 @@ export function WorkspaceRowMenu({
onClose: () => void;
/** Items the tree itself carries out (rename field, modals). */
onAction: (action: WorkspaceMenuAction) => void;
/** The sidebar's groups and the one this workspace is in. */
groups?: WorkspaceGroup[];
groupId?: string | null;
/** Move it: to a group's id, out of its group (null), or into a new group ("new"). */
onGroup?: (target: string | null | "new") => void;
}) {
const { t } = useTranslation("lanes");
return (
@@ -43,6 +55,35 @@ export function WorkspaceRowMenu({
>
{t("menu.copyPath")}
</MenuItem>
{onGroup && (
<>
<MenuSeparator />
<div className="px-2 pb-0.5 pt-1 text-[10px] font-semibold uppercase tracking-wide text-fg-muted">
{t("menu.moveToGroup")}
</div>
{groups.map((g) => (
<MenuItem
key={g.id}
testId={`workspace-move-group-${g.id}`}
disabled={g.id === groupId}
onSelect={() => onGroup(g.id)}
>
<Check className={`h-3 w-3 shrink-0 ${g.id === groupId ? "" : "invisible"}`} />
<span className="truncate">{g.name}</span>
</MenuItem>
))}
<MenuItem testId="workspace-move-group-new" onSelect={() => onGroup("new")}>
<span className="w-3 shrink-0" />
{t("menu.newGroupWith")}
</MenuItem>
{groupId && (
<MenuItem testId="workspace-move-group-none" onSelect={() => onGroup(null)}>
<span className="w-3 shrink-0" />
{t("menu.removeFromGroup")}
</MenuItem>
)}
</>
)}
<MenuSeparator />
{lane.kind === "managed" && (
<MenuItem danger onSelect={() => onAction("reset")}>
@@ -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.
@@ -69,7 +71,7 @@ const TOOLS: AiToolsView = {
tool("codex", "Codex", { launcher: "codex", proxyCapable: true, prompt: null }),
tool("gemini", "Gemini"),
tool("aider", "Aider", { installed: false, path: null }),
tool("grok", "Grok", { enabled: false }),
tool("goose", "Goose", { enabled: false }),
],
};
@@ -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,6 +35,7 @@ vi.mock("../../../lib/api", () => ({
lanes: {
list: vi.fn(),
clone: vi.fn(),
cloneJobs: vi.fn(async () => ({ jobs: [] })),
gitDownloadUrl: (t: number, p: string) => `/api/lanes/${t}/git/download?path=${p}`,
},
files: {
@@ -72,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" },
@@ -135,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();
@@ -195,7 +254,8 @@ describe("FileExplorer", () => {
"a.txt",
file,
"keep",
expect.any(Function)
expect.any(Function),
expect.any(AbortSignal)
)
);
});
@@ -216,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"]);
@@ -235,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");
@@ -407,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();
@@ -534,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"));
@@ -553,9 +654,36 @@ describe("FileExplorer — New → Clone repository", () => {
url: "git@host:o/repo.git",
parent: "/w/one/src",
name: undefined,
background: true,
})
);
expect(toast.success).toHaveBeenCalled();
// Accepted: the dialog steps aside, the app-wide dock takes over.
await waitFor(() => expect(screen.queryByTestId("fx-clone-dialog")).toBeNull());
});
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();
await screen.findByText("a.txt");
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"));
await waitFor(() => expect(toast.success).toHaveBeenCalled());
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
});
@@ -564,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"));
@@ -583,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());
@@ -382,7 +434,8 @@ describe("LaneChangesPanel", () => {
const box = within(folder.parentElement!).getByRole("checkbox", {
name: "Tick every file in server/lib",
});
expect(box).toBeChecked();
// The default ticks land in an effect after the list renders.
await waitFor(() => expect(box).toBeChecked());
await user.click(box);
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
await user.click(folder);
@@ -444,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} />);
@@ -543,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,
@@ -771,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 () => {
@@ -881,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"
@@ -1011,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, {
@@ -1069,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(() =>
@@ -1107,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 () => {
@@ -1126,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();
});
@@ -1607,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();
}
+45 -8
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>
*/
@@ -78,6 +79,35 @@ 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";
/** 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;
}
+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")}
+1 -1
View File
@@ -765,7 +765,7 @@ export function Divider({
onRatio,
label,
}: {
gridRef: React.RefObject<HTMLDivElement>;
gridRef: React.RefObject<HTMLDivElement | null>;
dir: SplitDir;
at: number;
box: Rect;
+29 -17
View File
@@ -12,7 +12,8 @@
* remembered for the + menu's Recent uploads), and a panel row (Files,
* Changes, a context file or folder) dropped on it (`lib/promptAttach.ts`)
* as an `@path` chip — so is a local file a browser on the dashboard's
* own machine drops or pastes with its `file://` path, never uploaded —
* own machine drops or pastes with its `file://` path, or pastes from a
* file manager's Copy its OS clipboard holds, never uploaded —
* a `bare` one takes a drop anywhere on its box;
* its `/command` and `@path` tokens are coloured by a mirror laid over
* the textarea (`lib/promptTokens.ts`);
@@ -78,6 +79,8 @@ import {
localAttachment,
localCandidates,
sameFilesHere,
fileManagerPaths,
isClipboardBitmap,
SKILL_MIME,
rememberUploads,
} from "../../lib/promptAttach";
@@ -463,21 +466,26 @@ export function PromptEditor({
};
/** Dropped or pasted files: by their own paths when the browser is on the
* dashboard's machine and the server finds the same files there (a file
* manager hands over `file://` paths beside the bytes), else uploaded. */
* manager hands over `file://` paths beside the bytes) or its file manager
* holds them (a paste: its clipboard; a drop: its selection — no upload,
* no size limit), else uploaded. */
const attachDropped = (
dt: DataTransfer | null | undefined,
list: FileList | null | undefined
list: FileList | File[] | null | undefined,
from: "paste" | "drop"
) => {
const files = Array.from(list ?? []);
if (files.length === 0) return false;
const paths = localCandidates(dt, files);
if (!paths) return attachImages(files);
if (!isLocalBrowser()) return attachImages(files);
const candidates = localCandidates(dt, files);
setUpload("uploading");
void sameFilesHere(paths, files).then((same) => {
if (!same) return void attachImages(files);
void (async () => {
let paths = candidates && (await sameFilesHere(candidates, files)) ? candidates : null;
paths ??= await fileManagerPaths(files, from);
if (!paths) return void attachImages(files);
setUpload(null);
onImagesChange((prev) => addAttachments(prev, paths.map(localAttachment)));
});
})();
return true;
};
// Anything attached since — a path, a panel row, a + menu pick — ends a past
@@ -518,7 +526,7 @@ export function PromptEditor({
} else if (skill) {
e.preventDefault();
onChange(`/${skill} ${value.replace(/^\s+/, "")}`);
} else if (attachDropped(e.dataTransfer, e.dataTransfer?.files)) e.preventDefault();
} else if (attachDropped(e.dataTransfer, e.dataTransfer?.files, "drop")) e.preventDefault();
else {
// Only text (a file manager's or an editor's `file://` list, a
// selection, a link): what the terminal beneath would paste.
@@ -683,17 +691,21 @@ export function PromptEditor({
onBlur={() => setTrigger(null)}
onKeyDown={handleKeyDown}
onPaste={(e) => {
if (attachDropped(e.clipboardData, e.clipboardData?.files)) e.preventDefault();
else if (pastedLocalFiles(e.clipboardData)) e.preventDefault();
else {
const pasted = Array.from(e.clipboardData?.files ?? []);
const text = e.clipboardData?.getData?.("text/plain") ?? "";
// Cells from a spreadsheet come with a picture of them: the text
// is what was copied (isClipboardBitmap).
const textWithPicture =
pasted.length > 0 && pasted.every(isClipboardBitmap) && !!text.trim();
if (!textWithPicture && attachDropped(e.clipboardData, pasted, "paste"))
e.preventDefault();
else if (!textWithPicture && pastedLocalFiles(e.clipboardData)) e.preventDefault();
else if (text.length > PASTE_AS_FILE_CHARS) {
// A very long paste goes in as a text file, like Claude Desktop:
// the prompt then carries its path, never the text (#68: a CLI's
// argument and a tmux message both have a size limit).
const text = e.clipboardData?.getData("text/plain") ?? "";
if (text.length > PASTE_AS_FILE_CHARS) {
e.preventDefault();
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
}
e.preventDefault();
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
}
}}
placeholder={placeholder}
+190 -25
View File
@@ -18,7 +18,14 @@
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
* local terminal does on a drop — as `@` mentions in a `claude` pane, which
* reads them when the prompt is sent — an image as its bare path, which claude
* shows as `[Image #n]` like a native drop; a browser on the dashboard's own
* shows as `[Image #n]` like a native drop — except a PASTED image in a
* `claude` pane of a local-mode dashboard opened on its own machine, which is
* sent as its image-paste key (^V; Alt+V on Windows) so `claude` reads the
* desktop clipboard itself, uploading nothing — and a file manager's files
* pasted or dropped on that machine go in by the paths it holds them under
* (its clipboard / its selection: `POST /api/run/clipboard-files`,
* `selected-files`), with no upload and no size limit
* (`lib/nativePaste.ts`, a Settings switch); a browser on the dashboard's own
* machine pastes a file manager's files (dropped, or copied: `file://` text)
* by their own paths instead, uploading nothing. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
@@ -41,7 +48,9 @@
* most every 150 ms. Several browsers on one pane share ONE size: the server
* names a driver (see pty-attach.js) and the others FOLLOW — their xterm takes
* the driver's grid (`size`), scaled down to fit, until a click or a keystroke
* here takes the lead back (`resize` with `drive: true`).
* here takes the lead back (`resize` with `drive: true`). Cells are drawn by
* the WebGL addon (the DOM renderer after a lost context), and Alt+arrow keeps
* the word-move keys xterm 5 sent (`altArrowSeq`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import {
@@ -56,7 +65,7 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
import type { Terminal } from "@xterm/xterm";
import type { CanvasAddon } from "@xterm/addon-canvas";
import type { WebglAddon } from "@xterm/addon-webgl";
import type { ClipboardAddon, IClipboardProvider } from "@xterm/addon-clipboard";
import type { SearchAddon } from "@xterm/addon-search";
import { ArrowDown, ArrowUp, X } from "lucide-react";
@@ -69,20 +78,54 @@ import { MENU_CURSOR_ROW, notePaneFocus, notePaneOutput, registerPane } from "..
import {
claudePastes,
copiedFilePaths,
isClipboardBitmap,
droppedFilePaths,
fileManagerPaths,
isLocalBrowser,
localCandidates,
sameFilesHere,
} from "../../lib/promptAttach";
import { attachImeInput } from "../../lib/imeInput";
import { nativePasteKey, nativePasteWorks, readNativePaste } from "../../lib/nativePaste";
import { usePermissions } from "../AuthGate";
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigLocal";
type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigLocalPaste";
/** 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 {
@@ -112,10 +155,10 @@ const clipboardProvider: IClipboardProvider = {
* Workspace chunk. The result is cached, so every later pane (and a test that
* awaits `loadXterm()` first) sets up synchronously. */
const importXterm = async () => {
const [xterm, fit, canvas, clipboard, search, links] = await Promise.all([
const [xterm, fit, webgl, clipboard, search, links] = await Promise.all([
import("@xterm/xterm"),
import("@xterm/addon-fit"),
import("@xterm/addon-canvas"),
import("@xterm/addon-webgl"),
import("@xterm/addon-clipboard"),
import("@xterm/addon-search"),
import("@xterm/addon-web-links"),
@@ -123,7 +166,7 @@ const importXterm = async () => {
return {
Terminal: xterm.Terminal,
FitAddon: fit.FitAddon,
CanvasAddon: canvas.CanvasAddon,
WebglAddon: webgl.WebglAddon,
SearchAddon: search.SearchAddon,
WebLinksAddon: links.WebLinksAddon,
// The addon's typings declare one argument, its code takes (base64, provider).
@@ -147,6 +190,25 @@ export function loadXterm(): Promise<Xterm> {
return xtermLoading;
}
const IS_MAC = /^Mac/.test(typeof navigator === "undefined" ? "" : navigator.platform);
/** What xterm 5 sent for a bare Alt+arrow (its Keyboard.ts "HACK"s, removed
* in xterm 6): Ctrl+arrow's sequence, except macOS's left/right, which are
* readline's backward-word / forward-word and whose up/down stay Alt's. */
export function altArrowSeq(key: string, mac = IS_MAC): string | null {
switch (key) {
case "ArrowLeft":
return mac ? "\x1bb" : "\x1b[1;5D";
case "ArrowRight":
return mac ? "\x1bf" : "\x1b[1;5C";
case "ArrowUp":
return mac ? null : "\x1b[1;5A";
case "ArrowDown":
return mac ? null : "\x1b[1;5B";
default:
return null;
}
}
/** Reconnect delay: 1.5 s, doubling per failed attempt up to 30 s. */
const RETRY_MIN_MS = 1500;
const RETRY_MAX_MS = 30_000;
@@ -239,6 +301,12 @@ export const TerminalView = memo(function TerminalView({
// Ctrl+V was pressed and its paste has not arrived yet (see the key handler).
const ctrlVRef = useRef(false);
const { local: localMode } = usePermissions();
// Ask early whether `^V` can reach this machine's clipboard, so the first
// pasted screenshot already takes the right way.
useEffect(() => {
if (tool === "claude" && localMode && isLocalBrowser()) nativePasteWorks();
}, [tool, localMode]);
const onDrop = (e: DragEvent) => {
if (hasText(e)) {
@@ -256,7 +324,7 @@ export const TerminalView = memo(function TerminalView({
e.preventDefault();
dragDepth.current = 0;
const files = Array.from(e.dataTransfer.files);
void attachFiles(files, localCandidates(e.dataTransfer, files));
void attachFiles(files, localCandidates(e.dataTransfer, files), "drop");
};
// A pasted image (a screenshot) goes the way of a drop: xterm only reads
@@ -277,9 +345,27 @@ export const TerminalView = memo(function TerminalView({
return;
}
if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return;
// Cells from a spreadsheet, a page's selection: text WITH a picture of
// it. A terminal pastes the text there, so does this.
if (files.every(isClipboardBitmap) && e.clipboardData?.getData("text/plain")?.trim()) return;
e.preventDefault();
e.stopPropagation();
if (files.length > 0) void attachFiles(files, localCandidates(e.clipboardData, files));
// On the dashboard's own machine the browser's clipboard IS the pane's:
// `claude` reads a pasted image itself on ^V, as in a native terminal —
// nothing uploaded (lib/nativePaste.ts; off for an SSH tunnel's browser).
const nativeImage =
tool === "claude" &&
localMode &&
isLocalBrowser() &&
readNativePaste() &&
nativePasteWorks() &&
files.length > 0 &&
files.every(isClipboardBitmap);
if (nativeImage) {
termRef.current?.input(nativePasteKey(), true);
return;
}
if (files.length > 0) void attachFiles(files, localCandidates(e.clipboardData, files), "paste");
else termRef.current?.input("\x16", true);
};
@@ -299,11 +385,15 @@ export const TerminalView = memo(function TerminalView({
/** Pastes the files' paths: their own (`local`, the `file://` paths a
* browser on the dashboard's machine is handed beside the bytes) once the
* server finds the same files there — nothing uploaded —, else the paths
* the uploads were saved at. A refused file does not cost the others. */
const attachFiles = async (files: File[], local: string[] | null = null) => {
* that machine's file manager holds them under (a paste: its clipboard; a
* drop: its selection, what the drag carries — no upload, no size limit,
* as a native terminal), else the paths the uploads were saved at. A
* refused file does not cost the others. */
const attachFiles = async (files: File[], local: string[] | null, from: "paste" | "drop") => {
if (files.length === 0) return;
setDrop("uploading");
let paths = local && (await sameFilesHere(local, files)) ? local : null;
paths ??= await fileManagerPaths(files, from);
const errors: unknown[] = [];
if (!paths) {
const results = await Promise.allSettled(files.map((f) => api.run.dropFile(f, runId)));
@@ -321,7 +411,8 @@ export const TerminalView = memo(function TerminalView({
if (tooBig) setLimitMb(Math.round((tooBig.maxBytes ?? 0) / 1024 / 1024));
if (!errors.length) setDrop(null);
else if (!tooBig) flash("failed");
else flash(isLocalBrowser() ? "tooBigLocal" : "tooBig", 6000); // a sentence to read
// A terminal has no + menu: on this machine, a paste goes by path.
else flash(isLocalBrowser() ? "tooBigLocalPaste" : "tooBig", 6000); // a sentence to read
};
useEffect(() => {
@@ -329,7 +420,7 @@ export const TerminalView = memo(function TerminalView({
const attach = ({
Terminal,
FitAddon,
CanvasAddon,
WebglAddon,
SearchAddon,
WebLinksAddon,
ClipboardWithProvider,
@@ -339,7 +430,7 @@ export const TerminalView = memo(function TerminalView({
// which on Windows lacks Vietnamese precomposed diacritics — the OS then
// substitutes those specific glyphs from a different face with a
// mismatched advance width, corrupting the cell it's drawn into even
// under the canvas renderer (which only stops a glyph from shoving its
// under the WebGL renderer (which only stops a glyph from shoving its
// *neighbours*, not from overflowing its own cell). JetBrains Mono is
// already bundled for the rest of the UI; main.tsx additionally imports
// its `vietnamese` subset so this covers the diacritics too. Settings →
@@ -385,17 +476,31 @@ export const TerminalView = memo(function TerminalView({
// face whose advance width differs, so glyphs shove their neighbours along
// and the row's tail spills onto the row below. The buffer is correct
// throughout (copying the line yields clean text); only the painting drifts,
// and no CSS can correct it. Best-effort: a browser that cannot give a 2D
// context keeps the DOM renderer rather than losing the terminal.
let canvas: CanvasAddon | null = null;
// and no CSS can correct it. xterm 6 dropped its canvas renderer, so the
// cells are drawn by the WebGL one. Best-effort: a browser with no WebGL2
// keeps the DOM renderer rather than losing the terminal, and so does a
// pane whose context the browser takes back (GPU reset, or past the
// browser's cap of live contexts with many panes open) — disposing the
// addon hands the drawing back to the DOM renderer.
let gpu: WebglAddon | null = null;
try {
canvas = new CanvasAddon();
term.loadAddon(canvas);
const addon = new WebglAddon();
term.loadAddon(addon);
gpu = addon;
addon.onContextLoss(() => {
addon.dispose();
if (gpu === addon) gpu = null;
});
} catch {
canvas = null;
gpu = null;
}
fit.fit();
// 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;
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
// nowhere. A click elsewhere still moves focus away, as in any editor.
@@ -482,6 +587,17 @@ export const TerminalView = memo(function TerminalView({
term.input("\x1f", true);
return false;
}
// Alt+arrow moves by word, as it did before xterm 6 dropped its own
// mapping (xterm.js #5346): Ctrl+arrow's sequence, or readline's
// ESC b / ESC f for left/right on macOS — the same keys tmux,
// bash and claude's prompt have always been handed from this pane.
const altArrow =
ev.altKey && !ev.ctrlKey && !ev.metaKey && !ev.shiftKey ? altArrowSeq(ev.key) : null;
if (altArrow) {
ev.preventDefault();
term.input(altArrow, true);
return false;
}
if (ev.key === "Tab" && tabMovesRef.current) return false;
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
return true;
@@ -524,7 +640,7 @@ export const TerminalView = memo(function TerminalView({
*/
const sendSizeNow = (drive = false) => {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
if (term.cols < 2 || term.rows < 2) return;
if (!hasBox() || term.cols < 2 || term.rows < 2) return;
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows, drive }));
};
// This browser is the one in use now: back to its own size, and the
@@ -561,7 +677,10 @@ export const TerminalView = memo(function TerminalView({
// scrollback as torn fragments along the left margin, visible as soon as
// you scroll back (issue #33). Happens on every attach: page load,
// lane switch, and each reconnect.
const size = term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
// A hidden terminal sends none: the server attaches it at the window's
// own size, so a pane on a background tab is never resized by it.
const size =
hasBox() && term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`);
ws = sock;
wsRef.current = sock;
@@ -656,6 +775,9 @@ export const TerminalView = memo(function TerminalView({
if (!el || !el.clientWidth || !el.clientHeight) return;
if (follow) return scaleToFit(); // the driver's grid, not ours
fit.fit(); // a real change fires onResize, which reports it
// A tab shown for the first time may fit to the very size xterm already
// had (no onResize), yet tmux has never heard it: report it anyway.
sendSize();
};
const resizeObserver = new ResizeObserver(refit);
if (containerRef.current) resizeObserver.observe(containerRef.current);
@@ -670,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 = () => {
@@ -688,13 +848,18 @@ 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();
resizeDisposable.dispose();
dataDisposable.dispose();
ws?.close();
canvas?.dispose();
gpu?.dispose();
term.dispose();
termRef.current = null;
};
@@ -939,7 +1104,7 @@ export const TerminalView = memo(function TerminalView({
<div
data-testid="terminal-drop"
className={`pointer-events-none absolute inset-2 flex items-center justify-center rounded-lg border-2 border-dashed bg-black/50 text-sm font-medium ${
drop === "failed" || drop === "tooBig" || drop === "tooBigLocal"
drop === "failed" || drop === "tooBig" || drop === "tooBigLocalPaste"
? "border-red-400 text-red-300"
: "border-sky-400 text-sky-200"
}`}
@@ -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();
});
@@ -60,6 +60,8 @@ vi.mock("../../../lib/api", async (importOriginal) => {
files: r({ items: [] }),
dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }),
localPaths: r({ sizes: [1] }),
clipboardFiles: r({ files: [] }),
selectedFiles: r({ files: [] }),
},
ccConfig: {
commands: r({
@@ -377,6 +379,35 @@ describe("PromptEditor — newlines and images", () => {
expect(screen.getByTestId("sent").textContent).toBe("read @/home/u/demo.mp4");
});
// Chrome on Windows pastes a file manager's copy as bytes only: the path
// comes off the OS clipboard, so a file over the upload limit attaches too.
it("attaches a pasted file manager copy by the path the OS clipboard holds", async () => {
vi.mocked(api.run.clipboardFiles).mockResolvedValueOnce({
files: [{ path: "C:\\Users\\u\\big.iso", size: 3_000_000_000, dir: false }],
});
vi.mocked(api.run.dropFile).mockClear();
const onImagesChange = vi.fn();
render(
<PromptEditor
value=""
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash={false}
fileCwd="/home/u"
images={[]}
onImagesChange={onImagesChange}
/>
);
const iso = new File(["x"], "big.iso");
Object.defineProperty(iso, "size", { value: 3_000_000_000 });
fireEvent.paste(box(), { clipboardData: { files: [iso], getData: () => "" } });
await vi.waitFor(() => expect(onImagesChange).toHaveBeenCalled());
const update = onImagesChange.mock.calls[0]![0] as (p: unknown[]) => { path: string }[];
expect(update([]).map((a) => a.path)).toEqual(["@C:/Users/u/big.iso"]);
expect(api.run.dropFile).not.toHaveBeenCalled();
});
it("uploads a dropped file whose path the server does not have (an SSH tunnel's laptop)", async () => {
vi.mocked(api.run.localPaths).mockResolvedValueOnce({ sizes: [null] });
const onImagesChange = vi.fn();
@@ -6,13 +6,24 @@
* included) as outgoing binary frames, and
* re-attaches after the socket drops (server restart) with backoff, but not
* after `exit` or a policy close,
* and recolours live on a dark-theme change without re-attaching.
* recolours live on a dark-theme change without re-attaching, draws through
* the WebGL addon (DOM renderer again on a lost context) and keeps xterm 5's
* Alt+arrow word moves.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
import { render, cleanup, act, fireEvent, screen, waitFor } from "@testing-library/react";
import { TerminalView, loadXterm } from "../TerminalView";
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, onTestFinished } from "vitest";
import {
render,
cleanup,
act,
createEvent,
fireEvent,
screen,
waitFor,
} from "@testing-library/react";
import { TerminalView, altArrowSeq, loadXterm } from "../TerminalView";
import { pasteToLane, pasteToRun } from "../../../lib/panePaste";
import { __resetNativePaste, nativePasteWorks } from "../../../lib/nativePaste";
const writeMock = vi.fn();
const onDataHandlers: Array<(d: string) => void> = [];
@@ -27,11 +38,18 @@ const inputMock = vi.fn();
let textarea: HTMLTextAreaElement;
const dropFileMock = vi.fn();
const localPathsMock = vi.fn();
const nativePasteMock = vi.fn(() => Promise.resolve({ native: true }));
type Held = { files: { path: string; size: number | null; dir: boolean }[] };
const clipboardFilesMock = vi.fn((): Promise<Held> => Promise.resolve({ files: [] }));
const selectedFilesMock = vi.fn((): Promise<Held> => Promise.resolve({ files: [] }));
vi.mock("../../../lib/api", () => ({
api: {
run: {
dropFile: (f: File, lane?: string) => dropFileMock(f, lane),
localPaths: (paths: string[]) => localPathsMock(paths),
nativePaste: () => nativePasteMock(),
clipboardFiles: () => clipboardFilesMock(),
selectedFiles: () => selectedFilesMock(),
},
},
}));
@@ -45,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", () => ({
@@ -74,6 +95,7 @@ vi.mock("@xterm/xterm", () => ({
return { dispose: vi.fn() };
},
dispose: disposeMock,
refresh: refreshMock,
loadAddon: loadAddonMock,
attachCustomWheelEventHandler: wheelHandlerMock,
attachCustomKeyEventHandler: keyHandlerMock,
@@ -93,8 +115,47 @@ vi.mock("../../../lib/terminalTheme", () => ({
vi.mock("@xterm/addon-fit", () => ({
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
}));
vi.mock("@xterm/addon-canvas", () => ({
CanvasAddon: vi.fn().mockImplementation(() => ({ __canvas: true, dispose: 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;
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(),
};
return addon;
}),
}));
class MockWebSocket {
@@ -120,6 +181,17 @@ class MockWebSocket {
// @ts-expect-error test override
global.WebSocket = MockWebSocket;
/** Give every element a real box (jsdom's are 0×0, like a terminal on a hidden
* tab); returns the restore. */
function withBox(width = 800, height = 600) {
const w = vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(width);
const h = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(height);
return () => {
w.mockRestore();
h.mockRestore();
};
}
describe("TerminalView", () => {
// xterm is a lazy chunk; once loaded every terminal sets up synchronously.
beforeAll(() => loadXterm());
@@ -151,11 +223,51 @@ describe("TerminalView", () => {
// it: tmux resizes the window to the newest client, so attaching at the
// server's default 80x24 makes claude redraw its UI at 80 columns and
// leaves that width in the scrollback (issue #33).
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
expect(MockWebSocket.instances).toHaveLength(1);
expect(MockWebSocket.instances[0]!.url).toBe(
"ws://localhost:4820/ws-pty/agenthub-lane-1?cols=131&rows=28"
);
const restore = withBox();
try {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
expect(MockWebSocket.instances).toHaveLength(1);
expect(MockWebSocket.instances[0]!.url).toBe(
"ws://localhost:4820/ws-pty/agenthub-lane-1?cols=131&rows=28"
);
} finally {
restore();
}
});
it("a terminal on a hidden tab sends no size until it is shown", () => {
// Its box is 0×0 (display:none): fitting there shrank xterm to 12×6 and
// that size resized the real tmux pane under every other client.
let observed: (() => void) | null = null;
const RO = globalThis.ResizeObserver;
// @ts-expect-error test override
globalThis.ResizeObserver = class {
constructor(cb: () => void) {
observed = cb;
}
observe() {}
disconnect() {}
};
vi.useFakeTimers();
let restore = () => {};
try {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[0]!;
expect(ws.url).toBe("ws://localhost:4820/ws-pty/agenthub-lane-1");
ws.onopen?.();
expect(ws.sent).toEqual([]);
// The tab is shown: the box gets a size and its size reaches tmux.
restore = withBox();
observed!();
vi.advanceTimersByTime(200);
expect(ws.sent).toEqual([
JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false }),
]);
} finally {
restore();
vi.useRealTimers();
globalThis.ResizeObserver = RO;
}
});
it("omits the size when the terminal has none yet (a collapsed console is 0x0)", () => {
@@ -381,34 +493,119 @@ describe("TerminalView", () => {
expect(onExit).toHaveBeenCalledTimes(1);
});
it("renders through the canvas addon, not xterm's DOM renderer", () => {
it("renders through the WebGL addon, not xterm's DOM renderer", () => {
// The DOM renderer aligns a row with one `letter-spacing`, which holds only
// while every glyph shares a font. Vietnamese diacritics and CJK fall to a
// fallback face with a different advance, so glyphs shove their neighbours
// and the row's tail spills onto the row below — buffer clean, painting
// wrong. The canvas renderer draws each cell at a fixed position instead.
// wrong. The WebGL renderer draws each cell at a fixed position instead
// (xterm 6 removed the canvas one).
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
expect(loadAddonMock.mock.calls.some(([a]) => (a as { __canvas?: boolean }).__canvas)).toBe(
expect(loadAddonMock.mock.calls.some(([a]) => (a as { __webgl?: boolean }).__webgl)).toBe(
true
);
});
it("falls back to the DOM renderer when the browser takes the WebGL context back", () => {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const addon = gpu.last!;
expect(addon.dispose).not.toHaveBeenCalled();
addon.lose();
expect(addon.dispose).toHaveBeenCalledTimes(1);
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");
expect(altArrowSeq("ArrowRight", false)).toBe("\x1b[1;5C");
expect(altArrowSeq("ArrowUp", false)).toBe("\x1b[1;5A");
expect(altArrowSeq("ArrowDown", false)).toBe("\x1b[1;5B");
expect(altArrowSeq("ArrowLeft", true)).toBe("\x1bb");
expect(altArrowSeq("ArrowRight", true)).toBe("\x1bf");
expect(altArrowSeq("ArrowUp", true)).toBeNull();
expect(altArrowSeq("a", false)).toBeNull();
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
const ev = new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true, cancelable: true });
expect(handler(ev)).toBe(false);
expect(ev.defaultPrevented).toBe(true);
expect(inputMock).toHaveBeenCalledWith(altArrowSeq("ArrowLeft"), true);
// Another modifier with it is xterm's own sequence.
expect(
handler(new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true, shiftKey: true }))
).toBe(true);
});
describe("size reporting (tmux must never be wider than the terminal)", () => {
// A tmux pane wider than xterm wraps each over-long row's tail onto the
// next row's left margin, and tmux never repaints a row it believes
// unchanged — the stray characters glued to the left edge after scrolling.
it("reports its size when the socket opens", () => {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[0]!;
ws.onopen?.();
expect(ws.sent).toContain(
JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false })
);
const restore = withBox();
try {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[0]!;
ws.onopen?.();
expect(ws.sent).toContain(
JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false })
);
} finally {
restore();
}
});
it("reports a size change xterm made on its own, with no container resize", async () => {
// Webfont load and page zoom both move cols without resizing the element
// ResizeObserver watches, which is why onResize is the hook.
const restore = withBox();
onTestFinished(restore);
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[0]!;
ws.onopen?.();
@@ -590,6 +787,168 @@ describe("TerminalView", () => {
expect(dropFileMock).toHaveBeenCalledWith(img, "r1");
});
it("hands a local claude pane's pasted image to claude as ^V, uploading nothing", async () => {
__resetNativePaste();
dropFileMock.mockReset();
inputMock.mockClear();
pasteMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
await waitFor(() => expect(nativePasteWorks()).toBe(true));
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [img], getData: () => "" },
});
expect(inputMock).toHaveBeenCalledWith("\x16", true);
expect(dropFileMock).not.toHaveBeenCalled();
expect(pasteMock).not.toHaveBeenCalled();
});
// Claude Code on Windows binds its image paste to Alt+V; a ^V did nothing.
it("sends Alt+V instead of ^V when the dashboard runs on Windows", async () => {
__resetNativePaste();
nativePasteMock.mockResolvedValueOnce({ native: true, key: "alt+v" } as never);
dropFileMock.mockReset();
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
await waitFor(() => expect(nativePasteWorks()).toBe(true));
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [img], getData: () => "" },
});
expect(inputMock).toHaveBeenCalledWith("\x1bv", true);
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
expect(dropFileMock).not.toHaveBeenCalled();
});
// No wl-paste/xclip (or no display) on the dashboard's machine: ^V would
// paste nothing, so the screenshot is uploaded like a remote browser's.
it("uploads a claude pane's pasted image when the machine cannot read its clipboard", async () => {
__resetNativePaste();
nativePasteMock.mockResolvedValueOnce({ native: false });
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-image.png" });
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
await waitFor(() => expect(nativePasteMock).toHaveBeenCalled());
await Promise.resolve();
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [img], getData: () => "" },
});
await waitFor(() => expect(dropFileMock).toHaveBeenCalledWith(img, "r1"));
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
});
it("uploads a claude pane's pasted image once native paste is turned off", async () => {
localStorage.setItem("agenthub.terminal.nativePaste", "off");
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-image.png" });
inputMock.mockClear();
try {
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [img], getData: () => "" },
});
await waitFor(() => expect(dropFileMock).toHaveBeenCalledWith(img, "r1"));
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
} finally {
localStorage.removeItem("agenthub.terminal.nativePaste");
}
});
// A file manager's Copy reaches Chrome on Windows as bytes and a name only:
// the pane gets the file's own path off the OS clipboard — no upload, so
// no size limit (a 3 GB video too), as a native terminal pastes it.
it("pastes a copied file by the path the OS clipboard holds, uploading nothing", async () => {
__resetNativePaste();
dropFileMock.mockReset();
pasteMock.mockClear();
inputMock.mockClear();
clipboardFilesMock.mockResolvedValueOnce({
files: [{ path: "C:\\Users\\u\\Videos\\demo.mp4", size: 3_000_000_000, dir: false }],
});
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
await waitFor(() => expect(nativePasteWorks()).toBe(true));
const video = new File([new Uint8Array([1])], "demo.mp4", { type: "video/mp4" });
Object.defineProperty(video, "size", { value: 3_000_000_000 });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [video], getData: () => "" },
});
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("@C:/Users/u/Videos/demo.mp4 "));
expect(dropFileMock).not.toHaveBeenCalled();
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
});
// Chrome hands a file dragged from Explorer no path; the drag carries the
// items selected there, so the pane gets their own paths — as a native
// terminal types them — and nothing is uploaded, whatever the size.
it("drops a file manager's file by the path selected there, uploading nothing", async () => {
dropFileMock.mockReset();
pasteMock.mockClear();
selectedFilesMock.mockResolvedValueOnce({
files: [{ path: "C:\\Users\\u\\Videos\\big.mkv", size: 5_000_000_000, dir: false }],
});
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
const video = new File([new Uint8Array([1])], "big.mkv");
Object.defineProperty(video, "size", { value: 5_000_000_000 });
fireEvent.drop(screen.getByTestId("terminal-view"), {
dataTransfer: { files: [video], types: ["Files"], getData: () => "" },
});
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("@C:/Users/u/Videos/big.mkv "));
expect(dropFileMock).not.toHaveBeenCalled();
});
it("uploads a dropped file the file manager does not hold selected", async () => {
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-notes.txt" });
selectedFilesMock.mockResolvedValueOnce({
files: [{ path: "/home/u/other.txt", size: 1, dir: false }],
});
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="codex" />);
const notes = new File(["x"], "notes.txt");
fireEvent.drop(screen.getByTestId("terminal-view"), {
dataTransfer: { files: [notes], types: ["Files"], getData: () => "" },
});
await waitFor(() => expect(dropFileMock).toHaveBeenCalledWith(notes, "r1"));
});
// A copied image FILE is not a screenshot: claude's own paste finds no
// picture on a clipboard holding a file, so it goes by path too.
it("pastes a copied image file by path instead of claude's image paste", async () => {
__resetNativePaste();
dropFileMock.mockReset();
pasteMock.mockClear();
inputMock.mockClear();
clipboardFilesMock.mockResolvedValueOnce({
files: [{ path: "/home/u/Pictures/logo.png", size: 2, dir: false }],
});
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
await waitFor(() => expect(nativePasteWorks()).toBe(true));
const logo = new File([new Uint8Array([137, 80])], "logo.png", { type: "image/png" });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [logo], getData: () => "" },
});
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("/home/u/Pictures/logo.png "));
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
expect(dropFileMock).not.toHaveBeenCalled();
});
// Cells from a spreadsheet come with a picture of them; a terminal pastes
// the text, so the browser's paste is left to xterm.
it("leaves text that comes with a picture of it to xterm's paste", async () => {
__resetNativePaste();
dropFileMock.mockReset();
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
await waitFor(() => expect(nativePasteWorks()).toBe(true));
const pic = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
const ev = createEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [pic], getData: (t: string) => (t === "text/plain" ? "a\tb" : "") },
});
fireEvent(screen.getByTestId("terminal-view"), ev);
expect(ev.defaultPrevented).toBe(false);
expect(inputMock).not.toHaveBeenCalled();
expect(dropFileMock).not.toHaveBeenCalled();
});
// Ctrl+V is how claude pastes an image; xterm would send ^V, and claude then
// reads the SERVER's clipboard, not the browser's. Text on the clipboard is
// pasted like any browser paste: sending ^V for it pasted nothing.
@@ -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" })}
@@ -4,7 +4,9 @@
* one card holds one subject: **Text & fonts** (`#fonts`) — the interface
* language (`#language`), the text size (`lib/fontSize.ts`, `#text-size`,
* issue #79), the terminal font and its size (`lib/terminalFont.ts`,
* `#terminal-font`, with a preview line); **Sessions** (`#sessions-view`) —
* `#terminal-font`, with a preview line), and — local mode only — whether a
* pasted image reaches a `claude` pane natively (`lib/nativePaste.ts`,
* `#native-paste`, this browser only); **Sessions** (`#sessions-view`) —
* the per-user Chat UI default (`ChatUiSettings`, `#chat-ui`) and whether the
* session view's tabs hold every workspace's sessions (`lib/workspaceTabs.ts`,
* `#workspace-tabs`), and whether a Markdown file opens as Preview (the
@@ -39,6 +41,8 @@ import {
} from "../../lib/terminalFont";
import { readAllTabs, writeAllTabs } from "../../lib/workspaceTabs";
import { readMarkdownPreview, writeMarkdownPreview } from "../../lib/markdownView";
import { readNativePaste, writeNativePaste } from "../../lib/nativePaste";
import { usePermissions } from "../AuthGate";
import {
TERMINAL_FONT_SIZES,
UI_FONT_SIZES,
@@ -100,6 +104,8 @@ export function AppearanceSettings() {
const [uiSize, setUiSize] = useState(readUiFontSize);
const [allTabs, setAllTabs] = useState(readAllTabs);
const [mdPreview, setMdPreview] = useState(readMarkdownPreview);
const [nativePaste, setNativePaste] = useState(readNativePaste);
const { local } = usePermissions();
const pickTermFont = (font: TerminalFont) => {
writeTerminalFont(font);
setTermFont(font);
@@ -209,6 +215,18 @@ export function AppearanceSettings() {
>
❯ Tiếng Việt: ấ ế ố ồ ủ ữ — const x = {"{ ok: true }"}; 0O il1|
</p>
{local && (
<SettingsSwitchRow
id="native-paste"
label={t("nativePaste.label")}
description={t("nativePaste.description")}
checked={nativePaste}
onChange={(on) => {
writeNativePaste(on);
setNativePaste(on);
}}
/>
)}
</SettingsSection>
<SettingsSection
@@ -0,0 +1,26 @@
/**
* @file ChangesPanelSettings — Settings → Git → Changes panel: whether a
* workspace folder that holds several git repositories shows each of them as
* its own section in the Changes panel (off by default: only the folder's own
* git). Writes the account preference in `lib/innerReposPref.ts`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useTranslation } from "react-i18next";
import { useInnerReposMode, writeInnerReposMode } from "../../lib/innerReposPref";
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
export function ChangesPanelSettings() {
const { t } = useTranslation("settings");
const mode = useInnerReposMode();
return (
<SettingsSection title={t("changesPanel.title")} description={t("changesPanel.description")}>
<SettingsSwitchRow
label={t("changesPanel.innerRepos")}
description={t("changesPanel.innerReposDesc")}
checked={mode === "all"}
onChange={(on) => writeInnerReposMode(on ? "all" : "own")}
/>
</SettingsSection>
);
}

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