Compare commits

...

97 Commits

Author SHA1 Message Date
nntrivi2001 6958a7b4f6 chore: v2.8.68 — server for coding only (OOM kills the command, no tests from Claude, 4 AI sessions), pet without chat, branch search 2026-11-03 12:08:26 +07:00
nntrivi2001 aa7f1b1c60 feat(pet): remove the pet's chat
Each question started a full AI session (a heavy process on a shared
server, and one of a member's 4). A click on the pet now opens its window
(PetPanel): its name (rename), a pat, and the status strip. PetChat, its
off-screen terminal, its AI hand-off and its strings are gone; the window
is where the pet's own care comes next.
2026-11-03 12:08:14 +07:00
nntrivi2001 70d7cf30c2 feat(git): search box for the branch switcher (#88)
A Search branches… box beside Switch branch narrows the local and remote
lists to names containing the text (case-insensitive). One match is
picked at once, Enter picks the first, Esc clears. The picked branch
stays listed, so the select never loses its value.

Closes #88
2026-11-03 12:00:10 +07:00
nntrivi2001 5912e9e613 fix(pet): a member's pet chat starts in <their folder>/sagi
POST /api/lanes/ensure without a cwd defaulted to the dashboard's own
home, outside a member's folder, so every member's pet chat failed with
"a workspace must be inside your own folder" (EFOLDER). A member now
gets <their folder>/sagi, made as them (write-as mkdir) on first use.
2026-11-03 11:53:21 +07:00
nntrivi2001 497faae3bf feat(settings): admin switch to allow or block members' test runs
Settings → AI tools → Tests on this server (admin, S3 only). Stored in
/opt/agenthub/app/member-policy.json, which member-guard.js reads as the
member at each Bash call — no restart; no file = blocked. GET/PUT
/api/ai-tools/member-policy, audited, 409 ENOS3 outside S3.
2026-11-03 11:37:13 +07:00
nntrivi2001 e6ce8009b1 feat(server): S3 box is for coding — OOM kills the command not the session, no tests from Claude, 4 AI sessions per member
- tool-run (CLAUDE_CODE_SHELL_PREFIX in member panes) runs each command
  Claude runs at oom_score_adj 1000 (session 300, Terminal tab 800,
  dashboard/tmux -100), so an OOM inside the capped unit kills the test
  run and tells Claude why, instead of killing the conversation.
- member-guard.js, a synchronous managed hook for ah-* accounts: denies
  test runners and container commands from Claude's Bash, and refuses
  the prompts of a session started by hand past MAX_AI_SESSIONS.
- ai-procs.js counts a member's top-level AI processes from /proc;
  spawnRun refuses a new pane past the limit (429 ESESSIONLIMIT).
Needs `agenthub server install-system` once, then a restart.
2026-11-03 10:50:53 +07:00
nntrivi2001 705353b0da fix(server): a member's test run can no longer freeze the box
On 11-02 open sessions plus test runs filled RAM and the 4 GB swap on a
slow disk; the box hung until a hard reset. Member panes now start at
nice 10, IO priority 7 and oom_score_adj 500 with Vitest capped at 2
workers by default, and the system unit caps its cgroup (server + every
pane) at 85% of RAM with 1G swap and OOMPolicy=continue, so the kernel
kills the biggest pane process instead of hanging the machine. Needs
`agenthub server install-system` once.
2026-11-03 10:27:58 +07:00
nntrivi2001 0792c88612 chore: v2.8.67 — remove user chat and drop its data
The chat_* tables are dropped at start, and <data dir>/chat/ (its uploads)
is deleted.
2026-11-03 10:08:57 +07:00
nntrivi2001 a3d1dce2ca revert: remove user chat (direct + groups) from the pet window
Reverts 21a042d, 0d3e92c, 0016b6a, c797e98, 03ebea1. Keeps the pet's
own-sessions scope, status bar mine=1, scroll speed and the health hover card.
The chat_* tables and <data dir>/chat/ files are left in place (unused).
2026-11-03 10:06:12 +07:00
nntrivi2001 db2e61a74b chore: v2.8.66 — chat: notices, right-click, delete chat, Seen, chat button 2026-11-03 10:00:08 +07:00
nntrivi2001 03ebea1d92 fix(chat): the review's gaps — owner hand-over, live group lines, names, scroll, one tab's sound, uploads on disk, a chat button without the pet
- A group whose creator left or was deleted passes to its longest-standing
  member (ensureOwner), so it can still be managed.
- Group changes (added / removed / renamed / left) are sent live as
  chat_message and never count as unread, here or on the server.
- A disabled account is still named in old chats (people carries
  `disabled`) and never offered for a new one; a deleted one reads
  'Deleted user' instead of '?'.
- Load older keeps the message being read in place.
- With several tabs open only the first to see a message plays its sound /
  system notification (agenthub.chat.notified).
- The people list is read again on New chat and on every room change.
- Uploads go to <data dir>/chat/.incoming/ (multer disk storage) and are
  renamed into place — never held in memory; a refused one is removed.
- With the pet hidden, a chat button in the bottom-right corner (ChatDock,
  server mode) opens the same window, unread count and new-message card
  included.
2026-11-03 09:59:13 +07:00
nntrivi2001 c797e9868e feat(chat): delete a chat, disband a group, Seen — and purge what everyone deleted
Delete chat is the caller's alone: the history so far vanishes for them
(chat_members.cleared_id) and a direct chat leaves their list (hidden) until a
new message arrives; the others keep theirs. What EVERY member has deleted is
deleted for real (purge): messages up to the lowest cleared_id and their files
on disk, and a direct chat both people deleted, whole. Purge also runs when a
member leaves or is removed. A group's creator disbands it for everyone
(DELETE /api/chat/rooms/:id).

Seen: rooms carry every member's read mark (reads); a mark that moves is told
to the members (chat_read, exactAudience) and never passes the newest
message. Under your newest message: Seen / Seen by … / Seen by everyone.
A room open on a tab you come back to is marked read then.
2026-11-03 09:54:55 +07:00
nntrivi2001 0016b6a427 feat(chat): right-click menus everywhere in the chat, and deleting your own message
Rail: a room — open, mark as read, mute / unmute notifications (per browser,
the unread count still shows), copy name, group settings, leave; the pet and
+ — chat with the pet, new chat, mark all chats as read. A message — copy
text, reply (quotes it into the box), message the sender privately (group),
delete your own (asks first). A file — open in a new tab, download, copy file
name. A group member — message privately, copy name, remove (the creator).

DELETE /api/chat/rooms/:id/messages/:messageId deletes the caller's OWN text
message and its files (403 for anyone else, an admin included) and tells the
members with a chat_deleted frame (exactAudience).
2026-11-03 09:47:32 +07:00
nntrivi2001 0d3e92cfda feat(chat): a new message shows on the pet; chat panels scroll one way only
A message from someone else in a room not on screen raises a card on the
pet (sender · group, the text or its files; click opens the room, gone after
10 s), plays the notification sound when notifications are on, and while
the tab is hidden shows a local system notification — never through the
server's push relay, which would reach other users' devices.

Group settings scrolled both ways (the name field and Rename overflowed, the
add-people list scrolled inside the panel): one vertical scroll now, the
field shrinks, the list grows in place. Every chat list hides horizontal
overflow and a long word or link wraps.
2026-11-03 09:43:09 +07:00
nntrivi2001 1dd2521183 chore: v2.8.65 — chat between users, own-only counts, slower scroll 2026-11-03 09:36:54 +07:00
nntrivi2001 21a042dc2b feat(chat): direct chats and groups between users in the pet's window
Server mode: a rail beside the pet's chat lists the pet, then the user's
direct chats (one per pair) and groups with unread counts, and + for a new
chat. A room shows its messages (group senders named), loads older pages,
and sends text, pasted / dropped / attached files (<= 10 x 25 MB; images
inline). A group's creator renames it and adds or removes people; anyone may
leave. A blue dot on the pet counts unread messages.

Only a room's members read or write it, an admin included (roomFor → 404).
Files are stored under server-made names in <data dir>/chat/<room>/ and
served to members only under a sandbox CSP + nosniff. chat_message /
chat_room frames reach exactly the ids in `to` (scope.exactAudience).
New tables chat_rooms / chat_members / chat_messages / chat_files (additive).
2026-11-03 09:33:33 +07:00
nntrivi2001 85edc88a6d fix(status-bar): running / need-you count only the viewer's own sessions
An admin's status bar and the Dashboard's Needs-you strip counted every
user's waiting sessions; both now ask GET /api/sessions with mine=1, like
Today's spend already did.
2026-11-03 09:07:06 +07:00
nntrivi2001 c229b4b79b fix(pet): each user's pet tells only of their own sessions
An admin/analyst's pet counted every user's sessions (live, waiting, errors)
because their WebSocket carries everyone's. Server mode now marks their
session/agent/event frames `mine: true|false` (scope.broadcastAudience owners,
worked out once per broadcast), and GET /api/sessions?mine=1 lists only the
caller's own sessions (ownSessionWhere). The pet seeds from mine=1 and skips
frames marked mine: false. Developers and local mode are unchanged.
2026-11-03 09:01:57 +07:00
nntrivi2001 9721941962 feat(terminal): scroll 30% slower
lib/wheelReports.ts SCROLL_SPEED = 0.7: a touchpad's travel is scaled, and a
wheel notch's reports are carried as a fraction (rounded, so the first notch
still moves; reset when the direction turns). xterm's own scrollback gets the
same scrollSensitivity.
2026-11-03 08:52:43 +07:00
nntrivi2001 b777e08371 fix(health): the user hover card leaves out the login name
It now opens only once the users list is read (admin/analyst): role, today's
spend, workspaces, sessions and last sign-in. A developer gets no card.
2026-11-03 08:50:26 +07:00
nntrivi2001 d0b4f8aaf6 fix(health): the user hover card sits on the name row only
Over the whole tile it opened together with the CPU bar's own help tip and
the two overlapped.
2026-11-03 08:47:13 +07:00
nntrivi2001 916505fbac chore: v2.8.64 — user hover card on System Health 2026-11-03 08:38:51 +07:00
nntrivi2001 75a4693f27 feat(health): hovering a user tile shows their login, role and today's spend
System Health's Users · CPU / RAM card: each tile's hover card names the login
name; for an admin or analyst it adds role, $ spent today, workspaces,
sessions and last sign-in from GET /api/users (staff-only, re-read every 60 s).
A developer's card shows the login name alone and never calls /api/users.
2026-11-03 08:38:48 +07:00
nntrivi2001 cd6a1d36ff chore: v2.8.63 — commit signing per workspace 2026-11-02 16:50:15 +07:00
nntrivi2001 2342f54179 feat(git-identity): commit signing per workspace, and Delete in the signing card
Signing was account-wide (gpg.format/user.signingkey/commit.gpgsign in
~/.gitconfig), so every repository was signed. Now Settings → Git →
Commit signing lists the user's workspaces, each with its own key picker
("Don't sign" by default). PUT /api/me/git/signing/lane {laneId, key}
writes a small file of ours (lane-<id>.gitconfig beside the account's
keys; a member's through write-as) and includes it from ~/.gitconfig
with includeIf "gitdir:<the workspace's git dir>" and
"gitdir:<common dir>/worktrees/ah-<id>-*" (its sessions' worktrees) —
only that workspace signs, from the terminal, the Changes panel or an
agent, and nothing is written into the repository. The git dirs come
from git run in the workspace's folder as its account; a workspace that
runs as another account is EFOREIGN, a folder that is not a repo
ENOREPO. Stopping needs no workspace, so one removed from the dashboard
while it signed is still listed and can be turned off.

The card now lists the keys that can sign, each with Delete (confirm;
deleting a key stops every workspace signing with it), a way out of the
old account-wide setting, and Remove for a signing setup the user made
themselves (PUT /signing {clearForeign: true}: the three settings only,
never their key file). Importing a key no longer turns on signing.

Verified with real signatures: the picked workspace's commits are G,
another repo's and an unrelated worktree's N, a session worktree's G.
2026-11-02 16:49:27 +07:00
nntrivi2001 246b554dab chore: v2.8.62 — every account sees other users' avatars 2026-11-02 16:40:37 +07:00
nntrivi2001 7ee79292fa docs(api): GET /api/users/:id/avatar is open to every signed-in account 2026-11-02 16:40:37 +07:00
nntrivi2001 600e8eec6a fix(avatars): every signed-in account sees other users' pictures
System Health shows every user's name and avatarAt to everyone, but the
picture route GET /api/users/:id/avatar sat under the staff-only /users
prefix, so a developer got 403 EADMIN and saw only initials. authGuard now
lets any signed-in account make that one read (EVERYONE_READS); every
other /users path stays staff-only, and the test checks both.
2026-11-02 16:40:29 +07:00
nntrivi2001 3ec1978176 chore: v2.8.61 — uploads survive a restart, SSH key import 2026-11-02 16:14:32 +07:00
nntrivi2001 12ea853170 feat(files): uploads survive a dashboard restart
An upload through the File Explorer is now sent in 8 MB parts that the
server stages on the host (<data dir>/upload-staging/<user>/<id>/,
server/lib/upload-staging.js) and writes only once every byte is there,
through the same upload/uploadBatch code and guards (?staged=<id>). A
restart, an update or a crash pauses it: the browser waits up to 3 min,
asks how much arrived (GET /fs/upload-part) and sends the rest; the dock
says the dashboard is restarting meanwhile. A commit's answer is stored
and replayed, never written twice; a refused one (EEXISTS) keeps the
bytes for a retry with another conflict policy.

A file uploaded into a member's folder is now written to a hidden temp
name and moved onto its name only when whole: write-as cannot tell a
dropped connection from the end of a file, and `replace` left a truncated
file under the real name when the server died mid-upload.

New: GET|POST|DELETE /api/{lanes/:id,homes/:who}/fs/upload-part; 409
EOFFSET/EINCOMPLETE/EBUSY, 429 ETOOMANY. Verified on a sandbox server:
SIGTERM mid-upload of a 60 MB file in a real browser, resumed and
sha256-identical.
2026-11-02 16:12:57 +07:00
nntrivi2001 7e41c81d53 feat(git-identity): import an existing SSH key and sign commits with it
Settings → Git gains Commit signing: one picker signs every commit
(terminal, Changes panel, agents) with one of the account's keys —
gpg.format ssh, user.signingkey = the key's private file, commit.gpgsign
true in its own ~/.gitconfig; a signing key the user set up themselves is
replaced only after a confirm (EFOREIGNSIGN without `replace`). Import a key
you have takes a key file or paste (OpenSSH or PEM, ed25519 or RSA >= 2048)
into ~/.config/agenthub/ssh/ as a named key, written like a generated one
(member: write-as, 0600), and by default signs with it in the same step.
A passphrase key, a .pub, an ecdsa key, a mismatched paste and the same key
twice are refused; only the public half ever comes back. Deleting the
signing key stops signing so commits keep working.

The SSH tab is renamed "Git (GitHub, GitLab, Gitea, Backlog…)" with the
Gitea and Backlog steps, and the File Explorer's ESECRET toast now says
that top-level dot folders of a home are edited in a terminal, and where
the SSH keys live instead.
2026-11-02 16:12:57 +07:00
nntrivi2001 2d6c751fb2 fix(terminal): touchpad scrolls 1:1 with the finger
xterm.js sends one wheel report per event whatever its size and damps a
touchpad's pixel deltas x0.3, so a swipe crawled. With mouse tracking on,
TerminalView now sends the SGR reports itself (lib/wheelReports.ts): one
per 5 rows of travel (what one report scrolls in a Claude pane), the
remainder carried between events, at least one per wheel notch, at the
pointer's cell with xterm's modifier bits.
2026-11-02 16:06:43 +07:00
nntrivi2001 e0b8d5641e chore: v2.8.60 — Claude panes scroll faster 2026-11-02 15:32:46 +07:00
nntrivi2001 faa96d1964 fix(terminal): Claude panes scroll 5 lines per wheel notch
Fullscreen Claude Code scrolls one line per wheel report when its terminal
does not answer XTVERSION as xterm.js (behind tmux it never does), and
xterm.js sends one report per notch — so a notch moved a single line.
spawnRun now sets CLAUDE_CODE_SCROLL_SPEED=5 unless the dashboard's env
already sets it; settings.json env and /config Scroll speed still win.
2026-11-02 15:32:46 +07:00
nntrivi2001 68c3cfaf26 chore: v2.8.59 — Preview explains a blank outside site 2026-11-02 15:10:55 +07:00
nntrivi2001 1ad2fec073 fix(preview): say why an outside site shows blank, with Open tab beside it
YouTube, Google and login pages refuse framing and the browser draws a
broken-page icon; the hint lived only in the empty state. A page not on this
machine now gets a strip under the address bar with Open tab. The refused-
address toast says what is allowed in plain words.
2026-11-02 15:10:22 +07:00
nntrivi2001 1294ce1faa chore: v2.8.58 — Preview tab in the session panel 2026-11-02 15:06:43 +07:00
nntrivi2001 5d62003fe5 feat(workspace): Preview tab in the session panel; local links open there
The session panel's header becomes two tabs, Changes and Preview. Preview
shows a web page by address (Reload, Open in a browser tab, the workspace's
ports as chips, last address per workspace) in an iframe on the page's own
origin: http(s) only, no userinfo, never the dashboard's origin, no top
navigation. One rule for every link (lib/openInPreview.ts): a chat or
terminal link to localhost/127.0.0.1/0.0.0.0/[::1] opens in Preview while a
session panel answers and the dashboard is on loopback; anything else, or a
Ctrl/Cmd click, opens a browser tab as before.
2026-11-02 15:06:04 +07:00
nntrivi2001 12d53afaeb fix(changes): long branch name truncates; armed Discard matches the row icons
The branch name was shrink-0, so a long one ran over the head sha and the
action icons. It now truncates after the commit subject (full name in the
tooltip). The armed per-file Discard is the trash icon plus a small red
word on a faint tint, sized like its neighbours, instead of a bordered chip.
2026-11-02 14:44:23 +07:00
nntrivi2001 4263b3eb26 chore: v2.8.57 — chat column widens to 64rem 2026-11-02 14:42:01 +07:00
nntrivi2001 d58f13e1ce fix(chat): conversation column widens to 64rem instead of 48rem
A wide pane left the chat squeezed into a 768px column with empty space
either side, so a wide table was cut off. The message list, the prompt
box and the session replay now share max-w-5xl.
2026-11-02 14:41:56 +07:00
nntrivi2001 81b3cbda65 chore: v2.8.56 — new tab / split pane starts in the Session defaults 2026-11-02 13:34:36 +07:00
nntrivi2001 0a99eed369 fix(workspace): a new tab or split pane starts in the account's Session defaults
newTabBody sent only prompt/tool/model, so a Claude tab opened from the tab
bar + or a split menu always started in auto mode even with Bypass
permissions set as the default; a Codex one ignored its sandbox, approval
and YOLO defaults. It now sends the same defaults the workspace home's
prompt box starts from.
2026-11-02 13:34:10 +07:00
nntrivi2001 1ec9f2e592 chore: v2.8.55 — chat + menu no longer covered by the controls under the prompt box 2026-11-02 13:30:33 +07:00
nntrivi2001 a401a318c5 fix(chat): + menu paints above the controls row under the prompt box
Opened downward, the panel had no z-index, so the AI Proxy / Permission /
Local selects (positioned, later in the DOM) drew over its first rows.
2026-11-02 13:29:08 +07:00
nntrivi2001 5caad0df2d chore: v2.8.54 — chat Stop shows while Claude thinks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:29:05 +07:00
nntrivi2001 01e66ba122 fix(chat): Stop shows while Claude thinks, not only while its footer says "esc to interrupt"
The chat's live line said "Thinking… 21s" while the box offered Send, so the
turn could not be stopped. Stop shows only when a hook marked main "working"
or the pane's busy line matched, and Claude Code 2.1.3xx writes
"esc to interrupt" only in its footer, which a narrow pane, a tip or a notice
replaces. Claude's busy line now also matches its spinner (verb… (41s · …)),
which the line a finished turn leaves ("Cooked for 1m 2s") does not; and the
chat re-reads "working" on transcript_changed, so a hook that reached another
dashboard on the same database no longer leaves Stop hidden.

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

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

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

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

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

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

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

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

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

sessions.account records the Linux account a session ran as (the hook's
peer uid, else its folder for poller-made and old rows); a session no
workspace owns is owned by that account's user — a member's to them, the
host's to the admin ticked 'Owns this server's host account' (or the only
admin). Workspace ownership still wins.
2026-10-30 08:50:34 +07:00
nntrivi2001 c28395402b chore: v2.8.40 — a Codex account switch reaches running sessions 2026-10-29 15:59:19 +07:00
nntrivi2001 7798530528 fix(profiles): a Codex switch reaches running and new sessions
Codex >= 0.164 sends every request through a shared app-server daemon that
outlives its TUIs and keeps the login it started with, so writing a new
auth.json changed nothing: open sessions and even new ones stayed on the
old (out-of-quota) account. After a switch, restart that CODEX_HOME's
daemon when one runs; open TUIs reconnect to it on their own (verified on
0.164.0 with two keys). The response says so (following), and the toast
tells the user running sessions follow.
2026-10-29 15:54:20 +07:00
nntrivi2001 25f63d9ad3 fix(terminal): rebuild the WebGL glyph atlas when the page comes back
A browser can drop the atlas's GPU memory without a context-loss event
(sleep/resume, a long-backgrounded tab, display/zoom change on macOS),
leaving every cell painted with a clipped or foreign glyph.
2026-10-29 15:44:16 +07:00
nntrivi2001 d060d288fb fix(sessions): Top tools counts each tool call once
The session analysis page's Top tools list counted every event carrying a
tool_name, so each call was counted twice (its PreToolUse and its
PostToolUse) while the Tool calls total beside it counted once — a session
with 840 calls showed Bash at 1,533. The list now counts the same way as
the total: PreToolUse, or PostToolUse in a session with none (imported).
2026-10-29 14:53:45 +07:00
nntrivi2001 6099841cef fix(sessions): archiving a live session stops its pane
Archive on a session a live pane held only hid its row, and the list
deliberately does not bring such a tab back, so the pane kept running
with nothing on screen showing it — a member archived five sessions
believing them closed and kept ~2.7 GB of claude/omp/postgres-mcp alive
for up to two days. Archive on a live session is now a Stop (endRuns,
the tab X's confirm, DELETE /api/run/:id?archive=1) in the sidebar row,
the workspace home's Recents and their right-click menu.
2026-10-29 14:26:25 +07:00
nntrivi2001 d70a7c9403 chore: v2.8.39 — no one can take a colleague's display name 2026-10-29 13:47:31 +07:00
nntrivi2001 63eaa739d4 fix(accounts): a display name may not read as another account's
A member kept renaming themselves to a colleague's display name, plus
"(you)", so the CPU/RAM card and every owner list showed two of that person.
A display name is now refused (409 ENAMETAKEN) when, with marks, case,
spaces, punctuation, bracketed parts and look-alike Cyrillic/Greek letters
ignored, it equals another account's display name or username — in
Settings → Account and on the Users page alike. Keeping the name one already
has stays allowed, so an older clash does not block an admin's other edits.
2026-10-29 13:47:20 +07:00
nntrivi2001 9f8dc9082e chore: v2.8.38 — every user row shows its avatar 2026-10-29 13:40:39 +07:00
nntrivi2001 f1a90e120e fix(users): every user row shows its avatar, not only its initials
System Health's per-user CPU/RAM card asked UserAvatar for the viewer's own
picture with no user id, and the host stats never carried avatarAt, so every
card fell back to letters. The groups now carry avatarAt and the card passes
userId. The user editor's header, the pool board's member chips and the pool
member picker drew hand-made initials; they use UserAvatar too.
2026-10-29 13:40:28 +07:00
nntrivi2001 db5a7776cc chore: v2.8.37 — the Artifacts installs list is never cut off 2026-10-29 11:25:10 +07:00
nntrivi2001 6c2c094f64 fix(artifacts): the installs list floats over the page, never cut off
It was an absolute box inside the card list, whose overflow-hidden clipped it
at the last card, and it opened to the right off a button near the edge. It
is now a portal at the button: below it, above when there is no room, kept
inside the window, following scroll and resize.
2026-10-29 11:25:10 +07:00
nntrivi2001 609a35f251 chore: v2.8.36 — an HTML page opens in a modal over the screen (Changes Preview, Chat) 2026-10-29 11:20:54 +07:00
nntrivi2001 97c39d5e15 feat(files): an HTML page opens in a modal over the screen
- Changes panel: Preview beside Whole file opens the page in a modal
  (HtmlViewerModal, shared with the File Explorer) instead of inside the
  panel; Changes and Whole file stay as they were.
- Chat: an HTML file's card, Outputs row or a path in a reply (in the
  workspace or not) opens FilePreview as a modal — Preview | Code, versions,
  Save to Artifacts kept — rather than the side panel or the Changes panel.
- Esc closes both modals: they stop every key from reaching the view below,
  which also kept useDialog's document listener from seeing it.
2026-10-29 11:20:54 +07:00
nntrivi2001 4a82790734 chore: v2.8.35 — an HTML file's Preview sits beside Whole file 2026-10-29 10:47:39 +07:00
nntrivi2001 80000793f4 feat(files): an HTML file's Preview sits beside Whole file
The Changes panel's view toggle becomes Changes | Whole file | Preview for an
HTML file; the Preview/Raw button on the file's name row stays for markdown.
2026-10-29 10:47:39 +07:00
227 changed files with 30827 additions and 2535 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": ""
}
}
+5 -1
View File
@@ -388,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 |
+12 -10
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,385 @@
/**
* @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>
)}
{job.waiting && !stopping && (
<p className="text-[11px] text-status-warning" role="status" data-testid="upload-waiting">
{t("uploads.waiting")}
</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>
);
}
+4 -2
View File
@@ -15,8 +15,10 @@ 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. */
const HTML_MAX = 10 * 1024 * 1024;
/** 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,
+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 && (
+3 -2
View File
@@ -1,7 +1,8 @@
/**
* @file NeedsYouStrip.tsx
* @description The Dashboard's pinned "Needs you" strip: every active session
* blocked on the user (`awaiting_input_since`), each linking straight to its
* of the viewer's OWN blocked on them (`awaiting_input_since`, `mine=1` — an
* admin's lists no other user's), each linking straight to its
* workspace pane (or replay), else its analysis page. Renders nothing while no
* session is waiting. `compact` is the one-line form shown above Analytics.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -27,7 +28,7 @@ export function NeedsYouStrip({ compact = false }: { compact?: boolean }) {
const load = useCallback(() => {
api.sessions
.list({ awaiting: true, limit: 20 })
.list({ awaiting: true, limit: 20, mine: true })
.then((r) => {
setSessions(r.sessions);
setTotal(r.total);
-595
View File
@@ -1,595 +0,0 @@
/**
* @file PetChat.tsx
* @description The pet's chat window — its whole UI: clicking the pet opens
* it. Above the conversation sits what the pet knows (`info`: live / waiting /
* errored, the sessions that need you, Mute, Clear alerts — `SagiInfo.tsx`).
* Before a chat runs, a question the pet can answer itself (`onLocal`: status,
* who are you…) is answered right here; anything else — and every message once
* a chat runs — goes to the AI: the first message starts a
* session of the user's DEFAULT AI tool (`agenthub.defaultTool`, Claude or
* Codex — the two with a readable transcript) in the "Sagi" workspace, in its
* first free tab (`startInFreeTabDetailed`), and the conversation is shown as
* chat bubbles read from the session's transcript. Follow-ups are typed into
* that session's pane through a terminal attached OFF-SCREEN (`TerminalView`
* in a fixed box left of the viewport — `submitToRun` needs a mounted,
* attached pane, exactly like the workspace's own Chat view), so nothing new
* reaches the server. A menu the CLI asks with (a permission or trust prompt)
* is read off that pane and answered with `PaneMenuCard`. The chat is kept in
* `localStorage` (`agenthub.pet.chat`) while its pane lives, so closing and
* reopening the window continues it; **New chat** STOPS that session (kills
* its pane and archives it, asking first only mid-reply), **Open in workspace**
* opens its pane there. Opening never resumes an ended session: once the pane
* is gone, the next message starts a new one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { ArrowUp, ExternalLink, Loader2, SquarePen, Wrench, X } from "lucide-react";
import { api } from "../../lib/api";
import type { TranscriptMessage } from "../../lib/types";
import { readDefaultMode, readDefaultTool } from "../../lib/sessionDefaults";
import {
answerRunMenu,
chooseRunOption,
interruptRun,
readRunMenu,
stepRunMenu,
submitToRun,
type PaneMenu,
} from "../../lib/panePaste";
import { isSubmitEnter } from "../../lib/keys";
import { confirmDialog } from "../../lib/confirm";
import { toast } from "../../lib/toast";
import { startInFreeTabDetailed } from "../lanes/startSession";
import { TerminalView } from "../run/TerminalView";
import { PaneMenuCard } from "../run/PaneChat";
import { MarkdownContent } from "../conversation/MarkdownContent";
/** Tools whose session the chat can read back (a transcript AgentHub parses). */
export const PET_CHAT_TOOLS = new Set(["claude", "codex"]);
const CHAT_KEY = "agenthub.pet.chat";
const POLL_MS = 1500;
const MENU_POLL_MS = 700;
// A follow-up waits this long for the off-screen pane to attach.
const SEND_TRIES = 20;
const SEND_RETRY_MS = 250;
/** The session the chat is talking to. */
export interface PetChatRef {
laneId: number;
runId: string;
tool: string;
}
export function readPetChat(): PetChatRef | null {
try {
const v = JSON.parse(localStorage.getItem(CHAT_KEY) ?? "null") as Partial<PetChatRef> | null;
if (
v &&
typeof v.laneId === "number" &&
typeof v.runId === "string" &&
typeof v.tool === "string"
)
return { laneId: v.laneId, runId: v.runId, tool: v.tool };
} catch {
/* unreadable: no chat */
}
return null;
}
function writePetChat(ref: PetChatRef | null) {
try {
if (ref) localStorage.setItem(CHAT_KEY, JSON.stringify(ref));
else localStorage.removeItem(CHAT_KEY);
} catch {
/* best-effort */
}
}
/** The tool a new chat starts: the user's default, when the chat can show it. */
export function petChatTool(): string | null {
const tool = readDefaultTool() ?? "claude";
return PET_CHAT_TOOLS.has(tool) ? tool : null;
}
type Line =
| { key: string; role: "user" | "assistant"; text: string }
| { key: string; role: "tools"; names: string[] };
/** A human line's text: Claude Code's own injected lines (`<command-…>`,
* `<system-reminder>`…) are not something the user said. */
const humanText = (m: TranscriptMessage) =>
m.content
.filter((c) => c.type === "text" && c.text)
.map((c) => c.text!.trim())
.filter((t) => t && !t.startsWith("<"))
.join("\n\n");
/** The transcript as chat lines: the user's words, the assistant's prose, and
* each run of tool calls between them folded into one chip. */
export function chatLines(messages: TranscriptMessage[]): Line[] {
const out: Line[] = [];
messages.forEach((m, i) => {
const sender = m.sender ?? m.type;
if (sender === "user") {
const text = humanText(m);
if (text) out.push({ key: `u${i}`, role: "user", text });
return;
}
if (m.type !== "assistant") return;
m.content.forEach((c, j) => {
if (c.type === "text" && c.text?.trim()) {
out.push({ key: `a${i}-${j}`, role: "assistant", text: c.text.trim() });
} else if (c.type === "tool_use" && c.name) {
const last = out[out.length - 1];
if (last?.role === "tools") last.names.push(c.name);
else out.push({ key: `t${i}-${j}`, role: "tools", names: [c.name] });
}
});
});
return out;
}
/** Still working: the last line is the user's, or an assistant turn not ended. */
function isBusy(messages: TranscriptMessage[]): boolean {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]!;
const sender = m.sender ?? m.type;
if (m.type === "assistant") return m.stop_reason !== undefined && m.stop_reason !== "end_turn";
if (sender === "user" && humanText(m)) return true;
}
return false;
}
/** "Read ×2 · Bash" */
function toolSummary(names: string[]): string {
const counts = new Map<string, number>();
names.forEach((n) => counts.set(n, (counts.get(n) ?? 0) + 1));
return [...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(" · ");
}
/** What the container made of a message: an answer the pet gives itself, or
* `handled` (it took the message elsewhere); null sends it to the AI. */
export type PetLocalReply = { answer: string } | { handled: true } | null;
interface PetChatProps {
/** The pet's shown name, for the header and greeting. */
name: string;
/** Shown in place of the plain name (the rename button). */
title?: ReactNode;
/** Extra header buttons, before Open in workspace (the pat heart). */
headerActions?: ReactNode;
/** Under the header: what the pet knows (status, sessions that need you). */
info?: ReactNode;
/** Asked first while no chat runs. */
onLocal?: (text: string) => PetLocalReply;
onClose: () => void;
/** Opens the chat's pane in its workspace. */
onOpenWorkspace: (laneId: number, runId: string) => void;
/** The thinking mood while a reply is being written. */
onBusy?: (busy: boolean) => void;
}
export function PetChat({
name,
title,
headerActions,
info,
onLocal,
onClose,
onOpenWorkspace,
onBusy,
}: PetChatProps) {
const { t } = useTranslation("settings");
const [chat, setChat] = useState<PetChatRef | null>(readPetChat);
const [sessionId, setSessionId] = useState<string | null>(null);
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
// Sent but not in the transcript yet.
const [pending, setPending] = useState<string | null>(null);
const [starting, setStarting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const [menu, setMenu] = useState<PaneMenu | null>(null);
// The pet's own answers, given before any chat ran.
const [local, setLocal] = useState<Line[]>([]);
const listRef = useRef<HTMLDivElement>(null);
const remember = (ref: PetChatRef | null) => {
writePetChat(ref);
setChat(ref);
setSessionId(null);
setMessages([]);
setMenu(null);
};
// A remembered chat whose pane is gone is over: forget it, the next message starts anew.
useEffect(() => {
if (!chat) return;
let live = true;
const check = () =>
api.run
.get(chat.runId)
.then((h) => {
if (!live) return;
if (h.status !== "running") remember(null);
else if (h.sessionId) setSessionId(h.sessionId);
})
.catch(() => live && remember(null));
void check();
// Codex learns its session only after a while; Claude's is known at start.
const timer = window.setInterval(() => {
if (!sessionId) void check();
}, POLL_MS);
return () => {
live = false;
window.clearInterval(timer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chat?.runId, sessionId]);
// The conversation, polled while the window is open.
useEffect(() => {
if (!sessionId) return;
let live = true;
const load = () =>
api.sessions
.transcript(sessionId, { limit: 60 })
.then((r) => {
if (!live) return;
setMessages((prev) =>
prev.length === r.messages.length &&
JSON.stringify(prev[prev.length - 1]) ===
JSON.stringify(r.messages[r.messages.length - 1])
? prev
: r.messages
);
})
.catch(() => undefined); // not started yet: the next poll asks again
void load();
const timer = window.setInterval(load, POLL_MS);
return () => {
live = false;
window.clearInterval(timer);
};
}, [sessionId]);
// A menu the CLI shows in the pane (permission, trust, a question).
useEffect(() => {
if (!chat) return;
const read = () =>
setMenu((prev) => {
const next = readRunMenu(chat.runId);
return JSON.stringify(next) === JSON.stringify(prev) ? prev : next;
});
const timer = window.setInterval(read, MENU_POLL_MS);
return () => window.clearInterval(timer);
}, [chat]);
const lines = [...local, ...chatLines(messages)];
// The pending line shows until the transcript has it.
useEffect(() => {
if (pending && lines.some((l) => l.role === "user" && l.text === pending.trim()))
setPending(null);
}, [lines, pending]);
const busy = starting || !!pending || isBusy(messages);
useEffect(() => onBusy?.(busy), [busy, onBusy]);
useEffect(() => () => onBusy?.(false), [onBusy]);
// Keep the newest in sight.
useEffect(() => {
const el = listRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [lines.length, pending, busy, menu]);
const send = useCallback(
async (text: string) => {
const prompt = text.trim();
if (!prompt || starting) return;
setError(null);
if (!chat && onLocal) {
const reply = onLocal(prompt);
if (reply && "handled" in reply) return;
if (reply) {
const n = Date.now();
setLocal((prev) => [
...prev,
{ key: `lu${n}`, role: "user", text: prompt },
{ key: `la${n}`, role: "assistant", text: reply.answer },
]);
return;
}
}
setPending(prompt);
if (chat) {
// Typed into the attached pane; it may still be attaching.
for (let i = 0; i < SEND_TRIES; i++) {
if (submitToRun(chat.runId, prompt)) return;
await new Promise((r) => setTimeout(r, SEND_RETRY_MS));
}
setPending(null);
setDraft(prompt);
setError(t("sagi.chat.notSent"));
return;
}
const tool = petChatTool() ?? "claude";
setStarting(true);
try {
const { lane } = await api.lanes.ensure({ title: "Sagi" });
const r = await startInFreeTabDetailed(lane.id, {
prompt,
...(tool !== lane.tool ? { tool } : {}),
...(tool === "claude" ? { permissionMode: readDefaultMode({ safe: true }) } : {}),
});
remember({ laneId: r.laneId, runId: r.runId, tool });
} catch (err) {
setPending(null);
setDraft(prompt);
setError(
t("sagi.startFailed", { error: err instanceof Error ? err.message : String(err) })
);
} finally {
setStarting(false);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[chat, starting, t, onLocal]
);
// New chat: the old one's session is stopped, not left running unseen —
// asking first only while it is still writing a reply.
const newChat = async () => {
if (chat) {
if (
busy &&
!(await confirmDialog({
title: t("sagi.chat.endTitle"),
message: t("sagi.chat.endBody"),
confirmLabel: t("sagi.chat.endConfirm"),
destructive: true,
}))
)
return;
try {
await api.run.kill(chat.runId, { archive: true });
} catch (err) {
toast.error(err);
return;
}
}
remember(null);
setLocal([]);
setPending(null);
setError(null);
};
const submit = (e?: FormEvent) => {
e?.preventDefault();
if (!draft.trim()) return;
const text = draft;
setDraft("");
void send(text);
};
const toolLabel = chat?.tool ?? petChatTool() ?? "claude";
const empty = lines.length === 0 && !pending;
return (
<div
className="pet-chat flex h-[30rem] max-h-[calc(100vh-6rem)] w-80 flex-col overflow-hidden rounded-3xl border border-border-light shadow-2xl animate-slide-up"
role="dialog"
aria-label={t("sagi.chat.label", { name })}
>
{/* header */}
<div className="pet-chat-head flex items-center gap-1 px-2 py-2">
<span className="pl-1.5 text-base leading-none" aria-hidden>
🐾
</span>
<div className="min-w-0 flex-1 px-1">
{title ?? <div className="truncate text-sm font-semibold text-fg-primary">{name}</div>}
<div className="flex items-center gap-1 text-[10px] uppercase tracking-wider text-fg-muted">
<span
className={`inline-block h-1.5 w-1.5 rounded-full ${
busy
? "animate-pulse bg-status-warning"
: chat
? "bg-status-success"
: "bg-fg-muted"
}`}
aria-hidden
/>
{busy ? t("sagi.chat.typing") : toolLabel}
</div>
</div>
{headerActions}
{chat && (
<IconButton
label={t("sagi.chat.openWorkspace")}
onClick={() => onOpenWorkspace(chat.laneId, chat.runId)}
>
<ExternalLink size={15} />
</IconButton>
)}
<IconButton
label={t("sagi.chat.new")}
onClick={() => void newChat()}
disabled={(!chat && !local.length) || starting}
>
<SquarePen size={15} />
</IconButton>
<IconButton label={t("sagi.chat.close", { name })} onClick={onClose}>
<X size={16} />
</IconButton>
</div>
{info}
{/* conversation */}
<div ref={listRef} className="flex-1 space-y-2 overflow-y-auto px-3 py-3">
{empty && (
<div className="flex h-full flex-col items-center justify-center gap-2 px-4 text-center">
<span className="text-3xl" aria-hidden>
🐾
</span>
<p className="text-sm font-medium text-fg-primary">
{t("sagi.chat.emptyTitle", { name })}
</p>
<p className="text-xs text-fg-muted">{t("sagi.chat.emptyHint", { tool: toolLabel })}</p>
</div>
)}
{lines.map((l) =>
l.role === "tools" ? (
<div key={l.key} className="flex justify-start">
<span className="pet-chat-tools inline-flex max-w-[85%] items-center gap-1 truncate rounded-full px-2 py-0.5 text-[10px] text-fg-muted">
<Wrench size={10} className="shrink-0" aria-hidden />
<span className="truncate">{toolSummary(l.names)}</span>
</span>
</div>
) : l.role === "user" ? (
<div key={l.key} className="flex justify-end">
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-accent-solid px-3 py-2 text-sm text-white shadow-sm">
{l.text}
</div>
</div>
) : (
<div key={l.key} className="flex justify-start">
<div className="pet-chat-reply max-w-[90%] break-words rounded-2xl rounded-bl-md px-3 py-2 text-sm text-fg-primary shadow-sm">
<MarkdownContent text={l.text} dense prose />
</div>
</div>
)
)}
{pending && (
<div className="flex justify-end">
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-accent-solid px-3 py-2 text-sm text-white opacity-70 shadow-sm">
{pending}
</div>
</div>
)}
{busy && !menu && (
<div className="flex justify-start" aria-label={t("sagi.chat.typing")}>
<div className="pet-chat-reply flex gap-1 rounded-2xl rounded-bl-md px-3 py-3 shadow-sm">
<span className="pet-chat-dot" />
<span className="pet-chat-dot" />
<span className="pet-chat-dot" />
</div>
</div>
)}
{starting && (
<p className="flex items-center justify-center gap-1.5 text-[11px] text-fg-muted">
<Loader2 size={12} className="animate-spin" aria-hidden />
{t("sagi.chat.starting", { tool: toolLabel })}
</p>
)}
{chat && !sessionId && !starting && (
<p className="text-center text-[11px] text-fg-muted">{t("sagi.chat.notStarted")}</p>
)}
</div>
{error && (
<p className="mx-3 mb-1 rounded-lg bg-status-danger/10 px-2 py-1 text-[11px] text-status-danger">
{error}
</p>
)}
{/* prompt, or the question the CLI asks */}
<div className="pet-chat-foot px-2.5 py-2.5">
{menu && chat ? (
<div className="max-h-56 overflow-y-auto">
<PaneMenuCard
menu={menu}
onChoose={(i) => {
if (chooseRunOption(chat.runId, menu, i)) setMenu(null);
else setError(t("sagi.chat.notSent"));
}}
onAnswer={(text) => {
if (answerRunMenu(chat.runId, menu, text)) setMenu(null);
else setError(t("sagi.chat.notSent"));
}}
onStep={(dir) => {
if (!stepRunMenu(chat.runId, dir)) setError(t("sagi.chat.notSent"));
}}
onCancel={() => {
if (interruptRun(chat.runId)) setMenu(null);
else setError(t("sagi.chat.notSent"));
}}
/>
</div>
) : (
<form onSubmit={submit} className="flex items-end gap-1.5">
<textarea
rows={1}
autoFocus
className="max-h-28 min-h-[2.25rem] flex-1 resize-none rounded-2xl border border-border bg-surface-1 px-3.5 py-2 text-sm text-fg-primary placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
placeholder={t("sagi.chat.placeholder", { name })}
aria-label={t("sagi.chat.placeholder", { name })}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e) && !e.shiftKey) {
e.preventDefault();
submit();
} else if (e.key === "Escape") {
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
onClose();
}
}}
/>
<button
type="submit"
disabled={!draft.trim() || starting}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent-solid text-white shadow-sm transition-opacity hover:opacity-90 disabled:opacity-40"
aria-label={t("sagi.panel.send")}
>
<ArrowUp size={16} />
</button>
</form>
)}
</div>
{/* The chat's pane, attached off-screen: the only way a follow-up or a
menu answer reaches it. A real size, so tmux does not shrink the pane. */}
{chat && (
<div className="pet-chat-term" aria-hidden>
<TerminalView
runId={chat.runId}
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
autoFocus={false}
// The pane ended: the conversation stays on screen, the next
// message starts a new session.
onExit={() => {
writePetChat(null);
setChat(null);
}}
/>
</div>
)}
</div>
);
}
function IconButton({
label,
onClick,
disabled,
children,
}: {
label: string;
onClick: () => void;
disabled?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
>
{children}
</button>
);
}
+60
View File
@@ -0,0 +1,60 @@
/**
* @file PetPanel.tsx
* @description The pet's window — clicking the pet opens it: its name (which
* the user can rename), a pat button, and what the pet knows (`info`: live /
* waiting / errored, the sessions that need you, Mute, Clear alerts —
* `SagiInfo.tsx`). The pet no longer chats: it started a full AI session per
* question, a heavy process on a shared server, so the chat was removed
* (v2.8.68); this window is where the pet's own care comes next.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
export function PetPanel({
name,
title,
headerActions,
info,
onClose,
}: {
name: string;
/** The name line (a rename button); plain text without it. */
title?: ReactNode;
/** Buttons before Close (the pat). */
headerActions?: ReactNode;
/** What the pet knows (`SagiInfo`). */
info: ReactNode;
onClose: () => void;
}) {
const { t } = useTranslation("settings");
return (
<div
className="pet-chat flex max-h-[calc(100vh-6rem)] w-80 flex-col overflow-hidden rounded-3xl border border-border-light shadow-2xl animate-slide-up"
role="dialog"
aria-label={t("sagi.panel.label", { name })}
>
<div className="pet-chat-head flex items-center gap-1 px-2 py-2">
<span className="pl-1.5 text-base leading-none" aria-hidden>
🐾
</span>
<div className="min-w-0 flex-1 px-1">
{title ?? <div className="truncate text-sm font-semibold text-fg-primary">{name}</div>}
</div>
{headerActions}
<button
type="button"
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary"
onClick={onClose}
aria-label={t("sagi.panel.close", { name })}
title={t("sagi.panel.close", { name })}
>
<X size={16} />
</button>
</div>
<div className="overflow-y-auto">{info}</div>
</div>
);
}
+7 -60
View File
@@ -5,9 +5,8 @@
* enabled; `Sagi` gates on the same preference itself, and the body (brain,
* tick, WS reducer) runs only while it is on. Persists across routes and
* shares the single WebSocket.
* Clicking the pet opens its chat window (`PetChat`), which is its whole UI:
* the status strip (`SagiInfo`) sits on top, a question the pet can answer
* itself is answered there, anything else goes to the AI.
* Clicking the pet opens its window (`PetPanel`): its name, a pat, and the
* status strip (`SagiInfo`). The pet no longer chats (v2.8.68).
* Owns the open/closed chat state, the ⌘B / Esc shortcuts, reduced-motion
* detection, and route navigation. Reactive personality + status/Ask come
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
@@ -15,10 +14,7 @@
* bubble/panel render in a self-clamping flyout so
* they never spill off any screen edge regardless of where the mascot is docked.
*
* The "do the job" path: an unmatched question is the chat's — it starts a
* session of the default AI tool in the "Sagi" workspace; a default tool the
* chat cannot read back starts there and its session view opens
* (`/workspace?lane=<id>&run=<runId>`) — no new LLM backend. ⌘B/Ctrl+B is taken only while the mascot is shown and focus is not
* ⌘B/Ctrl+B is taken only while the mascot is shown and focus is not
* in an editable field (a terminal's Ctrl+B is tmux's prefix).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -91,15 +87,12 @@ import { PetNameButton, SagiInfo } from "./SagiInfo";
import { useSagiBrain } from "./useSagiBrain";
import { useSagiPosition } from "./useSagiPosition";
import { useSagiEnabled } from "./SagiMount";
import { matchIntent } from "./intents";
import { petName, sagiPrefs } from "./prefs";
import type { SagiSubject } from "./brain";
import { api } from "../../lib/api";
import { readDefaultTool } from "../../lib/sessionDefaults";
import { startInFreeTabDetailed } from "../lanes/startSession";
import { workspaceHref } from "../SessionRowMenu";
import { Heart } from "lucide-react";
import { PetChat, petChatTool, type PetLocalReply } from "./PetChat";
import { PetPanel } from "./PetPanel";
// Stroking: this many left/right turns of the pointer over the pet, within
// STROKE_MS and with no button held, is a pat; then a pause before the next.
@@ -229,7 +222,7 @@ function SagiBody() {
// The name it goes by: the user's nickname, the pet's own, else Sagi.
const [name, setName] = useState(() => petName(sagiPrefs.getPet()));
const [size, setSize] = useState(() => sagiPrefs.getSize());
// The chat window (PetChat.tsx): the pet's whole UI.
// The pet's window (PetPanel.tsx).
const [open, setOpen] = useState(false);
// The right-click pet menu (`PetMenu.tsx`).
const [menu, setMenu] = useState(false);
@@ -340,43 +333,6 @@ function SagiBody() {
[brain, navigate]
);
// Asked by the chat before any session runs.
const onLocal = useCallback(
(query: string): PetLocalReply => {
const result = matchIntent(query, brain.status, { name, attention: brain.attention });
if (result.kind === "answer") {
if (result.moment) brain.playMoment(result.moment);
return { answer: result.text };
}
// A question the pet cannot answer: the chat starts a session of the
// default AI tool and shows it as chat.
if (petChatTool()) return null;
// A default tool the chat cannot read back (not Claude/Codex): start it
// in the "Sagi" workspace and open its pane there, as before.
const prompt = result.prompt;
brain.setThinking(true);
(async () => {
try {
const { lane } = await api.lanes.ensure({ title: "Sagi" });
const tool = readDefaultTool();
const r = await startInFreeTabDetailed(lane.id, {
prompt,
...(tool && tool !== lane.tool ? { tool } : {}),
});
navigate(`/workspace?lane=${r.laneId}&run=${encodeURIComponent(r.runId)}`);
setOpen(false);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
brain.setBubble(t("sagi.startFailed", { error: msg }));
} finally {
brain.setThinking(false);
}
})();
return { handled: true };
},
[brain, navigate, t, name]
);
// Stroke the pet (move the pointer back and forth over it) to pat it.
const stroke = useRef({ dir: 0, x: 0, turns: [] as number[], until: 0 });
const onStroke = (e: React.PointerEvent) => {
@@ -398,8 +354,6 @@ function SagiBody() {
s.dir = dir;
};
const onBusy = useCallback((b: boolean) => brain.setThinking(b), [brain.setThinking]);
const anchor: Anchor = {
left: place.left,
top: place.top,
@@ -412,11 +366,10 @@ function SagiBody() {
return (
<>
{/* Beside the pet, riding along with it mid-drag too (SagiFlyout follows
it 1:1). Closing unmounts it; its chat is remembered, so reopening
re-attaches the same session. */}
it 1:1). */}
{open && (
<SagiFlyout anchor={anchor} beside>
<PetChat
<PetPanel
name={name}
title={
<PetNameButton
@@ -449,13 +402,7 @@ function SagiBody() {
defaultOpen={brain.attention.length > 0}
/>
}
onLocal={onLocal}
onClose={() => setOpen(false)}
onOpenWorkspace={(laneId, runId) => {
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
setOpen(false);
}}
onBusy={onBusy}
/>
</SagiFlyout>
)}
+3 -3
View File
@@ -1,6 +1,6 @@
/**
* @file SagiInfo.tsx
* @description What the pet knows, shown inside its chat window (`PetChat`):
* @description What the pet knows, shown inside its window (`PetPanel`):
* the name you rename by clicking it (`PetNameButton`), and a status strip
* (`SagiInfo`) — live / waiting / errored counts and the connection state
* always in sight, and on expanding, the sessions that need the user, by
@@ -118,8 +118,8 @@ export function SagiInfo({
className="flex w-full items-center gap-1.5 rounded-lg text-left"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-label={t("sagi.chat.status")}
title={t("sagi.chat.status")}
aria-label={t("sagi.panel.status")}
title={t("sagi.panel.status")}
>
<StatChip
icon={Radio}
@@ -1,46 +0,0 @@
/**
* @file PetChat.test.ts
* @description Unit tests for the pet chat's transcript-to-bubbles folding (`chatLines`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { chatLines } from "../PetChat";
import type { TranscriptMessage } from "../../../lib/types";
const msg = (m: Partial<TranscriptMessage>): TranscriptMessage =>
({ type: "user", timestamp: null, content: [], ...m }) as TranscriptMessage;
describe("chatLines", () => {
it("keeps the user's words and the assistant's prose, folding tool calls into one chip", () => {
const lines = chatLines([
msg({ type: "user", sender: "user", content: [{ type: "text", text: "hi" }] }),
msg({
type: "assistant",
content: [
{ type: "thinking", text: "hmm" },
{ type: "tool_use", name: "Read" },
],
}),
msg({ type: "user", sender: "tool", content: [{ type: "tool_result", output: "x" }] }),
msg({
type: "assistant",
content: [
{ type: "tool_use", name: "Read" },
{ type: "text", text: "hello!" },
],
}),
]);
expect(lines.map((l) => (l.role === "tools" ? l.names : l.text))).toEqual([
"hi",
["Read", "Read"],
"hello!",
]);
});
it("drops Claude Code's own injected lines", () => {
const lines = chatLines([
msg({ sender: "user", content: [{ type: "text", text: "<command-name>/clear</command-name>" }] }),
]);
expect(lines).toEqual([]);
});
});
@@ -1,6 +1,6 @@
/**
* @file Sagi.test.tsx
* @description Render tests for the Sagi companion component — mounting, mood rendering, the ⌘B chat toggle (the chat window is the pet's whole UI: status strip, local answers, New chat stopping the old session), and accessibility attributes.
* @description Render tests for the Sagi companion component — mounting, mood rendering, the ⌘B window toggle (the pet's window: name, pat, status strip — the pet no longer chats), and accessibility attributes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -18,7 +18,6 @@ import { useSagiPosition } from "../useSagiPosition";
import { MemoryRouter } from "react-router-dom";
import { Sagi } from "../Sagi";
import { eventBus } from "../../../lib/eventBus";
import { api } from "../../../lib/api";
import type { WSMessage, Session } from "../../../lib/types";
function renderSagi() {
@@ -55,16 +54,15 @@ describe("Sagi widget", () => {
expect(screen.getByRole("img", { name: /sagi/i })).toBeInTheDocument();
});
it("opens the chat on click and answers a local status question there", () => {
it("opens its window on click — status, no chat box", () => {
renderSagi();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
expect(panel).toBeInTheDocument();
const input = within(panel).getByLabelText(/ask sagi anything/i);
fireEvent.change(input, { target: { value: "status" } });
fireEvent.submit(input.closest("form")!);
expect(within(panel).getByText(/live ·/i)).toBeInTheDocument();
const panel = screen.getByRole("dialog", { name: /pet sagi/i });
expect(within(panel).getByText("live")).toBeInTheDocument();
expect(within(panel).queryByRole("textbox")).toBeNull();
expect(within(panel).queryByRole("button", { name: /new chat/i })).toBeNull();
fireEvent.click(within(panel).getByRole("button", { name: /close sagi/i }));
expect(screen.queryByRole("dialog")).toBeNull();
});
it("toggles open/closed with Cmd/Ctrl+B and closes with Esc", () => {
@@ -89,7 +87,7 @@ describe("Sagi widget", () => {
expect(within(btn).getByText("1")).toBeInTheDocument();
});
it("names the errored session in its bubble and in the chat", () => {
it("names the errored session in its bubble and in its window", () => {
renderSagi();
act(() => {
eventBus.publish({
@@ -102,7 +100,7 @@ describe("Sagi widget", () => {
expect(within(bubble).getByText("Fix login")).toBeInTheDocument();
expect(within(bubble).getByRole("button", { name: "Open" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
const panel = screen.getByRole("dialog", { name: /pet sagi/i });
expect(within(panel).getByText("Needs you")).toBeInTheDocument();
expect(within(panel).getByRole("button", { name: /Fix login/ })).toBeInTheDocument();
});
@@ -122,54 +120,17 @@ describe("Sagi widget", () => {
expect(JSON.parse(localStorage.getItem("agenthub.pet.dismissedErrors")!)).toEqual(["a"]);
});
it("renames the pet from the chat header", () => {
it("renames the pet from its window header", () => {
renderSagi();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
fireEvent.click(screen.getByRole("button", { name: /rename sagi/i }));
const input = screen.getByLabelText("Pet name");
fireEvent.change(input, { target: { value: "Mochi" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByRole("dialog", { name: /chat with mochi/i })).toBeInTheDocument();
expect(screen.getByLabelText(/ask mochi anything/i)).toBeInTheDocument();
expect(screen.getByRole("dialog", { name: /pet mochi/i })).toBeInTheDocument();
});
it("hands a question it cannot answer to the AI", () => {
localStorage.setItem("agenthub.defaultTool", "claude");
const ensure = vi.spyOn(api.lanes, "ensure").mockReturnValue(new Promise(() => {}));
renderSagi();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
const chat = screen.getByRole("dialog", { name: /chat with sagi/i });
const input = within(chat).getByLabelText(/ask sagi anything/i);
fireEvent.change(input, { target: { value: "write me a haiku" } });
fireEvent.submit(input.closest("form")!);
expect(within(chat).getByText("write me a haiku")).toBeInTheDocument();
expect(ensure).toHaveBeenCalledWith({ title: "Sagi" });
ensure.mockRestore();
});
it("reopens the remembered chat, and New chat stops its session", async () => {
localStorage.setItem(
"agenthub.pet.chat",
JSON.stringify({ laneId: 7, runId: "agenthub-lane-7-1", tool: "claude" })
);
const get = vi
.spyOn(api.run, "get")
.mockReturnValue(new Promise(() => {}) as ReturnType<typeof api.run.get>);
const kill = vi.spyOn(api.run, "kill").mockResolvedValue({ ok: true });
renderSagi();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
expect(screen.getByRole("button", { name: /open in workspace/i })).toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /new chat/i }));
});
expect(kill).toHaveBeenCalledWith("agenthub-lane-7-1", { archive: true });
expect(localStorage.getItem("agenthub.pet.chat")).toBeNull();
expect(screen.queryByRole("button", { name: /open in workspace/i })).not.toBeInTheDocument();
get.mockRestore();
kill.mockRestore();
});
it("a pat shows its reaction on the pet even while the chat is open", () => {
it("a pat shows its reaction on the pet even while its window is open", () => {
renderSagi();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
fireEvent.click(screen.getByRole("button", { name: /pat sagi/i }));
@@ -187,26 +148,40 @@ describe("Sagi widget", () => {
expect(pet.querySelector(".sagi-reaction-text")).not.toBeNull();
});
it("reflects the live count in the chat status", () => {
it("reflects the live count in its status", () => {
renderSagi();
act(() => {
eventBus.publish(sessionMsg("a", "active"));
eventBus.publish(sessionMsg("b", "active"));
});
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
const panel = screen.getByRole("dialog", { name: /pet sagi/i });
// The "live" stat chip shows value 2 next to its label.
const liveChip = within(panel).getByText("live").closest("span.rounded-full")! as HTMLElement;
expect(within(liveChip).getByText("2")).toBeInTheDocument();
});
it("counts only the viewer's own sessions (another user's arrive marked mine: false)", () => {
renderSagi();
act(() => {
eventBus.publish({ ...sessionMsg("mine", "active"), mine: true });
eventBus.publish({ ...sessionMsg("theirs", "active"), mine: false });
eventBus.publish({ ...sessionMsg("theirs-err", "error"), mine: false });
});
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
const panel = screen.getByRole("dialog", { name: /pet sagi/i });
const liveChip = within(panel).getByText("live").closest("span.rounded-full")! as HTMLElement;
expect(within(liveChip).getByText("1")).toBeInTheDocument();
expect(screen.queryByText(/theirs/)).toBeNull();
});
it("respects the enabled preference", () => {
localStorage.setItem("agent-dashboard-sagi-enabled", "false");
renderSagi();
expect(screen.queryByRole("button", { name: /open sagi companion/i })).not.toBeInTheDocument();
});
it("a tap (no movement) still opens the chat", () => {
it("a tap (no movement) still opens its window", () => {
renderSagi();
const btn = screen.getByRole("button", { name: /open sagi companion/i });
act(() => {
@@ -214,7 +189,7 @@ describe("Sagi widget", () => {
fireEvent.pointerUp(btn, { clientX: 990, clientY: 700 });
});
fireEvent.click(btn);
expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument();
expect(screen.getByRole("dialog", { name: /pet sagi/i })).toBeInTheDocument();
});
it("dragging snaps to an edge, persists position, and does not open the panel", () => {
@@ -243,7 +218,7 @@ describe("Sagi widget", () => {
fireEvent.pointerUp(btn, { clientX: 992, clientY: 701 });
});
fireEvent.click(btn);
expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument();
expect(screen.getByRole("dialog", { name: /pet sagi/i })).toBeInTheDocument();
// No position was persisted because no real drag happened.
expect(localStorage.getItem("agent-dashboard-sagi-pos")).toBeNull();
});
+3 -61
View File
@@ -529,13 +529,10 @@
z-index: 42;
}
/* ════════ the pet's chat window (PetChat.tsx) ════════ */
/* ════════ the pet's window (PetPanel.tsx) ════════ */
/* The reference's soft gradient, in the theme's own accent rather than a fixed
colour: a tint of the accent at the top fading into the surface, so it
reads in every light and dark theme. Replies sit on the plain surface like
the reference's white bubbles; the user's own are accent-solid (white text
is only ever on accent-solid). */
/* A tint of the theme's accent at the top fading into the surface, so it
reads in every light and dark theme. */
.pet-chat {
background: linear-gradient(
180deg,
@@ -547,61 +544,6 @@
.pet-chat-head {
border-bottom: 1px solid color-mix(in srgb, rgb(var(--accent)) 18%, transparent);
}
.pet-chat-reply {
background: rgb(var(--surface-0));
border: 1px solid color-mix(in srgb, rgb(var(--accent)) 12%, transparent);
}
.pet-chat-tools {
background: color-mix(in srgb, rgb(var(--surface-0)) 70%, transparent);
border: 1px dashed color-mix(in srgb, rgb(var(--accent)) 25%, transparent);
}
.pet-chat-foot {
background: rgb(var(--surface-0));
border-top: 1px solid rgb(var(--border) / 0.6);
}
/* Three bouncing dots while a reply is written. */
.pet-chat-dot {
width: 6px;
height: 6px;
border-radius: 9999px;
background: rgb(var(--accent));
opacity: 0.5;
animation: pet-chat-dot 1.2s ease-in-out infinite;
}
.pet-chat-dot:nth-child(2) {
animation-delay: 0.15s;
}
.pet-chat-dot:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes pet-chat-dot {
0%,
60%,
100% {
transform: translateY(0);
opacity: 0.35;
}
30% {
transform: translateY(-4px);
opacity: 0.9;
}
}
/* The chat's terminal, attached but off-screen. It keeps a real size (so tmux
does not shrink the pane to fit it) and a non-zero box (so the paste
registry counts it as on screen). */
.pet-chat-term {
position: fixed;
left: -10000px;
top: 0;
width: 760px;
height: 460px;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.pet-chat-dot {
animation: none;
}
}
/* ════════ a pat's reaction, drawn on the pet itself ════════ */
+6 -2
View File
@@ -7,7 +7,8 @@
* burst of the same event into one count), the pet's one-shot animation
* "moment", and imperative controls (mute, clear alerts, pat, set thinking)
* for the UI shell. It also greets once per browser session and makes an
* idle pet fidget now and then.
* idle pet fidget now and then. It only ever counts the viewer's OWN sessions
* (`mine=1` seed, and frames marked `mine: false` skipped).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -278,7 +279,7 @@ export function useSagiBrain(): SagiBrain {
}, GREET_DELAY_MS);
};
api.sessions
.list({ status: "active", limit: 100 })
.list({ status: "active", limit: 100, mine: true })
.then((res) => {
if (cancelled) return;
update((prev) => seedSessions(prev, res.sessions, Date.now()));
@@ -294,6 +295,9 @@ export function useSagiBrain(): SagiBrain {
// Subscribe to the live stream and connection status.
useEffect(() => {
const unsubMsg = eventBus.subscribe((msg: WSMessage) => {
// Each user's pet tells only of their own sessions: an admin/analyst
// gets every user's, marked `mine: false` when not theirs.
if (msg.mine === false) return;
const prev = stateRef.current;
const { state: next, pulse, subject } = reduceSagi(prev, msg, Date.now());
// Most frames only move lastActivityAt, which the moods read at a 1 s
+4 -3
View File
@@ -4,7 +4,8 @@
* which the sidebar owns and which also carries the update check), the version
* — 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
* `?awaiting=1` — the Dashboard's Needs-you rule —, both `&mine=1`: the user's
* OWN sessions even for an admin —, re-read on session
* 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
@@ -388,11 +389,11 @@ export function StatusBar({
const loadCounts = useCallback(() => {
api.sessions
.list({ status: "active", limit: 1 })
.list({ status: "active", limit: 1, mine: true })
.then((r) => setActive(r.total))
.catch(() => undefined);
api.sessions
.list({ awaiting: true, limit: 1 })
.list({ awaiting: true, limit: 1, mine: true })
.then((r) => setNeedYou(r.total))
.catch(() => undefined);
}, []);
+6 -2
View File
@@ -25,7 +25,9 @@ function Item({ item }: { item: ToastItem }) {
className={`pointer-events-auto rounded-lg border px-3 py-2 shadow-lg flex items-start gap-2 ${TONE[item.kind]}`}
>
<Icon className="w-4 h-4 flex-shrink-0 mt-0.5" aria-hidden />
<span className="text-xs leading-relaxed flex-1 break-words">{item.message}</span>
<span className="text-xs leading-relaxed flex-1 min-w-0 break-words [overflow-wrap:anywhere]">
{item.message}
</span>
<button
type="button"
onClick={() => dismissToast(item.id)}
@@ -44,8 +46,10 @@ export function Toaster() {
useEffect(() => subscribeToasts(setItems), []);
const errors = items.filter((i) => i.kind === "error");
const rest = items.filter((i) => i.kind !== "error");
// Above the background-jobs dock (BackgroundJobsDock sets --jobs-dock-h
// to its height), so a toast never covers a chip's buttons.
return (
<div className="pointer-events-none fixed bottom-10 right-4 z-[70] flex w-[min(28rem,calc(100vw-2rem))] flex-col gap-2">
<div className="pointer-events-none fixed bottom-[calc(2.5rem+var(--jobs-dock-h,0px))] right-4 z-[70] flex w-[min(28rem,calc(100vw-2rem))] flex-col gap-2">
<div role="status" aria-live="polite" className="flex flex-col gap-2">
{rest.map((i) => (
<Item key={i.id} item={i} />
@@ -0,0 +1,317 @@
/**
* @file Tests for the background jobs store (`lib/cloneJobs.ts`,
* `lib/uploadJobs.ts`) and its dock: a clone started in the background is
* announced once when the store sees it finish (toast + window event); a
* fresh mount (F5) shows what the server kept — an interrupted clone with
* Retry, a login failure with an inline login form that retries with it —
* without toasting it again; × dismisses; Stop (click twice) cancels a
* running clone, which then reads as stopped and can be retried; an older
* server that cannot stop says so. An upload shows with Stop, and one a
* reload cut short is shown as interrupted until dismissed. Several chips
* collapse to one pill.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import "../../i18n";
vi.mock("../../lib/api", () => ({
api: {
lanes: {
clone: vi.fn(),
cloneFolder: vi.fn(),
cloneJobs: vi.fn(),
retryCloneJob: vi.fn(),
dismissCloneJob: vi.fn(),
cancelCloneJob: vi.fn(),
},
},
}));
vi.mock("../../lib/toast", () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
import { api, type CloneJob } from "../../lib/api";
import { toast } from "../../lib/toast";
import {
CLONE_JOB_EVENT,
refreshCloneJobs,
resetCloneJobsForTests,
startClone,
} from "../../lib/cloneJobs";
import { beginUpload, reloadUploadsForTests } from "../../lib/uploadJobs";
import { BackgroundJobsDock } from "../BackgroundJobsDock";
const base: CloneJob = {
id: "j1",
kind: "workspace",
state: "running",
url: "https://git.example/o/repo.git",
name: "repo",
parent: "/home/an",
target: "/home/an/repo",
laneId: null,
position: null,
phase: "receiving",
label: null,
percent: 40,
detail: null,
error: null,
result: null,
createdAt: "",
finishedAt: null,
request: { url: "https://git.example/o/repo.git", parent: "/home/an", name: "repo" },
};
const job = (over: Partial<CloneJob> = {}): CloneJob => ({ ...base, ...over });
const renderDock = () =>
render(
<MemoryRouter>
<BackgroundJobsDock />
</MemoryRouter>
);
beforeEach(() => {
vi.clearAllMocks();
resetCloneJobsForTests();
try {
localStorage.clear();
} catch {
/* none */
}
reloadUploadsForTests();
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [] });
vi.mocked(api.lanes.dismissCloneJob).mockResolvedValue({ ok: true });
});
describe("cloneJobs store", () => {
it("announces a background clone once, when it sees it finish", async () => {
const onDone = vi.fn();
const events: CloneJob[] = [];
const listen = (e: Event) => events.push((e as CustomEvent<CloneJob>).detail);
window.addEventListener(CLONE_JOB_EVENT, listen);
vi.mocked(api.lanes.clone).mockResolvedValue({ job: job({ state: "queued", position: 1 }) });
await startClone(
{ kind: "workspace", body: { url: base.url, parent: "/home/an" } },
{ onDone }
);
expect(api.lanes.clone).toHaveBeenCalledWith(expect.objectContaining({ background: true }));
expect(toast.success).not.toHaveBeenCalled();
const done = job({ state: "done", result: { laneId: 7, path: "/home/an/repo" } });
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [done] });
await refreshCloneJobs();
await refreshCloneJobs();
expect(toast.success).toHaveBeenCalledTimes(1);
expect(vi.mocked(toast.success).mock.calls[0]![0]).toMatch(/Cloned repo into \/home\/an\/repo/);
expect(events).toEqual([done]);
expect(onDone).toHaveBeenCalledWith(done, undefined);
window.removeEventListener(CLONE_JOB_EVENT, listen);
});
});
describe("BackgroundJobsDock", () => {
it("shows a running clone's progress after a fresh mount, without a toast", async () => {
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [job()] });
renderDock();
const chip = await screen.findByTestId("clone-job");
expect(chip).toHaveTextContent("Cloning repo");
expect(within(chip).getByRole("progressbar")).toHaveAttribute("aria-valuenow", "40");
expect(toast.success).not.toHaveBeenCalled();
expect(toast.error).not.toHaveBeenCalled();
});
it("shows an interrupted clone after F5 and retries it", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
jobs: [
job({
state: "interrupted",
error: { code: "EINTERRUPTED", message: "server restarted" },
}),
],
});
vi.mocked(api.lanes.retryCloneJob).mockResolvedValue({
job: job({ id: "j2", state: "queued", position: 1 }),
});
renderDock();
const chip = await screen.findByTestId("clone-job");
expect(chip).toHaveTextContent("AgentHub restarted while this clone was running.");
expect(toast.error).not.toHaveBeenCalled();
await user.click(within(chip).getByRole("button", { name: "Retry" }));
expect(api.lanes.retryCloneJob).toHaveBeenCalledWith("j1", undefined);
await waitFor(() =>
expect(screen.getByTestId("clone-job")).toHaveTextContent("Waiting in the clone queue — #1")
);
});
it("asks for a login on ECREDENTIALS and retries with it", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
jobs: [
job({
state: "failed",
error: {
code: "ECREDENTIALS",
message: "login",
auth: { protocol: "https", host: "git.example" },
},
}),
],
});
vi.mocked(api.lanes.retryCloneJob).mockResolvedValue({ job: job({ id: "j2" }) });
renderDock();
const form = await screen.findByTestId("clone-job-login");
expect(screen.getByTestId("clone-job")).toHaveTextContent("git.example needs a login");
await user.type(within(form).getByLabelText("Username"), "ann");
await user.type(within(form).getByLabelText("Password or access token"), "tok");
await user.click(within(form).getByRole("button", { name: "Retry with this login" }));
expect(api.lanes.retryCloneJob).toHaveBeenCalledWith("j1", {
username: "ann",
password: "tok",
remember: true,
});
await waitFor(() =>
expect(screen.getByTestId("clone-job")).toHaveAttribute("data-state", "running")
);
});
it("dismisses a finished clone", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
jobs: [job({ state: "done", result: { laneId: 7, path: "/home/an/repo" } })],
});
renderDock();
const chip = await screen.findByTestId("clone-job");
expect(within(chip).getByRole("button", { name: "Open workspace" })).toBeInTheDocument();
await user.click(within(chip).getByTestId("clone-job-dismiss"));
expect(api.lanes.dismissCloneJob).toHaveBeenCalledWith("j1");
expect(screen.queryByTestId("clone-job")).toBeNull();
});
it("stops a running clone on a second click, then lets it be retried", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [job()] });
vi.mocked(api.lanes.cancelCloneJob).mockResolvedValue({
job: job({ state: "cancelled", error: { code: "ECANCELLED", message: "cancelled" } }),
});
vi.mocked(api.lanes.retryCloneJob).mockResolvedValue({ job: job({ id: "j2" }) });
renderDock();
const stop = await screen.findByTestId("clone-job-stop");
await user.click(stop);
expect(api.lanes.cancelCloneJob).not.toHaveBeenCalled();
expect(screen.getByTestId("clone-job")).toHaveTextContent("Click again to stop");
await user.click(screen.getByTestId("clone-job-stop"));
expect(api.lanes.cancelCloneJob).toHaveBeenCalledWith("j1");
const chip = await screen.findByText("You stopped this clone.");
expect(chip).toBeInTheDocument();
// The chip says it; no toast repeats it on top of the chip.
expect(toast.info).not.toHaveBeenCalled();
expect(toast.error).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Retry" }));
expect(api.lanes.retryCloneJob).toHaveBeenCalledWith("j1", undefined);
await waitFor(() =>
expect(screen.getByTestId("clone-job")).toHaveAttribute("data-state", "running")
);
});
it("says so when the server cannot stop a clone yet", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [job()] });
vi.mocked(api.lanes.cancelCloneJob).mockRejectedValue(
Object.assign(new Error("HTTP 404"), { status: 404 })
);
renderDock();
await user.click(await screen.findByTestId("clone-job-stop"));
await user.click(screen.getByTestId("clone-job-stop"));
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/cannot stop a clone yet/))
);
});
it("shows an upload with Stop, which aborts it", async () => {
renderDock();
let up!: ReturnType<typeof beginUpload>;
act(() => {
up = beginUpload({ folder: "one/src", total: 3, bytes: 300 });
up.update({ name: "a.txt", done: 1, sent: 150 });
});
const chip = await screen.findByTestId("fx-progress");
expect(chip).toHaveTextContent("Uploading 2/3");
expect(chip).toHaveTextContent("one/src · a.txt");
fireEvent.click(within(chip).getByTestId("upload-job-stop"));
expect(up.signal.aborted).toBe(false);
fireEvent.click(within(chip).getByTestId("upload-job-stop"));
expect(up.signal.aborted).toBe(true);
act(() => up.end());
expect(screen.queryByTestId("fx-progress")).toBeNull();
});
it("shows an upload a reload cut short until it is dismissed", async () => {
localStorage.setItem(
"agenthub.uploads.inflight",
JSON.stringify([
{
id: "up-old",
folder: "one/src",
name: "b.txt",
done: 2,
total: 5,
sent: 10,
bytes: 50,
state: "running",
beat: Date.now() - 60_000,
},
])
);
act(() => reloadUploadsForTests());
renderDock();
const chip = await screen.findByTestId("upload-job");
expect(chip).toHaveTextContent(
"The upload of 5 file(s) to one/src was interrupted by a page reload — 2 sent."
);
fireEvent.click(within(chip).getByRole("button", { name: "Dismiss" }));
expect(screen.queryByTestId("upload-job")).toBeNull();
expect(localStorage.getItem("agenthub.uploads.inflight")).toBeNull();
});
it("collapses several chips to one pill", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
jobs: [job(), job({ id: "j2", name: "other" })],
});
renderDock();
await waitFor(() => expect(screen.getAllByTestId("clone-job")).toHaveLength(2));
await user.click(screen.getByTestId("jobs-dock-collapse"));
expect(screen.queryByTestId("clone-job")).toBeNull();
expect(screen.getByTestId("jobs-dock-pill")).toHaveTextContent("2 background tasks");
await user.click(screen.getByTestId("jobs-dock-pill"));
expect(screen.getAllByTestId("clone-job")).toHaveLength(2);
});
it("wraps a long SSH error, links to Settings in-app and lifts toasts above itself", async () => {
const long = "mdpartners@mdpartners.git.backlog.jp:/PETITGIFT_CONSOLE4/console4-specs.git";
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
jobs: [
job({
state: "failed",
url: long,
error: { code: "ESSHAUTH", message: "the SSH login was refused" },
}),
],
});
renderDock();
const alert = await screen.findByRole("alert");
// A URL with no space to break at must still wrap inside the chip.
expect(alert.className).toContain("[overflow-wrap:anywhere]");
// A router link, not a full page reload.
const link = screen.getByRole("link", { name: /Settings/ });
expect(link).toHaveAttribute("href", "/settings/git#git-identity");
await waitFor(() =>
expect(document.documentElement.style.getPropertyValue("--jobs-dock-h")).not.toBe("")
);
});
});
@@ -183,6 +183,9 @@ describe("StatusBar", () => {
);
await screen.findByText("1 need you");
expect(screen.getByTestId("status-bar-counts").textContent).toBe("2 running · 1 need you");
// The user's OWN sessions, even for an admin who sees every user's rows.
for (const [p] of vi.mocked(api.sessions.list).mock.calls)
expect(p).toMatchObject({ mine: true });
await user.click(conn);
expect(onConnectionClick).toHaveBeenCalled();
// Polite live region: the connection and who is waiting, nothing that ticks.
@@ -3,7 +3,8 @@
* @description Tests for the sidebar's workspace list: one flat row per
* workspace in the stored order, a row expanding to its sessions (remembered),
* a live session opening its pane and an ended one its transcript, the
* workspace's OWN sessions only, a session's state dot, rename and archive,
* workspace's OWN sessions only, a session's state dot, rename and archive
* (a live one's pane stopped first),
* selection
* by URL, Alt+arrow reordering kept in localStorage, double-click rename, the
* search, the row menu's items by kind and tool, the status dot, and the
@@ -225,6 +226,8 @@ describe("WorkspaceTree", () => {
await user.type(input, "Better name{Enter}");
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { name: "Better name" });
await user.click(screen.getByTestId("workspace-session-archive-s-live"));
// A live session's Archive stops its pane first — never a hidden pane left running.
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2", { archive: true });
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
});
@@ -63,6 +63,7 @@
import React from "react";
import { CodeBlock } from "./CodeBlock";
import { onLinkClick } from "../../lib/openInPreview";
type Block =
| { kind: "code"; lang: string; code: string }
@@ -305,7 +306,13 @@ const LABEL_CLASS = (word: string) =>
function UrlLink({ url }: { url: string }) {
return (
<a href={url} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => onLinkClick(e, url)}
className={LINK_CLASS}
>
{url}
</a>
);
@@ -466,6 +473,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
href={href}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => onLinkClick(e, href)}
className="text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70"
>
{renderInline(linkM[1]!, `${baseKey}-l${n}`)}
@@ -865,7 +865,7 @@ function MessageListView({
className={
compact
? "space-y-3 py-1 pr-2"
: "mx-auto flex w-full max-w-3xl flex-col gap-3 px-4 py-6"
: "mx-auto flex w-full max-w-5xl flex-col gap-3 px-4 py-6"
}
>
{items.map((item) => {
+73 -17
View File
@@ -11,7 +11,9 @@
* 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
* live CPU / RAM share (a tile per user; click for their busiest processes; hover
* for — admin/analyst, from `GET /api/users` — their role, $ spent today,
* workspaces, sessions and last sign-in). 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.
@@ -38,14 +40,17 @@ import {
type HostHistoryRange,
type HostStats,
type HostUsageGroup,
type ServerUserUsage,
} from "../../lib/api";
import { formatBytes, formatDateTime } from "../../lib/format";
import { fmt, fmtCost, formatBytes, formatDateTime } from "../../lib/format";
import { usePermissions } from "../AuthGate";
import { Tip } from "../Tip";
import { MiniChart } from "../admin/MiniChart";
import { UserAvatar } from "../users/UserAvatar";
const POLL_MS = 5000;
const HISTORY_POLL_MS = 30000;
const USERS_POLL_MS = 60000;
const RANGES: HostHistoryRange[] = ["1h", "6h", "24h"];
export const pct = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0);
@@ -324,6 +329,25 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
const [history, setHistory] = useState<HostHistory | null>(null);
// The user tile whose busiest processes are shown.
const [open, setOpen] = useState<string | null>(null);
// Each account's role and spend for the tiles' hover card. `GET /api/users`
// is admin/analyst only, so a developer gets no card.
const { seesAll, local } = usePermissions();
const { t: tu } = useTranslation("users");
const [accounts, setAccounts] = useState<Map<number, ServerUserUsage>>(new Map());
const hasPeople = (stats?.usage?.groups ?? []).some((g) => g.kind === "user");
useEffect(() => {
if (!seesAll || local || !hasPeople) return;
const load = () =>
api.users
.list()
.then((r) => setAccounts(new Map(r.users.map((u) => [u.id, u]))))
.catch(() => {});
void load();
const id = setInterval(() => {
if (document.visibilityState === "visible") void load();
}, USERS_POLL_MS);
return () => clearInterval(id);
}, [seesAll, local, hasPeople]);
const loadHistory = useCallback(
() =>
@@ -365,6 +389,29 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
if (key === "system") return t("host.systemGroup");
return key;
};
const userTip = (g: HostUsageGroup) => {
const u = g.userId != null ? accounts.get(g.userId) : undefined;
// No account row (a developer, or not read yet): no card at all.
if (!u) return "";
const lines = [groupName(g)];
{
lines.push(
t("host.userTipRole", { v: tu(`roles.${u.role}`) }) +
(u.disabled ? ` · ${t("host.userTipDisabled")}` : "")
);
if (u.today)
lines.push(
t("host.userTipToday", { cost: fmtCost(u.today.cost), tokens: fmt(u.today.tokens) })
);
lines.push(t("host.userTipWork", { w: u.workspaces, s: u.sessions }));
lines.push(
t("host.userTipLastLogin", {
v: u.lastLoginAt ? formatDateTime(u.lastLoginAt) : t("host.userTipNever"),
})
);
}
return lines.join("\n");
};
const perSec = (v: number) => t("host.perSecond", { v: formatBytes(v) });
const timeLabel = (iso: string) =>
new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
@@ -676,21 +723,30 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
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} 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>
{/* The hover card sits on the name row only: over the whole
tile it stacked on the CPU bar's own help tip. */}
<Tip raw={userTip(g)} block>
<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>
</Tip>
<HealthBar label="CPU" value={g.cpu} tip={t("host.cpuHelp")} />
<HealthBar label={`RAM · ${formatBytes(g.memory)}`} value={g.memoryPercent} />
{isOpen && (
@@ -7,7 +7,8 @@
* 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);
* each user's live CPU / RAM as a tile (their busiest processes on click; on
* hover, for staff, their role and $ spent today);
* off Linux the cards it can never fill are left out.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -69,7 +70,10 @@ const stats: HostStats = {
const api = vi.hoisted(() => ({
host: { stats: vi.fn(), history: vi.fn() },
me: { avatarUrl: () => null },
users: { avatarUrl: () => null, list: vi.fn() },
}));
const perms = vi.hoisted(() => ({ seesAll: true, local: true, isAdmin: true }));
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, api };
@@ -104,6 +108,7 @@ const full: HostStats = {
beforeEach(() => {
vi.clearAllMocks();
Object.assign(perms, { seesAll: true, local: true, isAdmin: true });
api.host.stats.mockResolvedValue(full);
api.host.history.mockResolvedValue({
range: "1h",
@@ -181,6 +186,50 @@ describe("HostHealth", () => {
expect(within(tile).getByText("claude")).toBeTruthy();
});
it("names the role and today's spend when a user tile is hovered", async () => {
Object.assign(perms, { local: false });
api.users.list.mockResolvedValue({
users: [
{
id: 2,
username: "mia",
role: "developer",
disabled: false,
workspaces: 2,
sessions: 5,
today: { cost: 1.5, tokens: 12000 },
last7d: { cost: 0, tokens: 0 },
last30d: { cost: 0, tokens: 0 },
lastLoginAt: null,
},
],
});
render(<Tab />);
const tile = await screen.findByTestId("server-user");
// The card exists only once the users list has arrived.
const nameRow = () => within(tile).getByRole("button").parentElement!;
const tip = await vi.waitFor(() => {
fireEvent.mouseEnter(nameRow());
return screen.getByText(/Role: Developer/);
});
expect(tip.textContent).not.toContain("Login");
expect(tip.textContent).toMatch(/Today: \$1\.5/);
expect(tip.textContent).toContain("2 workspaces · 5 sessions");
// Only the name row carries it: the CPU bar keeps its own help tip alone.
fireEvent.mouseLeave(within(tile).getByRole("button").parentElement!);
fireEvent.mouseEnter(within(tile).getByRole("meter", { name: "CPU" }));
expect(screen.queryByText(/Role: Developer/)).toBeNull();
});
it("gives a developer no hover card, without reading the users list", async () => {
Object.assign(perms, { seesAll: false, local: false, isAdmin: false });
render(<Tab />);
const tile = await screen.findByTestId("server-user");
fireEvent.mouseEnter(within(tile).getByRole("button").parentElement!);
expect(screen.queryByText(/Today:/)).toBeNull();
expect(api.users.list).not.toHaveBeenCalled();
});
it("leaves out what Windows cannot report instead of empty cards", async () => {
api.host.stats.mockResolvedValue({
...stats,
+47 -34
View File
@@ -9,8 +9,11 @@
* manually-typed branch name; "Clone" runs `POST /api/lanes/clone` (an
* https/ssh URL cloned into a folder of the user's, with their OWN SSH key or
* HTTPS login — a 409 `ECREDENTIALS` opens a login row, `ESSHAUTH` points to
* Settings → Git; **Partial clone** makes it blobless, and `CloneProgress`
* shows its queue place, then git's progress). There is no AI tool picker: the lane is created with the
* Settings → Git; **Partial clone** makes it blobless). The clone is a
* background job (`lib/cloneJobs.ts`): the modal closes once the server
* accepted it, the app-wide dock shows its queue place, git's progress and
* outcome, and the new workspace is selected when it ends while the user is
* still on that page. There is no AI tool picker: the lane is created with the
* default AI tool (Settings → AI Tools), and each new session picks its own
* AI on the workspace home. Either
* mode's path field can be filled by typing, by the CwdAutocomplete
@@ -31,7 +34,7 @@ import { useDialog } from "../../hooks/useDialog";
import { CwdAutocomplete } from "../run/RunSetup";
import { PathPickButton } from "../PathPickButton";
import { api } from "../../lib/api";
import { CloneProgress, newCloneToken } from "./CloneProgress";
import { startClone } from "../../lib/cloneJobs";
import type { CwdSuggestion } from "../../lib/api";
import type { Lane, LaneWorktree } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
@@ -119,8 +122,6 @@ export function AddLaneModal({
const [cloneUrl, setCloneUrl] = useState("");
const [cloneName, setCloneName] = useState("");
const [clonePartial, setClonePartial] = useState(false);
/** The running clone's progress token (CloneProgress polls it). */
const [cloneToken, setCloneToken] = useState<string | null>(null);
const [login, setLogin] = useState<{
host: string;
username: string;
@@ -242,28 +243,46 @@ export function AddLaneModal({
const url = cloneUrl.trim();
if (!url) return;
setSshRefused(false);
const progressToken = newCloneToken();
setCloneToken(progressToken);
// The new workspace is selected when the clone ends only while the user
// is still on the page they started it from; elsewhere the dock's
// "Open workspace" does it.
const from = window.location.pathname;
try {
const result = await api.lanes.clone({
progressToken,
url,
parent: repo,
name: cloneName.trim() || undefined,
title: name || undefined,
tool,
...(clonePartial ? { partial: true } : {}),
...(login?.username.trim() && login.token
? {
credentials: {
username: login.username.trim(),
password: login.token,
remember: login.remember,
},
}
: {}),
});
onAdded(result.lane);
await startClone(
{
kind: "workspace",
body: {
url,
parent: repo,
name: cloneName.trim() || undefined,
title: name || undefined,
tool,
...(clonePartial ? { partial: true } : {}),
...(login?.username.trim() && login.token
? {
credentials: {
username: login.username.trim(),
password: login.token,
remember: login.remember,
},
}
: {}),
},
},
{
onDone: (job, lane) => {
if (window.location.pathname !== from) return;
if (lane) return onAdded(lane);
const id = job.result?.laneId;
if (id == null) return;
api.lanes
.get(id)
.then((r) => onAdded(r.lane))
.catch(() => undefined);
},
}
);
// Accepted: the dock carries the clone from here.
reset();
onClose();
} catch (err) {
@@ -284,8 +303,6 @@ export function AddLaneModal({
setError(err instanceof Error ? err.message : String(err));
}
setBusy(false);
} finally {
setCloneToken(null);
}
return;
}
@@ -571,11 +588,7 @@ export function AddLaneModal({
/>
{t("clone.partial")}
</label>
{cloneToken && (
<div className="mt-2">
<CloneProgress token={cloneToken} />
</div>
)}
<p className="mt-1 text-[10px] text-fg-muted">{t("cloneJobs.backgroundHint")}</p>
</div>
)}
@@ -856,7 +869,7 @@ export function AddLaneModal({
{done
? t("addLaneSetupClose")
: busy && mode === "clone"
? t("clone.cloning")
? t("cloneJobs.starting")
: t("add")}
</button>
</div>
+36 -88
View File
@@ -5,12 +5,13 @@
* `POST /api/lanes/clone` as Add workspace → Clone — the server holds the URL
* to its allowlist and the folder to the user's own, and clones as the
* folder's account with that account's own SSH key / HTTPS login — and opens
* a workspace on the new folder. A 409 `ECREDENTIALS` opens a login row (sent
* once, optionally remembered), `ESSHAUTH` points to Settings → Git.
* A running clone shows `CloneProgress` and never holds the page: closing the
* dialog only hides it behind a small progress chip (click to reopen), the
* request keeps going, and its outcome arrives as a toast — a failure is kept
* for the next open so a login row is still there to fill in.
* a workspace on the new folder. The clone is a server-side background job
* (`lib/cloneJobs.ts`): the dialog closes as soon as the server accepted it
* and the app-wide dock (`BackgroundJobsDock`) carries its progress and
* outcome, across pages, F5 and server restarts. A refusal before the clone
* starts (a bad URL, a folder that is not the user's, a 409 `ECREDENTIALS` /
* `ESSHAUTH`) stays in the form: a login row (sent once, optionally
* remembered), or a pointer to Settings → Git.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -18,12 +19,9 @@ import { useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { FolderGit2 } from "lucide-react";
import { api } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { useDialog } from "../../hooks/useDialog";
import { cloneDirName } from "./AddLaneModal";
import { CloneProgress, newCloneToken } from "./CloneProgress";
import { toast } from "../../lib/toast";
import { startClone } from "../../lib/cloneJobs";
type Login = { host: string; username: string; token: string; remember: boolean };
@@ -31,16 +29,11 @@ export function CloneHereDialog({
open,
parent,
onClose,
onCloned,
onReopen,
}: {
open: boolean;
/** The absolute folder the clone goes into. */
parent: string;
onClose: () => void;
onCloned: (lane: Lane, path: string) => void;
/** Show the dialog again from the background chip. */
onReopen: () => void;
}) {
const { t } = useTranslation(["lanes"]);
const panelRef = useRef<HTMLDivElement>(null);
@@ -51,23 +44,10 @@ export function CloneHereDialog({
const [sshRefused, setSshRefused] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [token, setToken] = useState<string | null>(null);
/** Where the running clone goes (the open folder may change meanwhile). */
const [target, setTarget] = useState("");
// Read by the request's own continuation and by the reset below.
const openRef = useRef(open);
openRef.current = open;
const busyRef = useRef(false);
/** A clone failed while hidden: the next open shows that, not a blank form. */
const keepRef = useRef(false);
useDialog(open, panelRef, onClose);
useEffect(() => {
if (!open || busyRef.current) return;
if (keepRef.current) {
keepRef.current = false;
return;
}
if (!open) return;
setUrl("");
setName("");
setLogin(null);
@@ -76,67 +56,42 @@ export function CloneHereDialog({
setError(null);
}, [open, parent]);
const folder = name.trim() || cloneDirName(url);
const into = busy ? target : `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
if (!open) return null;
if (!open) {
if (!busy || !token) return null;
return createPortal(
<button
type="button"
data-testid="fx-clone-chip"
onClick={onReopen}
title={into}
className="fixed bottom-10 right-4 z-[60] w-72 space-y-1 rounded-lg border border-border bg-surface-1 p-2.5 text-left shadow-xl shadow-black/40 hover:bg-surface-2"
>
<span className="flex items-center gap-2 text-xs font-medium text-fg-primary">
<FolderGit2 className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">
{t("clone.cloning")} {into.split("/").pop()}
</span>
</span>
<CloneProgress token={token} />
</button>,
document.body
);
}
const folder = name.trim() || cloneDirName(url);
const into = `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
const submit = async () => {
const u = url.trim();
if (!u || busy) return;
const progressToken = newCloneToken();
busyRef.current = true;
setBusy(true);
setToken(progressToken);
setTarget(into);
setError(null);
setSshRefused(false);
try {
const result = await api.lanes.clone({
url: u,
parent,
name: name.trim() || undefined,
progressToken,
...(login?.username.trim() && login.token
? {
credentials: {
username: login.username.trim(),
password: login.token,
remember: login.remember,
},
}
: {}),
await startClone({
kind: "workspace",
body: {
url: u,
parent,
name: name.trim() || undefined,
...(login?.username.trim() && login.token
? {
credentials: {
username: login.username.trim(),
password: login.token,
remember: login.remember,
},
}
: {}),
},
});
busyRef.current = false;
setBusy(false);
setToken(null);
onCloned(result.lane, result.path);
// Accepted: the dock takes it from here.
onClose();
} catch (err) {
const e = err as { code?: string; auth?: { host?: string }; message?: string };
const host = e.auth?.host || u;
let message: string;
if (e.code === "ECREDENTIALS") {
message = t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host });
setError(t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host }));
setLogin((l) => ({
host,
username: l?.username ?? "",
@@ -145,18 +100,11 @@ export function CloneHereDialog({
}));
} else if (e.code === "ESSHAUTH") {
setSshRefused(true);
message = t("clone.sshRefused", { host });
setError(t("clone.sshRefused", { host }));
} else {
message = err instanceof Error ? err.message : String(err);
setError(err instanceof Error ? err.message : String(err));
}
setError(message);
busyRef.current = false;
setBusy(false);
setToken(null);
if (!openRef.current) {
keepRef.current = true;
toast.error(message);
}
}
};
@@ -258,7 +206,6 @@ export function CloneHereDialog({
</label>
</div>
)}
{busy && token && <CloneProgress token={token} />}
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}{" "}
@@ -269,9 +216,10 @@ export function CloneHereDialog({
)}
</p>
)}
<p className="text-[11px] text-fg-muted">{t("cloneJobs.backgroundHint")}</p>
<div className="flex justify-end gap-2">
<button type="button" onClick={onClose} className="btn-ghost text-xs">
{busy ? t("clone.background") : t("fx.cancel")}
{t("fx.cancel")}
</button>
<button
type="submit"
@@ -279,7 +227,7 @@ export function CloneHereDialog({
className="btn-primary text-xs"
data-testid="fx-clone-submit"
>
{busy ? t("clone.cloning") : t("fx.cloneSubmit")}
{busy ? t("cloneJobs.starting") : t("fx.cloneSubmit")}
</button>
</div>
</form>
+20 -42
View File
@@ -2,59 +2,35 @@
* @file CloneProgress — the live line under a running clone: its place in the
* server's clone queue while it waits (clones run a few at a time machine-wide,
* `server/lib/clone-queue.js`), then git's own phase, percent, size and speed
* with a bar. It polls `GET /api/lanes/clone/progress/:token` every second for
* the token the clone's POST carried (`newCloneToken`); a 404 — the POST not
* yet arrived, or an older server — just shows nothing yet.
* with a bar. It renders the progress fields of a background clone job
* (`lib/cloneJobs.ts`, polled by the store) — it fetches nothing itself.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type CloneProgressView } from "../../lib/api";
import type { CloneJob } from "../../lib/api";
const POLL_MS = 1000;
export type CloneProgressFields = Pick<
CloneJob,
"state" | "position" | "phase" | "label" | "percent" | "detail"
> & { running?: number; waiting?: number; limit?: number };
/** A fresh, unguessable progress token (works on plain http, unlike randomUUID). */
export function newCloneToken(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}
export function CloneProgress({ token }: { token: string }) {
export function CloneProgress({ view }: { view: CloneProgressFields }) {
const { t } = useTranslation("lanes");
const [view, setView] = useState<CloneProgressView | null>(null);
useEffect(() => {
let alive = true;
setView(null);
// Never throws: progress is a courtesy, the clone's own answer decides.
const poll = () =>
Promise.resolve()
.then(() => api.lanes.cloneProgress(token))
.then((v) => {
if (alive) setView(v);
})
.catch(() => {});
void poll();
const timer = setInterval(() => void poll(), POLL_MS);
return () => {
alive = false;
clearInterval(timer);
};
}, [token]);
if (!view || view.state === "done" || view.state === "failed") return null;
if (view.state !== "queued" && view.state !== "running") return null;
if (view.state === "queued") {
return (
<p data-testid="clone-progress" className="text-[11px] text-status-warning">
{t("clone.queued", {
position: view.position ?? 1,
waiting: view.waiting,
running: view.running,
limit: view.limit,
})}
{view.waiting !== undefined && view.running !== undefined && view.limit !== undefined
? t("clone.queued", {
position: view.position ?? 1,
waiting: view.waiting,
running: view.running,
limit: view.limit,
})
: t("cloneJobs.queued", { position: view.position ?? 1 })}
</p>
);
}
@@ -73,7 +49,9 @@ export function CloneProgress({ token }: { token: string }) {
{view.phase ? ` ${percent}%` : ""}
</span>
{view.detail && (
<span className="shrink-0 font-mono text-[10px] text-fg-muted">{view.detail}</span>
<span className="shrink-0 truncate font-mono text-[10px] text-fg-muted">
{view.detail}
</span>
)}
</p>
{view.phase && (
@@ -10,16 +10,19 @@
* sent to Settings → Git first — and an HTTPS host with no saved login offers
* the login row up front (optional: a public repo needs none). The identity is
* read again when the window regains focus, so a key made in another tab
* counts at once. The running clone shows its queue place, then git's
* progress (`CloneProgress`); **Partial clone** sends `partial: true`.
* counts at once. Each clone is a background job (`lib/cloneJobs.ts`): once
* the server accepted it the app-wide dock shows its queue place, git's
* progress and outcome (it survives leaving the page, F5 and a server
* restart), and the folder list is re-read when it ends; **Partial clone**
* sends `partial: true`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, Loader2, X } from "lucide-react";
import { api, type GitIdentity, type LaneSourcesView } from "../../lib/api";
import { CloneProgress, newCloneToken } from "./CloneProgress";
import { api, type CloneJob, type GitIdentity, type LaneSourcesView } from "../../lib/api";
import { CLONE_JOB_EVENT, startClone } from "../../lib/cloneJobs";
type Row = {
url: string;
@@ -27,8 +30,6 @@ type Row = {
note?: string;
/** The note points at Settings → Git (no key, or a key the host refused). */
ssh?: boolean;
/** While busy: the progress token CloneProgress polls. */
token?: string;
};
type Login = { host: string; username: string; token: string; remember: boolean };
@@ -88,6 +89,23 @@ export function CloneSourcesForm({
.catch(() => {}),
[]
);
// A background clone into THIS workspace ended: its folder is now in the
// list (the server added it), so read the list again.
const onAddedRef = useRef(onAdded);
onAddedRef.current = onAdded;
useEffect(() => {
const onJob = (e: Event) => {
const job = (e as CustomEvent<CloneJob>).detail;
if (job?.state !== "done" || job.kind !== "source" || job.laneId !== laneId) return;
Promise.resolve()
.then(() => api.lanes.sources(laneId))
.then((view) => onAddedRef.current(view))
.catch(() => undefined);
};
window.addEventListener(CLONE_JOB_EVENT, onJob);
return () => window.removeEventListener(CLONE_JOB_EVENT, onJob);
}, [laneId]);
useEffect(() => {
void loadIdentity();
const onFocus = () => void loadIdentity();
@@ -121,25 +139,28 @@ export function CloneSourcesForm({
for (const row of next) {
if (row.state === "failed") continue;
row.state = "busy";
row.token = newCloneToken();
show();
try {
const view = await api.lanes.cloneFolder(laneId, {
url: row.url,
progressToken: row.token,
...(partial ? { partial: true } : {}),
...(login?.username.trim() && login.token
? {
credentials: {
username: login.username.trim(),
password: login.token,
remember: login.remember,
},
}
: {}),
await startClone({
kind: "source",
laneId,
body: {
url: row.url,
...(partial ? { partial: true } : {}),
...(login?.username.trim() && login.token
? {
credentials: {
username: login.username.trim(),
password: login.token,
remember: login.remember,
},
}
: {}),
},
});
// Accepted: the dock shows its progress; the folder list is re-read
// when it ends (the CLONE_JOB_EVENT listener above).
row.state = "done";
onAdded(view);
} catch (err) {
const e = err as { code?: string; auth?: { host?: string }; message?: string };
const host = e.auth?.host || row.url;
@@ -159,7 +180,6 @@ export function CloneSourcesForm({
row.note = e.message || String(err);
}
}
row.token = undefined;
show();
}
if (asked) setLogin(asked);
@@ -301,10 +321,8 @@ export function CloneSourcesForm({
{r.url}
</span>
</span>
{r.state === "busy" && r.token && (
<div className="ml-[18px] mt-0.5">
<CloneProgress token={r.token} />
</div>
{r.state === "done" && (
<span className="ml-[18px] block text-fg-muted">{t("cloneJobs.started")}</span>
)}
{r.note && (
<span className="ml-[18px] block text-status-danger">
+125 -101
View File
@@ -15,7 +15,10 @@
* 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 — 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 /
@@ -88,16 +91,21 @@ import {
type FsPlace,
type GitSource,
isHomePlace,
newUploadId,
} from "../../lib/api";
import type { Lane } from "../../lib/types";
import { useDialog } from "../../hooks/useDialog";
import { HtmlFrame, fetchVia } from "../HtmlFrame";
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";
@@ -228,6 +236,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 (;;) {
@@ -324,13 +334,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;
@@ -366,16 +369,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
@@ -436,6 +444,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)
@@ -452,8 +462,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 ?? [];
@@ -464,13 +486,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);
};
@@ -493,7 +530,13 @@ export function FileBrowser({
await fn();
if (done) toast.success(done);
} catch (e) {
toast.error(errCode(e) === "EUPGRADE" ? t("fx.needsUpgrade") : errText(e));
toast.error(
errCode(e) === "EUPGRADE"
? t("fx.needsUpgrade")
: errCode(e) === "ESECRET"
? t("fx.secret")
: errText(e)
);
} finally {
setBusy(false);
changed();
@@ -613,8 +656,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) {
@@ -624,19 +673,37 @@ 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 });
const onWait = (waiting: boolean) => job.update({ waiting });
/** 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));
// One id for every attempt: a retry with another `conflict` reuses the staged bytes.
const upload = newUploadId();
const send = (c: FsConflict) =>
api.files.uploadStaged(place, dest, file, c, {
upload,
onProgress: (n) => show(rel, n),
onWait,
signal,
});
try {
const r = await send(forAll ?? "fail").catch(async (e) => {
if (errCode(e) !== "EEXISTS") throw e;
@@ -647,8 +714,11 @@ 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) }));
} finally {
onWait(false);
void api.files.dropUpload(place, upload);
}
sent += file.size;
done++;
@@ -667,9 +737,11 @@ export function FileBrowser({
if (a.all && a.choice) forAll = a.choice;
return a.choice;
};
if (signal.aborted) return "cancel";
const upload = newUploadId();
try {
const c = await api.files.uploadCheck(
target,
place,
dir,
b.map((f) => f.rel)
);
@@ -680,7 +752,12 @@ 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.uploadBatchStaged(place, dir, b, p, {
upload,
onProgress: (n) => show(label, n),
onWait,
signal,
});
const r = await send(policy).catch(async (e) => {
// Taken meanwhile: ask about those, then once more.
if (errCode(e) !== "EEXISTS") throw e;
@@ -691,7 +768,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;
@@ -699,6 +776,9 @@ export function FileBrowser({
return "fallback";
}
toast.error(t("fx.uploadFailed", { name: label, message: errText(e) }));
} finally {
onWait(false);
void api.files.dropUpload(place, upload);
}
sent += size;
done += b.length;
@@ -715,9 +795,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();
}
};
@@ -923,11 +1004,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"
@@ -950,9 +1038,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) ||
@@ -985,18 +1073,7 @@ export function FileBrowser({
/>
)}
{cloneParent && (
<CloneHereDialog
open={cloning}
parent={cloneParent}
onClose={() => setCloning(false)}
onReopen={() => setCloning(true)}
onCloned={(lane, at) => {
setCloning(false);
toast.success(t("fx.cloned", { path: at, workspace: lane.title || lane.cwd }));
setLanes((ls) => (ls.some((l) => l.id === lane.id) ? ls : [...ls, lane]));
changed();
}}
/>
<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">
@@ -1643,26 +1720,6 @@ export function FileBrowser({
<span className="ml-auto hidden md:inline">{t("fx.dropHint")}</span>
</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 && (
@@ -1678,9 +1735,7 @@ export function FileBrowser({
</MenuItem>
)}
{isHtml(menuItem) && menuTargets.length === 1 && (
<MenuItem onSelect={close(() => download([menuItem]))}>
{t("fx.download")}
</MenuItem>
<MenuItem onSelect={close(() => download([menuItem]))}>{t("fx.download")}</MenuItem>
)}
<MenuSeparator />
{!ro && (
@@ -1935,10 +1990,8 @@ export function FileExplorer({
);
}
/** An HTML file shown as the page, over the explorer: its relative CSS,
* scripts and images read through the same download route (`HtmlFrame`).
* Keys and clicks stop here — through the portal they would otherwise reach
* the explorer's own shortcuts (Delete would trash the selection). */
/** 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,
@@ -1950,49 +2003,20 @@ function HtmlViewer({
onDownload: () => void;
onClose: () => void;
}) {
const { t } = useTranslation(["lanes"]);
const panelRef = useRef<HTMLDivElement>(null);
useDialog(true, panelRef, onClose);
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 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) => e.stopPropagation()}
role="presentation"
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={entry.name}
data-testid="fx-html-viewer"
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">
{entry.path}
</span>
<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={entry.path} fetchFile={fetchFile} title={entry.name} />
</div>
</div>,
document.body
return (
<HtmlViewerModal
path={entry.path}
fetchFile={fetchFile}
onDownload={onDownload}
onClose={onClose}
testId="fx-html-viewer"
/>
);
}
+283 -303
View File
@@ -14,11 +14,13 @@
* 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), an HTML
* file shown as the page on Preview, from Changes too
* (`HtmlFrame`, its relative CSS, scripts and images read like
* downloads), and an
* 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`
@@ -54,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`).
*
@@ -92,6 +101,7 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
import {
Archive,
ChevronDown,
ChevronLeft,
ChevronRight,
@@ -139,11 +149,17 @@ import { LaneConflictView } from "./LaneConflictView";
import { FileExplorer } from "./FileExplorer";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
import { HtmlFrame, fetchVia } from "../HtmlFrame";
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,
@@ -163,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. */
@@ -198,7 +218,7 @@ function Delta({
);
}
/** An HTML page, which the viewer can show rendered (`HtmlFrame`). */
/** 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). */
@@ -469,8 +489,9 @@ function FileActions({
title={t("changes.discardConfirm")}
disabled={busy}
onClick={() => onAct("discard", f.path, true)}
className={`${HUNK_CHIP} border-status-danger bg-status-danger/20 text-status-danger`}
className="flex shrink-0 items-center gap-1 rounded bg-status-danger/10 px-1 py-0.5 text-[11px] leading-3 text-status-danger hover:bg-status-danger/20 disabled:opacity-40"
>
<Trash2 className="h-3 w-3" />
{t("changes.discardShortConfirm")}
</button>
) : (
@@ -770,54 +791,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,
@@ -860,6 +833,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) => {
@@ -875,6 +850,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).
@@ -890,11 +870,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 or HTML file shown rendered instead of as source; per
// file, markdown opening as Settings → Appearance says
// (`lib/markdownView.ts`), an HTML page only once Preview is clicked.
// 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(!isHtml(openFile) && readMarkdownPreview()), [openFile]);
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
@@ -906,7 +888,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[]>>({});
@@ -976,6 +957,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
@@ -1073,30 +1057,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;
@@ -1263,7 +1225,6 @@ export function LaneChangesPanel({
setTreeBusy(false);
setBusy(false);
setError(null);
setCommits(null);
setData(null);
setDraft(null);
setCollapsed(new Set());
@@ -1344,7 +1305,6 @@ export function LaneChangesPanel({
if (!ok) return;
setHistoryFile(path);
setHistoryRef(null);
setCommits(null);
setMode("history");
});
};
@@ -1467,25 +1427,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.
@@ -1575,7 +1588,6 @@ export function LaneChangesPanel({
const fileCtx = useContextMenu<LaneChangeFile>();
const treeCtx = useContextMenu<LaneTreeEntry>();
const commitCtx = useContextMenu<LaneCommit>();
const viewCommit = (sha: string) => {
setSha(sha);
setRange("commit");
@@ -1751,68 +1763,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}
/>
</>
);
@@ -1994,6 +1953,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
@@ -2059,13 +2019,12 @@ export function LaneChangesPanel({
});
const dirty = draft !== null && draft !== text?.lines.join("\n");
// Rendered only where the toggle is offered: a whole markdown or HTML file.
const mdShown = mdPreview && whole && (isMarkdown(openFile) || isHtml(openFile));
// 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)),
() => (range === "commit" ? null : fetchVia((p) => api.lanes.gitDownloadUrl(target, p))),
// eslint-disable-next-line react-hooks/exhaustive-deps
[range, JSON.stringify(target)]
);
@@ -2394,10 +2353,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 ${
@@ -2412,6 +2368,12 @@ export function LaneChangesPanel({
</div>
)}
{!notRepo && (
<GitToolsButton
onPick={setGitTool}
hasRemote={git?.available ? git.hasRemote : undefined}
/>
)}
<button
type="button"
onClick={runReread}
@@ -2563,9 +2525,6 @@ export function LaneChangesPanel({
aria-pressed={whole === w}
onClick={() => {
if (whole === w) return;
// Changes and Whole file show an HTML page's source;
// only Preview runs it.
if (isHtml(openFile)) setMdPreview(false);
// Both views of a working file are the same editor: the
// draft goes along, unsaved or not.
if (editable && draft !== null) return setWhole(w);
@@ -2588,6 +2547,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>
@@ -2598,7 +2582,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"
@@ -2616,6 +2610,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}
@@ -2632,6 +2633,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
@@ -2644,103 +2651,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 ? (
@@ -2838,15 +2782,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
@@ -2967,6 +2958,9 @@ export function LaneChangesPanel({
editWanted.current = true;
setWhole(true);
}}
onMergeEditor={() =>
setGitTool({ kind: "merge", path: openFile, onResolved: goBack })
}
/>
) : mode === "file" ? (
current || (whole && text) || openImage ? (
@@ -2997,21 +2991,17 @@ export function LaneChangesPanel({
{t("changes.annotate")}
</button>
)}
{/* Offered on Changes too: from there it opens the rendered whole file. */}
{(!whole || text) && !dirty && (isMarkdown(openFile) || isHtml(openFile)) && (
{/* An HTML file's Preview sits beside Whole file instead. */}
{whole && text && !dirty && isMarkdown(openFile) && (
<button
type="button"
data-testid="lane-file-preview"
aria-pressed={whole && mdPreview}
onClick={() => {
if (whole) return setMdPreview((v) => !v);
setMdPreview(true);
setWhole(true);
}}
aria-pressed={mdPreview}
onClick={() => setMdPreview((v) => !v)}
className="flex shrink-0 items-center gap-1 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
>
{whole && mdPreview ? <Code2 className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
{whole && mdPreview ? t("changes.raw") : t("changes.preview")}
{mdPreview ? <Code2 className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
{mdPreview ? t("changes.raw") : t("changes.preview")}
</button>
)}
{!local && range !== "commit" && openFile && (
@@ -3146,17 +3136,7 @@ export function LaneChangesPanel({
</Suspense>
)
) : whole ? (
text && mdShown && isHtml(openFile) ? (
<HtmlFrame
key={openFile}
path={text.path}
// A cut-off page is read whole through the download.
html={text.truncated && htmlFetch ? undefined : text.lines.join("\n")}
fetchFile={htmlFetch}
title={text.path}
className="rounded border border-border-light"
/>
) : text && mdShown ? (
text && mdShown ? (
<div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0 p-4">
<MarkdownPreview text={text.lines.join("\n")} />
</div>
@@ -8,7 +8,8 @@
* (`gitSaveFile` with its `sha`, 409 ESTALE otherwise) and then runs
* `resolve done`, which re-checks that no marker is left. Take all mine /
* theirs is `resolve ours|theirs` for the whole file; Edit by hand hands the
* file to the panel's plain editor.
* file to the panel's plain editor; Merge editor opens the 3-way editor
* (`./git/MergeEditor`, base/current/incoming side by side).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -42,7 +43,10 @@ export function LaneConflictView({
path,
onResolved,
onEdit,
onMergeEditor,
}: {
/** Open the 3-way merge editor (./git/MergeEditor) on this file. */
onMergeEditor?: () => void;
laneId: number;
/** Work in that session's own worktree (see `gitTarget`). */
session?: string;
@@ -176,6 +180,17 @@ export function LaneConflictView({
<button type="button" className={BTN} disabled={busy} onClick={onEdit}>
{t("conflict.editByHand")}
</button>
{onMergeEditor && (
<button
type="button"
data-testid="conflict-open-merge-editor"
className={BTN}
disabled={busy}
onClick={onMergeEditor}
>
{t("gitTools:merge.open")}
</button>
)}
</div>
{error && (
@@ -179,6 +179,18 @@ export function langFor(filePath: string) {
}
/** Tokenized line text. `emph` marks the span that actually changed. */
/** The tail of a row the server cut down (`LaneDiffLine.elided`). */
function Elided({ n }: { n?: number }) {
const { t, i18n } = useTranslation("lanes");
if (!n) return null;
const size = new Intl.NumberFormat(i18n.language, { notation: "compact" }).format(n);
return (
<span className="ml-1 select-none rounded bg-surface-3 px-1 font-sans text-[10px] text-fg-muted">
{t("changes.lineElided", { size })}
</span>
);
}
function Code({ text, lang, emph }: { text: string; lang: string; emph?: [number, number] }) {
if (emph) {
const [from, to] = emph;
@@ -544,6 +556,7 @@ function UnifiedLine({
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
</span>
<Code text={line.text} lang={lang} emph={emph} />
<Elided n={line.elided} />
</span>
{bar && <HunkBar change={bar} />}
</Row>
@@ -621,6 +634,7 @@ function SplitLine({
/>
<span className="whitespace-pre px-1">
{line ? <Code text={line.text} lang={lang} emph={e} /> : null}
{line ? <Elided n={line.elided} /> : null}
</span>
{bar && <HunkBar change={bar} />}
</Row>
+205 -21
View File
@@ -15,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
@@ -23,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>
*/
@@ -45,6 +55,8 @@ import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys";
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
import { restoreShelf } from "./LaneShelves";
import { BranchesButton } from "./git/BranchesButton";
type Action =
"fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete" | "merge" | "publish";
@@ -120,6 +132,8 @@ export function LaneGitBar({
const [remoteBranches, setRemoteBranches] = useState<string[]>([]);
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
const [picked, setPicked] = useState("");
// Issue #88: typing narrows the switcher to the branches whose name holds it.
const [branchQuery, setBranchQuery] = useState("");
const [newBranch, setNewBranch] = useState("");
// Set when git needed a login for `action`; the form resends that action.
const [login, setLogin] = useState<{ action: Action; auth: GitAuthNeeded } | null>(null);
@@ -128,6 +142,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
@@ -145,11 +162,13 @@ export function LaneGitBar({
setRemoteBranches([]);
setWorktrees([]);
setPicked("");
setBranchQuery("");
setNewBranch("");
setLogin(null);
setPassword("");
setPublishing(false);
setUrl("");
setChoice(null);
inFlight.current = false;
}
const laneNow = useRef(laneId);
@@ -160,6 +179,26 @@ export function LaneGitBar({
// 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);
// The switcher's lists, narrowed by the search box (case-insensitive, any
// part of the name). The picked branch always stays in its list, so the
// select never shows a value it has no option for.
const query = branchQuery.trim().toLowerCase();
const matches = (b: string) => !query || b.toLowerCase().includes(query) || b === picked;
const shownBranches = (branches || []).filter(matches);
const shownRemote = remoteBranches.filter(matches);
const matchCount = query
? [...(branches || []), ...remoteBranches].filter((b) => b.toLowerCase().includes(query))
.length
: null;
const searchBranches = (text: string) => {
setBranchQuery(text);
const q = text.trim().toLowerCase();
if (!q) return;
const hits = [...(branches || []), ...remoteBranches].filter((b) =>
b.toLowerCase().includes(q)
);
if (hits.length === 1) setPicked(hits[0]!);
};
const merging = !!git.merging;
const run = async (action: Action, body?: RunBody) => {
@@ -168,6 +207,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
@@ -190,20 +232,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("");
@@ -221,6 +293,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 () => {
@@ -306,6 +387,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])
);
@@ -424,23 +519,57 @@ 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>
{switching && (
<div className="flex flex-wrap items-center gap-1.5">
<input
type="search"
aria-label={t("gitBar.branchSearch")}
placeholder={t("gitBar.branchSearch")}
value={branchQuery}
disabled={!!busy}
onChange={(e) => searchBranches(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape" && branchQuery) {
e.stopPropagation();
setBranchQuery("");
} else if (isSubmitEnter(e)) {
e.preventDefault();
const first = [...shownBranches, ...shownRemote].find(
(b) => b.toLowerCase().includes(query) && b !== git.branch
);
if (first) setPicked(first);
}
}}
className="w-36 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"
/>
<select
aria-label={t("gitBar.switchLabel")}
value={picked}
@@ -448,8 +577,12 @@ export function LaneGitBar({
onChange={(e) => setPicked(e.target.value)}
className="select min-w-[8rem] flex-1 !rounded !py-0.5 !pl-2 !pr-6 font-mono !text-[11px]"
>
<option value="">{t("gitBar.switchLabel")}</option>
{(branches || []).map((b) => {
<option value="">
{matchCount === null
? t("gitBar.switchLabel")
: t("gitBar.branchMatches", { count: matchCount })}
</option>
{shownBranches.map((b) => {
const held = heldBy.get(b);
return (
<option key={b} value={b}>
@@ -458,9 +591,9 @@ export function LaneGitBar({
</option>
);
})}
{remoteBranches.length > 0 && (
{shownRemote.length > 0 && (
<optgroup label={t("gitBar.remoteBranches")}>
{remoteBranches.map((b) => (
{shownRemote.map((b) => (
<option key={`remote:${b}`} value={b}>
{b}
</option>
@@ -473,9 +606,11 @@ export function LaneGitBar({
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")}
@@ -521,34 +656,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>
)}
+16 -5
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>
*/
@@ -21,6 +23,7 @@ import { GitBranch } from "lucide-react";
import { LaneGitBar } from "./LaneGitBar";
import { api, gitTarget, type GitSource } from "../../lib/api";
import { keepIfSame } from "../../lib/keepIfSame";
import { useGitChanged } from "../../lib/gitChanged";
import type { LaneGitFacts, LaneGitFile } from "../../lib/types";
/** How often the mounted panel re-reads its working-copy facts. Slow on
@@ -76,6 +79,9 @@ export function useLaneGitFacts(
clearInterval(timer);
};
}, [laneId, session, source, nonce]);
// A git write from ANY view of this workspace (another panel, the session
// view, a second tab of the same page) moves these facts too.
useGitChanged(laneId, () => setNonce((n) => n + 1));
return [facts, () => setNonce((n) => n + 1)];
}
@@ -367,12 +373,17 @@ export function LaneGitHeader({
className="flex min-w-0 flex-1 items-baseline gap-1.5 font-mono text-[11px]"
title={`${git.head} ${git.subject}`}
>
<span className="flex shrink-0 items-center gap-1 self-center font-semibold text-fg-primary">
<GitBranch className="h-3 w-3 text-fg-muted" />
{git.branch}
{/* A long branch name truncates too (after the subject has gone),
or it overflowed onto the head and the action icons. */}
<span
className="flex min-w-[5rem] items-center gap-1 self-center font-semibold text-fg-primary"
title={git.branch}
>
<GitBranch className="h-3 w-3 shrink-0 text-fg-muted" />
<span className="truncate">{git.branch}</span>
</span>
<span className="shrink-0 text-fg-muted">{git.head}</span>
<span className="min-w-0 truncate text-fg-muted">{git.subject}</span>
<span className="min-w-0 shrink-[999] truncate text-fg-muted">{git.subject}</span>
</div>
</>
);
+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;
@@ -145,7 +145,7 @@ export function SessionReplay({
{livePane ? (
<div
data-testid="session-replay-live"
className="mx-auto flex w-full max-w-3xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
className="mx-auto flex w-full max-w-5xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
>
<span className="min-w-0 flex-1">{t("replay.live")}</span>
<button
@@ -160,7 +160,7 @@ export function SessionReplay({
) : session && !resumable ? (
<div
data-testid="session-replay-no-resume"
className="mx-auto flex w-full max-w-3xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
className="mx-auto flex w-full max-w-5xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
>
<span className="min-w-0 flex-1">{t("replay.noResume", { tool })}</span>
<button
@@ -172,7 +172,7 @@ export function SessionReplay({
</button>
</div>
) : (
<div className="mx-auto w-full max-w-3xl rounded-2xl border border-border p-2 shadow-sm transition-colors focus-within:border-accent/50">
<div className="mx-auto w-full max-w-5xl rounded-2xl border border-border p-2 shadow-sm transition-colors focus-within:border-accent/50">
<PromptEditor
bare
images={images}
+180
View File
@@ -0,0 +1,180 @@
/**
* @file UrlPreview.tsx
* @description The session panel's Preview tab: a web page (a lane's dev
* server, mostly) shown beside the console. One bar — address, Reload, Open
* in a browser tab — and, before anything is open, the workspace's own ports
* as one-click chips. The page runs in an `<iframe src>` on ITS OWN origin:
* `normalizePreviewUrl` refuses the dashboard's origin and anything but
* http(s), and the sandbox leaves out top navigation so a page cannot move
* the dashboard away. A site that forbids framing (most login pages) shows
* blank or as the browser's broken-page icon; that is the browser's rule and
* cannot be read from here, so any page not on this machine gets a strip
* saying so with the Open button beside it. The
* last address is remembered per workspace in localStorage (a viewer
* convenience, never the server's).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ExternalLink, Globe, RotateCw } from "lucide-react";
import { isLoopbackUrl, normalizePreviewUrl } from "../../lib/openInPreview";
import { isSubmitEnter } from "../../lib/keys";
import { toast } from "../../lib/toast";
const KEY = (laneId: number) => `agenthub.preview.url.${laneId}`;
function readSaved(laneId: number): string {
try {
return window.localStorage.getItem(KEY(laneId)) ?? "";
} catch {
return "";
}
}
/** The frame's sandbox: everything a web app needs, minus moving the
* dashboard (`allow-top-navigation*`). `allow-same-origin` is the PAGE's
* own origin — never the dashboard's, which `normalizePreviewUrl` refuses. */
const FRAME_SANDBOX =
"allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-modals allow-downloads";
export function UrlPreview({
laneId,
ports,
request,
}: {
laneId: number;
/** The lane's bound ports, by name — the chips of the empty state. */
ports: Record<string, number>;
/** A link asked for this page (`openInPreview`); a new nonce re-opens it. */
request: { url: string; nonce: number } | null;
}) {
const { t } = useTranslation("lanes");
const [url, setUrl] = useState(() => readSaved(laneId));
const [draft, setDraft] = useState(url);
const [reload, setReload] = useState(0);
// Another workspace: its own last page.
useEffect(() => {
const saved = readSaved(laneId);
setUrl(saved);
setDraft(saved);
}, [laneId]);
useEffect(() => {
if (request) go(request.url);
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the nonce
}, [request?.nonce]);
function go(raw: string) {
const next = normalizePreviewUrl(raw);
if (!next) {
toast.error(t("preview.badUrl"));
return;
}
setUrl(next);
setDraft(next);
setReload((n) => n + 1);
try {
window.localStorage.setItem(KEY(laneId), next);
} catch {
/* disabled storage: the tab just forgets between visits */
}
}
const openTab = () => url && window.open(url, "_blank", "noopener,noreferrer");
const portList = Object.entries(ports ?? {}).filter(([, p]) => Number.isInteger(p) && p > 0);
const btn =
"flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40 disabled:hover:bg-transparent";
return (
<div className="flex min-h-0 flex-1 flex-col" data-testid="url-preview">
<div className="flex shrink-0 items-center gap-1 border-b border-border px-2 py-1.5">
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
go(draft);
}
}}
placeholder="localhost:3000"
aria-label={t("preview.address")}
spellCheck={false}
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
/>
<button
type="button"
className={btn}
disabled={!url}
onClick={() => setReload((n) => n + 1)}
title={t("preview.reload")}
aria-label={t("preview.reload")}
>
<RotateCw className="h-3.5 w-3.5" />
</button>
<button
type="button"
className={btn}
disabled={!url}
onClick={openTab}
title={t("preview.openTab")}
aria-label={t("preview.openTab")}
>
<ExternalLink className="h-3.5 w-3.5" />
</button>
</div>
{url && !isLoopbackUrl(url) && (
// An outside site: most refuse to be framed, and a refusal cannot be
// detected from here — so say it, with the way out beside it.
<div
data-testid="url-preview-blocked-hint"
className="flex shrink-0 items-center gap-2 border-b border-border bg-surface-2 px-2 py-1.5 text-[11px] text-fg-secondary"
>
<span className="min-w-0 flex-1">{t("preview.blockedHint")}</span>
<button
type="button"
onClick={openTab}
className="flex shrink-0 items-center gap-1 rounded-md px-1.5 py-0.5 font-medium text-accent hover:bg-surface-4"
>
<ExternalLink className="h-3 w-3" />
{t("preview.openTabShort")}
</button>
</div>
)}
{url ? (
<iframe
key={`${url}#${reload}`}
src={url}
title={t("preview.title")}
sandbox={FRAME_SANDBOX}
referrerPolicy="no-referrer"
className="min-h-0 w-full flex-1 border-0 bg-white"
data-testid="url-preview-frame"
/>
) : (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
<Globe className="h-6 w-6 text-fg-muted" />
<p className="text-xs text-fg-secondary">{t("preview.empty")}</p>
{portList.length > 0 && (
<div className="flex flex-wrap justify-center gap-1.5">
{portList.map(([name, port]) => (
<button
key={name}
type="button"
onClick={() => go(`localhost:${port}`)}
title={name}
className="rounded-full border border-border px-2.5 py-0.5 font-mono text-[11px] text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary"
>
localhost:{port}
</button>
))}
</div>
)}
<p className="text-[11px] text-fg-muted">{t("preview.loginHint")}</p>
</div>
)}
</div>
);
}
+29 -16
View File
@@ -14,7 +14,7 @@
* a catalog tool's bypass — a YOLO chip for Codex) and Local / New worktree — the workspace's Recents — the sessions it
* owns and has not archived (`?lane=&archived=0`, with each one's state dot,
* **Show more** past the first page, and the sidebar row's Rename/Archive in
* its right-click menu), then the archived ones folded with Unarchive; a live session opens its pane,
* its right-click menu — Archive on a live session stops its pane first, `endRuns`), then the archived ones folded with Unarchive; a live session opens its pane,
* any other its conversation (`&session=`, `SessionReplay`), nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and
* Folders (`WorkspaceContext.tsx`, also folded at the bottom of the Changes panel).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -22,6 +22,8 @@
import { isSubmitEnter } from "../../lib/keys";
import { confirmDialog } from "../../lib/confirm";
import { useGitChanged } from "../../lib/gitChanged";
import { endRuns } from "../../lib/stopSession";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
@@ -237,6 +239,17 @@ function HomeBody({
live = false;
};
}, [lane.id]);
// A branch moved from the git bar: the main worktree's branch shown here is
// the server's cached list, so it is refreshed rather than re-read.
useGitChanged(lane.id, (action) => {
if (!["switch", "branch-create", "branch-delete"].includes(action)) return;
api.projects
.refresh()
.then(({ projects }) =>
setProject(projects.find((p) => p.workspaces.includes(lane.id)) ?? null)
)
.catch(() => undefined);
});
// The AI menu's Open terminal: a plain shell in the first free slot, opened
// at once — the same start the session view's tab bar `+` makes.
@@ -307,30 +320,30 @@ function HomeBody({
}
};
const archiveSession = async (s: Session) => {
const live = !!liveRunFor(lane, liveRuns, s.id);
const run = liveRunFor(lane, liveRuns, s.id);
// A live one is stopped first, as the tab's X does (its own confirm):
// archiving only its row left a pane running that no list showed.
if (run) {
if ((await endRuns([run], { name: s.name || t("tree.newSession"), t })).length)
await patchSession(s, { archived: true });
return;
}
const wt = sessionWorktreeName(lane, s);
const ok = await confirmDialog(
wt && !live
wt
? {
title: t("tree.archiveWorktreeTitle"),
message: t("tree.archiveWorktreeBody", { name: wt }),
confirmLabel: t("tree.archiveWorktreeConfirm"),
}
: live
? {
title: t("tree.archiveLiveTitle"),
message: t("tree.archiveLiveBody"),
confirmLabel: t("sessionMenu.archive"),
destructive: false,
}
: {
title: t("tree.archiveSession"),
confirmLabel: t("sessionMenu.archive"),
destructive: false,
}
: {
title: t("tree.archiveSession"),
confirmLabel: t("sessionMenu.archive"),
destructive: false,
}
);
if (!ok) return;
if (wt && !live) {
if (wt) {
try {
await api.lanes.removeSessionWorktree(lane.id, s.id).catch((err: unknown) => {
if ((err as { code?: string }).code !== "ENOWORKTREE") throw err;
+9 -2
View File
@@ -161,12 +161,19 @@ export function WorkspaceRow({
e.preventDefault();
onReorder(index, index + (e.key === "ArrowUp" ? -1 : 1));
}}
title={`${lane.cwd}\n${readOnly ?? t("row.hint")}`}
title={`${lane.cwd}\n${lane.folder_missing ? t("row.folderMissing") : (readOnly ?? t("row.hint"))}`}
className="flex min-w-0 flex-1 items-center gap-2 py-1.5 text-left"
>
<span aria-hidden className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
<span className="sr-only">{t(`row.state.${workspaceDotLabel(lane)}`)}</span>
<span className="truncate text-[13px] font-medium text-fg-primary">{name}</span>
<span
data-folder-missing={lane.folder_missing ? "true" : undefined}
className={`truncate text-[13px] font-medium ${
lane.folder_missing ? "text-fg-muted line-through" : "text-fg-primary"
}`}
>
{name}
</span>
</button>
)}
{readOnly && <Eye aria-label={readOnly} className="mr-1 h-3 w-3 shrink-0 text-fg-muted" />}
@@ -24,7 +24,7 @@
* The list shows `SESSION_LIMIT` rows and a **See all (N more)** under them that
* loads every one, then a **Show less** that goes back to `SESSION_LIMIT`. A
* live tab whose session was archived is not brought back as a New session,
* and archiving a live one asks first. F2 renames the focused row too; each
* and archiving a live one STOPS it first (`endRuns`, the tab X's confirm). F2 renames the focused row too; each
* dot carries its meaning as screen-reader text. `readOnly` (another user's
* workspace, for an analyst): no live tabs, no rename/archive/menu, and a row
* opens the session's analysis page (`/sessions/<id>`) — never a pane or a resume.
@@ -32,7 +32,7 @@
*/
import { useEffect, useState, type ReactNode } from "react";
import { confirmDialog } from "../../lib/confirm";
import { endRuns } from "../../lib/stopSession";
import { isSubmitEnter } from "../../lib/keys";
import { Archive, GitBranch, Pin, PinOff, SquareTerminal } from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -363,17 +363,10 @@ export function WorkspaceSessionList({
};
const archive = async (s: Session) => {
if (sessionWorktreeName(lane, s) && !runFor(s)) return setArchiving(s);
// A live one keeps running in its tab; archiving only hides its row.
if (
runFor(s) &&
!(await confirmDialog({
title: t("tree.archiveLiveTitle"),
message: t("tree.archiveLiveBody"),
confirmLabel: t("sessionMenu.archive"),
destructive: false,
}))
)
return;
// A live one is stopped first, as the tab's X does: archiving only its row
// left a pane running that no list showed any more.
const run = runFor(s);
if (run && !(await endRuns([run], { name: s.name || t("tree.newSession"), t })).length) return;
await patch(s, { archived: true });
};
const archiveWithWorktree = async (s: Session) => {
@@ -16,6 +16,7 @@ import { AddLaneModal } from "../AddLaneModal";
import { api } from "../../../lib/api";
import type { AiTool, AiToolsView, CwdSuggestion } from "../../../lib/api";
import { __resetAiTools } from "../../../hooks/useAiTools";
import { resetCloneJobsForTests } from "../../../lib/cloneJobs";
import type { Lane } from "../../../lib/types";
vi.mock("../../../lib/api", () => ({
@@ -26,6 +27,7 @@ vi.mock("../../../lib/api", () => ({
worktree: vi.fn(),
ensure: vi.fn(),
clone: vi.fn(),
cloneJobs: vi.fn(),
browse: vi.fn(),
pickPath: vi.fn(),
// The folder browser's Places list the user's workspaces.
@@ -158,6 +160,8 @@ beforeEach(() => {
vi.mocked(api.lanes.worktree).mockReset();
vi.mocked(api.lanes.ensure).mockReset();
vi.mocked(api.lanes.clone).mockReset();
vi.mocked(api.lanes.cloneJobs).mockReset().mockResolvedValue({ jobs: [] });
resetCloneJobsForTests();
vi.mocked(api.lanes.browse).mockReset();
vi.mocked(api.lanes.pickPath).mockReset();
// Provisioning finishes instantly by default - tests that care about the
@@ -790,6 +794,54 @@ describe("AddLaneModal — Clone", () => {
await waitFor(() => expect(onAdded).toHaveBeenCalled());
});
it("hands a background clone to the dock and selects the workspace when it ends", async () => {
const onAdded = vi.fn();
const onClose = vi.fn();
const job = {
id: "j1",
kind: "workspace",
state: "queued",
url: "git@github.com:owner/repo.git",
name: "repo",
parent: "/home/an/code",
target: "/home/an/code/repo",
laneId: null,
position: 1,
phase: null,
label: null,
percent: null,
detail: null,
error: null,
result: null,
createdAt: "",
finishedAt: null,
request: { url: "git@github.com:owner/repo.git", parent: "/home/an/code", name: "repo" },
} as const;
vi.mocked(api.lanes.clone).mockResolvedValue({ job: { ...job } });
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
jobs: [{ ...job, state: "done", result: { laneId: 5, path: "/home/an/code/repo" } }],
});
vi.mocked(api.lanes.get).mockResolvedValue({ lane: laneFixture({ id: 5 }) });
const user = userEvent.setup();
renderModal({ onAdded, onClose });
fireEvent.click(screen.getByRole("button", { name: "Clone" }));
const urlField = screen.getByLabelText("Repository URL");
await focusField(user, urlField);
await user.type(urlField, "git@github.com:owner/repo.git");
await user.type(screen.getByLabelText("Clone into folder"), "/home/an/code");
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() =>
expect(api.lanes.clone).toHaveBeenCalledWith(expect.objectContaining({ background: true }))
);
// Accepted: the modal closes at once, before the clone is done.
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(onAdded).not.toHaveBeenCalled();
// The store's next read sees it done: the new workspace is selected.
await waitFor(() => expect(onAdded).toHaveBeenCalledWith(expect.objectContaining({ id: 5 })), {
timeout: 4000,
});
});
it("sends partial only when the box is ticked", async () => {
vi.mocked(api.lanes.clone).mockResolvedValue({
lane: laneFixture({ id: 7 }),
@@ -0,0 +1,296 @@
/**
* @file BranchManager.test.tsx
* @description Pins the git bar's Branches panel: local branches with the
* current one marked and ahead/behind shown, remote branches grouped by
* remote, the search box, arrow-key walking, and each action's request —
* rename, new branch from a remote branch (switch only offered on a clean
* tree), set/unset upstream, merge into current, delete refused with its
* reason — plus the Tags tab: annotated create, push one tag, delete with
* `confirm: true` after the themed confirm, and git's error shown verbatim.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { BranchManager } from "../git/BranchManager";
import { api, type GitRefs } from "../../../lib/api";
import { confirmDialog } from "../../../lib/confirm";
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
vi.mock("../../../lib/toast", () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
vi.mock("../../../lib/api", () => ({
gitTarget: (lane: number) => lane,
api: { lanes: { gitRefs: vi.fn(), gitRefAction: vi.fn() } },
}));
const REFS: GitRefs = {
available: true,
current: "main",
head: "aaaaaaa",
truncated: false,
local: [
{
name: "main",
sha: "a1",
current: true,
upstream: "origin/main",
ahead: 2,
behind: 1,
gone: false,
subject: "latest work",
date: null,
merged: true,
worktree: null,
protected: true,
},
{
name: "feature/very-long-branch-name",
sha: "b2",
current: false,
upstream: null,
ahead: null,
behind: null,
gone: false,
subject: "feature commit",
date: null,
merged: false,
worktree: null,
protected: false,
},
{
name: "held",
sha: "c3",
current: false,
upstream: null,
ahead: null,
behind: null,
gone: false,
subject: "x",
date: null,
merged: true,
worktree: "/tmp/other",
protected: false,
},
],
remotes: [
{
remote: "origin",
branches: [
{ name: "origin/main", branch: "main", sha: "a1", subject: "s", date: null },
{ name: "origin/remote-only", branch: "remote-only", sha: "d4", subject: "r", date: null },
],
},
],
tags: [
{
name: "v1",
sha: "a1b2c3d4",
annotated: true,
message: "release one",
tagger: "T",
date: null,
},
],
};
function setup(over: { dirty?: number } = {}) {
const onChanged = vi.fn();
render(
<BranchManager
laneId={3}
dirty={over.dirty ?? 0}
merging={false}
onClose={vi.fn()}
onChanged={onChanged}
/>
);
return { onChanged };
}
beforeEach(() => {
vi.mocked(api.lanes.gitRefs).mockReset().mockResolvedValue(REFS);
vi.mocked(api.lanes.gitRefAction)
.mockReset()
.mockResolvedValue({ action: "x", ok: true, output: "" });
vi.mocked(confirmDialog).mockClear();
});
const row = (name: RegExp) => screen.getByRole("button", { name });
describe("BranchManager", () => {
it("lists local branches with the current marked and remote ones grouped", async () => {
setup();
await screen.findByText("feature/very-long-branch-name");
const main = row(/^main/);
expect(within(main).getByText("current")).toBeInTheDocument();
expect(main).toHaveTextContent("2");
expect(main).toHaveTextContent("1");
expect(screen.getByText("Remote · origin")).toBeInTheDocument();
expect(screen.getByText("origin/remote-only")).toBeInTheDocument();
// A long name truncates with its full text as the tooltip.
expect(screen.getByText("feature/very-long-branch-name")).toHaveAttribute(
"title",
"feature/very-long-branch-name"
);
});
it("filters by the search box and walks rows with the arrow keys", async () => {
setup();
await screen.findByText("held");
await userEvent.type(screen.getByRole("textbox", { name: "Search branches" }), "remote");
expect(screen.queryByText("held")).toBeNull();
expect(screen.getByText("origin/remote-only")).toBeInTheDocument();
await userEvent.clear(screen.getByRole("textbox", { name: "Search branches" }));
await userEvent.keyboard("{ArrowDown}");
expect(row(/^main/)).toHaveFocus();
await userEvent.keyboard("{ArrowDown}");
expect(row(/^feature\/very-long/)).toHaveFocus();
});
it("renames a branch and re-reads the list", async () => {
const { onChanged } = setup();
await userEvent.click(await screen.findByText("feature/very-long-branch-name"));
await userEvent.click(screen.getByRole("button", { name: /^Rename$/ }));
const input = screen.getByRole("textbox", { name: "New name" });
await userEvent.clear(input);
await userEvent.type(input, "feature/short{Enter}");
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-rename", {
branch: "feature/very-long-branch-name",
name: "feature/short",
});
await waitFor(() => expect(onChanged).toHaveBeenCalled());
expect(api.lanes.gitRefs).toHaveBeenCalledTimes(2);
});
it("refuses rename and delete of a protected current branch, with the reason", async () => {
setup();
await userEvent.click(await screen.findByText("main"));
expect(screen.getByRole("button", { name: /^Rename$/ })).toBeDisabled();
expect(screen.getByRole("button", { name: /^Delete$/ })).toBeDisabled();
expect(screen.getByText(/You are on this branch/)).toBeInTheDocument();
});
it("explains a branch another worktree holds", async () => {
setup();
await userEvent.click(await screen.findByText("held"));
expect(screen.getByRole("button", { name: /^Delete$/ })).toBeDisabled();
expect(screen.getByText(/another worktree: \/tmp\/other/)).toBeInTheDocument();
});
it("deletes an unmerged branch only after the confirm warns", async () => {
setup();
await userEvent.click(await screen.findByText("feature/very-long-branch-name"));
await userEvent.click(screen.getByRole("button", { name: /^Delete$/ }));
expect(vi.mocked(confirmDialog).mock.calls[0]?.[0].message).toMatch(/not merged/);
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-delete", {
branch: "feature/very-long-branch-name",
});
});
it("creates a branch from a remote branch, switch offered only on a clean tree", async () => {
setup({ dirty: 2 });
await userEvent.click(await screen.findByText("origin/remote-only"));
await userEvent.click(screen.getByRole("button", { name: /New branch from here/ }));
expect(screen.getByRole("checkbox", { name: "Switch to it" })).toBeDisabled();
const input = screen.getByRole("textbox", { name: "New branch name" });
expect(input).toHaveValue("remote-only");
await userEvent.type(input, "{Enter}");
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-create", {
branch: "remote-only",
from: "origin/remote-only",
});
});
it("sets and unsets an upstream, and merges into the current branch", async () => {
setup();
await userEvent.click(await screen.findByText("feature/very-long-branch-name"));
await userEvent.click(screen.getByRole("button", { name: /Set upstream/ }));
await userEvent.selectOptions(screen.getByRole("combobox"), "origin/remote-only");
await userEvent.click(screen.getByRole("button", { name: /Apply/ }));
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-upstream", {
branch: "feature/very-long-branch-name",
upstream: "origin/remote-only",
});
await userEvent.click(screen.getByRole("button", { name: /Merge into main/ }));
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "merge", {
branch: "feature/very-long-branch-name",
});
await userEvent.click(screen.getByText("main"));
await userEvent.click(screen.getByRole("button", { name: /Unset upstream/ }));
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-upstream", { branch: "main" });
});
it("shows git's own words when a write is refused", async () => {
vi.mocked(api.lanes.gitRefAction).mockRejectedValueOnce(
Object.assign(new Error("branch x already exists"), { code: "EBRANCHEXISTS" })
);
setup();
await userEvent.click(await screen.findByText("feature/very-long-branch-name"));
await userEvent.click(screen.getByRole("button", { name: /^Rename$/ }));
await userEvent.type(screen.getByRole("textbox", { name: "New name" }), "x{Enter}");
expect(await screen.findByTestId("branch-manager-error")).toHaveTextContent(
"branch x already exists"
);
});
});
describe("TagManager (Tags tab)", () => {
const openTags = async () => {
setup();
await screen.findByText("main");
await userEvent.click(screen.getByRole("tab", { name: /Tags/ }));
};
it("lists tags with their message and creates an annotated one", async () => {
await openTags();
expect(screen.getByText("v1")).toBeInTheDocument();
expect(screen.getByText(/release one/)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /New tag/ }));
await userEvent.type(screen.getByRole("textbox", { name: /Tag name/ }), "v2");
await userEvent.type(
screen.getByRole("textbox", { name: /^On / }),
"feature/very-long-branch-name"
);
await userEvent.type(screen.getByRole("textbox", { name: /Message/ }), "notes");
await userEvent.click(screen.getByRole("button", { name: "Create annotated tag" }));
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "tag-create", {
tag: "v2",
message: "notes",
from: "feature/very-long-branch-name",
});
});
it("pushes one tag and deletes one only with confirm: true", async () => {
await openTags();
await userEvent.click(screen.getByText("v1"));
await userEvent.click(screen.getByRole("button", { name: /Push to origin/ }));
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "tag-push", { tag: "v1" });
await userEvent.click(screen.getByRole("button", { name: /Delete tag/ }));
expect(vi.mocked(confirmDialog).mock.lastCall?.[0].destructive).toBe(true);
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "tag-delete", {
tag: "v1",
confirm: true,
});
});
it("opens from the git bar's button and closes on Escape", async () => {
const { BranchesButton } = await import("../git/BranchesButton");
render(<BranchesButton laneId={3} dirty={0} merging={false} onChanged={vi.fn()} />);
await userEvent.click(screen.getByRole("button", { name: "Manage branches & tags" }));
expect(await screen.findByTestId("branch-manager")).toBeInTheDocument();
await screen.findByText("main");
await userEvent.keyboard("{Escape}");
expect(screen.queryByTestId("branch-manager")).toBeNull();
});
it("does not delete when the confirm is cancelled", async () => {
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
await openTags();
await userEvent.click(screen.getByText("v1"));
await userEvent.click(screen.getByRole("button", { name: /Delete tag/ }));
expect(api.lanes.gitRefAction).not.toHaveBeenCalled();
});
});
@@ -1,28 +1,19 @@
/**
* @file Tests for CloneProgress: a queued clone shows its place in the
* server's clone queue, a running one git's phase, percent and speed with a
* bar, and nothing shows before the server knows the token. Also that
* `newCloneToken` makes a token the server accepts.
* server's clone queue (with the machine-wide counts when it has them), a
* running one git's phase, percent and speed with a bar, and a finished one
* nothing — all rendered from a clone job's fields, no fetching.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import "../../../i18n";
import { CloneProgress, type CloneProgressFields } from "../CloneProgress";
vi.mock("../../../lib/api", () => ({
api: { lanes: { cloneProgress: vi.fn() } },
}));
import { api, type CloneProgressView } from "../../../lib/api";
import { CloneProgress, newCloneToken } from "../CloneProgress";
const base: CloneProgressView = {
const base: CloneProgressFields = {
state: "running",
position: null,
running: 2,
waiting: 1,
limit: 2,
phase: null,
label: null,
percent: null,
@@ -30,50 +21,44 @@ const base: CloneProgressView = {
};
describe("CloneProgress", () => {
// Braces: a function returned from beforeEach is run as its teardown.
beforeEach(() => {
vi.mocked(api.lanes.cloneProgress).mockReset();
it("shows the clone's place in the queue while it waits", () => {
render(<CloneProgress view={{ ...base, state: "queued", position: 2 }} />);
expect(screen.getByTestId("clone-progress")).toHaveTextContent(
"Waiting in the clone queue — #2"
);
});
it("shows the clone's place in the queue while it waits", async () => {
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
...base,
state: "queued",
position: 1,
});
render(<CloneProgress token="t" />);
expect(await screen.findByTestId("clone-progress")).toHaveTextContent(
it("adds the machine-wide counts when the view carries them", () => {
render(
<CloneProgress
view={{ ...base, state: "queued", position: 1, waiting: 1, running: 2, limit: 2 }}
/>
);
expect(screen.getByTestId("clone-progress")).toHaveTextContent(
"Waiting in the clone queue — #1 of 1 (2/2 clones running)"
);
});
it("shows git's phase, percent and speed with a bar while it runs", async () => {
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
...base,
phase: "receiving",
label: "Receiving objects",
percent: 45,
detail: "450/1000, 120.50 MiB | 5.20 MiB/s",
});
render(<CloneProgress token="t" />);
const line = await screen.findByTestId("clone-progress");
it("shows git's phase, percent and speed with a bar while it runs", () => {
render(
<CloneProgress
view={{
...base,
phase: "receiving",
label: "Receiving objects",
percent: 45,
detail: "450/1000, 120.50 MiB | 5.20 MiB/s",
}}
/>
);
const line = screen.getByTestId("clone-progress");
expect(line).toHaveTextContent("Downloading 45%");
expect(line).toHaveTextContent("120.50 MiB | 5.20 MiB/s");
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "45");
});
it("shows nothing while the server does not know the token yet", async () => {
vi.mocked(api.lanes.cloneProgress).mockImplementation(() =>
Promise.reject(Object.assign(new Error("no such clone"), { code: "ENOTFOUND" }))
);
render(<CloneProgress token="t" />);
await new Promise((r) => setTimeout(r, 20));
it("shows nothing once the clone finished", () => {
render(<CloneProgress view={{ ...base, state: "done" }} />);
expect(screen.queryByTestId("clone-progress")).toBeNull();
});
it("makes tokens the server accepts", () => {
const a = newCloneToken();
expect(a).toMatch(/^[A-Za-z0-9_-]{16,64}$/);
expect(newCloneToken()).not.toBe(a);
});
});
@@ -14,17 +14,23 @@ import userEvent from "@testing-library/user-event";
import "../../../i18n";
vi.mock("../../../lib/api", () => ({
api: { lanes: { cloneFolder: vi.fn() }, gitIdentity: { get: vi.fn() } },
api: { lanes: { cloneFolder: vi.fn(), sources: vi.fn(), cloneJobs: vi.fn() }, gitIdentity: { get: vi.fn() } },
}));
import { api, type GitIdentity, type LaneSourcesView } from "../../../lib/api";
import { CloneSourcesForm, cloneTarget } from "../CloneSourcesForm";
import { CLONE_JOB_EVENT, resetCloneJobsForTests } from "../../../lib/cloneJobs";
const view = { sources: [] } as unknown as LaneSourcesView & { path: string };
describe("CloneSourcesForm", () => {
beforeEach(() => {
vi.mocked(api.lanes.cloneFolder).mockReset();
// A finished clone (an older server's answer, or a job the store saw end)
// makes the form read the folder list again.
vi.mocked(api.lanes.sources).mockReset().mockResolvedValue(view);
vi.mocked(api.lanes.cloneJobs).mockReset().mockResolvedValue({ jobs: [] });
resetCloneJobsForTests();
// Unknown identity: nothing is checked up front.
vi.mocked(api.gitIdentity.get).mockReset().mockRejectedValue(new Error("old server"));
});
@@ -49,7 +55,7 @@ describe("CloneSourcesForm", () => {
await user.click(screen.getByTestId("workspace-clone-run"));
await waitFor(() => expect(api.lanes.cloneFolder).toHaveBeenCalledTimes(2));
expect(onAdded).toHaveBeenCalledTimes(1);
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(1));
expect(await screen.findByText(/private\.test needs a login/)).toBeInTheDocument();
// Only what is left stays in the box.
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("https://private.test/b.git");
@@ -60,13 +66,52 @@ describe("CloneSourcesForm", () => {
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(2));
expect(api.lanes.cloneFolder).toHaveBeenLastCalledWith(3, {
progressToken: expect.any(String),
background: true,
url: "https://private.test/b.git",
credentials: { username: "dev", password: "tok", remember: true },
});
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("");
});
it("hands a background clone to the dock and re-reads the folders when it ends", async () => {
const user = userEvent.setup();
const onAdded = vi.fn();
const job = {
id: "s1",
kind: "source",
state: "queued",
url: "https://h/a.git",
name: "a",
parent: "/",
target: "/a",
laneId: 3,
position: 1,
phase: null,
label: null,
percent: null,
detail: null,
error: null,
result: null,
createdAt: "",
finishedAt: null,
request: { url: "https://h/a.git", parent: "/", name: "a" },
} as const;
vi.mocked(api.lanes.cloneFolder).mockResolvedValue({ job: { ...job } });
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={onAdded} />);
await user.type(screen.getByTestId("workspace-clone-input"), "https://h/a.git");
await user.click(screen.getByTestId("workspace-clone-run"));
expect(await screen.findByText(/progress is in the corner/)).toBeInTheDocument();
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("");
expect(onAdded).not.toHaveBeenCalled();
// Another workspace's clone is not ours.
window.dispatchEvent(
new CustomEvent(CLONE_JOB_EVENT, { detail: { ...job, laneId: 4, state: "done" } })
);
window.dispatchEvent(new CustomEvent(CLONE_JOB_EVENT, { detail: { ...job, state: "done" } }));
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(1));
expect(api.lanes.sources).toHaveBeenCalledWith(3);
});
it("asks for partial clones when the box is ticked", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.cloneFolder).mockResolvedValue(view);
@@ -76,7 +121,7 @@ describe("CloneSourcesForm", () => {
await user.click(screen.getByTestId("workspace-clone-run"));
await waitFor(() =>
expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, {
progressToken: expect.any(String),
background: true,
url: "https://h/big.git",
partial: true,
})
@@ -112,9 +157,11 @@ describe("CloneSourcesForm", () => {
await waitFor(() => expect(api.lanes.cloneFolder).toHaveBeenCalledTimes(1));
expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, {
url: "https://h.test/a.git",
progressToken: expect.any(String),
background: true,
});
expect(await screen.findByText(/has no SSH key yet/)).toBeInTheDocument();
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("git@github.com:o/r.git");
await waitFor(() =>
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("git@github.com:o/r.git")
);
});
});
@@ -3,22 +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); opens an HTML file as the page
* instead of downloading it.
* 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) }));
@@ -27,11 +31,15 @@ vi.mock("../../../lib/api", () => ({
gitTarget: (lane: number, session?: string | null, source?: number | null) =>
source ? { lane, source } : session ? { lane, session } : lane,
isHomePlace: (p: unknown) => typeof p === "object" && p !== null && "home" in p,
newUploadId: (() => {
let n = 0;
return () => `upload-id-${String(++n).padStart(8, "0")}`;
})(),
api: {
lanes: {
list: vi.fn(),
clone: vi.fn(),
cloneProgress: vi.fn(),
cloneJobs: vi.fn(async () => ({ jobs: [] })),
gitDownloadUrl: (t: number, p: string) => `/api/lanes/${t}/git/download?path=${p}`,
},
files: {
@@ -48,8 +56,9 @@ vi.mock("../../../lib/api", () => ({
allTrash: vi.fn(),
restore: vi.fn(),
purge: vi.fn(),
upload: vi.fn(),
uploadBatch: vi.fn(),
uploadStaged: vi.fn(),
uploadBatchStaged: vi.fn(),
dropUpload: vi.fn(async () => null),
uploadCheck: vi.fn(),
zipUrl: (t: number, ps: string[]) => `/api/lanes/${t}/fs/zip?p=${ps.join("&p=")}`,
},
@@ -74,9 +83,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" },
@@ -211,7 +241,7 @@ describe("FileExplorer", () => {
});
it("asks about a taken name on upload, and sends the choice", async () => {
vi.mocked(api.files.upload)
vi.mocked(api.files.uploadStaged)
.mockRejectedValueOnce(
Object.assign(new Error("taken"), { code: "EEXISTS", names: ["a.txt"] })
)
@@ -224,19 +254,24 @@ describe("FileExplorer", () => {
const ask = await screen.findByTestId("fx-conflict");
await user.click(within(ask).getByRole("button", { name: "Keep both" }));
await waitFor(() =>
expect(api.files.upload).toHaveBeenLastCalledWith(
expect(api.files.uploadStaged).toHaveBeenLastCalledWith(
1,
"a.txt",
file,
"keep",
expect.any(Function)
expect.objectContaining({ upload: expect.any(String), signal: expect.any(AbortSignal) })
)
);
// The retry names the SAME upload, so the staged bytes are not sent again,
// and the staged upload is dropped once answered.
const [first, second] = vi.mocked(api.files.uploadStaged).mock.calls.map((c) => c[4].upload);
expect(second).toBe(first);
await waitFor(() => expect(api.files.dropUpload).toHaveBeenCalledWith(1, first));
});
it("sends many small files as one batch, asking once about every taken name", async () => {
vi.mocked(api.files.uploadCheck).mockResolvedValue({ taken: ["a.txt", "b.txt"], batch: true });
vi.mocked(api.files.uploadBatch).mockResolvedValue({ saved: 3, skipped: 0, paths: [] });
vi.mocked(api.files.uploadBatchStaged).mockResolvedValue({ saved: 3, skipped: 0, paths: [] });
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
@@ -245,28 +280,45 @@ describe("FileExplorer", () => {
const ask = await screen.findByTestId("fx-conflict");
await user.click(within(ask).getByRole("button", { name: "Replace" }));
await waitFor(() =>
expect(api.files.uploadBatch).toHaveBeenCalledWith(
expect(api.files.uploadBatchStaged).toHaveBeenCalledWith(
1,
"",
files.map((file) => ({ file, rel: file.name })),
"replace",
expect.any(Function)
expect.objectContaining({ upload: expect.any(String), signal: expect.any(AbortSignal) })
)
);
expect(api.files.uploadCheck).toHaveBeenCalledWith(1, "", ["a.txt", "b.txt", "c.txt"]);
expect(api.files.upload).not.toHaveBeenCalled();
expect(api.files.uploadStaged).not.toHaveBeenCalled();
});
it("falls back to one request per file when the server cannot take a batch", async () => {
vi.mocked(api.files.uploadCheck).mockResolvedValue({ taken: [], batch: false });
vi.mocked(api.files.upload).mockResolvedValue({ path: "x" });
vi.mocked(api.files.uploadStaged).mockResolvedValue({ path: "x" });
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
const files = ["x.txt", "y.txt"].map((n) => new File(["hi"], n));
await user.upload(screen.getByTestId("fx-upload-files"), files);
await waitFor(() => expect(api.files.upload).toHaveBeenCalledTimes(2));
expect(api.files.uploadBatch).not.toHaveBeenCalled();
await waitFor(() => expect(api.files.uploadStaged).toHaveBeenCalledTimes(2));
expect(api.files.uploadBatchStaged).not.toHaveBeenCalled();
});
it("leaves a picked repo's .git out of a folder upload, with one notice", async () => {
vi.mocked(api.files.uploadStaged).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.uploadStaged).toHaveBeenCalledTimes(1));
expect(vi.mocked(api.files.uploadStaged).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 () => {
@@ -276,7 +328,7 @@ describe("FileExplorer", () => {
Object.defineProperty(big, "size", { value: 600 * 1024 * 1024 });
fireEvent.change(screen.getByTestId("fx-upload-files"), { target: { files: [big] } });
await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(api.files.upload).not.toHaveBeenCalled();
expect(api.files.uploadStaged).not.toHaveBeenCalled();
});
it("restores from the Trash", async () => {
@@ -441,6 +493,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();
@@ -568,12 +647,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"));
@@ -587,26 +662,28 @@ describe("FileExplorer — New → Clone repository", () => {
url: "git@host:o/repo.git",
parent: "/w/one/src",
name: undefined,
progressToken: expect.stringMatching(/^[0-9a-f]{32}$/),
background: true,
})
);
expect(toast.success).toHaveBeenCalled();
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
// Accepted: the dialog steps aside, the app-wide dock takes over.
await waitFor(() => expect(screen.queryByTestId("fx-clone-dialog")).toBeNull());
});
it("shows progress and lets the page go on while the clone runs", async () => {
let finish: (v: unknown) => void = () => {};
vi.mocked(api.lanes.clone).mockReturnValue(new Promise((r) => (finish = r)) as never);
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
state: "running",
position: null,
running: 1,
waiting: 0,
limit: 2,
phase: "receiving",
label: null,
percent: 45,
detail: "450/1000",
it("re-reads the folder and workspaces when a background clone ends", async () => {
open();
await screen.findByText("a.txt");
const lists = vi.mocked(api.files.list).mock.calls.length;
const laneReads = vi.mocked(api.lanes.list).mock.calls.length;
window.dispatchEvent(new CustomEvent(CLONE_JOB_EVENT, { detail: { ...job, state: "done" } }));
await waitFor(() => expect(vi.mocked(api.files.list).mock.calls.length).toBeGreaterThan(lists));
expect(vi.mocked(api.lanes.list).mock.calls.length).toBeGreaterThan(laneReads);
});
it("treats an older server's synchronous answer as a finished clone", async () => {
vi.mocked(api.lanes.clone).mockResolvedValue({
lane: { id: 9, title: "repo", cwd: "/w/one/repo" },
created: true,
path: "/w/one/repo",
} as never);
const user = userEvent.setup();
open();
@@ -614,43 +691,8 @@ describe("FileExplorer — New → Clone repository", () => {
const dialog = await openClone(user);
await user.type(within(dialog).getByLabelText("Repository URL"), "git@host:o/repo.git");
await user.click(within(dialog).getByTestId("fx-clone-submit"));
expect(await within(dialog).findByRole("progressbar")).toHaveAttribute("aria-valuenow", "45");
await user.click(within(dialog).getByRole("button", { name: "Run in background" }));
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
const chip = await screen.findByTestId("fx-clone-chip");
expect(within(chip).getByText(/repo/)).toBeInTheDocument();
await user.click(chip);
expect(screen.getByTestId("fx-clone-dialog")).toBeInTheDocument();
await user.keyboard("{Escape}");
finish({
lane: { id: 9, title: "repo", cwd: "/w/one/repo" },
created: true,
path: "/w/one/repo",
});
await waitFor(() => expect(toast.success).toHaveBeenCalled());
expect(screen.queryByTestId("fx-clone-chip")).toBeNull();
});
it("toasts a failure that lands while hidden and keeps it for the next open", async () => {
let fail: (e: unknown) => void = () => {};
vi.mocked(api.lanes.clone).mockReturnValue(new Promise((_, r) => (fail = r)) as never);
vi.mocked(api.lanes.cloneProgress).mockRejectedValue(new Error("404"));
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
const dialog = await openClone(user);
await user.type(within(dialog).getByLabelText("Repository URL"), "https://git.example/o/r.git");
await user.click(within(dialog).getByTestId("fx-clone-submit"));
await user.keyboard("{Escape}");
fail(
Object.assign(new Error("login"), { code: "ECREDENTIALS", auth: { host: "git.example" } })
);
await waitFor(() => expect(toast.error).toHaveBeenCalled());
const again = await openClone(user);
expect(within(again).getByTestId("fx-clone-login")).toBeInTheDocument();
expect(within(again).getByLabelText("Repository URL")).toHaveValue(
"https://git.example/o/r.git"
);
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
});
it("asks for a login on ECREDENTIALS and sends it once", async () => {
@@ -658,18 +700,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"));
@@ -677,6 +717,7 @@ describe("FileExplorer — New → Clone repository", () => {
expect(api.lanes.clone).toHaveBeenLastCalledWith(
expect.objectContaining({
parent: "/w/one",
background: true,
credentials: { username: "ann", password: "tok", remember: true },
})
)
@@ -20,6 +20,7 @@ import userEvent from "@testing-library/user-event";
import { LaneChangesPanel, gitChangeFor } from "../LaneChangesPanel";
import { confirmDialog, confirmDiscard } from "../../../lib/confirm";
import { api } from "../../../lib/api";
import { emitGitChanged } from "../../../lib/gitChanged";
import type { LaneChanges } from "../../../lib/types";
// The themed confirm needs its host (App); answer it here.
@@ -84,6 +85,19 @@ vi.mock("../../../lib/api", () => ({
gitDownloadUrl: (id: number, path: string) =>
`/api/lanes/${id}/git/download?path=${encodeURIComponent(path)}`,
gitUpload: vi.fn(),
gitHistory: {
commit: vi.fn(),
patch: vi.fn(),
fileHistory: vi.fn(),
blameAt: vi.fn(),
treeAt: vi.fn(),
compare: vi.fn(),
compareFile: vi.fn(),
pickPlan: vi.fn(),
sequence: vi.fn(),
pick: vi.fn(),
act: vi.fn(),
},
},
},
}));
@@ -153,6 +167,44 @@ function discardOf(path: string) {
beforeEach(() => {
perms.local = true;
const h = api.lanes.gitHistory;
vi.mocked(h.commit).mockReset().mockResolvedValue({ available: false });
vi.mocked(h.sequence).mockReset().mockResolvedValue({ available: true, active: false });
vi.mocked(h.pick).mockReset();
vi.mocked(h.pickPlan).mockReset().mockImplementation(async (_t, op, shas) => ({
available: true,
op,
commits: shas.map((sha) => ({
sha,
short: sha.slice(0, 7),
subject: "feat: the commit",
author: "t",
date: "2026-09-22T01:00:00Z",
})),
branch: "feat/thing",
clean: true,
repoRoot: true,
busy: false,
}));
vi.mocked(h.fileHistory)
.mockReset()
.mockResolvedValue({
available: true,
path: "server/lib/git-ops.js",
hasMore: false,
commits: [
{
sha: "a".repeat(40),
short: "aaaaaaa",
subject: "feat: the commit",
author: "t",
date: "2026-09-22T01:00:00Z",
parents: [],
status: "M",
path: "server/lib/git-ops.js",
},
],
});
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
vi.mocked(api.lanes.changes).mockReset();
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
@@ -445,6 +497,84 @@ describe("LaneChangesPanel", () => {
);
});
it("shows the NEW branch everywhere after a switch from the git bar", async () => {
// The repo as the server sees it: which branch, which changes.
let branch = "feat/thing";
vi.mocked(api.lanes.git).mockImplementation(async () => ({
available: true,
branch,
head: "9b3e74a",
subject: "wip",
dirty: 0,
untracked: 0,
files: [],
hasRemote: false,
upstream: null,
ahead: null,
behind: null,
}));
vi.mocked(api.lanes.changes).mockImplementation(async () =>
changes({
files: [
{
path: branch === "other" ? "only-on-other.ts" : "only-on-thing.ts",
status: " M",
untracked: false,
unstaged: true,
added: 1,
deleted: 0,
binary: false,
},
],
})
);
vi.mocked(api.lanes.gitAction).mockImplementation(async (_t, action, body) => {
if (action === "switch") branch = (body as { branch: string }).branch;
return { action, ok: true, output: "", shelves: [] };
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
expect(await screen.findByTestId("lane-git")).toHaveTextContent("feat/thing");
await screen.findByTitle("only-on-thing.ts");
await user.click(screen.getByRole("button", { name: /^Branch$/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "other");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await waitFor(() => expect(screen.getByTestId("lane-git")).toHaveTextContent("other"));
expect(await screen.findByTitle("only-on-other.ts")).toBeInTheDocument();
expect(screen.queryByTitle("only-on-thing.ts")).toBeNull();
});
it("re-reads when ANOTHER view of the lane changed its git state", async () => {
let branch = "feat/thing";
vi.mocked(api.lanes.git).mockImplementation(async () => ({
available: true,
branch,
head: "9b3e74a",
subject: "wip",
dirty: 0,
untracked: 0,
files: [],
hasRemote: false,
upstream: null,
ahead: null,
behind: null,
}));
render(<LaneChangesPanel laneId={7} />);
expect(await screen.findByTestId("lane-git")).toHaveTextContent("feat/thing");
const before = vi.mocked(api.lanes.changes).mock.calls.length;
branch = "other";
act(() => emitGitChanged(7, "switch"));
await waitFor(() => expect(screen.getByTestId("lane-git")).toHaveTextContent("other"));
expect(vi.mocked(api.lanes.changes).mock.calls.length).toBeGreaterThan(before);
// Another workspace's write is not this panel's business.
const now = vi.mocked(api.lanes.git).mock.calls.length;
act(() => emitGitChanged(8, "switch"));
await new Promise((r) => setTimeout(r, 20));
expect(vi.mocked(api.lanes.git).mock.calls.length).toBe(now);
});
it("re-reads history too when Refresh is pressed there", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
@@ -544,7 +674,7 @@ describe("LaneChangesPanel", () => {
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
});
it("opens a whole HTML file as its source, the page after Preview, the source after Raw", async () => {
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: "",
@@ -560,21 +690,31 @@ describe("LaneChangesPanel", () => {
lines: ["<p>deck</p>"],
sha: "c".repeat(40),
});
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 frame = await screen.findByTestId("html-frame");
expect(frame.getAttribute("srcdoc")).toContain("<p>deck</p>");
await user.click(screen.getByTestId("lane-file-preview"));
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("<p>deck</p>");
expect(screen.queryByTestId("html-frame")).toBeNull();
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("opens a changed HTML file on its Changes, the page only after Preview", async () => {
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: [
@@ -582,28 +722,21 @@ describe("LaneChangesPanel", () => {
],
})
);
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 user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await user.click(await screen.findByTitle("deck.html"));
await screen.findByRole("button", { name: "Changes", pressed: true });
expect(screen.queryByTestId("html-frame")).toBeNull();
await user.click(await screen.findByTestId("lane-file-preview"));
const frame = await screen.findByTestId("html-frame");
expect(frame.getAttribute("srcdoc")).toContain("<p>deck</p>");
expect(frame.getAttribute("sandbox")).not.toContain("allow-same-origin");
// Back to Changes, then Whole file: the source again, not the page.
await user.click(screen.getByRole("button", { name: "Changes", pressed: false }));
await user.click(screen.getByRole("button", { name: "Whole file", pressed: false }));
await waitFor(() => expect(screen.queryByTestId("html-frame")).toBeNull());
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 () => {
@@ -834,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 () => {
@@ -944,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"
@@ -1074,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, {
@@ -1132,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(() =>
@@ -1170,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 () => {
@@ -1189,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();
});
@@ -1670,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,10 @@
* @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), that branches only the remote has are offered (and the list
* 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>
@@ -202,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 () => {
@@ -237,6 +337,47 @@ describe("LaneGitBar", () => {
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(2));
});
it("narrows the branch list as you type, picks a single match and Enter's first one (#88)", async () => {
vi.mocked(api.lanes.gitBranches).mockResolvedValue({
available: true,
branches: ["development", "feat/campaign-pause", "fix/campaign-step", "main"],
remotes: ["origin/feat/campaign-statistic", "origin/feature/thienpt/unit_test"],
current: "development",
worktrees: [],
});
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
const select = await screen.findByLabelText("Switch branch…");
const search = screen.getByLabelText("Search branches…");
const options = () =>
Array.from(select.querySelectorAll("option"))
.map((o) => o.value)
.filter(Boolean);
await user.type(search, "CAMPAIGN");
expect(options()).toEqual([
"feat/campaign-pause",
"fix/campaign-step",
"origin/feat/campaign-statistic",
]);
expect(select.querySelector("option")).toHaveTextContent("3 matching branches");
expect(select).toHaveValue("");
await user.keyboard("{Enter}");
expect(select).toHaveValue("feat/campaign-pause");
await user.clear(search);
await user.type(search, "unit_t");
// One match: picked at once (the earlier pick stays listed until replaced).
expect(select).toHaveValue("origin/feature/thienpt/unit_test");
expect(options()).toEqual(["origin/feature/thienpt/unit_test"]);
await user.keyboard("{Escape}");
expect(search).toHaveValue("");
expect(options()).toHaveLength(6);
});
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,
@@ -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,59 @@
/**
* @file UrlPreview.test.tsx
* @description The Preview tab: port chips before a page is open, the
* address bar opening a page in a sandboxed frame without top navigation,
* a link's request opening its page, the dashboard's own origin refused, and
* the framing-refusal strip shown for an outside site only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import "../../../i18n";
import { UrlPreview } from "../UrlPreview";
beforeEach(() => window.localStorage.clear());
describe("UrlPreview", () => {
it("offers the workspace's ports, and a chip opens that page", () => {
render(<UrlPreview laneId={1} ports={{ web: 3001 }} request={null} />);
fireEvent.click(screen.getByText("localhost:3001"));
const frame = screen.getByTestId("url-preview-frame");
expect(frame.getAttribute("src")).toBe("http://localhost:3001/");
expect(frame.getAttribute("sandbox")).not.toMatch(/top-navigation/);
expect(window.localStorage.getItem("agenthub.preview.url.1")).toBe("http://localhost:3001/");
});
it("opens what the address bar names, and refuses the dashboard itself", () => {
render(<UrlPreview laneId={2} ports={{}} request={null} />);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: window.location.origin + "/settings" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.queryByTestId("url-preview-frame")).toBeNull();
fireEvent.change(input, { target: { value: "127.0.0.1:8080/app" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByTestId("url-preview-frame").getAttribute("src")).toBe(
"http://127.0.0.1:8080/app"
);
});
it("warns that an outside site may refuse to be framed, never for a local page", () => {
render(<UrlPreview laneId={4} ports={{}} request={null} />);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "localhost:3000" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.queryByTestId("url-preview-blocked-hint")).toBeNull();
fireEvent.change(input, { target: { value: "https://youtube.com" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByTestId("url-preview-blocked-hint")).toBeTruthy();
});
it("opens a link's request", () => {
render(
<UrlPreview laneId={3} ports={{}} request={{ url: "http://localhost:5173/", nonce: 1 }} />
);
expect(screen.getByTestId("url-preview-frame").getAttribute("src")).toBe(
"http://localhost:5173/"
);
});
});
@@ -0,0 +1,156 @@
/**
* @file Blame / annotate at any revision (JetBrains' Annotate, GitLens'
* blame): the file's lines with, on the first line of each run, the commit
* that last changed it — short sha, author, relative date, summary. A commit
* opens its whole change; "Blame before this change" re-reads the file at
* that commit's parent (`previous`). Rows are windowed, so a 20 000-line
* file scrolls as fast as a short one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type GitTarget } from "../../../lib/api";
import type { BlameAt } from "../../../lib/gitHistory";
import { timeAgo } from "../../../lib/format";
import { GitModal } from "./GitModal";
const ROW = 18;
const OVERSCAN = 40;
export function BlameView({
target,
path,
rev,
onClose,
onOpenCommit,
}: {
target: GitTarget;
path: string;
/** A commit, or `work` for the file on disk. */
rev: string;
onClose: () => void;
onOpenCommit: (sha: string) => void;
}) {
const { t } = useTranslation(["gitHistory", "lanes"]);
const [at, setAt] = useState({ path, rev });
const [blame, setBlame] = useState<BlameAt | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let live = true;
setBlame(null);
setError(null);
void api.lanes.gitHistory
.blameAt(target, at.path, at.rev)
.then((r) => live && (r.available ? setBlame(r) : setError(t("noBlame"))))
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- target is stable
}, [at.path, at.rev]);
// Shade alternate commit runs so a run reads as one block.
const runs = useMemo(() => {
const starts: boolean[] = [];
const band: number[] = [];
let b = 0;
blame?.lines.forEach((l, i) => {
const start = i === 0 || blame.lines[i - 1]!.sha !== l.sha;
if (start && i) b++;
starts.push(start);
band.push(b);
});
return { starts, band };
}, [blame]);
const box = useRef<HTMLDivElement>(null);
const [top, setTop] = useState(0);
const height = 800;
const n = blame?.lines.length ?? 0;
const first = Math.max(0, Math.floor(top / ROW) - OVERSCAN);
const last = Math.min(n, Math.ceil((top + height) / ROW) + OVERSCAN);
return (
<GitModal
tall
testId="blame-view"
onClose={onClose}
title={
<span>
{t("blameTitle", { path: at.path })}{" "}
<span className="font-mono text-fg-muted">
@ {at.rev === "work" ? t("workingTree") : at.rev.slice(0, 7)}
</span>
</span>
}
>
{error && <p className="text-[12px] text-status-danger [overflow-wrap:anywhere]">{error}</p>}
{!blame && !error && (
<p className="text-[12px] text-fg-muted">{t("lanes:changes.loading")}</p>
)}
{blame && (
<div
ref={box}
onScroll={(e) => setTop(e.currentTarget.scrollTop)}
className="min-h-0 flex-1 overflow-auto font-mono text-[11px] leading-[18px]"
>
<div style={{ height: n * ROW, position: "relative", minWidth: "100%" }}>
{blame.lines.slice(first, last).map((l, k) => {
const i = first + k;
const c = l.sha ? blame.commits[l.sha] : null;
return (
<div
key={i}
data-testid="blame-line"
style={{ position: "absolute", top: i * ROW, height: ROW, left: 0, right: 0 }}
className={`flex ${runs.band[i]! % 2 ? "bg-surface-2/60" : ""}`}
>
<span className="flex w-[16rem] shrink-0 items-center gap-1 overflow-hidden border-r border-border-light pr-1 text-fg-muted">
{runs.starts[i] &&
(c && l.sha ? (
<>
<button
type="button"
onClick={() => onOpenCommit(l.sha!)}
title={`${c.summary ?? ""}\n${c.author ?? ""} · ${c.date ? new Date(c.date).toLocaleString() : ""}`}
className="shrink-0 text-accent hover:underline"
>
{c.short}
</button>
<span className="min-w-0 truncate font-sans" title={c.author}>
{c.author}
</span>
<span className="shrink-0 font-sans">
{c.date ? timeAgo(c.date, true) : ""}
</span>
{c.previous && (
<button
type="button"
title={t("blameBefore")}
aria-label={t("blameBefore")}
onClick={() => setAt({ path: at.path, rev: c.previous! })}
className="shrink-0 font-sans hover:text-fg-primary"
>
↶
</button>
)}
</>
) : (
<span className="font-sans italic">{t("notCommitted")}</span>
))}
</span>
<span className="w-10 shrink-0 select-none pr-1 text-right text-fg-muted">
{i + 1}
</span>
<span className="whitespace-pre pl-1 text-fg-primary">{l.text}</span>
</div>
);
})}
</div>
</div>
)}
</GitModal>
);
}
@@ -0,0 +1,641 @@
/**
* @file The git bar's Branches panel (JetBrains' branch popup, GitLens'
* Branches view): a dialog over the workspace listing every local branch
* (current marked, upstream with ahead/behind, last commit, why a delete would
* be refused) and the fetched remote branches grouped by remote, filtered by a
* search box and walked with the arrow keys; picking a row opens its actions —
* new branch from it (optionally switching to it on a clean tree), rename,
* set/unset upstream, merge into the current branch, delete. Its Tags tab is
* `TagManager`. Everything it may do is decided by the server
* (`GET …/git/refs`, `POST …/git/:action`, server/lib/git-refs.js); a disabled
* button here is a courtesy, and git's own text is shown when it refuses.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowDown,
ArrowUp,
Check,
GitBranch,
GitMerge,
Link2,
Link2Off,
Pencil,
Plus,
Search,
Tag,
Trash2,
X,
} from "lucide-react";
import {
api,
gitTarget,
type GitLocalBranch,
type GitRefs,
type GitRemoteBranch,
type GitSource,
} from "../../../lib/api";
import { useDialog } from "../../../hooks/useDialog";
import { confirmDialog } from "../../../lib/confirm";
import { toast } from "../../../lib/toast";
import { isSubmitEnter } from "../../../lib/keys";
import { timeAgo } from "../../../lib/format";
import { Checkbox } from "../../Checkbox";
import { TagManager } from "./TagManager";
import { onListKeys, smallBtn, smallInput, type RunRefAction } from "./refUi";
type Mode = "new" | "rename" | "upstream" | null;
export function BranchManager({
laneId,
session,
source,
dirty,
merging,
onClose,
onChanged,
}: {
laneId: number;
session?: string;
source?: GitSource;
/** Uncommitted files: a "switch to it" is refused on a dirty tree. */
dirty: number;
/** A merge, cherry-pick or revert is open: every write here is refused. */
merging: boolean;
onClose: () => void;
/** Called after every write, so the git bar and the Changes panel re-read. */
onChanged: () => void;
}) {
const { t } = useTranslation(["gitRefs"]);
const titleId = useId();
const panelRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
useDialog(true, panelRef, onClose, searchRef);
const target = gitTarget(laneId, session, source);
const [refs, setRefs] = useState<GitRefs | null>(null);
const [tab, setTab] = useState<"branches" | "tags">("branches");
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<string | null>(null);
const [mode, setMode] = useState<Mode>(null);
const [text, setText] = useState("");
const [switchTo, setSwitchTo] = useState(false);
const [upstream, setUpstream] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const inFlight = useRef(false);
const load = useCallback(async () => {
try {
setRefs(await api.lanes.gitRefs(target));
} catch {
setRefs({
available: false,
current: null,
head: null,
local: [],
remotes: [],
tags: [],
truncated: false,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [laneId, session, source]);
useEffect(() => {
void load();
}, [load]);
const run: RunRefAction = async (action, body, done) => {
if (inFlight.current) return false;
inFlight.current = true;
setBusy(true);
setError(null);
try {
await api.lanes.gitRefAction(target, action, body);
if (done) toast.success(done);
setMode(null);
setText("");
await load();
onChanged();
return true;
} catch (err) {
const code = (err as { code?: string })?.code;
setError(
code === "EGITAUTH"
? t("errLogin")
: code === "ESUBDIR"
? t("errSubdir")
: err instanceof Error
? err.message
: String(err)
);
return false;
} finally {
inFlight.current = false;
setBusy(false);
}
};
const q = query.trim().toLowerCase();
const match = (s: string) => !q || s.toLowerCase().includes(q);
const local = useMemo(
() => (refs?.local || []).filter((b) => match(b.name)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[refs, q]
);
const remoteGroups = useMemo(
() =>
(refs?.remotes || [])
.map((g) => ({ ...g, branches: g.branches.filter((b) => match(b.name)) }))
.filter((g) => g.branches.length),
// eslint-disable-next-line react-hooks/exhaustive-deps
[refs, q]
);
const allRemote = useMemo(
() => (refs?.remotes || []).flatMap((g) => g.branches.map((b) => b.name)),
[refs]
);
const current = refs?.current ?? null;
const pick = (key: string) => {
setSelected((s) => (s === key ? null : key));
setMode(null);
setText("");
setError(null);
};
const open = (m: Mode, initial = "") => {
setMode((v) => (v === m ? null : m));
setText(initial);
setSwitchTo(false);
setError(null);
};
const merge = async (branch: string) => {
const ok = await confirmDialog({
title: t("merge"),
message: t("mergeConfirm", { branch, current: current ?? "HEAD" }),
confirmLabel: t("merge"),
destructive: false,
});
if (ok) await run("merge", { branch }, t("done.merge", { branch }));
};
const remove = async (b: GitLocalBranch) => {
const ok = await confirmDialog({
title: t("delete"),
message: b.merged
? t("deleteConfirm", { branch: b.name })
: t("deleteUnmerged", { branch: b.name }),
confirmLabel: t("delete"),
destructive: true,
});
if (ok) {
if (await run("branch-delete", { branch: b.name }, t("done.delete", { branch: b.name })))
setSelected(null);
}
};
/** Why `git branch -d` would refuse this branch, or null. */
const deleteBlock = (b: GitLocalBranch) =>
b.current
? t("why.current")
: b.protected
? t("why.protected")
: b.worktree
? t("why.worktree", { path: b.worktree })
: null;
const newBranchForm = (from: string) => (
<div className="flex flex-wrap items-center gap-1.5">
<input
autoFocus
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e) && text.trim())
void run(
"branch-create",
{ branch: text.trim(), from, ...(switchTo ? { switch: true } : {}) },
t("done.create", { branch: text.trim() })
);
}}
placeholder={t("newBranchName")}
aria-label={t("newBranchName")}
className={smallInput}
/>
<Checkbox
checked={switchTo}
onChange={setSwitchTo}
disabled={dirty > 0 || merging}
label={t("switchToIt")}
/>
<button
type="button"
className={smallBtn}
disabled={busy || !text.trim()}
onClick={() =>
void run(
"branch-create",
{ branch: text.trim(), from, ...(switchTo ? { switch: true } : {}) },
t("done.create", { branch: text.trim() })
)
}
>
<Plus className="h-3 w-3" />
{t("create")}
</button>
{dirty > 0 && <span className="text-[10px] text-fg-muted">{t("switchDirty")}</span>}
</div>
);
const localActions = (b: GitLocalBranch) => {
const block = deleteBlock(b);
return (
<div className="space-y-1.5 border-t border-border-light px-2 py-1.5">
<div className="flex flex-wrap gap-1">
<button type="button" className={smallBtn} disabled={busy} onClick={() => open("new")}>
<Plus className="h-3 w-3" />
{t("newFrom")}
</button>
<button
type="button"
className={smallBtn}
disabled={busy || b.protected || merging}
title={b.protected ? t("why.protected") : t("renameHint")}
onClick={() => open("rename", b.name)}
>
<Pencil className="h-3 w-3" />
{t("rename")}
</button>
<button
type="button"
className={smallBtn}
disabled={busy || merging || !allRemote.length}
title={allRemote.length ? t("upstreamHint") : t("noRemoteBranches")}
onClick={() => {
open("upstream");
setUpstream(b.upstream ?? "");
}}
>
<Link2 className="h-3 w-3" />
{t("setUpstream")}
</button>
{b.upstream && (
<button
type="button"
className={smallBtn}
disabled={busy || merging}
onClick={() =>
void run("branch-upstream", { branch: b.name }, t("done.unsetUpstream"))
}
>
<Link2Off className="h-3 w-3" />
{t("unsetUpstream")}
</button>
)}
{!b.current && (
<button
type="button"
className={smallBtn}
disabled={busy || merging || dirty > 0 || !current}
title={dirty > 0 ? t("switchDirty") : t("mergeHint", { current: current ?? "" })}
onClick={() => void merge(b.name)}
>
<GitMerge className="h-3 w-3" />
{t("mergeInto", { current: current ?? "HEAD" })}
</button>
)}
<button
type="button"
className={`${smallBtn} hover:text-status-danger`}
disabled={busy || !!block}
title={block ?? (b.merged ? t("deleteHint") : t("why.unmerged"))}
onClick={() => void remove(b)}
>
<Trash2 className="h-3 w-3" />
{t("delete")}
</button>
</div>
{block && <p className="text-[10px] text-fg-muted [overflow-wrap:anywhere]">{block}</p>}
{!block && !b.merged && (
<p className="text-[10px] text-status-warning">{t("why.unmerged")}</p>
)}
{mode === "new" && newBranchForm(b.name)}
{mode === "rename" && (
<div className="flex items-center gap-1.5">
<input
autoFocus
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e) && text.trim() && text.trim() !== b.name)
void run(
"branch-rename",
{ branch: b.name, name: text.trim() },
t("done.rename", { branch: text.trim() })
);
}}
aria-label={t("renameTo")}
placeholder={t("renameTo")}
className={smallInput}
/>
<button
type="button"
className={smallBtn}
disabled={busy || !text.trim() || text.trim() === b.name}
onClick={() =>
void run(
"branch-rename",
{ branch: b.name, name: text.trim() },
t("done.rename", { branch: text.trim() })
)
}
>
<Check className="h-3 w-3" />
{t("rename")}
</button>
</div>
)}
{mode === "upstream" && (
<div className="flex items-center gap-1.5">
<select
value={upstream}
onChange={(e) => setUpstream(e.target.value)}
aria-label={t("setUpstream")}
className="select min-w-0 flex-1 !rounded !py-0.5 !pl-2 !pr-6 font-mono !text-[11px]"
>
<option value="">{t("pickUpstream")}</option>
{allRemote.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<button
type="button"
className={smallBtn}
disabled={busy || !upstream || upstream === b.upstream}
onClick={() =>
void run(
"branch-upstream",
{ branch: b.name, upstream },
t("done.setUpstream", { upstream })
)
}
>
<Check className="h-3 w-3" />
{t("apply")}
</button>
</div>
)}
</div>
);
};
const remoteActions = (r: GitRemoteBranch) => (
<div className="space-y-1.5 border-t border-border-light px-2 py-1.5">
<div className="flex flex-wrap gap-1">
<button
type="button"
className={smallBtn}
disabled={busy}
onClick={() => open("new", r.branch)}
>
<Plus className="h-3 w-3" />
{t("newFrom")}
</button>
<button
type="button"
className={smallBtn}
disabled={busy || merging || dirty > 0 || !current}
title={dirty > 0 ? t("switchDirty") : t("mergeHint", { current: current ?? "" })}
onClick={() => void merge(r.name)}
>
<GitMerge className="h-3 w-3" />
{t("mergeInto", { current: current ?? "HEAD" })}
</button>
</div>
{mode === "new" && newBranchForm(r.name)}
</div>
);
const row = (
key: string,
name: string,
sub: React.ReactNode,
badges: React.ReactNode,
actions: () => React.ReactNode
) => (
<li key={key} className={selected === key ? "rounded bg-surface-2" : undefined}>
<button
type="button"
data-ref-row
aria-expanded={selected === key}
onClick={() => pick(key)}
className="flex w-full min-w-0 items-start gap-2 rounded px-2 py-1 text-left hover:bg-surface-3 focus:outline-none focus-visible:ring-1 focus-visible:ring-accent"
>
<GitBranch className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" aria-hidden />
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-1.5">
<span title={name} className="truncate font-mono text-xs text-fg-primary">
{name}
</span>
{badges}
</span>
<span className="block truncate text-[10px] text-fg-muted">{sub}</span>
</span>
</button>
{selected === key && actions()}
</li>
);
const badge = (label: string, tone = "text-fg-muted border-border-light") => (
<span className={`shrink-0 rounded border px-1 text-[9px] uppercase ${tone}`}>{label}</span>
);
const localRow = (b: GitLocalBranch) =>
row(
`l:${b.name}`,
b.name,
<>
{b.subject}
{b.date ? ` · ${timeAgo(b.date)}` : ""}
</>,
<>
{b.current && badge(t("current"), "text-accent border-accent/40")}
{b.protected && badge(t("protected"))}
{b.worktree && (
<span title={b.worktree} className="shrink-0">
{badge(t("worktree"))}
</span>
)}
{b.upstream && (
<span
title={b.upstream}
className="flex shrink-0 items-center gap-0.5 font-mono text-[10px] text-fg-muted"
>
{b.gone ? (
<span className="text-status-warning">{t("gone")}</span>
) : (
<>
{(b.ahead ?? 0) > 0 && (
<span className="flex items-center text-status-warning">
<ArrowUp className="h-3 w-3" aria-label={t("ahead")} />
{b.ahead}
</span>
)}
{(b.behind ?? 0) > 0 && (
<span className="flex items-center text-accent">
<ArrowDown className="h-3 w-3" aria-label={t("behind")} />
{b.behind}
</span>
)}
{!b.ahead && !b.behind && <Check className="h-3 w-3" aria-label={t("inSync")} />}
</>
)}
</span>
)}
</>,
() => localActions(b)
);
const remoteRow = (r: GitRemoteBranch) =>
row(
`r:${r.name}`,
r.name,
<>
{r.subject}
{r.date ? ` · ${timeAgo(r.date)}` : ""}
</>,
null,
() => remoteActions(r)
);
const tabBtn = (id: "branches" | "tags", label: string, icon: React.ReactNode) => (
<button
type="button"
role="tab"
aria-selected={tab === id}
onClick={() => {
setTab(id);
setSelected(null);
setMode(null);
setError(null);
}}
className={`flex items-center gap-1 border-b-2 px-2 py-1 text-xs ${
tab === id
? "border-accent text-fg-primary"
: "border-transparent text-fg-muted hover:text-fg-primary"
}`}
>
{icon}
{label}
</button>
);
return (
<div
className="fixed inset-0 z-[55] flex items-start justify-center bg-black/60 p-4 sm:pt-16"
onClick={onClose}
role="presentation"
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
data-testid="branch-manager"
onClick={(e) => e.stopPropagation()}
className="flex max-h-full w-full max-w-xl min-w-0 flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
>
<div className="flex items-center gap-2 border-b border-border-light px-3 pt-2">
<h3 id={titleId} className="sr-only">
{t("title")}
</h3>
<div role="tablist" className="flex min-w-0 flex-1 gap-1">
{tabBtn("branches", t("branches"), <GitBranch className="h-3.5 w-3.5" />)}
{tabBtn("tags", t("tags"), <Tag className="h-3.5 w-3.5" />)}
</div>
<button
type="button"
className="btn-icon -mt-1"
aria-label={t("close")}
onClick={onClose}
>
<X className="h-4 w-4" aria-hidden />
</button>
</div>
<div className="flex items-center gap-1.5 px-3 py-2">
<Search className="h-3.5 w-3.5 shrink-0 text-fg-muted" aria-hidden />
<input
ref={searchRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "ArrowDown") {
e.preventDefault();
panelRef.current?.querySelector<HTMLElement>("[data-ref-row]")?.focus();
}
}}
placeholder={tab === "tags" ? t("searchTags") : t("searchBranches")}
aria-label={tab === "tags" ? t("searchTags") : t("searchBranches")}
className={`${smallInput} !text-xs`}
/>
</div>
{merging && <p className="px-3 pb-1 text-[11px] text-status-warning">{t("merging")}</p>}
{error && (
<pre
role="alert"
data-testid="branch-manager-error"
className="mx-3 mb-2 max-h-24 overflow-auto whitespace-pre-wrap rounded border border-status-danger/40 bg-status-danger/10 px-2 py-1 text-[10px] text-status-danger [overflow-wrap:anywhere]"
>
{error}
</pre>
)}
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3" onKeyDown={onListKeys}>
{!refs ? (
<p className="px-2 py-4 text-xs text-fg-muted">{t("loading")}</p>
) : !refs.available ? (
<p className="px-2 py-4 text-xs text-fg-muted">{t("unavailable")}</p>
) : tab === "tags" ? (
<TagManager
tags={(refs.tags || []).filter((x) => match(x.name))}
busy={busy}
merging={merging}
dirty={dirty}
run={run}
/>
) : (
<>
<h4 className="px-2 pb-1 pt-1 text-[10px] font-semibold uppercase tracking-wide text-fg-muted">
{t("local")}
</h4>
{local.length ? (
<ul>{local.map(localRow)}</ul>
) : (
<p className="px-2 text-xs text-fg-muted">{t("noMatch")}</p>
)}
{remoteGroups.map((g) => (
<section key={g.remote}>
<h4
title={g.remote}
className="truncate px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wide text-fg-muted"
>
{t("remote", { remote: g.remote })}
</h4>
<ul>{g.branches.map(remoteRow)}</ul>
</section>
))}
{refs.truncated && (
<p className="px-2 pt-2 text-[10px] text-fg-muted">{t("truncated")}</p>
)}
</>
)}
</div>
</div>
</div>
);
}
export default BranchManager;
@@ -0,0 +1,71 @@
/**
* @file The git bar's "Branches & tags" icon button, which owns the Branches
* panel (`BranchManager`) it opens — portalled to `document.body`, so no
* scrolling or transformed ancestor of the Changes panel can clip it. The
* panel closes when the bar starts showing another workspace.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Tags } from "lucide-react";
import type { GitSource } from "../../../lib/api";
import { BranchManager } from "./BranchManager";
export function BranchesButton({
laneId,
session,
source,
dirty,
merging,
disabled,
onChanged,
}: {
laneId: number;
session?: string;
source?: GitSource;
dirty: number;
merging: boolean;
disabled?: boolean;
onChanged: () => void;
}) {
const { t } = useTranslation(["gitRefs"]);
const [open, setOpen] = useState(false);
const [shown, setShown] = useState(`${laneId}|${session ?? ""}|${source ?? ""}`);
const key = `${laneId}|${session ?? ""}|${source ?? ""}`;
if (shown !== key) {
setShown(key);
setOpen(false);
}
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
disabled={disabled}
title={t("openPanelHint")}
aria-label={t("openPanel")}
aria-haspopup="dialog"
className="flex h-6 w-6 items-center justify-center rounded text-[11px] text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
>
<Tags className="h-3.5 w-3.5" />
</button>
{open &&
createPortal(
<BranchManager
laneId={laneId}
session={session}
source={source}
dirty={dirty}
merging={merging}
onClose={() => setOpen(false)}
onChanged={onChanged}
/>,
document.body
)}
</>
);
}
export default BranchesButton;
@@ -0,0 +1,175 @@
/**
* @file Browse files at a revision (GitHub's "Browse files", GitLens' "Open
* files at revision"): a read-only tree of the workspace folder as it was at
* one commit — folders open on a click, read lazily — and the picked file's
* text (or image) beside it, with Blame at that commit. Every read goes
* through the server's own guards: a secret file is neither listed nor
* readable, a big file is capped, a binary one is named, not shown.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, File, Folder } from "lucide-react";
import { api, type GitTarget } from "../../../lib/api";
import type { TreeAtEntry } from "../../../lib/gitHistory";
import type { LaneFileContent } from "../../../lib/types";
import { LaneFileView } from "../LaneDiffView";
import { GitModal } from "./GitModal";
import { BlameView } from "./BlameView";
const IMAGE = /\.(png|jpe?g|gif|webp|avif|bmp|ico)$/i;
export function BrowseAtRevision({
target,
sha,
short,
onClose,
}: {
target: GitTarget;
sha: string;
short: string;
onClose: () => void;
}) {
const { t } = useTranslation(["gitHistory", "lanes"]);
// Folder path → its entries; "" is the workspace folder.
const [tree, setTree] = useState<Record<string, TreeAtEntry[]>>({});
const [open, setOpen] = useState<Set<string>>(new Set([""]));
const [file, setFile] = useState<string | null>(null);
const [content, setContent] = useState<LaneFileContent | null>(null);
const [error, setError] = useState<string | null>(null);
const [blame, setBlame] = useState(false);
const readDir = (dir: string) => {
if (tree[dir]) return;
void api.lanes.gitHistory
.treeAt(target, sha, dir)
.then((r) => r.available && setTree((m) => ({ ...m, [dir]: r.entries })))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
};
useEffect(() => {
readDir("");
// eslint-disable-next-line react-hooks/exhaustive-deps -- once per commit
}, [sha]);
const openFile = (p: string) => {
setFile(p);
setContent(null);
setError(null);
if (IMAGE.test(p)) return;
void api.lanes
.gitFile(target, p, sha)
.then(setContent)
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
};
const toggleDir = (p: string) => {
setOpen((s) => {
const next = new Set(s);
if (next.has(p)) next.delete(p);
else next.add(p);
return next;
});
readDir(p);
};
const rows = (dir: string, depth: number): ReactNode =>
(tree[dir] ?? []).map((e) => (
<li key={e.path}>
<button
type="button"
onClick={() => (e.dir ? toggleDir(e.path) : e.submodule ? undefined : openFile(e.path))}
title={e.path}
style={{ paddingLeft: depth * 12 + 4 }}
className={`flex w-full items-center gap-1 rounded py-0.5 pr-1 text-left text-[11px] hover:bg-surface-2 ${
file === e.path ? "bg-surface-3 text-fg-primary" : "text-fg-secondary"
}`}
>
{e.dir ? (
open.has(e.path) ? (
<ChevronDown className="h-3 w-3 shrink-0" aria-hidden />
) : (
<ChevronRight className="h-3 w-3 shrink-0" aria-hidden />
)
) : (
<span className="w-3 shrink-0" />
)}
{e.dir ? (
<Folder className="h-3 w-3 shrink-0 text-fg-muted" aria-hidden />
) : (
<File className="h-3 w-3 shrink-0 text-fg-muted" aria-hidden />
)}
<span className="min-w-0 truncate">{e.name}</span>
</button>
{e.dir && open.has(e.path) && <ul>{rows(e.path, depth + 1)}</ul>}
</li>
));
return (
<GitModal
tall
testId="browse-at-revision"
onClose={onClose}
title={t("browseTitle", { short })}
>
<div className="flex min-h-0 flex-1 flex-col gap-2 sm:flex-row">
<ul
data-testid="browse-tree"
className="max-h-[35vh] shrink-0 overflow-auto rounded border border-border-light p-1 sm:max-h-none sm:w-64"
>
{tree[""] ? (
rows("", 0)
) : (
<li className="text-[11px] text-fg-muted">{t("lanes:changes.loading")}</li>
)}
</ul>
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
{file && (
<div className="mb-1 flex items-center gap-1.5 text-[11px]">
<span className="min-w-0 flex-1 font-mono text-fg-primary [overflow-wrap:anywhere]">
{file}
</span>
{!IMAGE.test(file) && (
<button
type="button"
onClick={() => setBlame(true)}
className="shrink-0 rounded border border-border-light px-1.5 py-0.5 text-fg-secondary hover:bg-surface-2"
>
{t("blame")}
</button>
)}
</div>
)}
{error && (
<p role="alert" className="text-[12px] text-status-danger [overflow-wrap:anywhere]">
{error}
</p>
)}
{!file && !error && <p className="text-[12px] text-fg-muted">{t("pickAFile")}</p>}
{file && IMAGE.test(file) && (
<img
src={api.lanes.gitRawUrl(target, file, sha)}
alt={file}
className="max-h-full max-w-full self-start object-contain"
/>
)}
{file && content && content.available && content.binary && (
<p className="text-[12px] text-fg-muted">{t("binaryFile")}</p>
)}
{file && content && content.available && !content.binary && (
<LaneFileView path={file} lines={content.lines} truncated={content.truncated} fill />
)}
</div>
</div>
{blame && file && (
<BlameView
target={target}
path={file}
rev={sha}
onClose={() => setBlame(false)}
onOpenCommit={() => setBlame(false)}
/>
)}
</GitModal>
);
}
@@ -0,0 +1,152 @@
/**
* @file The confirmation a cherry-pick or revert opens with, showing exactly
* what will happen before anything does (the reference tools' preview): the
* commits in the order git will apply them (numbered — read from the server,
* which orders them the same way the action will), the branch they land on,
* whether the working tree is clean, and what happens on a conflict. Confirm
* is disabled, with the reason, when the server would refuse (a dirty tree, a
* subfolder workspace, another operation open, a merge commit).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { CheckCircle2, AlertTriangle } from "lucide-react";
import { api, type GitTarget } from "../../../lib/api";
import type { PickPlan, PickResult } from "../../../lib/gitHistory";
import { GitModal, ModalButton } from "./GitModal";
export function CherryPickDialog({
target,
op,
shas,
onClose,
onDone,
}: {
target: GitTarget;
op: "cherry-pick" | "revert";
shas: string[];
onClose: () => void;
onDone: (r: PickResult) => void;
}) {
const { t } = useTranslation(["gitHistory"]);
const [plan, setPlan] = useState<PickPlan | null>(null);
const [error, setError] = useState<string | null>(null);
const [running, setRunning] = useState(false);
useEffect(() => {
let live = true;
void api.lanes.gitHistory
.pickPlan(target, op, shas)
.then((r) => live && (r.available ? setPlan(r) : setError(t("planUnavailable"))))
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- one plan per dialog
}, []);
const branch = plan?.branch ?? "HEAD";
const blocker = !plan
? null
: plan.busy
? t("blockBusy")
: !plan.repoRoot
? t("blockSubdir")
: !plan.clean
? t("blockDirty")
: null;
const n = plan?.commits.length ?? shas.length;
const title =
op === "revert" ? t("revertTitle", { count: n }) : t("pickTitle", { count: n, branch });
const run = async () => {
setRunning(true);
setError(null);
try {
onDone(
await api.lanes.gitHistory.pick(
target,
op,
plan!.commits.map((c) => c.sha)
)
);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setRunning(false);
}
};
return (
<GitModal
title={title}
onClose={onClose}
testId="pick-dialog"
footer={
<>
<ModalButton onClick={onClose}>{t("cancel")}</ModalButton>
<ModalButton
primary
testId="pick-confirm"
disabled={!plan || !!blocker || running}
title={blocker ?? undefined}
onClick={() => void run()}
>
{running
? t("running")
: op === "revert"
? t("revertN", { count: n })
: t("pickN", { count: n, branch })}
</ModalButton>
</>
}
>
<div className="space-y-2 text-[12px] text-fg-secondary">
{!plan && !error && <p className="text-fg-muted">{t("planning")}</p>}
{plan && (
<>
<p className="[overflow-wrap:anywhere]">
{op === "revert"
? t("revertIntro", { count: n, branch })
: t("pickIntro", { count: n, branch })}
</p>
<ol
data-testid="pick-plan"
className="space-y-0.5 rounded border border-border-light bg-surface-0 p-1.5"
>
{plan.commits.map((c, i) => (
<li key={c.sha} className="flex min-w-0 items-baseline gap-1.5">
<span className="w-5 shrink-0 text-right text-fg-muted">{i + 1}.</span>
<span className="shrink-0 font-mono text-[11px] text-fg-muted">{c.short}</span>
<span className="min-w-0 flex-1 truncate text-fg-primary" title={c.subject}>
{c.subject}
</span>
</li>
))}
</ol>
<p className="flex items-center gap-1.5">
{plan.clean && plan.repoRoot && !plan.busy ? (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-status-success" aria-hidden />
) : (
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-status-warning" aria-hidden />
)}
<span data-testid="pick-tree-state" className="[overflow-wrap:anywhere]">
{blocker ?? t("treeClean")}
</span>
</p>
<p className="text-fg-muted">{t(op === "revert" ? "revertResult" : "pickResult")}</p>
<p className="text-fg-muted">{t("onConflict", { op: t(`op.${op}`) })}</p>
</>
)}
{error && (
<p
role="alert"
className="whitespace-pre-wrap text-status-danger [overflow-wrap:anywhere]"
>
{error}
</p>
)}
</div>
</GitModal>
);
}
@@ -0,0 +1,203 @@
/**
* @file The History tab's commit details pane (GitKraken's / JetBrains'
* right-hand commit panel): the full message, author and committer with
* dates, parents (each opens that commit), the commit's refs and the branches
* and tags that contain it, its changed files with +/- counts (a file opens
* that commit's diff of it in the panel's own diff viewer), and Copy SHA,
* Copy patch, Browse files, Compare with current and View all changes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import { api, type GitTarget } from "../../../lib/api";
import type { CommitDetails as Details } from "../../../lib/gitHistory";
import type { LaneChangeFile } from "../../../lib/types";
import { copyText } from "../../../lib/clipboard";
import { RefChip, sortBadges } from "./CommitGraph";
export function CommitDetails({
target,
sha,
onClose,
onOpenCommit,
onViewChanges,
onOpenFile,
onBrowse,
onCompare,
onCopyPatch,
}: {
target: GitTarget;
sha: string;
onClose: () => void;
/** A parent clicked: focus that commit. */
onOpenCommit: (sha: string) => void;
onViewChanges: () => void;
onOpenFile: (path: string) => void;
onBrowse: () => void;
onCompare: () => void;
onCopyPatch: () => void;
}) {
const { t } = useTranslation(["gitHistory", "lanes"]);
const [details, setDetails] = useState<Details | null>(null);
const [files, setFiles] = useState<LaneChangeFile[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let live = true;
void api.lanes.gitHistory
.commit(target, sha)
.then((r) => live && (r.available ? setDetails(r) : setError(t("noDetails"))))
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
void api.lanes
.changes(target, { range: "commit", sha })
.then((r) => live && setFiles(r.available ? r.files : []))
.catch(() => live && setFiles([]));
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on sha by the parent
}, [sha]);
const button =
"shrink-0 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary";
const added = files?.reduce((n, f) => n + (f.added ?? 0), 0) ?? 0;
const deleted = files?.reduce((n, f) => n + (f.deleted ?? 0), 0) ?? 0;
return (
<section
data-testid="commit-details"
aria-label={t("detailsTitle")}
className="max-h-[45%] min-h-[6rem] shrink-0 overflow-y-auto rounded border border-border-light bg-surface-0 p-2 text-[11px]"
>
<div className="mb-1 flex items-start gap-1.5">
<span className="font-mono text-fg-muted">{sha.slice(0, 10)}</span>
<span className="min-w-0 flex-1" />
<button
type="button"
aria-label={t("close")}
onClick={onClose}
className="shrink-0 text-fg-muted hover:text-fg-primary"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
{error && <p className="text-status-danger [overflow-wrap:anywhere]">{error}</p>}
{!details && !error && <p className="text-fg-muted">{t("lanes:changes.loading")}</p>}
{details && (
<>
<p
data-testid="commit-details-message"
className="mb-1.5 whitespace-pre-wrap text-fg-primary [overflow-wrap:anywhere]"
>
{details.message}
</p>
<dl className="mb-1.5 grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 text-fg-secondary">
<dt className="text-fg-muted">{t("author")}</dt>
<dd className="min-w-0 [overflow-wrap:anywhere]">
{details.author} &lt;{details.email}&gt; · {new Date(details.date).toLocaleString()}
</dd>
{(details.committer !== details.author || details.committedAt !== details.date) && (
<>
<dt className="text-fg-muted">{t("committer")}</dt>
<dd className="min-w-0 [overflow-wrap:anywhere]">
{details.committer} · {new Date(details.committedAt).toLocaleString()}
</dd>
</>
)}
<dt className="text-fg-muted">{t("parents", { count: details.parents.length })}</dt>
<dd className="flex min-w-0 flex-wrap gap-1">
{details.parents.length === 0 && <span>{t("rootCommit")}</span>}
{details.parents.map((p) => (
<button
key={p}
type="button"
onClick={() => onOpenCommit(p)}
className="font-mono text-accent hover:underline"
>
{p.slice(0, 7)}
</button>
))}
</dd>
{details.badges.length > 0 && (
<>
<dt className="text-fg-muted">{t("refs")}</dt>
<dd className="flex min-w-0 flex-wrap gap-1">
{sortBadges(details.badges).map((b) => (
<RefChip key={`${b.kind}:${b.name}`} badge={b} />
))}
</dd>
</>
)}
{(details.branches.length > 0 || details.tags.length > 0) && (
<>
<dt className="text-fg-muted">{t("containedIn")}</dt>
<dd className="min-w-0 [overflow-wrap:anywhere]">
{[...details.branches, ...details.tags.map((x) => `tag ${x}`)].join(", ")}
{details.containsTruncated ? " …" : ""}
</dd>
</>
)}
</dl>
<div className="mb-1.5 flex flex-wrap gap-1">
<button type="button" className={button} onClick={onViewChanges}>
{t("viewAllChanges")}
</button>
<button type="button" className={button} onClick={() => void copyText(details.sha)}>
{t("lanes:changes.copySha")}
</button>
<button
type="button"
className={button}
disabled={details.parents.length > 1}
onClick={onCopyPatch}
>
{t("copyPatch")}
</button>
<button type="button" className={button} onClick={onBrowse}>
{t("browseFiles")}
</button>
<button type="button" className={button} onClick={onCompare}>
{t("compareWithCurrent")}
</button>
</div>
</>
)}
{files && (
<>
<p className="mb-0.5 text-fg-muted">
{t("filesChanged", { count: files.length })}{" "}
<span className="text-status-success">+{added}</span>{" "}
<span className="text-status-danger">−{deleted}</span>
</p>
<ul data-testid="commit-details-files">
{files.map((f) => (
<li key={f.path}>
<button
type="button"
onClick={() => onOpenFile(f.path)}
title={f.path}
className="flex w-full items-baseline gap-1.5 rounded px-1 text-left hover:bg-surface-2"
>
<span className="w-3 shrink-0 font-mono text-fg-muted">{f.status.trim()}</span>
<span className="min-w-0 flex-1 text-fg-primary [overflow-wrap:anywhere]">
{f.path}
</span>
{f.binary ? (
<span className="shrink-0 text-fg-muted">{t("binary")}</span>
) : (
<span className="shrink-0 font-mono">
<span className="text-status-success">+{f.added ?? 0}</span>{" "}
<span className="text-status-danger">−{f.deleted ?? 0}</span>
</span>
)}
</button>
</li>
))}
</ul>
</>
)}
</section>
);
}
@@ -0,0 +1,182 @@
/**
* @file The History tab's commit rows, drawn like VS Code's Source Control
* Graph / GitLens: the graph cell (lanes and merge edges from
* `lib/commitGraph`), the ref badges (current branch, local and remote
* branches, tags, a detached HEAD), the subject, author and relative date.
* Colours are theme tokens (CSS variables), so a lane reads in every theme.
* Long subjects and names truncate with the whole text in a tooltip.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { memo } from "react";
import { useTranslation } from "react-i18next";
import { Cloud, GitBranch, Tag, Crosshair } from "lucide-react";
import type { GraphRow } from "../../../lib/commitGraph";
import type { RefBadge } from "../../../lib/gitHistory";
import { timeAgo } from "../../../lib/format";
/** One fixed-height row per commit so the lanes join up row to row. */
export const GRAPH_ROW = 30;
const GRAPH_COL = 12;
/** Columns drawn at most; a wider graph is clipped, not the row. */
const MAX_COLS = 12;
/** Theme tokens, cycled per lane. */
const GRAPH_COLORS = [
"rgb(var(--accent))",
"rgb(var(--status-success))",
"rgb(var(--status-warning))",
"rgb(var(--syn-property))",
"rgb(var(--status-danger))",
"rgb(var(--syn-function))",
"rgb(var(--kept))",
"rgb(var(--diff-mod))",
];
const hue = (i: number) => GRAPH_COLORS[i % GRAPH_COLORS.length];
export function GraphCell({
row,
width,
merge,
head,
}: {
row: GraphRow;
width: number;
/** A merge commit: a hollow node. */
merge?: boolean;
/** HEAD: a ringed node. */
head?: boolean;
}) {
const cols = Math.min(width, MAX_COLS);
const x = (col: number) => col * GRAPH_COL + GRAPH_COL / 2;
const y = (f: number) => f * GRAPH_ROW;
return (
<svg
aria-hidden
width={cols * GRAPH_COL}
height={GRAPH_ROW}
className="shrink-0 overflow-hidden"
data-testid="lane-changes-graph"
>
{row.segs.map((g, i) => {
const ym = (y(g.y1) + y(g.y2)) / 2;
return (
<path
key={i}
d={`M${x(g.x1)} ${y(g.y1)} C${x(g.x1)} ${ym} ${x(g.x2)} ${ym} ${x(g.x2)} ${y(g.y2)}`}
fill="none"
stroke={hue(g.color)}
strokeWidth={1.5}
/>
);
})}
{head && (
<circle
cx={x(row.col)}
cy={y(0.5)}
r={6}
fill="none"
stroke={hue(row.color)}
strokeWidth={1.5}
/>
)}
<circle
cx={x(row.col)}
cy={y(0.5)}
r={3.5}
fill={merge ? "rgb(var(--surface-1))" : hue(row.color)}
stroke={hue(row.color)}
strokeWidth={merge ? 1.5 : 0}
/>
</svg>
);
}
/** One ref badge: the kind's icon, the name truncated, the full name on hover. */
export function RefChip({ badge }: { badge: RefBadge }) {
const { t } = useTranslation(["gitHistory"]);
const Icon =
badge.kind === "tag"
? Tag
: badge.kind === "remote"
? Cloud
: badge.kind === "head"
? Crosshair
: GitBranch;
const tone = badge.current
? "border-accent/60 bg-accent/15 text-accent font-semibold"
: badge.kind === "tag"
? "border-status-warning/50 text-status-warning"
: badge.kind === "remote"
? "border-border text-fg-muted"
: badge.kind === "head"
? "border-status-danger/50 text-status-danger"
: "border-status-success/50 text-status-success";
const label = t(`badge.${badge.kind}`, { name: badge.name });
return (
<span
title={badge.current ? t("badge.current", { name: badge.name }) : label}
data-testid="ref-badge"
data-kind={badge.kind}
className={`inline-flex max-w-[10rem] shrink items-center gap-0.5 rounded border px-1 text-[10px] leading-4 ${tone}`}
>
<Icon className="h-2.5 w-2.5 shrink-0" aria-hidden />
<span className="truncate">{badge.name}</span>
</span>
);
}
/** Badges in the order the reference tools show them: HEAD's branch first. */
export function sortBadges(badges: RefBadge[]): RefBadge[] {
const rank = (b: RefBadge) =>
b.current ? 0 : b.kind === "head" ? 1 : b.kind === "local" ? 2 : b.kind === "remote" ? 3 : 4;
return [...badges].sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name));
}
export interface GraphCommit {
sha: string;
short: string;
subject: string;
author: string;
email?: string;
date: string;
parents: string[];
badges?: RefBadge[];
}
/** The text half of a history row (the graph cell is drawn beside it). */
export const CommitText = memo(function CommitText({ c }: { c: GraphCommit }) {
const badges = sortBadges(c.badges ?? []);
const when = new Date(c.date);
return (
<span className="flex min-w-0 flex-1 flex-col justify-center">
<span className="flex min-w-0 items-center gap-1">
{badges.slice(0, 4).map((b) => (
<RefChip key={`${b.kind}:${b.name}`} badge={b} />
))}
{badges.length > 4 && (
<span
className="shrink-0 text-[10px] text-fg-muted"
title={badges
.slice(4)
.map((b) => b.name)
.join(", ")}
>
+{badges.length - 4}
</span>
)}
<span className="min-w-0 flex-1 truncate text-[11px] text-fg-primary" title={c.subject}>
{c.subject}
</span>
</span>
<span className="flex min-w-0 items-baseline gap-1.5 text-[10px] text-fg-muted">
<span className="shrink-0 font-mono">{c.short}</span>
<span className="min-w-0 truncate" title={c.email ? `${c.author} <${c.email}>` : c.author}>
{c.author}
</span>
<span className="shrink-0" title={when.toLocaleString()}>
{timeAgo(c.date)}
</span>
</span>
</span>
);
});
@@ -0,0 +1,245 @@
/**
* @file Compare any two refs (GitLens' Search & Compare, GitHub's compare
* view): branches, tags or commits A and B; the commits only in A and only in
* B, and the files that differ — "Changes since they diverged" (A...B, what B
* adds; GitHub's default) or "Difference between the two" (A B, the trees as
* they stand). A file opens its diff in the panel's diff viewer; a commit
* opens its whole change. Swap flips the two sides.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ArrowLeftRight } from "lucide-react";
import { api, type GitTarget } from "../../../lib/api";
import type { CompareResult, CompareFile } from "../../../lib/gitHistory";
import { LaneDiffView } from "../LaneDiffView";
import { GitModal } from "./GitModal";
type Tab = "files" | "onlyA" | "onlyB";
export function CompareRefs({
target,
initialA,
initialB,
refs,
onClose,
onOpenCommit,
}: {
target: GitTarget;
initialA: string;
initialB: string;
/** Branch names offered in the inputs' suggestion lists. */
refs: string[];
onClose: () => void;
onOpenCommit: (sha: string) => void;
}) {
const { t } = useTranslation(["gitHistory", "lanes"]);
const [a, setA] = useState(initialA);
const [b, setB] = useState(initialB);
const [asked, setAsked] = useState({ a: initialA, b: initialB });
const [mode, setMode] = useState<"three" | "two">("three");
const [result, setResult] = useState<CompareResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<Tab>("files");
const [file, setFile] = useState<CompareFile["file"] | null>(null);
const [fileLoading, setFileLoading] = useState(false);
useEffect(() => {
let live = true;
setResult(null);
setError(null);
setFile(null);
void api.lanes.gitHistory
.compare(target, asked.a, asked.b, mode)
.then((r) => live && (r.available ? setResult(r) : setError(t("compareUnavailable"))))
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- target is stable
}, [asked, mode]);
const openFile = (path: string, from?: string) => {
if (!result) return;
setFileLoading(true);
void api.lanes.gitHistory
.compareFile(target, result.a, result.b, mode, path, from)
.then((r) => r.available && setFile(r.file))
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
.finally(() => setFileLoading(false));
};
const input =
"min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-1.5 py-0.5 font-mono text-[11px] text-fg-primary";
const tabBtn = (k: Tab, label: string) => (
<button
key={k}
type="button"
aria-pressed={tab === k}
onClick={() => {
setTab(k);
setFile(null);
}}
className={`rounded px-2 py-0.5 text-[11px] ${
tab === k ? "bg-accent-solid text-white" : "text-fg-secondary hover:bg-surface-3"
}`}
>
{label}
</button>
);
const commits = tab === "onlyA" ? result?.onlyA : result?.onlyB;
return (
<GitModal tall testId="compare-refs" onClose={onClose} title={t("compareTitle")}>
<form
className="mb-2 flex flex-wrap items-center gap-1.5"
onSubmit={(e) => {
e.preventDefault();
if (a.trim() && b.trim()) setAsked({ a: a.trim(), b: b.trim() });
}}
>
<datalist id="compare-refs-list">
{refs.map((r) => (
<option key={r} value={r} />
))}
</datalist>
<input
aria-label={t("compareA")}
data-testid="compare-a"
list="compare-refs-list"
value={a}
maxLength={200}
onChange={(e) => setA(e.target.value)}
className={input}
/>
<button
type="button"
aria-label={t("swap")}
title={t("swap")}
onClick={() => {
setA(b);
setB(a);
setAsked({ a: asked.b, b: asked.a });
}}
className="shrink-0 rounded p-1 text-fg-secondary hover:bg-surface-2"
>
<ArrowLeftRight className="h-3.5 w-3.5" />
</button>
<input
aria-label={t("compareB")}
data-testid="compare-b"
list="compare-refs-list"
value={b}
maxLength={200}
onChange={(e) => setB(e.target.value)}
className={input}
/>
<button
type="submit"
className="shrink-0 rounded bg-accent-solid px-2 py-0.5 text-[11px] text-white hover:bg-accent-solid-hover"
>
{t("compare")}
</button>
<select
data-testid="compare-mode"
value={mode}
onChange={(e) => setMode(e.target.value === "two" ? "two" : "three")}
className="min-w-0 rounded border border-border-light bg-surface-0 px-1 py-0.5 text-[11px] text-fg-primary"
>
<option value="three">{t("modeThree")}</option>
<option value="two">{t("modeTwo")}</option>
</select>
</form>
{error && (
<p role="alert" className="text-[12px] text-status-danger [overflow-wrap:anywhere]">
{error}
</p>
)}
{!result && !error && (
<p className="text-[12px] text-fg-muted">{t("lanes:changes.loading")}</p>
)}
{result && (
<>
<div className="mb-1.5 flex flex-wrap gap-1 rounded border border-border bg-surface-2 p-0.5">
{tabBtn("files", t("tabFiles", { count: result.files.length }))}
{tabBtn("onlyA", t("tabOnly", { name: asked.a, count: result.onlyA.length }))}
{tabBtn("onlyB", t("tabOnly", { name: asked.b, count: result.onlyB.length }))}
</div>
{tab === "files" && !file && (
<ul data-testid="compare-files" className="min-h-0 flex-1 overflow-auto">
{result.files.length === 0 && (
<li className="text-[12px] text-fg-muted">{t("noDifference")}</li>
)}
{result.files.map((f) => (
<li key={f.path}>
<button
type="button"
disabled={fileLoading}
onClick={() => openFile(f.path, f.from)}
className="flex w-full items-baseline gap-1.5 rounded px-1 text-left text-[11px] hover:bg-surface-2"
>
<span className="w-3 shrink-0 font-mono text-fg-muted">{f.status.trim()}</span>
<span className="min-w-0 flex-1 text-fg-primary [overflow-wrap:anywhere]">
{f.from ? `${f.from} → ${f.path}` : f.path}
</span>
<span className="shrink-0 font-mono">
<span className="text-status-success">+{f.added}</span>{" "}
<span className="text-status-danger">−{f.deleted}</span>
</span>
</button>
</li>
))}
{result.filesTruncated && (
<li className="text-[11px] text-status-warning">{t("lanes:changes.truncated")}</li>
)}
</ul>
)}
{tab === "files" && file && (
<div className="flex min-h-0 flex-1 flex-col">
<button
type="button"
onClick={() => setFile(null)}
className="mb-1 self-start rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2"
>
{t("backToFiles")}
</button>
{file.secret ? (
<p className="text-[12px] text-fg-muted">{t("secretFile")}</p>
) : file.hunks?.length ? (
<LaneDiffView file={file} layout="unified" fill />
) : (
<p className="text-[12px] text-fg-muted">{t("noTextChange")}</p>
)}
</div>
)}
{tab !== "files" && (
<ul className="min-h-0 flex-1 overflow-auto">
{commits?.length === 0 && (
<li className="text-[12px] text-fg-muted">{t("noCommitsSide")}</li>
)}
{commits?.map((c) => (
<li key={c.sha}>
<button
type="button"
onClick={() => onOpenCommit(c.sha)}
className="flex w-full items-baseline gap-1.5 rounded px-1 text-left text-[11px] hover:bg-surface-2"
>
<span className="shrink-0 font-mono text-fg-muted">{c.short}</span>
<span className="min-w-0 flex-1 truncate text-fg-primary" title={c.subject}>
{c.subject}
</span>
<span className="min-w-0 max-w-[30%] truncate text-fg-muted">{c.author}</span>
</button>
</li>
))}
{result.commitsTruncated && (
<li className="text-[11px] text-status-warning">{t("lanes:changes.truncated")}</li>
)}
</ul>
)}
</>
)}
</GitModal>
);
}
@@ -0,0 +1,128 @@
/**
* @file "Create branch here…" and "Create tag here…" from a History commit's
* menu: a name (and, for a tag, an optional message making it annotated),
* then git-refs.js's `branch-create {branch, from}` / `tag-create {tag,
* message?, from}` with that commit's sha as `from` — the server confirms the
* sha itself. Creating a branch never switches to it here. Announces
* `agenthub:git-changed` so the other views of the workspace read again.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState, type KeyboardEvent } from "react";
import { useTranslation } from "react-i18next";
import { api, type GitTarget } from "../../../lib/api";
import { toast } from "../../../lib/toast";
import { isSubmitEnter } from "../../../lib/keys";
import { emitGitChanged } from "../../../lib/gitChanged";
import { GitModal, ModalButton } from "./GitModal";
export function CreateBranchDialog({
target,
sha,
short,
kind = "branch",
onClose,
onDone,
}: {
target: GitTarget;
sha: string;
short: string;
kind?: "branch" | "tag";
onClose: () => void;
onDone: () => void;
}) {
const { t } = useTranslation(["gitHistory"]);
const [name, setName] = useState("");
const [message, setMessage] = useState("");
const [error, setError] = useState<string | null>(null);
const [running, setRunning] = useState(false);
const tag = kind === "tag";
const run = async () => {
const ref = name.trim();
if (!ref) return;
setRunning(true);
setError(null);
const action = tag ? "tag-create" : "branch-create";
try {
await api.lanes.gitRefAction(
target,
action,
tag
? { tag: ref, from: sha, ...(message.trim() ? { message: message.trim() } : {}) }
: { branch: ref, from: sha }
);
toast.success(t(tag ? "tagCreated" : "branchCreated", { name: ref, branch: ref, short }));
onDone();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setRunning(false);
} finally {
emitGitChanged(typeof target === "number" ? target : target.lane, action);
}
};
const submit = (e: KeyboardEvent) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void run();
}
};
const input =
"rounded border border-border-light bg-surface-0 px-2 py-1 font-mono text-[12px] text-fg-primary";
return (
<GitModal
testId="create-ref"
onClose={onClose}
title={t(tag ? "createTagTitle" : "createBranchTitle", { short })}
footer={
<>
<ModalButton onClick={onClose}>{t("cancel")}</ModalButton>
<ModalButton
primary
testId="create-ref-confirm"
disabled={!name.trim() || running}
onClick={() => void run()}
>
{t(tag ? "createTag" : "createBranch")}
</ModalButton>
</>
}
>
<label className="flex flex-col gap-1 text-[12px] text-fg-secondary">
{t(tag ? "tagName" : "branchName")}
<input
autoFocus
data-testid="create-ref-name"
value={name}
maxLength={200}
onChange={(e) => setName(e.target.value)}
onKeyDown={submit}
className={input}
/>
</label>
{tag && (
<label className="mt-2 flex flex-col gap-1 text-[12px] text-fg-secondary">
{t("tagMessage")}
<input
data-testid="create-ref-message"
value={message}
maxLength={1000}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={submit}
className={input}
/>
</label>
)}
<p className="mt-1.5 text-[11px] text-fg-muted">
{t(tag ? "createTagHint" : "createBranchHint")}
</p>
{error && (
<p role="alert" className="mt-1.5 text-[12px] text-status-danger [overflow-wrap:anywhere]">
{error}
</p>
)}
</GitModal>
);
}
@@ -0,0 +1,194 @@
/**
* @file One file's history (Show History), like GitLens' File History: every
* commit that touched it, newest first, followed across renames (`git log
* --follow`). A version opens that commit's diff of the file under the name
* it had there (the panel's own diff viewer); Blame opens the file as it was
* at that version with each line's commit (BlameView), and a rename says
* where it came from. Paged with Load more.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type GitTarget } from "../../../lib/api";
import type { FileVersion } from "../../../lib/gitHistory";
import { timeAgo } from "../../../lib/format";
import { BlameView } from "./BlameView";
const PAGE = 100;
export function FileHistory({
target,
path,
reread,
onOpenVersion,
onViewCommit,
onWholeBranch,
}: {
target: GitTarget;
path: string;
reread: number;
/** That version's diff of the file: its name THEN, and the commit. */
onOpenVersion: (path: string, sha: string) => void;
onViewCommit: (sha: string) => void;
onWholeBranch: () => void;
}) {
const { t } = useTranslation(["gitHistory", "lanes"]);
const [versions, setVersions] = useState<FileVersion[] | null>(null);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [blame, setBlame] = useState<{ path: string; rev: string } | null>(null);
const seq = useRef(0);
useEffect(() => {
const mine = ++seq.current;
setVersions(null);
setError(null);
setLoading(true);
void api.lanes.gitHistory
.fileHistory(target, path, { limit: PAGE })
.then((r) => {
if (seq.current !== mine) return;
setVersions(r.available ? r.commits : []);
setHasMore(r.available && r.hasMore);
})
.catch(
(err) => seq.current === mine && setError(err instanceof Error ? err.message : String(err))
)
.finally(() => seq.current === mine && setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps -- target is stable per panel
}, [path, reread]);
const more = useCallback(() => {
if (!versions || loading) return;
const mine = ++seq.current;
setLoading(true);
void api.lanes.gitHistory
.fileHistory(target, path, { limit: PAGE, skip: versions.length })
.then((r) => {
if (seq.current !== mine || !r.available) return;
setVersions((v) => [...(v ?? []), ...r.commits]);
setHasMore(r.hasMore);
})
.catch(
(err) => seq.current === mine && setError(err instanceof Error ? err.message : String(err))
)
.finally(() => seq.current === mine && setLoading(false));
}, [versions, loading, target, path]);
return (
<div data-testid="file-history" className="flex min-h-0 flex-col gap-1">
<div
data-testid="lane-changes-history-file"
className="flex items-center gap-1.5 px-1 text-[11px] text-fg-secondary"
>
<span className="min-w-0 flex-1 [overflow-wrap:anywhere]">
{t("lanes:changes.historyOfFile", { path })}
</span>
<button
type="button"
onClick={() => setBlame({ path, rev: "work" })}
className="shrink-0 rounded border border-border-light px-1.5 py-0.5 text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
>
{t("blame")}
</button>
<button
type="button"
onClick={onWholeBranch}
className="shrink-0 rounded px-1 text-fg-muted hover:bg-surface-2 hover:text-fg-primary"
>
{t("lanes:changes.historyWholeBranch")}
</button>
</div>
{error && (
<p role="alert" className="px-1 text-[11px] text-status-danger [overflow-wrap:anywhere]">
{error}
</p>
)}
{versions === null && !error && (
<p className="px-1 text-[11px] text-fg-muted">{t("lanes:changes.loading")}</p>
)}
{versions && versions.length === 0 && (
<p className="px-1 text-[11px] text-fg-muted">{t("lanes:changes.noCommits")}</p>
)}
<ul>
{versions?.map((v) => (
<li key={v.sha} className="group flex items-center gap-1 rounded px-1 hover:bg-surface-2">
<button
type="button"
onClick={() => onOpenVersion(v.path, v.sha)}
className="flex min-w-0 flex-1 flex-col py-0.5 text-left"
>
<span className="flex min-w-0 items-baseline gap-1.5">
<span
className="w-3 shrink-0 font-mono text-[10px] text-fg-muted"
title={t(`status.${v.status}`, { defaultValue: v.status })}
>
{v.status}
</span>
<span className="shrink-0 font-mono text-[10px] text-fg-muted">{v.short}</span>
<span
className="min-w-0 flex-1 truncate text-[11px] text-fg-primary"
title={v.subject}
>
{v.subject}
</span>
</span>
<span className="flex min-w-0 items-baseline gap-1.5 pl-[1.2rem] text-[10px] text-fg-muted">
<span className="min-w-0 truncate">{v.author}</span>
<span className="shrink-0" title={new Date(v.date).toLocaleString()}>
{timeAgo(v.date)}
</span>
{v.from && (
<span className="min-w-0 [overflow-wrap:anywhere]">
{t("renamedFrom", { from: v.from })}
</span>
)}
</span>
</button>
{v.status !== "D" && (
<button
type="button"
onClick={() => setBlame({ path: v.path, rev: v.sha })}
className="shrink-0 rounded px-1 text-[10px] text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
{t("blame")}
</button>
)}
<button
type="button"
onClick={() => onViewCommit(v.sha)}
className="shrink-0 rounded px-1 text-[10px] text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
{t("commit")}
</button>
</li>
))}
</ul>
{hasMore && (
<button
type="button"
data-testid="file-history-more"
disabled={loading}
onClick={more}
className="w-full rounded px-1 py-1 text-[11px] text-fg-secondary hover:bg-surface-2 disabled:opacity-40"
>
{loading ? t("lanes:changes.loading") : t("lanes:changes.loadMore")}
</button>
)}
{blame && (
<BlameView
target={target}
path={blame.path}
rev={blame.rev}
onClose={() => setBlame(null)}
onOpenCommit={(sha) => {
setBlame(null);
onViewCommit(sha);
}}
/>
)}
</div>
);
}
@@ -0,0 +1,119 @@
/**
* @file ForcePushDialog.tsx
* @description "Force push (with lease)" for the CURRENT branch only: shows
* where it goes (remote and branch), the remote commit it will replace (the
* lease — the server refuses with ESTALE if the remote moved since the last
* fetch), how far ahead/behind HEAD is, and refuses a protected branch unless
* the server allows it. Push is a two-click (`useArmed`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type { GitTarget } from "../../../lib/api";
import { errCode, errText, gitRewrite, type PushPlan } from "../../../lib/gitRewrite";
import { toast } from "../../../lib/toast";
import { useArmed } from "../../../hooks/useArmed";
import { BTN, BTN_DANGER, ErrorLine, GitModal, Warn } from "./GitModal";
export function ForcePushDialog({
target,
onClose,
onChanged,
}: {
target: GitTarget;
onClose: () => void;
onChanged: () => void;
}) {
const { t } = useTranslation(["gitTools"]);
const [plan, setPlan] = useState<PushPlan | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const { isArmed, arm, disarm } = useArmed();
const key = JSON.stringify(target);
useEffect(() => {
let live = true;
gitRewrite
.planPush(target)
.then((p) => live && setPlan(p))
.catch((e) => live && setError(errText(e)));
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
const run = async () => {
if (!plan) return;
if (!isArmed("push")) return arm("push");
disarm();
setBusy(true);
setError(null);
try {
await gitRewrite.run(target, "force-push", { expect: plan.remoteSha ?? "", confirm: true });
toast.success(t("forcePush.done", { remote: plan.remote, branch: plan.remoteBranch }));
onChanged();
onClose();
} catch (e) {
setError(errCode(e) === "ESTALE" ? t("forcePush.stale") : errText(e));
} finally {
setBusy(false);
}
};
return (
<GitModal
title={t("forcePush.title")}
onClose={onClose}
testId="force-push-dialog"
footer={
<>
<button type="button" className={BTN} onClick={onClose}>
{t("cancel")}
</button>
<button
type="button"
data-testid="force-push-run"
className={BTN_DANGER}
disabled={busy || !plan || !plan.allowed}
onClick={() => void run()}
>
{isArmed("push") ? t("forcePush.runConfirm") : t("forcePush.run")}
</button>
</>
}
>
{!plan ? (
error ? (
<ErrorLine error={error} />
) : (
<p className="text-fg-muted">{t("loading")}</p>
)
) : (
<div className="space-y-1.5">
<p data-testid="force-push-where" className="break-words">
{t("forcePush.where", {
branch: plan.branch,
remote: plan.remote,
remoteBranch: plan.remoteBranch,
})}
</p>
<p className="break-words">
{plan.remoteSha
? t("forcePush.replaces", {
sha: plan.remoteSha.slice(0, 7),
ahead: plan.ahead,
behind: plan.behind,
})
: t("forcePush.newBranch")}
</p>
{plan.behind > 0 && <Warn>{t("forcePush.dropsRemote", { count: plan.behind })}</Warn>}
{!plan.allowed && <Warn testId="force-push-protected">{t("forcePush.protected")}</Warn>}
<p className="text-[11px] text-fg-muted">{t("forcePush.lease")}</p>
<ErrorLine error={error} />
</div>
)}
</GitModal>
);
}
@@ -0,0 +1,153 @@
/**
* @file GitModal.tsx
* @description The shared modal shell of the Changes panel's git tools (merge
* editor, reflog, rebase, interactive rebase, reset, force push, remotes): a
* portal over the screen with `useDialog`'s focus contract, a title row with a
* close button, a scrolling body and an optional footer. `wide` widens it for
* the three-pane merge editor; `tall` gives a viewer (blame, browse at a
* revision, compare refs) the full height and a column body its own scrolling
* child can fill; on a phone every size is the full width. `ModalButton` is
* the History tools' footer button over the same BTN classes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useId, useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import { useDialog } from "../../../hooks/useDialog";
export const BTN =
"rounded border border-border-light px-2 py-1 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40";
export const BTN_PRIMARY =
"rounded border border-accent-solid bg-accent-solid px-2 py-1 text-[11px] font-medium text-white hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40";
export const BTN_DANGER =
"rounded border border-status-danger/60 px-2 py-1 text-[11px] font-medium text-status-danger hover:bg-status-danger/10 disabled:cursor-not-allowed disabled:opacity-40";
export function GitModal({
title,
onClose,
testId,
wide = false,
tall = false,
footer,
children,
}: {
title: ReactNode;
onClose: () => void;
testId?: string;
wide?: boolean;
/** A viewer: the full height, and a flex-column body. */
tall?: boolean;
footer?: ReactNode;
children: ReactNode;
}) {
const { t } = useTranslation(["gitTools"]);
const panel = useRef<HTMLDivElement>(null);
const titleId = useId();
useDialog(true, panel, onClose);
return createPortal(
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-2 sm:p-3"
onClick={onClose}
onKeyDown={(e) => e.stopPropagation()}
role="presentation"
>
<div
ref={panel}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
data-testid={testId}
onClick={(e) => e.stopPropagation()}
className={`flex max-h-[92vh] w-full flex-col rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/50 ${
wide || tall ? "max-w-6xl" : "max-w-lg"
} ${tall ? "h-[92vh]" : ""}`}
>
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<h2
id={titleId}
className="min-w-0 flex-1 truncate text-sm font-semibold text-fg-primary"
>
{title}
</h2>
<button
type="button"
onClick={onClose}
aria-label={t("close")}
className="shrink-0 rounded p-1 text-fg-muted hover:bg-surface-2 hover:text-fg-primary"
>
<X className="h-4 w-4" />
</button>
</div>
<div
className={`min-h-0 flex-1 px-3 py-2 text-xs text-fg-secondary ${
tall ? "flex flex-col overflow-hidden" : "overflow-y-auto"
}`}
>
{children}
</div>
{footer && (
<div className="flex flex-wrap items-center justify-end gap-1.5 border-t border-border px-3 py-2">
{footer}
</div>
)}
</div>
</div>,
document.body
);
}
/** A red inline error line, or nothing. */
export function ErrorLine({ error }: { error: string | null }) {
if (!error) return null;
return (
<p role="alert" className="my-1 whitespace-pre-wrap break-words text-[11px] text-status-danger">
{error}
</p>
);
}
/** An amber explanation box shown before a rewrite runs. */
export function Warn({ children, testId }: { children: ReactNode; testId?: string }) {
return (
<div
data-testid={testId}
className="my-1.5 rounded border border-status-warning/40 bg-status-warning/10 px-2 py-1.5 text-[11px] text-status-warning"
>
{children}
</div>
);
}
/** A footer button: plain, `primary` (filled) or `danger` (red outline). */
export function ModalButton({
onClick,
children,
primary = false,
danger = false,
disabled = false,
testId,
title,
}: {
onClick: () => void;
children: ReactNode;
primary?: boolean;
danger?: boolean;
disabled?: boolean;
testId?: string;
title?: string;
}) {
return (
<button
type="button"
data-testid={testId}
title={title}
disabled={disabled}
onClick={onClick}
className={primary ? BTN_PRIMARY : danger ? BTN_DANGER : BTN}
>
{children}
</button>
);
}
@@ -0,0 +1,210 @@
/**
* @file GitTools.tsx
* @description The Changes panel's entry points to the history-rewrite and
* remote tools: `GitToolsButton` (a toolbar menu — Rebase onto…, Interactive
* rebase…, Reflog, Force push…, Remotes…), `GitTools` (renders whichever tool
* is open, so the panel holds one `GitTool | null` state), and
* `RebaseSkipButton` (the rebase banner's Skip — Continue and Abort are the
* banner's own Complete/Abort, which the server maps onto the rebase).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { History } from "lucide-react";
import type { GitTarget } from "../../../lib/api";
import { errText, gitRewrite } from "../../../lib/gitRewrite";
import { toast } from "../../../lib/toast";
import { ContextMenu, MenuItem, MenuSeparator } from "../../ContextMenu";
import { ForcePushDialog } from "./ForcePushDialog";
import { InteractiveRebase } from "./InteractiveRebase";
import { MergeEditor } from "./MergeEditor";
import { RebaseDialog } from "./RebaseDialog";
import { ReflogView } from "./ReflogView";
import { RemotesDialog } from "./RemotesDialog";
import { ResetDialog } from "./ResetDialog";
export type GitTool =
| { kind: "rebase" }
| { kind: "interactive"; base?: string }
| { kind: "reset"; commit: { sha: string; short?: string; subject?: string } }
| { kind: "reflog" }
| { kind: "force-push" }
| { kind: "remotes" }
| { kind: "merge"; path: string; onResolved?: () => void };
export function GitToolsButton({
onPick,
disabled,
hasRemote,
}: {
onPick: (tool: GitTool) => void;
disabled?: boolean;
hasRemote?: boolean;
}) {
const { t } = useTranslation(["gitTools"]);
const ref = useRef<HTMLButtonElement>(null);
const [at, setAt] = useState<{ x: number; y: number } | null>(null);
const pick = (tool: GitTool) => () => {
setAt(null);
onPick(tool);
};
return (
<>
<button
ref={ref}
type="button"
data-testid="git-tools-button"
aria-label={t("menu.label")}
title={t("menu.label")}
aria-haspopup="menu"
disabled={disabled}
onClick={() => {
const r = ref.current?.getBoundingClientRect();
setAt(r ? { x: r.left, y: r.bottom + 2 } : { x: 0, y: 0 });
}}
className="shrink-0 rounded p-0.5 text-fg-muted hover:text-fg-primary disabled:opacity-40"
>
<History className="h-3.5 w-3.5" />
</button>
{at && (
<ContextMenu x={at.x} y={at.y} onClose={() => setAt(null)} testId="git-tools-menu">
<MenuItem testId="git-tools-rebase" onSelect={pick({ kind: "rebase" })}>
{t("menu.rebase")}
</MenuItem>
<MenuItem testId="git-tools-interactive" onSelect={pick({ kind: "interactive" })}>
{t("menu.interactive")}
</MenuItem>
<MenuItem testId="git-tools-reflog" onSelect={pick({ kind: "reflog" })}>
{t("menu.reflog")}
</MenuItem>
<MenuSeparator />
<MenuItem
testId="git-tools-force-push"
danger
disabled={hasRemote === false}
onSelect={pick({ kind: "force-push" })}
>
{t("menu.forcePush")}
</MenuItem>
<MenuItem testId="git-tools-remotes" onSelect={pick({ kind: "remotes" })}>
{t("menu.remotes")}
</MenuItem>
</ContextMenu>
)}
</>
);
}
export function GitTools({
tool,
target,
branch,
onClose,
onOpen,
onChanged,
onViewCommit,
}: {
tool: GitTool | null;
target: GitTarget;
/** The current branch, for titles. */
branch: string | null;
onClose: () => void;
/** Open another tool (the reflog's "Reset here"). */
onOpen: (tool: GitTool) => void;
/** Re-read the panel's git facts after a write. */
onChanged: () => void;
onViewCommit?: (sha: string) => void;
}) {
if (!tool) return null;
switch (tool.kind) {
case "rebase":
return (
<RebaseDialog target={target} current={branch} onClose={onClose} onChanged={onChanged} />
);
case "interactive":
return (
<InteractiveRebase
target={target}
base={tool.base}
onClose={onClose}
onChanged={onChanged}
/>
);
case "reset":
return (
<ResetDialog target={target} commit={tool.commit} onClose={onClose} onChanged={onChanged} />
);
case "reflog":
return (
<ReflogView
target={target}
onClose={onClose}
onViewCommit={
onViewCommit &&
((sha) => {
onClose();
onViewCommit(sha);
})
}
onReset={(e) =>
onOpen({ kind: "reset", commit: { sha: e.sha, short: e.short, subject: e.subject } })
}
/>
);
case "force-push":
return <ForcePushDialog target={target} onClose={onClose} onChanged={onChanged} />;
case "remotes":
return <RemotesDialog target={target} onClose={onClose} onChanged={onChanged} />;
case "merge":
return (
<MergeEditor
target={target}
path={tool.path}
onClose={onClose}
onResolved={() => {
onChanged();
tool.onResolved?.();
}}
/>
);
}
}
/** The rebase banner's Skip: drops the commit the rebase stopped on. */
export function RebaseSkipButton({
target,
disabled,
onChanged,
}: {
target: GitTarget;
disabled?: boolean;
onChanged: () => void;
}) {
const { t } = useTranslation(["gitTools"]);
const [busy, setBusy] = useState(false);
return (
<button
type="button"
data-testid="rebase-skip"
title={t("rebaseBanner.skipHint")}
disabled={disabled || busy}
onClick={async () => {
setBusy(true);
try {
const r = await gitRewrite.run(target, "rebase-skip", {});
if (r.conflicts?.length) toast.error(t("rebase.stopped", { count: r.conflicts.length }));
else if (!r.stopped) toast.success(t("rebaseBanner.finished"));
} catch (e) {
toast.error(errText(e));
} finally {
setBusy(false);
onChanged();
}
}}
className="shrink-0 rounded border border-border-light px-1.5 py-0.5 text-fg-secondary hover:bg-surface-2 disabled:opacity-40"
>
{t("rebaseBanner.skip")}
</button>
);
}
@@ -0,0 +1,751 @@
/**
* @file The Changes panel's History tab, shaped like VS Code's Source Control
* Graph, GitLens and the JetBrains git log: a commit graph (lanes and merge
* edges, ref badges) read page by page and drawn windowed, so a repo with
* tens of thousands of commits stays responsive; a search box (message, a SHA
* prefix, `author:`, `path:`) and an all-branches switch; a details pane for
* the focused commit; multi-select (checkbox, Ctrl/⌘-click, Shift-click) with
* "Cherry-pick N" / "Revert N" that open a preview of exactly what will run
* (CherryPickDialog); the reference tools' commit context menu; and, after a
* pick or revert, a result banner with Undo while that is safe (the server
* refuses it once the commits are pushed or HEAD moved on).
*
* Every write still goes through the server's guards (git-pick.js); a
* disabled button here is a courtesy.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
import { useTranslation } from "react-i18next";
import { GitCompare, Search, X } from "lucide-react";
import { api, type GitTarget } from "../../../lib/api";
import type { LaneCommit } from "../../../lib/types";
import { commitGraph } from "../../../lib/commitGraph";
import { parseSearch, inListOrder, type PickResult } from "../../../lib/gitHistory";
import { toast } from "../../../lib/toast";
import { confirmDialog } from "../../../lib/confirm";
import { copyText } from "../../../lib/clipboard";
import { Checkbox } from "../../Checkbox";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../../ContextMenu";
import { GRAPH_ROW, GraphCell, CommitText } from "./CommitGraph";
import { CommitDetails } from "./CommitDetails";
import { CherryPickDialog } from "./CherryPickDialog";
import { BrowseAtRevision } from "./BrowseAtRevision";
import { CompareRefs } from "./CompareRefs";
import { CreateBranchDialog } from "./CreateBranchDialog";
/** Commits one page reads. */
export const PAGE = 100;
/** Rows drawn beyond the visible ones, each side. */
const OVERSCAN = 20;
type Op = "cherry-pick" | "revert";
export function HistoryView({
target,
branch,
branches,
historyRef,
onHistoryRef,
reread,
disabled,
headPushed,
onViewCommit,
onOpenFileAtCommit,
onUndoCommit,
onResetHere,
onEditAfter,
onPaused,
onChanged,
}: {
target: GitTarget;
/** The branch HEAD is on (null = detached / unknown). */
branch: string | null;
/** Local branches, for "History of branch". */
branches: string[];
historyRef: string | null;
onHistoryRef: (ref: string | null) => void;
/** Bumped by the panel's Refresh and after its own writes. */
reread: number;
/** A write is running, or a merge/pick is open: nothing new starts. */
disabled: boolean;
/** The branch's upstream already holds HEAD (Undo commit refused). */
headPushed: boolean;
onViewCommit: (sha: string) => void;
onOpenFileAtCommit: (path: string, sha: string) => void;
onUndoCommit: (c: LaneCommit) => void;
/** "Reset branch to here…" — the rewrite tools' reset dialog (GitTools). */
onResetHere: (c: LaneCommit) => void;
/** "Edit commits after this one…" — the interactive rebase (GitTools). */
onEditAfter: (sha: string) => void;
/** A pick/revert stopped on a conflict: the overview resolves it. */
onPaused: () => void;
/** History changed (a pick, revert or undo): re-read everything. */
onChanged: () => void;
}) {
const { t } = useTranslation(["gitHistory", "lanes", "gitTools"]);
const [search, setSearch] = useState("");
const [filter, setFilter] = useState(() => parseSearch(""));
const [all, setAll] = useState(false);
const [commits, setCommits] = useState<LaneCommit[] | null>(null);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [focused, setFocused] = useState<string | null>(null);
const [picked, setPicked] = useState<Set<string>>(new Set());
const anchor = useRef<string | null>(null);
const [dialog, setDialog] = useState<{ op: Op; shas: string[] } | null>(null);
const [browse, setBrowse] = useState<{ sha: string; short: string } | null>(null);
const [compare, setCompare] = useState<{ a: string; b: string } | null>(null);
const [newRef, setNewRef] = useState<{
sha: string;
short: string;
kind: "branch" | "tag";
} | null>(null);
const [lastPick, setLastPick] = useState<(PickResult & { op: Op }) | null>(null);
const menu = useContextMenu<LaneCommit>();
// The search box applies after a pause in typing, not on every key.
useEffect(() => {
const id = window.setTimeout(() => setFilter(parseSearch(search)), 300);
return () => window.clearTimeout(id);
}, [search]);
const filtered = !!(filter.q || filter.author || filter.path);
const query = useMemo(
() => ({
...(all ? { all: true } : historyRef ? { ref: historyRef } : {}),
...(filter.q ? { q: filter.q } : {}),
...(filter.author ? { author: filter.author } : {}),
...(filter.path ? { path: filter.path } : {}),
}),
[all, historyRef, filter]
);
const queryKey = JSON.stringify(query);
// The first page whenever the question changes; a reply for an older
// question is dropped.
const seq = useRef(0);
useEffect(() => {
const mine = ++seq.current;
setLoading(true);
setError(null);
void api.lanes
.gitLog(target, { limit: PAGE, ...query })
.then((r) => {
if (seq.current !== mine) return;
setCommits(r.available ? r.commits : []);
setHasMore(!!r.hasMore);
})
.catch(
(err) => seq.current === mine && setError(err instanceof Error ? err.message : String(err))
)
.finally(() => seq.current === mine && setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps -- target is stable per panel
}, [queryKey, reread]);
const loadMore = useCallback(() => {
if (loading || !hasMore || !commits) return;
const mine = ++seq.current;
setLoading(true);
void api.lanes
.gitLog(target, { limit: PAGE, skip: commits.length, ...query })
.then((r) => {
if (seq.current !== mine) return;
setCommits((prev) => {
const seen = new Set((prev ?? []).map((c) => c.sha));
return [...(prev ?? []), ...r.commits.filter((c) => !seen.has(c.sha))];
});
setHasMore(!!r.hasMore);
})
.catch(
(err) => seq.current === mine && setError(err instanceof Error ? err.message : String(err))
)
.finally(() => seq.current === mine && setLoading(false));
}, [loading, hasMore, commits, target, query]);
const list = useMemo(() => commits ?? [], [commits]);
// A search result is not a connected history: dots, no lanes.
const graph = useMemo(
() => commitGraph(filtered ? list.map((c) => ({ sha: c.sha, parents: [] })) : list),
[list, filtered]
);
const byShaIndex = useMemo(() => new Map(list.map((c, i) => [c.sha, i])), [list]);
// Which loaded commits HEAD already has: Revert applies to those,
// Cherry-pick to the rest. On HEAD's own history, all of them.
const onHead = useMemo(() => {
if (!all && !historyRef) return null;
const head = list.find((c) => (c.badges ?? []).some((b) => b.current || b.kind === "head"));
const set = new Set<string>();
if (!head) return set;
const stack = [head.sha];
while (stack.length) {
const s = stack.pop()!;
if (set.has(s) || !byShaIndex.has(s)) continue;
set.add(s);
stack.push(...(list[byShaIndex.get(s)!]!.parents ?? []));
}
return set;
}, [all, historyRef, list, byShaIndex]);
// The commit HEAD is on: Edit commits after it has nothing to edit.
const headSha = useMemo(
() =>
list.find((c) => (c.badges ?? []).some((b) => b.current || b.kind === "head"))?.sha ??
(!all && !historyRef ? list[0]?.sha : undefined),
[list, all, historyRef]
);
const isOnHead = useCallback((sha: string) => (onHead ? onHead.has(sha) : true), [onHead]);
// ---- selection -------------------------------------------------------
const clickRow = (e: MouseEvent, c: LaneCommit) => {
setFocused(c.sha);
if (e.shiftKey && anchor.current && byShaIndex.has(anchor.current)) {
const a = byShaIndex.get(anchor.current)!;
const b = byShaIndex.get(c.sha)!;
const [lo, hi] = a < b ? [a, b] : [b, a];
setPicked(new Set(list.slice(lo, hi + 1).map((x) => x.sha)));
return;
}
anchor.current = c.sha;
if (e.ctrlKey || e.metaKey) {
toggle(c.sha);
return;
}
setPicked(new Set());
};
const toggle = (sha: string) =>
setPicked((prev) => {
const next = new Set(prev);
if (next.has(sha)) next.delete(sha);
else next.add(sha);
return next;
});
const selected = inListOrder(list, picked);
const mergeIn = selected.some((c) => c.parents.length > 1);
const allOnHead = selected.length > 0 && selected.every((c) => isOnHead(c.sha));
const noneOnHead = selected.length > 0 && selected.every((c) => !isOnHead(c.sha));
const target_branch = branch ?? "HEAD";
const openPick = (op: Op, shas: string[]) => setDialog({ op, shas });
const afterPick = (op: Op, r: PickResult) => {
setDialog(null);
setPicked(new Set());
if (r.paused) {
toast.error(
r.empty
? t("pausedEmpty", { op: t(`op.${op}`) })
: t("pausedConflicts", { op: t(`op.${op}`), count: r.conflicts.length })
);
onPaused();
return;
}
const n = r.commits?.length ?? 0;
toast.success(
t(op === "revert" ? "revertedN" : "pickedN", { count: n, branch: target_branch })
);
if (r.from && r.to && n) setLastPick({ ...r, op });
onChanged();
};
const undoPick = async () => {
if (!lastPick?.from || !lastPick.to) return;
const n = lastPick.commits?.length ?? 0;
const ok = await confirmDialog({
title: t("undoTitle", { op: t(`op.${lastPick.op}`) }),
message: t("undoBody", { count: n, branch: target_branch }),
confirmLabel: t("undo"),
});
if (!ok) return;
try {
await api.lanes.gitHistory.act(target, "undo-pick", {
from: lastPick.from,
to: lastPick.to,
confirm: true,
});
toast.success(t("undone", { count: n }));
setLastPick(null);
onChanged();
} catch (err) {
toast.error(err);
}
};
const copyPatch = async (c: LaneCommit) => {
try {
const r = await api.lanes.gitHistory.patch(target, c.sha);
if (!r.available) return void toast.error(t("noPatch"));
if (r.omitted.length) toast.info(t("patchOmitted", { count: r.omitted.length }));
await copyText(r.patch);
} catch (err) {
toast.error(err);
}
};
// ---- windowing -------------------------------------------------------
const scroller = useRef<HTMLDivElement>(null);
const [top, setTop] = useState(0);
const [height, setHeight] = useState(600);
useEffect(() => {
const el = scroller.current;
if (!el || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(() => setHeight(el.clientHeight || 600));
ro.observe(el);
return () => ro.disconnect();
}, []);
const first = Math.max(0, Math.floor(top / GRAPH_ROW) - OVERSCAN);
const last = Math.min(list.length, Math.ceil((top + height) / GRAPH_ROW) + OVERSCAN);
const onScroll = () => {
const el = scroller.current;
if (!el) return;
setTop(el.scrollTop);
// Near the end: the next page, like an endless log.
if (el.scrollTop + el.clientHeight > list.length * GRAPH_ROW - GRAPH_ROW * 10) loadMore();
};
const focusedCommit = focused ? list.find((c) => c.sha === focused) : undefined;
const menuItem = menu.menu?.item;
const menuMulti = !!menuItem && picked.has(menuItem.sha) && picked.size > 1;
const pick = (fn: () => void) => () => {
menu.close();
fn();
};
return (
<div data-testid="lane-changes-history" className="flex min-h-0 flex-1 flex-col gap-1.5">
{/* Toolbar: search, all branches, branch, compare. Wraps on a narrow panel. */}
<div className="flex flex-wrap items-center gap-1.5 px-1 text-[11px]">
<label className="flex min-w-[10rem] flex-1 items-center gap-1 rounded border border-border-light bg-surface-0 px-1.5 py-0.5">
<Search className="h-3 w-3 shrink-0 text-fg-muted" aria-hidden />
<input
data-testid="history-search"
value={search}
maxLength={300}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("searchPlaceholder")}
aria-label={t("searchLabel")}
className="min-w-0 flex-1 bg-transparent text-[11px] text-fg-primary outline-none placeholder:text-fg-muted"
/>
{search && (
<button
type="button"
aria-label={t("clearSearch")}
onClick={() => setSearch("")}
className="shrink-0 text-fg-muted hover:text-fg-primary"
>
<X className="h-3 w-3" />
</button>
)}
</label>
<Checkbox
checked={all}
onChange={setAll}
label={t("allBranches")}
labelClassName="text-[11px] text-fg-secondary"
/>
{!all && branches.length > 1 && (
<label className="flex min-w-0 items-center gap-1 text-fg-secondary">
<span className="shrink-0">{t("lanes:changes.historyOf")}</span>
<select
data-testid="lane-changes-history-ref"
value={historyRef ?? ""}
onChange={(e) => onHistoryRef(e.target.value || null)}
className="min-w-0 max-w-[12rem] truncate rounded border border-border-light bg-surface-0 px-1 py-0.5 text-[11px] text-fg-primary"
>
<option value="">{target_branch}</option>
{branches
.filter((b) => b !== branch)
.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
</label>
)}
<button
type="button"
data-testid="history-compare"
onClick={() =>
setCompare({
a: target_branch,
b: historyRef ?? branches.find((b) => b !== branch) ?? target_branch,
})
}
className="flex shrink-0 items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
>
<GitCompare className="h-3 w-3" aria-hidden />
{t("compare")}
</button>
</div>
{lastPick && (
<div
data-testid="pick-result"
className="mx-1 flex flex-wrap items-center gap-1.5 rounded border border-status-success/40 bg-status-success/10 px-2 py-1 text-[11px] text-status-success"
>
<span className="min-w-0 flex-1 [overflow-wrap:anywhere]">
{t(lastPick.op === "revert" ? "revertedN" : "pickedN", {
count: lastPick.commits?.length ?? 0,
branch: target_branch,
})}{" "}
<span className="font-mono">
{(lastPick.commits ?? []).map((c) => c.short).join(", ")}
</span>
</span>
<button
type="button"
data-testid="pick-undo"
disabled={disabled}
title={t("undoHint")}
onClick={() => void undoPick()}
className="shrink-0 rounded border border-status-success/50 px-1.5 py-0.5 hover:bg-status-success/15 disabled:opacity-40"
>
{t("undo")}
</button>
<button
type="button"
aria-label={t("dismiss")}
onClick={() => setLastPick(null)}
className="shrink-0 text-fg-muted hover:text-fg-primary"
>
<X className="h-3 w-3" />
</button>
</div>
)}
{selected.length > 0 && (
<div
data-testid="history-selection"
className="mx-1 flex flex-wrap items-center gap-1.5 rounded border border-accent/40 bg-accent/10 px-2 py-1 text-[11px] text-fg-primary"
>
<span className="min-w-0 flex-1">{t("selectedN", { count: selected.length })}</span>
{noneOnHead && (
<button
type="button"
data-testid="history-pick-selected"
disabled={disabled || mergeIn}
title={mergeIn ? t("mergeCommitRefused") : undefined}
onClick={() =>
openPick(
"cherry-pick",
selected.map((c) => c.sha)
)
}
className="shrink-0 rounded bg-accent-solid px-1.5 py-0.5 text-white hover:bg-accent-solid-hover disabled:opacity-40"
>
{t("pickN", { count: selected.length, branch: target_branch })}
</button>
)}
{allOnHead && (
<button
type="button"
data-testid="history-revert-selected"
disabled={disabled || mergeIn}
title={mergeIn ? t("mergeCommitRefused") : undefined}
onClick={() =>
openPick(
"revert",
selected.map((c) => c.sha)
)
}
className="shrink-0 rounded border border-border px-1.5 py-0.5 hover:bg-surface-2 disabled:opacity-40"
>
{t("revertN", { count: selected.length })}
</button>
)}
{!noneOnHead && !allOnHead && (
<span className="text-fg-muted">{t("mixedSelection")}</span>
)}
<button
type="button"
onClick={() => setPicked(new Set())}
className="shrink-0 rounded px-1 text-fg-muted hover:bg-surface-2 hover:text-fg-primary"
>
{t("clearSelection")}
</button>
</div>
)}
{error && (
<p role="alert" className="px-1 text-[11px] text-status-danger [overflow-wrap:anywhere]">
{error}
</p>
)}
<div
ref={scroller}
onScroll={onScroll}
data-testid="history-list"
className="min-h-[8rem] flex-1 overflow-y-auto"
>
{commits === null ? (
<p className="px-1 text-[11px] text-fg-muted">{t("lanes:changes.loading")}</p>
) : list.length === 0 ? (
<p className="px-1 text-[11px] text-fg-muted">
{filtered ? t("noMatches") : t("lanes:changes.noCommits")}
</p>
) : (
<div role="list" style={{ height: list.length * GRAPH_ROW, position: "relative" }}>
{list.slice(first, last).map((c, k) => {
const i = first + k;
const isPicked = picked.has(c.sha);
const head = (c.badges ?? []).some((b) => b.current || b.kind === "head");
return (
<div
key={c.sha}
role="listitem"
data-sha={c.sha}
aria-selected={isPicked || focused === c.sha}
onContextMenu={(e) => menu.open(e, c)}
style={{
position: "absolute",
top: i * GRAPH_ROW,
height: GRAPH_ROW,
left: 0,
right: 0,
}}
className={`group flex items-center gap-1 rounded px-1 ${
isPicked
? "bg-accent/15"
: focused === c.sha
? "bg-surface-3"
: "hover:bg-surface-2"
}`}
>
<Checkbox
checked={isPicked}
onChange={() => {
// A tick is an anchor too: Shift-click extends from it.
anchor.current = c.sha;
toggle(c.sha);
}}
title={t("selectCommit", { short: c.short })}
className={isPicked || picked.size ? "" : "opacity-40 group-hover:opacity-100"}
/>
<GraphCell
row={graph.rows[i]!}
width={graph.width}
merge={c.parents.length > 1}
head={head}
/>
<button
type="button"
onClick={(e) => clickRow(e, c)}
onDoubleClick={() => onViewCommit(c.sha)}
className="flex h-full min-w-0 flex-1 items-center text-left"
>
<CommitText c={c} />
</button>
</div>
);
})}
</div>
)}
{hasMore && (
<button
type="button"
data-testid="lane-changes-history-more"
disabled={loading}
onClick={loadMore}
className="mt-1 w-full rounded px-1 py-1 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:opacity-40"
>
{loading ? t("lanes:changes.loading") : t("lanes:changes.loadMore")}
</button>
)}
</div>
{focusedCommit && (
<CommitDetails
key={focusedCommit.sha}
target={target}
sha={focusedCommit.sha}
onClose={() => setFocused(null)}
onOpenCommit={(sha) => {
setFocused(sha);
const el = scroller.current?.querySelector(`[data-sha="${sha}"]`);
(el as HTMLElement | null)?.scrollIntoView?.({ block: "nearest" });
}}
onViewChanges={() => onViewCommit(focusedCommit.sha)}
onOpenFile={(path) => onOpenFileAtCommit(path, focusedCommit.sha)}
onBrowse={() => setBrowse({ sha: focusedCommit.sha, short: focusedCommit.short })}
onCompare={() => setCompare({ a: target_branch, b: focusedCommit.sha })}
onCopyPatch={() => void copyPatch(focusedCommit)}
/>
)}
{menu.menu && menuItem && (
<ContextMenu
x={menu.menu.x}
y={menu.menu.y}
onClose={menu.close}
testId="lane-changes-commit-menu"
>
<MenuItem onSelect={pick(() => onViewCommit(menuItem.sha))}>
{t("lanes:changes.viewCommit")}
</MenuItem>
<MenuItem onSelect={pick(() => setFocused(menuItem.sha))}>{t("showDetails")}</MenuItem>
<MenuItem onSelect={pick(() => setBrowse({ sha: menuItem.sha, short: menuItem.short }))}>
{t("browseFiles")}
</MenuItem>
<MenuItem onSelect={pick(() => setCompare({ a: target_branch, b: menuItem.sha }))}>
{t("compareWithCurrent")}
</MenuItem>
<MenuSeparator />
<MenuItem onSelect={pick(() => void copyText(menuItem.sha))}>
{t("lanes:changes.copySha")}
</MenuItem>
<MenuItem onSelect={pick(() => void copyText(menuItem.subject))}>
{t("lanes:changes.copySubject")}
</MenuItem>
<MenuItem
disabled={menuItem.parents.length > 1}
onSelect={pick(() => void copyPatch(menuItem))}
>
{t("copyPatch")}
</MenuItem>
<MenuItem
testId="history-menu-branch"
disabled={disabled}
onSelect={pick(() =>
setNewRef({ sha: menuItem.sha, short: menuItem.short, kind: "branch" })
)}
>
{t("createBranchHere")}
</MenuItem>
<MenuItem
testId="history-menu-tag"
disabled={disabled}
onSelect={pick(() =>
setNewRef({ sha: menuItem.sha, short: menuItem.short, kind: "tag" })
)}
>
{t("createTagHere")}
</MenuItem>
<MenuSeparator />
{menuMulti ? (
<>
{noneOnHead && (
<MenuItem
disabled={disabled || mergeIn}
onSelect={pick(() =>
openPick(
"cherry-pick",
selected.map((c) => c.sha)
)
)}
>
{t("pickN", { count: selected.length, branch: target_branch })}
</MenuItem>
)}
{allOnHead && (
<MenuItem
disabled={disabled || mergeIn}
onSelect={pick(() =>
openPick(
"revert",
selected.map((c) => c.sha)
)
)}
>
{t("revertN", { count: selected.length })}
</MenuItem>
)}
</>
) : isOnHead(menuItem.sha) ? (
<>
<MenuItem
disabled={disabled || menuItem.parents.length !== 1}
title={menuItem.parents.length > 1 ? t("mergeCommitRefused") : undefined}
testId="lane-changes-menu-revert"
onSelect={pick(() => openPick("revert", [menuItem.sha]))}
>
{t("lanes:changes.revertCommit")}
</MenuItem>
{!all && !historyRef && list[0]?.sha === menuItem.sha && (
<MenuItem
danger
disabled={disabled || headPushed || menuItem.parents.length !== 1}
title={headPushed ? t("lanes:changes.undoCommitPushed") : undefined}
testId="lane-changes-menu-undo-commit"
onSelect={pick(() => onUndoCommit(menuItem))}
>
{t("lanes:changes.undoCommit")}
</MenuItem>
)}
{/* History rewrites (GitTools): each opens a dialog that asks first. */}
<MenuItem
disabled={disabled}
testId="lane-changes-menu-reset"
onSelect={pick(() => onResetHere(menuItem))}
>
{t("gitTools:menu.resetHere")}
</MenuItem>
<MenuItem
disabled={disabled || menuItem.sha === headSha}
testId="lane-changes-menu-interactive"
onSelect={pick(() => onEditAfter(menuItem.sha))}
>
{t("gitTools:menu.interactiveHere")}
</MenuItem>
</>
) : (
<MenuItem
disabled={disabled || menuItem.parents.length !== 1}
title={menuItem.parents.length > 1 ? t("mergeCommitRefused") : undefined}
testId="lane-changes-menu-cherry-pick"
onSelect={pick(() => openPick("cherry-pick", [menuItem.sha]))}
>
{t("lanes:changes.cherryPick", { branch: target_branch })}
</MenuItem>
)}
</ContextMenu>
)}
{dialog && (
<CherryPickDialog
target={target}
op={dialog.op}
shas={dialog.shas}
onClose={() => setDialog(null)}
onDone={(r) => afterPick(dialog.op, r)}
/>
)}
{browse && (
<BrowseAtRevision
target={target}
sha={browse.sha}
short={browse.short}
onClose={() => setBrowse(null)}
/>
)}
{compare && (
<CompareRefs
target={target}
initialA={compare.a}
initialB={compare.b}
refs={branches}
onClose={() => setCompare(null)}
onOpenCommit={(sha) => {
setCompare(null);
onViewCommit(sha);
}}
/>
)}
{newRef && (
<CreateBranchDialog
target={target}
sha={newRef.sha}
short={newRef.short}
kind={newRef.kind}
onClose={() => setNewRef(null)}
onDone={() => {
setNewRef(null);
onChanged();
}}
/>
)}
</div>
);
}
@@ -0,0 +1,295 @@
/**
* @file InteractiveRebase.tsx
* @description Interactive rebase as a list (JetBrains' "Interactively Rebase
* from Here"): the branch's commits since a base — by default the ones no
* remote has yet, oldest first — each with Pick / Reword / Squash / Fixup /
* Drop, moved up and down, a new message for a reword or a squash. Before it
* runs it says what it rewrites ("This rewrites 4 commits; 2 are already
* pushed, so a force-push will be needed") and pushed commits need an explicit
* tick; Start is a two-click (`useArmed`). The server builds the todo and runs
* `git rebase -i` with no editor; a clean run stays open with **Undo**.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ArrowDown, ArrowUp } from "lucide-react";
import type { GitTarget } from "../../../lib/api";
import {
errText,
gitRewrite,
type InteractivePlan,
type PlanCommit,
type RewriteResult,
type TodoAction,
} from "../../../lib/gitRewrite";
import { toast } from "../../../lib/toast";
import { useArmed } from "../../../hooks/useArmed";
import { BTN, BTN_DANGER, ErrorLine, GitModal, Warn } from "./GitModal";
import { RewriteDone } from "./RewriteDone";
import { blockedReason } from "./RebaseDialog";
const ACTIONS: TodoAction[] = ["pick", "reword", "squash", "fixup", "drop"];
interface Row {
commit: PlanCommit;
action: TodoAction;
message: string;
}
/** Why the list cannot run as it stands, or null. */
export function todoProblem(rows: Row[]): "first" | "allDropped" | "unchanged" | "reword" | null {
const kept = rows.filter((r) => r.action !== "drop");
if (!kept.length) return "allDropped";
if (kept[0]!.action === "squash" || kept[0]!.action === "fixup") return "first";
if (rows.some((r) => r.action === "reword" && !r.message.trim())) return "reword";
return null;
}
export function InteractiveRebase({
target,
base,
onClose,
onChanged,
}: {
target: GitTarget;
/** Rewrite the commits after this one; absent = the unpushed ones. */
base?: string;
onClose: () => void;
onChanged: () => void;
}) {
const { t } = useTranslation(["gitTools"]);
const [plan, setPlan] = useState<InteractivePlan | null>(null);
const [rows, setRows] = useState<Row[]>([]);
const [force, setForce] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<RewriteResult | null>(null);
const { isArmed, arm, disarm } = useArmed();
const key = JSON.stringify(target);
useEffect(() => {
let live = true;
gitRewrite
.planInteractive(target, base)
.then((p) => {
if (!live) return;
setPlan(p);
setRows(p.commits.map((c) => ({ commit: c, action: "pick", message: c.message })));
})
.catch((e) => live && setError(errText(e)));
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, base]);
const set = (i: number, patch: Partial<Row>) =>
setRows((prev) => prev.map((r, n) => (n === i ? { ...r, ...patch } : r)));
const move = (i: number, step: number) =>
setRows((prev) => {
const j = i + step;
if (j < 0 || j >= prev.length) return prev;
const next = [...prev];
[next[i], next[j]] = [next[j]!, next[i]!];
return next;
});
const blocked = plan ? blockedReason(t, plan) : null;
const problem = todoProblem(rows);
const unchanged =
!!plan && rows.every((r, i) => r.action === "pick" && r.commit.sha === plan.commits[i]?.sha);
const pushed = plan?.pushed ?? 0;
const run = async () => {
if (!plan) return;
if (!isArmed("run")) return arm("run");
disarm();
setBusy(true);
setError(null);
try {
const r = await gitRewrite.run(target, "rebase-interactive", {
head: plan.head,
...(base ? { base: plan.base } : {}),
confirm: true,
...(pushed ? { force: true } : {}),
todo: rows.map((row) => ({
sha: row.commit.sha,
action: row.action,
...((row.action === "reword" || row.action === "squash") &&
row.message.trim() &&
row.message.trim() !== row.commit.message
? { message: row.message }
: row.action === "reword"
? { message: row.message }
: {}),
})),
});
onChanged();
if (r.conflicts?.length || r.stopped) {
toast.error(t("rebase.stopped", { count: r.conflicts?.length ?? 0 }));
onClose();
return;
}
toast.success(t("interactive.done"));
setResult(r);
} catch (e) {
setError(errText(e));
} finally {
setBusy(false);
}
};
return (
<GitModal
title={t("interactive.title")}
onClose={onClose}
testId="interactive-rebase"
footer={
result ? (
<button type="button" className={BTN} onClick={onClose}>
{t("close")}
</button>
) : (
<>
<button type="button" className={BTN} onClick={onClose}>
{t("cancel")}
</button>
<button
type="button"
data-testid="interactive-run"
className={BTN_DANGER}
disabled={
busy || !plan || !!blocked || !!problem || unchanged || (pushed > 0 && !force)
}
onClick={() => void run()}
>
{isArmed("run") ? t("interactive.runConfirm") : t("interactive.run")}
</button>
</>
)
}
>
{result ? (
<RewriteDone target={target} result={result} onChanged={onChanged} onUndone={onClose} />
) : !plan ? (
error ? (
<ErrorLine error={error} />
) : (
<p className="text-fg-muted">{t("loading")}</p>
)
) : (
<>
<p className="mb-1.5 text-[11px] text-fg-muted">{t("interactive.explain")}</p>
{blocked && <Warn>{blocked}</Warn>}
{plan.merges && <Warn>{t("interactive.merges")}</Warn>}
{!rows.length ? (
<p data-testid="interactive-empty">{t("interactive.nothing")}</p>
) : (
<>
<Warn testId="interactive-summary">
{pushed
? t("rewritesPushed", { count: rows.length, pushed })
: t("rewrites", { count: rows.length })}
{pushed > 0 && (
<label className="mt-1 flex items-center gap-1.5">
<input
type="checkbox"
data-testid="interactive-force"
checked={force}
onChange={(e) => setForce(e.target.checked)}
/>
{t("forceAck")}
</label>
)}
</Warn>
<ol data-testid="interactive-list" className="space-y-1">
{rows.map((r, i) => (
<li
key={r.commit.sha}
data-testid="interactive-row"
className={`rounded border border-border-light p-1.5 ${
r.action === "drop" ? "opacity-50" : ""
}`}
>
<div className="flex flex-wrap items-center gap-1.5">
<select
aria-label={t("interactive.action", { short: r.commit.short })}
data-testid="interactive-action"
value={r.action}
onChange={(e) => set(i, { action: e.target.value as TodoAction })}
className="input py-0.5 text-[11px]"
>
{ACTIONS.map((a) => (
<option key={a} value={a}>
{t(`interactive.actions.${a}`)}
</option>
))}
</select>
<span className="shrink-0 font-mono text-[11px] text-fg-muted">
{r.commit.short}
</span>
<span
className={`min-w-0 flex-1 truncate text-[11px] text-fg-primary ${
r.action === "drop" ? "line-through" : ""
}`}
title={r.commit.subject}
>
{r.commit.subject}
</span>
{r.commit.pushed && (
<span className="shrink-0 text-[10px] text-status-warning">
{t("pushed")}
</span>
)}
<button
type="button"
className={BTN}
aria-label={t("interactive.up")}
disabled={i === 0}
onClick={() => move(i, -1)}
>
<ArrowUp className="h-3 w-3" />
</button>
<button
type="button"
className={BTN}
aria-label={t("interactive.down")}
disabled={i === rows.length - 1}
onClick={() => move(i, 1)}
>
<ArrowDown className="h-3 w-3" />
</button>
</div>
{(r.action === "reword" || r.action === "squash") && (
<textarea
data-testid="interactive-message"
aria-label={t("interactive.message")}
value={r.message}
rows={3}
onChange={(e) => set(i, { message: e.target.value })}
placeholder={
r.action === "squash" ? t("interactive.squashHint") : undefined
}
className="input mt-1 w-full font-mono text-[11px]"
/>
)}
</li>
))}
</ol>
{problem && (
<p
data-testid="interactive-problem"
className="mt-1 text-[11px] text-status-danger"
>
{t(`interactive.problem.${problem}`)}
</p>
)}
</>
)}
<ErrorLine error={error} />
</>
)}
</GitModal>
);
}
@@ -0,0 +1,444 @@
/**
* @file MergeEditor.tsx
* @description The 3-way merge editor for ONE conflicted file — a merge,
* cherry-pick, revert or rebase (VS Code's / JetBrains' layout): Incoming
* (theirs, git stage :3) | Result | Current (ours, stage :2), with the common
* base (stage :1) one click away. Each conflict block of the result takes
* Accept current / Accept incoming / Accept both (either order) / a hand edit,
* undoable; next/previous conflict and "N conflicts left"; or the whole result
* edited as text. Save writes the result through the `conflict-save` action —
* the whole-file editor's own guards (baseSha → 409 ESTALE, secret refusal)
* then `git add` — which the server refuses while a marker is left.
* In a rebase git's "ours" is the branch being rebased ONTO and "theirs" the
* commit being replayed, so the labels say that rather than "mine".
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronUp } from "lucide-react";
import type { GitTarget } from "../../../lib/api";
import { parseConflicts, type ConflictPart } from "../../../lib/conflicts";
import { errCode, errText, gitRewrite, type ConflictSides } from "../../../lib/gitRewrite";
import { toast } from "../../../lib/toast";
import { BTN, BTN_PRIMARY, ErrorLine, GitModal } from "./GitModal";
export type BlockChoice =
{ kind: "ours" | "theirs" | "ours-theirs" | "theirs-ours" } | { kind: "custom"; text: string };
type Block = Extract<ConflictPart, { kind: "conflict" }>;
/** The lines a block resolves to, or null while it is unresolved. */
export function blockLines(p: Block, c: BlockChoice | undefined): string[] | null {
if (!c) return null;
if (c.kind === "custom") return c.text === "" ? [] : c.text.split("\n");
if (c.kind === "ours") return p.ours;
if (c.kind === "theirs") return p.theirs;
if (c.kind === "ours-theirs") return [...p.ours, ...p.theirs];
return [...p.theirs, ...p.ours];
}
/** The result file: resolved blocks as chosen, unresolved ones with their markers. */
export function composeResult(parts: ConflictPart[], choices: (BlockChoice | undefined)[]): string {
const out: string[] = [];
let i = 0;
for (const p of parts) {
if (p.kind === "text") {
out.push(...p.lines);
continue;
}
const lines = blockLines(p, choices[i++]);
if (lines) out.push(...lines);
else
out.push(
`<<<<<<< ${p.oursLabel}`.trimEnd(),
...p.ours,
"=======",
...p.theirs,
`>>>>>>> ${p.theirsLabel}`.trimEnd()
);
}
return out.join("\n");
}
function Pane({
title,
text,
testId,
tone,
}: {
title: string;
text: string | null;
testId: string;
tone: "incoming" | "current" | "base";
}) {
const { t } = useTranslation(["gitTools"]);
const head =
tone === "incoming"
? "bg-accent/20 text-accent"
: tone === "current"
? "bg-status-success/20 text-status-success"
: "bg-surface-3 text-fg-secondary";
return (
<section
data-testid={testId}
className="flex min-h-0 min-w-0 flex-col rounded border border-border-light"
>
<h3 className={`truncate px-2 py-1 text-[11px] font-semibold ${head}`} title={title}>
{title}
</h3>
<pre className="max-h-60 min-h-0 flex-1 overflow-auto whitespace-pre bg-surface-0 px-2 py-1 font-mono text-[11px] leading-5 text-fg-primary md:max-h-none">
{text == null ? <span className="italic text-fg-muted">{t("merge.sideGone")}</span> : text}
</pre>
</section>
);
}
export function MergeEditor({
target,
path,
onClose,
onResolved,
}: {
target: GitTarget;
path: string;
onClose: () => void;
/** The file was saved and marked resolved. */
onResolved: () => void;
}) {
const { t } = useTranslation(["gitTools"]);
const [sides, setSides] = useState<ConflictSides | null>(null);
const [choices, setChoices] = useState<(BlockChoice | undefined)[]>([]);
const [editing, setEditing] = useState<{ i: number; text: string } | null>(null);
const [whole, setWhole] = useState<string | null>(null);
const [showBase, setShowBase] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [at, setAt] = useState(0);
const blockRefs = useRef<(HTMLDivElement | null)[]>([]);
const load = useCallback(() => {
let live = true;
setSides(null);
setChoices([]);
setEditing(null);
setWhole(null);
setError(null);
gitRewrite
.conflict(target, path)
.then((s) => live && setSides(s))
.catch((e) => live && setError(errText(e)));
return () => {
live = false;
};
// `target` is rebuilt by the caller on every render; its parts are stable.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(target), path]);
useEffect(load, [load]);
const parts =
sides?.usable && sides.working != null ? parseConflicts(sides.working.split("\n")) : null;
const blocks = (parts ?? []).filter((p): p is Block => p.kind === "conflict");
const total = blocks.length;
const left = blocks.filter((_, i) => !choices[i]).length;
const rebase = sides?.operation === "rebase";
const incomingLabel = sides?.incoming
? t(rebase ? "merge.incomingRebase" : "merge.incoming", {
ref: `${sides.incoming.short} ${sides.incoming.subject}`,
})
: t("merge.incomingPlain");
const currentLabel = t(rebase ? "merge.currentRebase" : "merge.current", {
branch: sides?.branch ?? "HEAD",
});
const choose = (i: number, c: BlockChoice | undefined) =>
setChoices((prev) => {
const next = [...prev];
next[i] = c;
return next;
});
const jump = (step: number) => {
if (!total) return;
const open = blocks.map((_, i) => i).filter((i) => !choices[i]);
const pool = open.length ? open : blocks.map((_, i) => i);
const from = pool.findIndex((i) => i > at);
let i: number;
if (step > 0) i = from === -1 ? pool[0]! : pool[from]!;
else {
const before = pool.filter((x) => x < at);
i = before.length ? before[before.length - 1]! : pool[pool.length - 1]!;
}
setAt(i);
blockRefs.current[i]?.scrollIntoView?.({ block: "center" });
};
const save = async () => {
if (!sides?.sha || !parts) return;
const content = whole ?? composeResult(parts, choices);
setBusy(true);
setError(null);
try {
await gitRewrite.run(target, "conflict-save", { path, content, baseSha: sides.sha });
toast.success(t("merge.saved", { path }));
onResolved();
onClose();
} catch (e) {
const code = errCode(e);
setError(
code === "EMARKERS"
? t("merge.markersLeft")
: code === "ESTALE"
? t("merge.stale")
: errText(e)
);
} finally {
setBusy(false);
}
};
const choiceBtn = (i: number, c: BlockChoice, label: string, testId: string) => (
<button type="button" data-testid={testId} className={BTN} onClick={() => choose(i, c)}>
{label}
</button>
);
let blockIndex = -1;
const result = parts && (
<section
data-testid="merge-result"
className="flex min-h-0 min-w-0 flex-col rounded border border-accent/50"
>
<h3 className="truncate bg-surface-3 px-2 py-1 text-[11px] font-semibold text-fg-primary">
{t("merge.result")}
</h3>
{whole != null ? (
<textarea
data-testid="merge-whole"
aria-label={t("merge.result")}
value={whole}
onChange={(e) => setWhole(e.target.value)}
spellCheck={false}
className="input min-h-60 flex-1 resize-none whitespace-pre rounded-none border-0 font-mono text-[11px] leading-5"
/>
) : (
<div className="max-h-[50vh] min-h-0 flex-1 overflow-auto bg-surface-0 md:max-h-none">
{parts.map((p, n) => {
if (p.kind === "text")
return (
<pre
key={n}
className="whitespace-pre px-2 font-mono text-[11px] leading-5 text-fg-primary"
>
{p.lines.length ? p.lines.join("\n") : " "}
</pre>
);
const i = ++blockIndex;
const c = choices[i];
const lines = blockLines(p, c);
return (
<div
key={n}
ref={(el) => {
blockRefs.current[i] = el;
}}
data-testid="merge-block"
className={`my-1 border-y-2 ${
c ? "border-status-success/60" : "border-status-warning/70"
} ${at === i ? "ring-1 ring-accent" : ""}`}
>
<div className="flex flex-wrap items-center gap-1 bg-surface-2 px-1.5 py-1 text-[11px]">
<span className="mr-auto text-fg-muted">
{t("merge.blockOf", { n: i + 1, total })}
</span>
{editing?.i === i ? (
<button
type="button"
className={BTN}
onClick={() => {
choose(i, { kind: "custom", text: editing.text });
setEditing(null);
}}
>
{t("merge.editDone")}
</button>
) : c ? (
<button type="button" className={BTN} onClick={() => choose(i, undefined)}>
{t("merge.undoChoice")}
</button>
) : (
<>
{choiceBtn(
i,
{ kind: "ours" },
t("merge.acceptCurrent"),
"merge-accept-current"
)}
{choiceBtn(
i,
{ kind: "theirs" },
t("merge.acceptIncoming"),
"merge-accept-incoming"
)}
{choiceBtn(
i,
{ kind: "ours-theirs" },
t("merge.bothCurrentFirst"),
"merge-both-ct"
)}
{choiceBtn(
i,
{ kind: "theirs-ours" },
t("merge.bothIncomingFirst"),
"merge-both-ic"
)}
</>
)}
{editing?.i !== i && (
<button
type="button"
data-testid="merge-edit-block"
className={BTN}
onClick={() =>
setEditing({ i, text: (lines ?? [...p.ours, ...p.theirs]).join("\n") })
}
>
{t("merge.editBlock")}
</button>
)}
</div>
{editing?.i === i ? (
<textarea
data-testid="merge-block-text"
aria-label={t("merge.editBlock")}
value={editing.text}
onChange={(e) => setEditing({ i, text: e.target.value })}
spellCheck={false}
rows={Math.min(14, Math.max(3, editing.text.split("\n").length + 1))}
className="input w-full whitespace-pre rounded-none font-mono text-[11px] leading-5"
/>
) : lines ? (
<pre className="whitespace-pre bg-status-success/10 px-2 font-mono text-[11px] leading-5 text-fg-primary">
{lines.length ? lines.join("\n") : " "}
</pre>
) : (
<>
<pre className="whitespace-pre border-l-4 border-status-success bg-status-success/15 px-2 font-mono text-[11px] leading-5 text-fg-primary">
{p.ours.length ? p.ours.join("\n") : " "}
</pre>
<pre className="whitespace-pre border-l-4 border-accent bg-accent/15 px-2 font-mono text-[11px] leading-5 text-fg-primary">
{p.theirs.length ? p.theirs.join("\n") : " "}
</pre>
</>
)}
</div>
);
})}
</div>
)}
</section>
);
return (
<GitModal
wide
testId="merge-editor"
title={t("merge.title", { path })}
onClose={onClose}
footer={
<>
<ErrorLine error={error} />
<button type="button" className={BTN} onClick={onClose}>
{t("cancel")}
</button>
<button
type="button"
data-testid="merge-save"
className={BTN_PRIMARY}
disabled={busy || !parts || !sides?.sha || (whole == null && left > 0)}
onClick={() => void save()}
>
{t("merge.save")}
</button>
</>
}
>
{!sides ? (
!error && <p className="text-fg-muted">{t("loading")}</p>
) : !sides.usable || !parts ? (
<p data-testid="merge-unusable" className="text-status-warning">
{sides.binary
? t("merge.binary")
: sides.tooBig
? t("merge.tooBig")
: sides.working == null
? t("merge.noWorking")
: t("merge.unbalanced")}
</p>
) : (
<div className="flex min-h-0 flex-col gap-2">
<div className="flex flex-wrap items-center gap-1.5">
<span
data-testid="merge-left"
className={`text-[11px] ${left ? "text-status-warning" : "text-status-success"}`}
>
{whole != null
? t("merge.wholeMode")
: left
? t("merge.left", { count: left, total })
: t("merge.allChosen")}
</span>
<button
type="button"
className={BTN}
aria-label={t("merge.prev")}
disabled={!total || whole != null}
onClick={() => jump(-1)}
>
<ChevronUp className="h-3 w-3" />
</button>
<button
type="button"
className={BTN}
aria-label={t("merge.next")}
disabled={!total || whole != null}
onClick={() => jump(1)}
>
<ChevronDown className="h-3 w-3" />
</button>
<label className="ml-auto flex items-center gap-1 text-[11px]">
<input
type="checkbox"
checked={showBase}
onChange={(e) => setShowBase(e.target.checked)}
/>
{t("merge.showBase")}
</label>
<button
type="button"
data-testid="merge-whole-toggle"
className={BTN}
onClick={() => setWhole(whole == null ? composeResult(parts, choices) : null)}
>
{whole == null ? t("merge.editWhole") : t("merge.backToBlocks")}
</button>
</div>
{showBase && (
<Pane title={t("merge.base")} text={sides.base} testId="merge-base" tone="base" />
)}
<div className="grid min-h-0 grid-cols-1 gap-2 md:h-[60vh] md:grid-cols-3">
<Pane
title={incomingLabel}
text={sides.theirs}
testId="merge-incoming"
tone="incoming"
/>
{result}
<Pane title={currentLabel} text={sides.ours} testId="merge-current" tone="current" />
</div>
</div>
)}
</GitModal>
);
}
@@ -0,0 +1,154 @@
/**
* @file The banner a paused cherry-pick or revert shows above the Changes
* panel (the reference tools' "sequencer" bar): "Cherry-pick paused at commit
* X (2 of 5): resolve the conflicted files, then Continue — or Skip this
* commit, or Abort to go back to where you were", with those three buttons.
* Continue is disabled while a file is still conflicted; Skip and Abort throw
* work away, so each takes a second click (`useArmed`) and sends
* `confirm: true`. Reads GET /git/sequence; acts through git-pick.js.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type GitTarget } from "../../../lib/api";
import type { PickAction, PickSequence } from "../../../lib/gitHistory";
import { toast } from "../../../lib/toast";
import { useArmed } from "../../../hooks/useArmed";
type Active = Extract<PickSequence, { active: true }>;
export function PickBanner({
target,
conflicts,
reread,
busy,
onBusy,
onDone,
}: {
target: GitTarget;
/** The git facts' conflict count: a change re-reads the sequence. */
conflicts: number;
reread: number;
busy: boolean;
onBusy: (busy: boolean) => void;
/** A button ran: re-read the facts and the changes. */
onDone: () => void;
}) {
const { t } = useTranslation(["gitHistory"]);
const [seq, setSeq] = useState<Active | null>(null);
const [error, setError] = useState<string | null>(null);
const { arm, disarm, isArmed } = useArmed();
useEffect(() => {
let live = true;
void api.lanes.gitHistory
.sequence(target)
.then((r) => live && setSeq(r.available && r.active ? r : null))
.catch(() => live && setSeq(null));
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- target is stable per panel
}, [conflicts, reread]);
if (!seq) return null;
const op = t(`op.${seq.operation}`);
const step = seq.done + 1;
const total = seq.done + 1 + seq.remaining.length;
const cur = seq.current;
const left = seq.conflicts.length;
const act = async (action: PickAction) => {
disarm();
onBusy(true);
setError(null);
try {
const r = await api.lanes.gitHistory.act(
target,
action,
action === "pick-continue" ? {} : { confirm: true }
);
if (action === "pick-abort") toast.success(t("aborted", { op }));
else if (r.paused) toast.error(t("pausedConflicts", { op, count: r.conflicts.length }));
else toast.success(t("sequenceDone", { op, count: r.commits?.length ?? 0 }));
onDone();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
onBusy(false);
}
};
const btn =
"shrink-0 rounded border px-1.5 py-0.5 disabled:cursor-not-allowed disabled:opacity-40";
return (
<div
data-testid="pick-banner"
className="mb-2 rounded border border-status-warning/40 bg-status-warning/10 px-2 py-1 text-[11px] text-status-warning"
>
<p className="[overflow-wrap:anywhere]">
<strong>
{cur
? t("pausedAt", { op, short: cur.short, subject: cur.subject, step, total })
: t("pausedBetween", { op, step, total })}
</strong>{" "}
{left
? t("pausedHelpConflicts", { count: left })
: cur
? t("pausedHelpReady")
: t("pausedHelpResume")}
</p>
{seq.remaining.length > 0 && (
<p className="mt-0.5 text-fg-muted [overflow-wrap:anywhere]">
{t("stillToApply", {
list: seq.remaining.map((c) => c.short).join(", "),
count: seq.remaining.length,
})}
</p>
)}
<div className="mt-1 flex flex-wrap items-center gap-1.5">
<button
type="button"
data-testid="pick-continue"
disabled={busy || left > 0}
title={left ? t("continueBlocked") : undefined}
onClick={() => void act("pick-continue")}
className={`${btn} border-accent/40 bg-accent/15 text-accent hover:bg-accent/25`}
>
{t("continue")}
</button>
{cur && (
<button
type="button"
data-testid="pick-skip"
disabled={busy}
title={t("skipHint")}
onClick={() => (isArmed("skip") ? void act("pick-skip") : arm("skip"))}
className={`${btn} border-border text-fg-secondary hover:bg-surface-2`}
>
{isArmed("skip") ? t("skipConfirm") : t("skip")}
</button>
)}
<button
type="button"
data-testid="pick-abort"
disabled={busy}
title={t("abortHint")}
onClick={() => (isArmed("abort") ? void act("pick-abort") : arm("abort"))}
className={`${btn} border-status-danger/40 text-status-danger hover:bg-status-danger/10`}
>
{isArmed("abort") ? t("abortConfirm", { op }) : t("abort")}
</button>
</div>
{error && (
<p
role="alert"
className="mt-1 whitespace-pre-wrap text-status-danger [overflow-wrap:anywhere]"
>
{error}
</p>
)}
</div>
);
}
@@ -0,0 +1,207 @@
/**
* @file RebaseDialog.tsx
* @description "Rebase current branch onto…": pick a local or fetched remote
* branch, see the plan before anything runs (GET /git/rewrite-plan
* kind=rebase — how many commits are replayed, how many are already pushed and
* so will need a force-push, how far behind the branch is), then `rebase`.
* A conflict closes the dialog: the Changes panel's banner then offers
* Continue / Skip / Abort and the merge editor resolves the files. A clean
* rebase stays open on its result with **Undo**.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type GitTarget } from "../../../lib/api";
import { errText, gitRewrite, type RebasePlan, type RewriteResult } from "../../../lib/gitRewrite";
import { toast } from "../../../lib/toast";
import { BTN, BTN_PRIMARY, ErrorLine, GitModal, Warn } from "./GitModal";
import { RewriteDone } from "./RewriteDone";
/** Why a rewrite cannot start, from the plan's common facts — or null. */
export function blockedReason(
t: (k: string, o?: Record<string, unknown>) => string,
p: { subdir: boolean; operation: string | null; dirty: boolean },
needsClean = true
): string | null {
if (p.subdir) return t("blocked.subdir");
if (p.operation) return t("blocked.operation", { op: p.operation });
if (needsClean && p.dirty) return t("blocked.dirty");
return null;
}
export function RebaseDialog({
target,
current,
onClose,
onChanged,
}: {
target: GitTarget;
current: string | null;
onClose: () => void;
onChanged: () => void;
}) {
const { t } = useTranslation(["gitTools"]);
const [refs, setRefs] = useState<string[]>([]);
const [onto, setOnto] = useState("");
const [plan, setPlan] = useState<RebasePlan | null>(null);
const [force, setForce] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<RewriteResult | null>(null);
const key = JSON.stringify(target);
useEffect(() => {
api.lanes
.gitBranches(target)
.then((b) => {
const list = [...(b.branches ?? []), ...(b.remotes ?? [])].filter((x) => x !== current);
setRefs(list);
})
.catch((e) => setError(errText(e)));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
useEffect(() => {
setPlan(null);
setForce(false);
if (!onto) return;
let live = true;
gitRewrite
.planRebase(target, onto)
.then((p) => live && setPlan(p))
.catch((e) => live && setError(errText(e)));
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, onto]);
const blocked = plan ? blockedReason(t, plan) : null;
const run = async () => {
if (!plan) return;
setBusy(true);
setError(null);
try {
const r = await gitRewrite.run(target, "rebase", {
onto,
head: plan.head,
...(plan.pushed ? { force: true } : {}),
});
onChanged();
if (r.conflicts?.length || r.stopped) {
toast.error(t("rebase.stopped", { count: r.conflicts?.length ?? 0 }));
onClose();
return;
}
toast.success(t("rebase.done", { onto }));
setResult(r);
} catch (e) {
setError(errText(e));
} finally {
setBusy(false);
}
};
return (
<GitModal
title={t("rebase.title", { branch: current ?? "HEAD" })}
onClose={onClose}
testId="rebase-dialog"
footer={
result ? (
<button type="button" className={BTN} onClick={onClose}>
{t("close")}
</button>
) : (
<>
<button type="button" className={BTN} onClick={onClose}>
{t("cancel")}
</button>
<button
type="button"
data-testid="rebase-run"
className={BTN_PRIMARY}
disabled={
busy || !plan || !!blocked || !plan.commits.length || (plan.pushed > 0 && !force)
}
onClick={() => void run()}
>
{t("rebase.run")}
</button>
</>
)
}
>
{result ? (
<RewriteDone target={target} result={result} onChanged={onChanged} onUndone={onClose} />
) : (
<>
<label className="mb-1 block text-[11px]" htmlFor="rebase-onto">
{t("rebase.onto")}
</label>
<select
id="rebase-onto"
data-testid="rebase-onto"
value={onto}
onChange={(e) => setOnto(e.target.value)}
className="input mb-2 w-full py-1 text-[11px]"
>
<option value="">{t("rebase.pick")}</option>
{refs.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<p className="mb-1 text-[11px] text-fg-muted">{t("rebase.explain")}</p>
{plan && (
<div data-testid="rebase-plan" className="space-y-1">
{blocked ? (
<Warn>{blocked}</Warn>
) : !plan.commits.length ? (
<p>{t("rebase.nothing", { onto })}</p>
) : (
<>
<p>
{t("rebase.summary", {
count: plan.commits.length,
onto,
behind: plan.behind,
})}
</p>
<ul className="max-h-40 overflow-y-auto rounded border border-border-light">
{plan.commits.map((c) => (
<li key={c.sha} className="flex gap-1.5 px-1.5 py-0.5 text-[11px]">
<span className="shrink-0 font-mono text-fg-muted">{c.short}</span>
<span className="min-w-0 flex-1 truncate text-fg-primary">{c.subject}</span>
{c.pushed && (
<span className="shrink-0 text-status-warning">{t("pushed")}</span>
)}
</li>
))}
</ul>
{plan.pushed > 0 && (
<Warn testId="rebase-pushed">
{t("rewritesPushed", { count: plan.commits.length, pushed: plan.pushed })}
<label className="mt-1 flex items-center gap-1.5">
<input
type="checkbox"
data-testid="rebase-force"
checked={force}
onChange={(e) => setForce(e.target.checked)}
/>
{t("forceAck")}
</label>
</Warn>
)}
</>
)}
</div>
)}
<ErrorLine error={error} />
</>
)}
</GitModal>
);
}
@@ -0,0 +1,157 @@
/**
* @file ReflogView.tsx
* @description The read-only reflog of HEAD or of one local branch, paged
* (GET /git/reflog): each entry's selector, what moved it (commit, rebase,
* reset, checkout…), git's message, when, and the commit it points at — a
* click opens that commit in the Changes panel. "Reset here" hands the entry
* to the reset dialog, which is how a lost commit is brought back.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type GitTarget } from "../../../lib/api";
import { errText, gitRewrite, type ReflogEntry } from "../../../lib/gitRewrite";
import { BTN, ErrorLine, GitModal } from "./GitModal";
const PAGE = 50;
function when(time: number | null, lang: string): string {
if (!time) return "";
try {
return new Date(time * 1000).toLocaleString(lang);
} catch {
return new Date(time * 1000).toISOString();
}
}
export function ReflogView({
target,
onClose,
onViewCommit,
onReset,
}: {
target: GitTarget;
onClose: () => void;
/** Open one commit (the panel's commit view). */
onViewCommit?: (sha: string) => void;
/** Open the reset dialog at this commit. */
onReset?: (entry: ReflogEntry) => void;
}) {
const { t, i18n } = useTranslation(["gitTools"]);
const [ref, setRef] = useState("HEAD");
const [branches, setBranches] = useState<string[]>([]);
const [entries, setEntries] = useState<ReflogEntry[] | null>(null);
const [more, setMore] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const key = JSON.stringify(target);
useEffect(() => {
api.lanes
.gitBranches(target)
.then((b) => setBranches(b.branches ?? []))
.catch(() => setBranches([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
const page = async (skip: number) => {
setBusy(true);
setError(null);
try {
const r = await gitRewrite.reflog(target, { ref, skip, limit: PAGE });
setEntries((prev) => (skip && prev ? [...prev, ...r.entries] : r.entries));
setMore(r.more);
} catch (e) {
setError(errText(e));
if (!skip) setEntries([]);
} finally {
setBusy(false);
}
};
useEffect(() => {
setEntries(null);
void page(0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, ref]);
return (
<GitModal title={t("reflog.title")} onClose={onClose} testId="reflog-view">
<div className="mb-2 flex flex-wrap items-center gap-1.5">
<label className="text-[11px]" htmlFor="reflog-ref">
{t("reflog.of")}
</label>
<select
id="reflog-ref"
data-testid="reflog-ref"
value={ref}
onChange={(e) => setRef(e.target.value)}
className="input min-w-0 max-w-full flex-1 py-0.5 text-[11px]"
>
<option value="HEAD">HEAD</option>
{branches.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
</div>
<p className="mb-1.5 text-[11px] text-fg-muted">{t("reflog.hint")}</p>
<ErrorLine error={error} />
{entries == null ? (
<p className="text-fg-muted">{t("loading")}</p>
) : !entries.length ? (
<p className="text-fg-muted">{t("reflog.empty")}</p>
) : (
<ul data-testid="reflog-list" className="divide-y divide-border">
{entries.map((e) => (
<li key={`${e.index}-${e.sha}`} className="flex flex-wrap items-start gap-1.5 py-1.5">
<span className="shrink-0 font-mono text-[10px] text-fg-muted">{e.selector}</span>
<span className="shrink-0 rounded bg-surface-3 px-1 text-[10px] text-fg-secondary">
{e.action}
</span>
<span className="min-w-0 flex-1 break-words text-[11px] text-fg-primary">
{e.message || e.subject}
<span className="block text-[10px] text-fg-muted">
{when(e.time, i18n.language)}
</span>
</span>
<span className="flex shrink-0 items-center gap-1">
{onViewCommit ? (
<button
type="button"
className="font-mono text-[11px] text-accent hover:underline"
title={e.subject}
onClick={() => onViewCommit(e.sha)}
>
{e.short}
</button>
) : (
<span className="font-mono text-[11px]" title={e.subject}>
{e.short}
</span>
)}
{onReset && (
<button type="button" className={BTN} onClick={() => onReset(e)}>
{t("reflog.resetHere")}
</button>
)}
</span>
</li>
))}
</ul>
)}
{more && (
<button
type="button"
data-testid="reflog-more"
disabled={busy}
className={`${BTN} mt-2 w-full`}
onClick={() => void page(entries?.length ?? 0)}
>
{t("reflog.more")}
</button>
)}
</GitModal>
);
}
@@ -0,0 +1,247 @@
/**
* @file RemotesDialog.tsx
* @description The repo's remotes: each one's URL (no password is ever
* shown — the server strips it) and default branch; add one (name + an
* https:// with no login, ssh:// or user@host:path URL — the server's
* allowlist), rename one, remove one (two clicks), and delete a branch ON a
* remote (two clicks; never its default branch nor main/master/develop —
* the server refuses those whatever this shows).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type { GitTarget } from "../../../lib/api";
import { errText, gitRewrite, type GitRemote } from "../../../lib/gitRewrite";
import { toast } from "../../../lib/toast";
import { useArmed } from "../../../hooks/useArmed";
import { BTN, BTN_DANGER, BTN_PRIMARY, ErrorLine, GitModal } from "./GitModal";
const PROTECTED = new Set(["main", "master", "development", "develop"]);
export function RemotesDialog({
target,
onClose,
onChanged,
}: {
target: GitTarget;
onClose: () => void;
onChanged: () => void;
}) {
const { t } = useTranslation(["gitTools"]);
const [remotes, setRemotes] = useState<GitRemote[] | null>(null);
const [name, setName] = useState("");
const [url, setUrl] = useState("");
const [renaming, setRenaming] = useState<{ from: string; to: string } | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const { isArmed, arm, disarm } = useArmed();
const key = JSON.stringify(target);
const load = useCallback(() => {
gitRewrite
.remotes(target)
.then((r) => setRemotes(r.remotes))
.catch((e) => setError(errText(e)));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
useEffect(load, [load]);
const act = async (fn: () => Promise<unknown>, ok: string) => {
setBusy(true);
setError(null);
try {
await fn();
toast.success(ok);
onChanged();
load();
return true;
} catch (e) {
setError(errText(e));
return false;
} finally {
setBusy(false);
}
};
const add = async () => {
const n = name.trim();
const u = url.trim();
if (
await act(
() => gitRewrite.run(target, "remote-add", { name: n, url: u }),
t("remotes.added", { name: n })
)
) {
setName("");
setUrl("");
}
};
const rename = async () => {
if (!renaming) return;
const { from, to } = renaming;
if (
await act(
() => gitRewrite.run(target, "remote-rename", { name: from, to: to.trim() }),
t("remotes.renamed", { from, to: to.trim() })
)
)
setRenaming(null);
};
const remove = (n: string) => {
if (!isArmed(`rm:${n}`)) return arm(`rm:${n}`);
disarm();
void act(
() => gitRewrite.run(target, "remote-remove", { name: n, confirm: true }),
t("remotes.removed", { name: n })
);
};
const deleteBranch = (ref: string) => {
if (!isArmed(`del:${ref}`)) return arm(`del:${ref}`);
disarm();
void act(
() => gitRewrite.run(target, "remote-branch-delete", { branch: ref, confirm: true }),
t("remotes.branchDeleted", { ref })
);
};
return (
<GitModal title={t("remotes.title")} onClose={onClose} testId="remotes-dialog">
{remotes == null ? (
!error && <p className="text-fg-muted">{t("loading")}</p>
) : !remotes.length ? (
<p className="mb-2">{t("remotes.none")}</p>
) : (
<ul data-testid="remotes-list" className="mb-3 space-y-2">
{remotes.map((r) => (
<li key={r.name} className="rounded border border-border-light p-1.5">
<div className="flex flex-wrap items-center gap-1.5">
{renaming?.from === r.name ? (
<input
aria-label={t("remotes.newName")}
data-testid="remote-rename-input"
value={renaming.to}
onChange={(e) => setRenaming({ from: r.name, to: e.target.value })}
className="input min-w-0 flex-1 py-0.5 font-mono text-[11px]"
/>
) : (
<span className="min-w-0 flex-1 truncate font-mono text-[11px] font-semibold text-fg-primary">
{r.name}
</span>
)}
{renaming?.from === r.name ? (
<>
<button
type="button"
className={BTN_PRIMARY}
disabled={busy || !renaming.to.trim()}
onClick={() => void rename()}
>
{t("remotes.rename")}
</button>
<button type="button" className={BTN} onClick={() => setRenaming(null)}>
{t("cancel")}
</button>
</>
) : (
<>
<button
type="button"
className={BTN}
disabled={busy}
onClick={() => setRenaming({ from: r.name, to: r.name })}
>
{t("remotes.rename")}
</button>
<button
type="button"
data-testid={`remote-remove-${r.name}`}
className={BTN_DANGER}
disabled={busy}
onClick={() => remove(r.name)}
>
{isArmed(`rm:${r.name}`) ? t("remotes.removeConfirm") : t("remotes.remove")}
</button>
</>
)}
</div>
<p className="mt-0.5 break-all font-mono text-[10px] text-fg-muted">{r.url}</p>
{r.defaultBranch && (
<p className="text-[10px] text-fg-muted">
{t("remotes.default", { branch: r.defaultBranch })}
</p>
)}
{!!r.branches?.length && (
<details className="mt-1">
<summary className="cursor-pointer text-[11px]">
{t("remotes.branches", { count: r.branches.length })}
</summary>
<ul className="mt-1 max-h-40 space-y-0.5 overflow-y-auto">
{r.branches.map((b) => {
const ref = `${r.name}/${b}`;
const locked = PROTECTED.has(b) || b === r.defaultBranch;
return (
<li key={b} className="flex items-center gap-1.5">
<span className="min-w-0 flex-1 truncate font-mono text-[11px]">{b}</span>
<button
type="button"
data-testid={`remote-branch-delete-${ref}`}
className={BTN_DANGER}
disabled={busy || locked}
title={locked ? t("remotes.branchLocked") : undefined}
onClick={() => deleteBranch(ref)}
>
{isArmed(`del:${ref}`)
? t("remotes.deleteBranchConfirm")
: t("remotes.deleteBranch")}
</button>
</li>
);
})}
</ul>
</details>
)}
</li>
))}
</ul>
)}
<form
onSubmit={(e) => {
e.preventDefault();
void add();
}}
className="space-y-1.5 rounded border border-border p-1.5"
>
<p className="text-[11px] font-medium text-fg-primary">{t("remotes.add")}</p>
<input
aria-label={t("remotes.name")}
placeholder={t("remotes.name")}
data-testid="remote-add-name"
value={name}
onChange={(e) => setName(e.target.value)}
className="input w-full font-mono text-[11px]"
/>
<input
aria-label={t("remotes.url")}
placeholder="https://host/owner/repo.git"
data-testid="remote-add-url"
value={url}
onChange={(e) => setUrl(e.target.value)}
className="input w-full font-mono text-[11px]"
/>
<p className="text-[10px] text-fg-muted">{t("remotes.urlHint")}</p>
<div className="flex justify-end">
<button
type="submit"
data-testid="remote-add-submit"
className={BTN_PRIMARY}
disabled={busy || !name.trim() || !url.trim()}
>
{t("remotes.addButton")}
</button>
</div>
</form>
<ErrorLine error={error} />
</GitModal>
);
}
@@ -0,0 +1,211 @@
/**
* @file ResetDialog.tsx
* @description "Reset current branch to here": the plan first (GET
* /git/rewrite-plan kind=reset — how many commits leave or join the branch and
* how many of those leaving are already pushed), then one of four modes, each
* explained: soft and mixed never touch the working tree, keep refuses to
* overwrite a local change, and hard runs only on a clean tree with the branch
* name typed back. Pushed commits need an explicit tick (a force-push will be
* needed). A done reset stays open on its result with **Undo**.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type { GitTarget } from "../../../lib/api";
import {
errText,
gitRewrite,
type ResetMode,
type ResetPlan,
type RewriteResult,
} from "../../../lib/gitRewrite";
import { toast } from "../../../lib/toast";
import { BTN, BTN_DANGER, ErrorLine, GitModal, Warn } from "./GitModal";
import { RewriteDone } from "./RewriteDone";
import { blockedReason } from "./RebaseDialog";
const MODES: ResetMode[] = ["soft", "mixed", "keep", "hard"];
export function ResetDialog({
target,
commit,
onClose,
onChanged,
}: {
target: GitTarget;
commit: { sha: string; short?: string; subject?: string };
onClose: () => void;
onChanged: () => void;
}) {
const { t } = useTranslation(["gitTools"]);
const [plan, setPlan] = useState<ResetPlan | null>(null);
const [mode, setMode] = useState<ResetMode>("mixed");
const [force, setForce] = useState(false);
const [typed, setTyped] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<RewriteResult | null>(null);
const key = JSON.stringify(target);
useEffect(() => {
let live = true;
gitRewrite
.planReset(target, commit.sha)
.then((p) => live && setPlan(p))
.catch((e) => live && setError(errText(e)));
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, commit.sha]);
const branch = plan?.branch ?? "HEAD";
const blocked = plan ? blockedReason(t, plan, false) : null;
const hardBlocked = mode === "hard" && !!plan?.dirty;
const ready =
!!plan &&
!blocked &&
plan.related &&
plan.head !== plan.target &&
!(plan.pushed > 0 && !force) &&
!hardBlocked &&
(mode !== "hard" || typed === branch);
const run = async () => {
if (!plan) return;
setBusy(true);
setError(null);
try {
const r = await gitRewrite.run(target, "reset", {
target: plan.target,
mode,
head: plan.head,
confirm: true,
...(plan.pushed ? { force: true } : {}),
...(mode === "hard" ? { confirmBranch: typed } : {}),
});
onChanged();
toast.success(t("reset.done", { short: commit.short ?? plan.target.slice(0, 7) }));
setResult(r);
} catch (e) {
setError(errText(e));
} finally {
setBusy(false);
}
};
return (
<GitModal
title={t("reset.title", { branch, short: commit.short ?? commit.sha.slice(0, 7) })}
onClose={onClose}
testId="reset-dialog"
footer={
result ? (
<button type="button" className={BTN} onClick={onClose}>
{t("close")}
</button>
) : (
<>
<button type="button" className={BTN} onClick={onClose}>
{t("cancel")}
</button>
<button
type="button"
data-testid="reset-run"
className={BTN_DANGER}
disabled={busy || !ready}
onClick={() => void run()}
>
{t("reset.run", { mode })}
</button>
</>
)
}
>
{result ? (
<RewriteDone target={target} result={result} onChanged={onChanged} onUndone={onClose} />
) : !plan ? (
error ? (
<ErrorLine error={error} />
) : (
<p className="text-fg-muted">{t("loading")}</p>
)
) : (
<>
{commit.subject && (
<p className="mb-1 truncate text-[11px] text-fg-primary" title={commit.subject}>
<span className="mr-1 font-mono text-fg-muted">{commit.short}</span>
{commit.subject}
</p>
)}
{blocked && <Warn>{blocked}</Warn>}
{!plan.related ? (
<Warn>{t("reset.unrelated")}</Warn>
) : plan.head === plan.target ? (
<p>{t("reset.already")}</p>
) : (
<p data-testid="reset-summary" className="mb-1.5">
{t("reset.summary", { dropped: plan.dropped, added: plan.added, branch })}
</p>
)}
<fieldset className="space-y-1">
<legend className="mb-1 text-[11px] font-medium text-fg-primary">
{t("reset.mode")}
</legend>
{MODES.map((m) => (
<label key={m} className="flex items-start gap-1.5 text-[11px]">
<input
type="radio"
name="reset-mode"
data-testid={`reset-mode-${m}`}
value={m}
checked={mode === m}
onChange={() => setMode(m)}
className="mt-0.5"
/>
<span className="min-w-0">
<span className="font-medium text-fg-primary">{t(`reset.modes.${m}`)}</span>
<span className="block text-fg-muted">{t(`reset.modesHelp.${m}`)}</span>
</span>
</label>
))}
</fieldset>
{mode === "hard" &&
(plan.dirty ? (
<Warn testId="reset-hard-dirty">{t("reset.hardDirty")}</Warn>
) : (
<div className="mt-1.5">
<label className="mb-0.5 block text-[11px]" htmlFor="reset-confirm-branch">
{t("reset.typeBranch", { branch })}
</label>
<input
id="reset-confirm-branch"
data-testid="reset-confirm-branch"
value={typed}
onChange={(e) => setTyped(e.target.value)}
autoComplete="off"
className="input w-full font-mono text-[11px]"
/>
</div>
))}
{plan.pushed > 0 && (
<Warn testId="reset-pushed">
{t("reset.pushed", { count: plan.pushed })}
<label className="mt-1 flex items-center gap-1.5">
<input
type="checkbox"
data-testid="reset-force"
checked={force}
onChange={(e) => setForce(e.target.checked)}
/>
{t("forceAck")}
</label>
</Warn>
)}
<ErrorLine error={error} />
</>
)}
</GitModal>
);
}
@@ -0,0 +1,90 @@
/**
* @file RewriteDone.tsx
* @description What a finished rebase / interactive rebase / reset leaves on
* screen: the result, a force-push reminder when the rewrite touched pushed
* commits, and the recovery button — **Undo** = `undo-rewrite` (`reset --keep`
* back to the recorded `before`), which the server runs only while HEAD is
* still the `after` this dialog saw (409 ESTALE otherwise).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { CheckCircle2, Undo2 } from "lucide-react";
import type { GitTarget } from "../../../lib/api";
import { errText, gitRewrite, type RewriteResult } from "../../../lib/gitRewrite";
import { toast } from "../../../lib/toast";
import { useArmed } from "../../../hooks/useArmed";
import { BTN, ErrorLine, Warn } from "./GitModal";
export function RewriteDone({
target,
result,
onChanged,
onUndone,
}: {
target: GitTarget;
result: RewriteResult;
onChanged: () => void;
onUndone: () => void;
}) {
const { t } = useTranslation(["gitTools"]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const { isArmed, arm, disarm } = useArmed();
const canUndo = !!result.before && !!result.after && result.before !== result.after;
const undo = async () => {
if (!isArmed("undo")) return arm("undo");
disarm();
setBusy(true);
setError(null);
try {
await gitRewrite.run(target, "undo-rewrite", {
to: result.before,
head: result.after,
confirm: true,
});
toast.success(t("undone"));
onChanged();
onUndone();
} catch (e) {
setError(errText(e));
} finally {
setBusy(false);
}
};
return (
<div data-testid="git-rewrite-done" className="space-y-1.5">
<p className="flex items-center gap-1.5 text-status-success">
<CheckCircle2 className="h-4 w-4 shrink-0" />
{t("doneTitle")}
</p>
{result.output && (
<pre className="max-h-32 overflow-auto whitespace-pre-wrap break-words rounded bg-surface-2 p-1.5 font-mono text-[10px] text-fg-muted">
{result.output}
</pre>
)}
{result.needsForcePush && <Warn>{t("needsForcePushAfter")}</Warn>}
{canUndo && (
<div className="flex flex-wrap items-center gap-1.5">
<button
type="button"
data-testid="git-rewrite-undo"
disabled={busy}
onClick={() => void undo()}
className={`${BTN} flex items-center gap-1`}
>
<Undo2 className="h-3 w-3" />
{isArmed("undo") ? t("undoConfirm") : t("undo")}
</button>
<span className="text-[11px] text-fg-muted">
{t("undoHint", { sha: result.before!.slice(0, 7) })}
</span>
</div>
)}
<ErrorLine error={error} />
</div>
);
}
@@ -0,0 +1,271 @@
/**
* @file The Tags tab of the git bar's Branches panel (`BranchManager`): the
* repo's tags (name, target commit, and for an annotated tag its message,
* tagger and date), a form that creates one — lightweight, or annotated when a
* message is typed — on HEAD or on a branch, tag or commit sha, and per tag:
* new branch from it, push it to origin (one tag, never `--tags`, never
* forced) and delete it locally, which is irreversible and so asks first. The
* server looks every name up (server/lib/git-refs.js); nothing here decides.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, Plus, Tag, Trash2, Upload } from "lucide-react";
import type { GitTag } from "../../../lib/api";
import { confirmDialog } from "../../../lib/confirm";
import { isSubmitEnter } from "../../../lib/keys";
import { timeAgo } from "../../../lib/format";
import { Checkbox } from "../../Checkbox";
import { smallBtn, smallInput, type RunRefAction } from "./refUi";
export function TagManager({
tags,
busy,
merging,
dirty,
run,
}: {
/** Already filtered by the panel's search box. */
tags: GitTag[];
busy: boolean;
merging: boolean;
dirty: number;
run: RunRefAction;
}) {
const { t } = useTranslation(["gitRefs"]);
const [creating, setCreating] = useState(false);
const [name, setName] = useState("");
const [message, setMessage] = useState("");
const [from, setFrom] = useState("");
const [selected, setSelected] = useState<string | null>(null);
const [branchName, setBranchName] = useState<string | null>(null);
const [switchTo, setSwitchTo] = useState(false);
const create = async () => {
const tag = name.trim();
if (!tag) return;
const ok = await run(
"tag-create",
{
tag,
...(message.trim() ? { message: message.trim() } : {}),
...(from.trim() ? { from: from.trim() } : {}),
},
t("done.tagCreate", { tag })
);
if (ok) {
setName("");
setMessage("");
setFrom("");
setCreating(false);
}
};
const push = async (tag: string) => {
const ok = await confirmDialog({
title: t("tagPush"),
message: t("tagPushConfirm", { tag }),
confirmLabel: t("tagPush"),
destructive: false,
});
if (ok) await run("tag-push", { tag }, t("done.tagPush", { tag }));
};
const remove = async (tag: string) => {
const ok = await confirmDialog({
title: t("tagDelete"),
message: t("tagDeleteConfirm", { tag }),
confirmLabel: t("tagDelete"),
destructive: true,
});
if (ok && (await run("tag-delete", { tag, confirm: true }, t("done.tagDelete", { tag }))))
setSelected(null);
};
const branchFrom = (tag: string) => {
const branch = (branchName ?? "").trim();
if (!branch) return;
void run(
"branch-create",
{ branch, from: `refs/tags/${tag}`, ...(switchTo ? { switch: true } : {}) },
t("done.create", { branch })
).then((ok) => ok && setBranchName(null));
};
return (
<div className="space-y-2">
<div className="px-2">
{creating ? (
<div className="space-y-1.5 rounded border border-border-light bg-surface-0/40 p-2">
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e)) void create();
}}
placeholder={t("tagName")}
aria-label={t("tagName")}
className={`${smallInput} w-full`}
/>
<input
value={from}
onChange={(e) => setFrom(e.target.value)}
placeholder={t("tagFrom")}
aria-label={t("tagFrom")}
className={`${smallInput} w-full`}
/>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={2}
placeholder={t("tagMessage")}
aria-label={t("tagMessage")}
className={`${smallInput} w-full resize-y font-sans`}
/>
<div className="flex flex-wrap items-center gap-1.5">
<button
type="button"
className={smallBtn}
disabled={busy || merging || !name.trim()}
onClick={() => void create()}
>
<Check className="h-3 w-3" />
{message.trim() ? t("tagCreateAnnotated") : t("tagCreateLight")}
</button>
<button type="button" className={smallBtn} onClick={() => setCreating(false)}>
{t("cancel")}
</button>
</div>
</div>
) : (
<button
type="button"
className={smallBtn}
disabled={busy || merging}
onClick={() => setCreating(true)}
>
<Plus className="h-3 w-3" />
{t("tagNew")}
</button>
)}
</div>
{tags.length === 0 ? (
<p className="px-2 text-xs text-fg-muted">{t("noTags")}</p>
) : (
<ul>
{tags.map((tag) => {
const open = selected === tag.name;
return (
<li key={tag.name} className={open ? "rounded bg-surface-2" : undefined}>
<button
type="button"
data-ref-row
aria-expanded={open}
onClick={() => {
setSelected(open ? null : tag.name);
setBranchName(null);
}}
className="flex w-full min-w-0 items-start gap-2 rounded px-2 py-1 text-left hover:bg-surface-3 focus:outline-none focus-visible:ring-1 focus-visible:ring-accent"
>
<Tag className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" aria-hidden />
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-1.5">
<span title={tag.name} className="truncate font-mono text-xs text-fg-primary">
{tag.name}
</span>
{tag.sha && (
<span className="shrink-0 font-mono text-[10px] text-fg-muted">
{tag.sha.slice(0, 7)}
</span>
)}
{tag.annotated && (
<span className="shrink-0 rounded border border-border-light px-1 text-[9px] uppercase text-fg-muted">
{t("annotated")}
</span>
)}
</span>
<span className="block truncate text-[10px] text-fg-muted">
{[tag.message, tag.tagger, tag.date ? timeAgo(tag.date) : null]
.filter(Boolean)
.join(" · ")}
</span>
</span>
</button>
{open && (
<div className="space-y-1.5 border-t border-border-light px-2 py-1.5">
<div className="flex flex-wrap gap-1">
<button
type="button"
className={smallBtn}
disabled={busy || !tag.sha}
onClick={() => {
setBranchName((v) => (v === null ? "" : null));
setSwitchTo(false);
}}
>
<Plus className="h-3 w-3" />
{t("newFrom")}
</button>
<button
type="button"
className={smallBtn}
disabled={busy || merging}
title={t("tagPushHint")}
onClick={() => void push(tag.name)}
>
<Upload className="h-3 w-3" />
{t("tagPush")}
</button>
<button
type="button"
className={`${smallBtn} hover:text-status-danger`}
disabled={busy || merging}
title={t("tagDeleteHint")}
onClick={() => void remove(tag.name)}
>
<Trash2 className="h-3 w-3" />
{t("tagDelete")}
</button>
</div>
{branchName !== null && (
<div className="flex flex-wrap items-center gap-1.5">
<input
autoFocus
value={branchName}
onChange={(e) => setBranchName(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e)) branchFrom(tag.name);
}}
placeholder={t("newBranchName")}
aria-label={t("newBranchName")}
className={smallInput}
/>
<Checkbox
checked={switchTo}
onChange={setSwitchTo}
disabled={dirty > 0 || merging}
label={t("switchToIt")}
/>
<button
type="button"
className={smallBtn}
disabled={busy || !branchName.trim()}
onClick={() => branchFrom(tag.name)}
>
<Plus className="h-3 w-3" />
{t("create")}
</button>
</div>
)}
</div>
)}
</li>
);
})}
</ul>
)}
</div>
);
}
export default TagManager;
@@ -0,0 +1,320 @@
/**
* @file Tests for the Changes panel's history tools: the 3-way merge editor
* (three panes, per-block accept/both/edit, "N left", Save only when every
* block is chosen and it sends the composed result with the opened sha), the
* rebase banner's Skip, the interactive-rebase list (pushed warning + tick,
* reorder, squash/reword/drop in the todo, two-click run, Undo after) and the
* reset dialog (mode, hard needs a clean tree and the typed branch name).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MergeEditor, composeResult } from "../MergeEditor";
import { InteractiveRebase, todoProblem } from "../InteractiveRebase";
import { ResetDialog } from "../ResetDialog";
import { RebaseSkipButton } from "../GitTools";
import { gitRewrite } from "../../../../lib/gitRewrite";
import { parseConflicts } from "../../../../lib/conflicts";
vi.mock("../../../../lib/gitRewrite", async (orig) => {
const real = await orig<typeof import("../../../../lib/gitRewrite")>();
return {
...real,
gitRewrite: {
conflict: vi.fn(),
planInteractive: vi.fn(),
planReset: vi.fn(),
run: vi.fn(),
},
};
});
vi.mock("../../../../lib/toast", () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
const WORKING = [
"top",
"<<<<<<< HEAD",
"mine",
"=======",
"theirs",
">>>>>>> feat",
"middle",
"<<<<<<< HEAD",
"a",
"=======",
"b",
">>>>>>> feat",
"",
].join("\n");
const HEAD = "a".repeat(40);
const commit = (n: number, pushed = false) => ({
sha: String(n).repeat(40),
short: String(n).repeat(7),
subject: `c${n}`,
message: `c${n}`,
pushed,
});
beforeEach(() => {
vi.mocked(gitRewrite.conflict)
.mockReset()
.mockResolvedValue({
available: true,
path: "a.txt",
operation: "merge",
branch: "main",
incoming: { sha: "f".repeat(40), short: "fffffff", subject: "feat work" },
base: "top\nbase\n",
ours: "top\nmine\n",
theirs: "top\ntheirs\n",
working: WORKING,
sha: "s1",
usable: true,
binary: false,
tooBig: false,
});
vi.mocked(gitRewrite.run).mockReset().mockResolvedValue({ action: "x", ok: true, output: "" });
});
describe("MergeEditor", () => {
it("shows three panes and saves the composed result once every block is chosen", async () => {
const user = userEvent.setup();
const onResolved = vi.fn();
render(<MergeEditor target={3} path="a.txt" onClose={vi.fn()} onResolved={onResolved} />);
const incoming = await screen.findByTestId("merge-incoming");
expect(incoming).toHaveTextContent("Incoming: fffffff feat work");
expect(incoming).toHaveTextContent("theirs");
expect(screen.getByTestId("merge-current")).toHaveTextContent("Current: main");
expect(screen.getByTestId("merge-left")).toHaveTextContent("2 of 2 conflicts left");
const save = screen.getByTestId("merge-save");
expect(save).toBeDisabled();
const [first, second] = screen.getAllByTestId("merge-block") as [HTMLElement, HTMLElement];
await user.click(within(first).getByTestId("merge-accept-incoming"));
expect(screen.getByTestId("merge-left")).toHaveTextContent("1 of 2 conflicts left");
await user.click(within(second).getByTestId("merge-both-ic"));
expect(screen.getByTestId("merge-left")).toHaveTextContent("Every conflict has a choice");
await user.click(save);
await waitFor(() =>
expect(gitRewrite.run).toHaveBeenCalledWith(3, "conflict-save", {
path: "a.txt",
content: "top\ntheirs\nmiddle\nb\na\n",
baseSha: "s1",
})
);
expect(onResolved).toHaveBeenCalled();
});
it("edits one block by hand, and the base pane is one click away", async () => {
const user = userEvent.setup();
render(<MergeEditor target={3} path="a.txt" onClose={vi.fn()} onResolved={vi.fn()} />);
const [first] = (await screen.findAllByTestId("merge-block")) as [HTMLElement];
await user.click(within(first).getByTestId("merge-edit-block"));
const box = within(first).getByTestId("merge-block-text");
await user.clear(box);
await user.type(box, "hand");
await user.click(within(first).getByRole("button", { name: "Done" }));
expect(first).toHaveTextContent("hand");
expect(screen.queryByTestId("merge-base")).toBeNull();
await user.click(screen.getByLabelText("Show base"));
expect(screen.getByTestId("merge-base")).toHaveTextContent("base");
});
it("labels the sides for a rebase and explains a refused save", async () => {
vi.mocked(gitRewrite.conflict).mockResolvedValueOnce({
...(await vi.mocked(gitRewrite.conflict).getMockImplementation()!(3, "a.txt")),
operation: "rebase",
});
vi.mocked(gitRewrite.run).mockRejectedValueOnce(
Object.assign(new Error("x"), { code: "EMARKERS" })
);
const user = userEvent.setup();
render(<MergeEditor target={3} path="a.txt" onClose={vi.fn()} onResolved={vi.fn()} />);
expect(await screen.findByTestId("merge-current")).toHaveTextContent(
"the branch you are rebasing onto"
);
await user.click(screen.getByTestId("merge-whole-toggle"));
await user.click(screen.getByTestId("merge-save"));
expect(await screen.findByRole("alert")).toHaveTextContent("still has conflict markers");
});
it("composeResult keeps markers on an unresolved block", () => {
const parts = parseConflicts(WORKING.split("\n"))!;
expect(composeResult(parts, [{ kind: "ours" }])).toBe(
"top\nmine\nmiddle\n<<<<<<< HEAD\na\n=======\nb\n>>>>>>> feat\n"
);
});
});
describe("RebaseSkipButton", () => {
it("skips the stopped commit and re-reads", async () => {
const user = userEvent.setup();
const onChanged = vi.fn();
render(<RebaseSkipButton target={3} onChanged={onChanged} />);
await user.click(screen.getByTestId("rebase-skip"));
await waitFor(() => expect(onChanged).toHaveBeenCalled());
expect(gitRewrite.run).toHaveBeenCalledWith(3, "rebase-skip", {});
});
});
describe("InteractiveRebase", () => {
beforeEach(() => {
vi.mocked(gitRewrite.planInteractive)
.mockReset()
.mockResolvedValue({
available: true,
kind: "interactive",
head: HEAD,
branch: "feat",
subdir: false,
operation: null,
dirty: false,
base: "b".repeat(40),
merges: false,
commits: [commit(1, true), commit(2), commit(3)],
pushed: 1,
});
});
it("warns about pushed commits, needs the tick, and sends the edited todo", async () => {
const user = userEvent.setup();
vi.mocked(gitRewrite.run).mockResolvedValueOnce({
action: "rebase-interactive",
ok: true,
output: "",
before: HEAD,
after: "c".repeat(40),
conflicts: [],
stopped: false,
needsForcePush: true,
});
render(<InteractiveRebase target={3} onClose={vi.fn()} onChanged={vi.fn()} />);
expect(await screen.findByTestId("interactive-summary")).toHaveTextContent(
"This rewrites 3 commits; 1 are already pushed, so a force-push will be needed."
);
const run = screen.getByTestId("interactive-run");
expect(run).toBeDisabled(); // unchanged + pushed not acknowledged
const rows = screen.getAllByTestId("interactive-row") as HTMLElement[];
await user.selectOptions(within(rows[1]!).getByTestId("interactive-action"), "squash");
await user.selectOptions(within(rows[2]!).getByTestId("interactive-action"), "reword");
const msg = within(rows[2]!).getByTestId("interactive-message");
await user.clear(msg);
await user.type(msg, "new words");
await user.click(within(rows[2]!).getByRole("button", { name: "Move up" }));
expect(run).toBeDisabled();
await user.click(screen.getByTestId("interactive-force"));
expect(run).toBeEnabled();
await user.click(run);
expect(gitRewrite.run).not.toHaveBeenCalled(); // armed only
await user.click(screen.getByRole("button", { name: "Click again to rewrite" }));
await waitFor(() =>
expect(gitRewrite.run).toHaveBeenCalledWith(3, "rebase-interactive", {
head: HEAD,
confirm: true,
force: true,
todo: [
{ sha: commit(1).sha, action: "pick" },
{ sha: commit(3).sha, action: "reword", message: "new words" },
{ sha: commit(2).sha, action: "squash" },
],
})
);
expect(await screen.findByTestId("git-rewrite-done")).toHaveTextContent(
"Force push (with lease)"
);
expect(screen.getByTestId("git-rewrite-undo")).toBeInTheDocument();
});
it("todoProblem refuses a squash with nothing above and an empty reword", () => {
const c = commit(1);
expect(todoProblem([{ commit: c, action: "squash", message: "" }])).toBe("first");
expect(todoProblem([{ commit: c, action: "drop", message: "" }])).toBe("allDropped");
expect(todoProblem([{ commit: c, action: "reword", message: " " }])).toBe("reword");
expect(todoProblem([{ commit: c, action: "pick", message: "" }])).toBeNull();
});
});
describe("ResetDialog", () => {
const plan = (over = {}) => ({
available: true,
kind: "reset" as const,
head: HEAD,
branch: "feat",
subdir: false,
operation: null,
dirty: false,
target: "d".repeat(40),
related: true,
dropped: 2,
added: 0,
pushed: 0,
...over,
});
it("hard needs the branch name typed back", async () => {
vi.mocked(gitRewrite.planReset).mockReset().mockResolvedValue(plan());
vi.mocked(gitRewrite.run).mockResolvedValueOnce({
action: "reset",
ok: true,
output: "",
before: HEAD,
after: "d".repeat(40),
});
const user = userEvent.setup();
render(
<ResetDialog
target={3}
commit={{ sha: "d".repeat(40), short: "ddddddd", subject: "old" }}
onClose={vi.fn()}
onChanged={vi.fn()}
/>
);
expect(await screen.findByTestId("reset-summary")).toHaveTextContent("2 commit(s) leave feat");
await user.click(screen.getByTestId("reset-mode-hard"));
const run = screen.getByTestId("reset-run");
expect(run).toBeDisabled();
await user.type(screen.getByTestId("reset-confirm-branch"), "feat");
expect(run).toBeEnabled();
await user.click(run);
await waitFor(() =>
expect(gitRewrite.run).toHaveBeenCalledWith(3, "reset", {
target: "d".repeat(40),
mode: "hard",
head: HEAD,
confirm: true,
confirmBranch: "feat",
})
);
expect(await screen.findByTestId("git-rewrite-undo")).toBeInTheDocument();
});
it("refuses hard on a dirty tree and asks for the tick on pushed commits", async () => {
vi.mocked(gitRewrite.planReset)
.mockReset()
.mockResolvedValue(plan({ dirty: true, pushed: 1 }));
const user = userEvent.setup();
render(
<ResetDialog
target={3}
commit={{ sha: "d".repeat(40) }}
onClose={vi.fn()}
onChanged={vi.fn()}
/>
);
await screen.findByTestId("reset-pushed");
const run = screen.getByTestId("reset-run");
expect(run).toBeDisabled();
await user.click(screen.getByTestId("reset-force"));
expect(run).toBeEnabled(); // mixed works on a dirty tree
await user.click(screen.getByTestId("reset-mode-hard"));
expect(screen.getByTestId("reset-hard-dirty")).toBeInTheDocument();
expect(run).toBeDisabled();
});
});
@@ -0,0 +1,308 @@
/**
* @file Pins the History tab's graph view: rows with their graph cell and ref
* badges, the search box turning `author:`/`path:` into the log's filters,
* all branches, multi-select (checkbox and Shift-click) into a cherry-pick
* preview that lists the commits in the server's order and runs only on
* Confirm, a paused result handed to the overview, and Undo after a clean
* pick (asked first, sent with the from/to the pick answered). Also the pure
* `parseSearch` helper, and the commit menu's rewrite tools (Reset branch to
* here, Edit commits after this one) and Create tag here.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, within, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { HistoryView } from "../HistoryView";
import { api } from "../../../../lib/api";
import { confirmDialog } from "../../../../lib/confirm";
import { parseSearch } from "../../../../lib/gitHistory";
import type { LaneCommit } from "../../../../lib/types";
vi.mock("../../../../lib/confirm", () => ({
confirmDialog: vi.fn(async () => true),
}));
vi.mock("../../../../lib/api", () => ({
api: {
lanes: {
gitLog: vi.fn(),
changes: vi.fn(),
gitRefAction: vi.fn(),
gitHistory: {
commit: vi.fn(),
patch: vi.fn(),
pickPlan: vi.fn(),
pick: vi.fn(),
act: vi.fn(),
compare: vi.fn(),
treeAt: vi.fn(),
},
},
},
}));
const sha = (c: string) => c.repeat(40);
const commit = (c: string, over: Partial<LaneCommit> = {}): LaneCommit => ({
sha: sha(c),
short: c.repeat(7),
subject: `commit ${c}`,
author: "Ann",
date: "2026-10-01T00:00:00Z",
refs: [],
parents: [],
badges: [],
...over,
});
// other: c -> b (feature branch), main: a (HEAD)
const LOG = [
commit("c", { parents: [sha("b")], badges: [{ kind: "local", name: "feature" }] }),
commit("b", { parents: [sha("a")] }),
commit("a", {
parents: [],
badges: [
{ kind: "local", name: "main", current: true },
{ kind: "tag", name: "v1" },
],
}),
];
const props = () => ({
target: 7,
branch: "main",
branches: ["main", "feature"],
historyRef: null,
onHistoryRef: vi.fn(),
reread: 0,
disabled: false,
headPushed: false,
onViewCommit: vi.fn(),
onOpenFileAtCommit: vi.fn(),
onUndoCommit: vi.fn(),
onResetHere: vi.fn(),
onEditAfter: vi.fn(),
onPaused: vi.fn(),
onChanged: vi.fn(),
});
beforeEach(() => {
vi.mocked(api.lanes.gitLog).mockReset().mockResolvedValue({ available: true, commits: LOG });
vi.mocked(api.lanes.changes)
.mockReset()
.mockResolvedValue({
available: true,
range: "commit",
base: null,
baseRef: null,
sha: sha("c"),
context: 3,
truncated: false,
files: [],
});
vi.mocked(api.lanes.gitHistory.commit).mockReset().mockResolvedValue({ available: false });
vi.mocked(api.lanes.gitHistory.pick).mockReset();
vi.mocked(api.lanes.gitHistory.act).mockReset();
vi.mocked(api.lanes.gitHistory.pickPlan)
.mockReset()
.mockImplementation(async (_t, op, shas) => ({
available: true,
op,
// The server's order: oldest first for a pick.
commits: [...shas].reverse().map((s) => ({
sha: s,
short: s.slice(0, 7),
subject: `commit ${s[0]}`,
author: "Ann",
date: "",
})),
branch: "main",
clean: true,
repoRoot: true,
busy: false,
}));
vi.mocked(confirmDialog).mockClear();
});
describe("parseSearch", () => {
it("splits author: and path: from the message text", () => {
expect(parseSearch('fix login author:ann path:"src/a b.ts"')).toEqual({
q: "fix login",
author: "ann",
path: "src/a b.ts",
});
expect(parseSearch(" ")).toEqual({});
});
});
describe("HistoryView", () => {
it("draws one graph cell per commit with its ref badges", async () => {
render(<HistoryView {...props()} />);
await screen.findByText("commit c");
expect(screen.getAllByTestId("lane-changes-graph")).toHaveLength(3);
const badges = screen.getAllByTestId("ref-badge");
expect(badges.map((b) => b.textContent)).toEqual(["feature", "main", "v1"]);
expect(badges[1]).toHaveAttribute("title", expect.stringMatching(/branch you are on/));
});
it("searches with filters and reads every branch", async () => {
const user = userEvent.setup();
render(<HistoryView {...props()} />);
await screen.findByText("commit c");
await user.type(screen.getByTestId("history-search"), "fix author:ann");
await waitFor(
() =>
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, {
limit: 100,
q: "fix",
author: "ann",
}),
{ timeout: 2000 }
);
await user.click(screen.getByRole("checkbox", { name: "All branches" }));
await waitFor(() =>
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, {
limit: 100,
all: true,
q: "fix",
author: "ann",
})
);
});
it("multi-selects, previews the cherry-pick in order, and runs it only on Confirm", async () => {
const user = userEvent.setup();
const p = { ...props(), historyRef: "feature" };
vi.mocked(api.lanes.gitHistory.pick).mockResolvedValue({
action: "cherry-pick",
ok: true,
output: "",
conflicts: [],
paused: false,
from: sha("a"),
to: sha("e"),
commits: [
{ sha: sha("d"), short: "ddddddd", subject: "commit b" },
{ sha: sha("e"), short: "eeeeeee", subject: "commit c" },
],
});
vi.mocked(api.lanes.gitHistory.act).mockResolvedValue({
action: "undo-pick",
ok: true,
output: "",
conflicts: [],
paused: false,
});
render(<HistoryView {...p} />);
await screen.findByText("commit c");
await user.click(screen.getByRole("checkbox", { name: "Select ccccccc" }));
// Shift-click extends from the anchor (c) to b.
fireEvent.click(screen.getByText("commit b"), { shiftKey: true });
expect(screen.getByTestId("history-selection")).toHaveTextContent("2 commits selected");
await user.click(screen.getByTestId("history-pick-selected"));
const plan = await screen.findByTestId("pick-plan");
expect(
within(plan)
.getAllByRole("listitem")
.map((li) => li.textContent)
).toEqual(["1.bbbbbbbcommit b", "2.ccccccccommit c"]);
expect(screen.getByTestId("pick-tree-state")).toHaveTextContent("clean");
expect(api.lanes.gitHistory.pick).not.toHaveBeenCalled();
await user.click(screen.getByTestId("pick-confirm"));
await waitFor(() =>
expect(api.lanes.gitHistory.pick).toHaveBeenCalledWith(7, "cherry-pick", [sha("b"), sha("c")])
);
expect(p.onChanged).toHaveBeenCalled();
// Undo is offered, asked first, and sent with what the pick answered.
await user.click(await screen.findByTestId("pick-undo"));
expect(confirmDialog).toHaveBeenCalled();
await waitFor(() =>
expect(api.lanes.gitHistory.act).toHaveBeenCalledWith(7, "undo-pick", {
from: sha("a"),
to: sha("e"),
confirm: true,
})
);
await waitFor(() => expect(screen.queryByTestId("pick-result")).toBeNull());
});
it("refuses to start when the tree is dirty, and hands a paused pick to the overview", async () => {
const user = userEvent.setup();
const p = { ...props(), historyRef: "feature" };
vi.mocked(api.lanes.gitHistory.pickPlan).mockResolvedValueOnce({
available: true,
op: "cherry-pick",
commits: [{ sha: sha("c"), short: "ccccccc", subject: "commit c", author: "", date: "" }],
branch: "main",
clean: false,
repoRoot: true,
busy: false,
});
render(<HistoryView {...p} />);
await screen.findByText("commit c");
await user.click(screen.getByRole("checkbox", { name: "Select ccccccc" }));
await user.click(screen.getByTestId("history-pick-selected"));
await screen.findByTestId("pick-plan");
expect(screen.getByTestId("pick-confirm")).toBeDisabled();
expect(screen.getByTestId("pick-tree-state")).toHaveTextContent(/uncommitted changes/);
await user.click(screen.getByRole("button", { name: "Cancel" }));
vi.mocked(api.lanes.gitHistory.pick).mockResolvedValue({
action: "cherry-pick",
ok: true,
output: "",
conflicts: ["a.txt"],
paused: true,
});
await user.click(screen.getByTestId("history-pick-selected"));
await user.click(await screen.findByTestId("pick-confirm"));
await waitFor(() => expect(p.onPaused).toHaveBeenCalled());
expect(screen.queryByTestId("pick-result")).toBeNull();
});
it("opens the details pane on a click and the full change on a double-click", async () => {
const user = userEvent.setup();
const p = props();
render(<HistoryView {...p} />);
await user.click(await screen.findByText("commit b"));
expect(await screen.findByTestId("commit-details")).toBeInTheDocument();
expect(api.lanes.gitHistory.commit).toHaveBeenCalledWith(7, sha("b"));
await user.dblClick(screen.getByText("commit c"));
expect(p.onViewCommit).toHaveBeenCalledWith(sha("c"));
});
it("keeps the rewrite tools in the commit menu and creates a tag at a commit", async () => {
const user = userEvent.setup();
const p = props();
vi.mocked(api.lanes.gitRefAction).mockReset().mockResolvedValue({
action: "tag-create",
ok: true,
output: "",
});
render(<HistoryView {...p} />);
const menuOn = async (text: string) => {
await user.pointer({ keys: "[MouseRight]", target: await screen.findByText(text) });
return screen.getByTestId("lane-changes-commit-menu");
};
// HEAD's own commit (main, the current branch): nothing after it to edit.
expect(
within(await menuOn("commit a")).getByTestId("lane-changes-menu-interactive")
).toBeDisabled();
await user.keyboard("{Escape}");
await user.click(within(await menuOn("commit b")).getByText("Reset branch to here…"));
expect(p.onResetHere).toHaveBeenCalledWith(expect.objectContaining({ sha: sha("b") }));
await user.click(within(await menuOn("commit b")).getByText("Edit commits after this one…"));
expect(p.onEditAfter).toHaveBeenCalledWith(sha("b"));
await user.click(within(await menuOn("commit a")).getByText("Create tag here…"));
await user.type(screen.getByTestId("create-ref-name"), "v2");
await user.click(screen.getByTestId("create-ref-confirm"));
await waitFor(() =>
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(7, "tag-create", {
tag: "v2",
from: sha("a"),
})
);
expect(p.onChanged).toHaveBeenCalled();
});
});
@@ -0,0 +1,88 @@
/**
* @file Pins the paused cherry-pick/revert banner: it names the commit it
* stopped at and the step ("2 of 3"), Continue waits for every conflict to be
* resolved, Skip and Abort each need a second click and only then send
* `confirm: true`, and it draws nothing when no pick is open.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { PickBanner } from "../PickBanner";
import { api } from "../../../../lib/api";
vi.mock("../../../../lib/api", () => ({
api: { lanes: { gitHistory: { sequence: vi.fn(), act: vi.fn() } } },
}));
const paused = (conflicts: string[]) => ({
available: true as const,
active: true as const,
operation: "cherry-pick" as const,
current: { sha: "b".repeat(40), short: "bbbbbbb", subject: "edit a" },
remaining: [{ sha: "c".repeat(40), short: "ccccccc", subject: "add c" }],
done: 1,
startedFrom: "a".repeat(40),
conflicts,
});
const props = () => ({
target: 7,
conflicts: 1,
reread: 0,
busy: false,
onBusy: vi.fn(),
onDone: vi.fn(),
});
beforeEach(() => {
vi.mocked(api.lanes.gitHistory.act).mockReset().mockResolvedValue({
action: "pick-continue",
ok: true,
output: "",
conflicts: [],
paused: false,
commits: [],
});
});
describe("PickBanner", () => {
it("names the stop and its step, and Continue waits for the conflicts", async () => {
vi.mocked(api.lanes.gitHistory.sequence).mockResolvedValue(paused(["a.txt"]));
render(<PickBanner {...props()} />);
const banner = await screen.findByTestId("pick-banner");
expect(banner).toHaveTextContent('cherry-pick paused at bbbbbbb "edit a" (2 of 3)');
expect(banner).toHaveTextContent("Still to apply: ccccccc");
expect(screen.getByTestId("pick-continue")).toBeDisabled();
});
it("continues once resolved, and skips/aborts only on a second click", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.gitHistory.sequence).mockResolvedValue(paused([]));
const p = props();
render(<PickBanner {...p} />);
await user.click(await screen.findByTestId("pick-continue"));
await waitFor(() =>
expect(api.lanes.gitHistory.act).toHaveBeenCalledWith(7, "pick-continue", {})
);
expect(p.onDone).toHaveBeenCalled();
await user.click(screen.getByTestId("pick-skip"));
expect(api.lanes.gitHistory.act).toHaveBeenCalledTimes(1);
await user.click(screen.getByTestId("pick-skip"));
expect(api.lanes.gitHistory.act).toHaveBeenLastCalledWith(7, "pick-skip", { confirm: true });
await user.click(screen.getByTestId("pick-abort"));
expect(api.lanes.gitHistory.act).toHaveBeenCalledTimes(2);
await user.click(screen.getByTestId("pick-abort"));
expect(api.lanes.gitHistory.act).toHaveBeenLastCalledWith(7, "pick-abort", { confirm: true });
});
it("draws nothing when no pick is open", async () => {
vi.mocked(api.lanes.gitHistory.sequence).mockResolvedValue({ available: true, active: false });
const { container } = render(<PickBanner {...props()} />);
await waitFor(() => expect(api.lanes.gitHistory.sequence).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
});
});
+41
View File
@@ -0,0 +1,41 @@
/**
* @file Shared bits of the Branches panel (`BranchManager`, `TagManager`): the
* small button and input classes, the write runner's type, and the arrow-key
* walk over a list's rows.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { KeyboardEvent } from "react";
import type { GitRefAction, GitRefBody } from "../../../lib/api";
/** Runs one write; resolves true when it succeeded (the list is then re-read). */
export type RunRefAction = (
action: GitRefAction,
body: GitRefBody,
done?: string
) => Promise<boolean>;
export const smallBtn =
"inline-flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40";
export const smallInput =
"min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none";
/** Arrow-key walk over the rows of a list (`[data-ref-row]`), Home/End too. */
export function onListKeys(e: KeyboardEvent<HTMLElement>) {
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) return;
// Inside a text field the arrows and Home/End move the caret.
if ((e.target as HTMLElement).closest("input, textarea, select")) return;
const rows = [...e.currentTarget.querySelectorAll<HTMLElement>("[data-ref-row]")];
if (!rows.length) return;
const at = rows.indexOf(document.activeElement as HTMLElement);
const next =
e.key === "Home"
? 0
: e.key === "End"
? rows.length - 1
: e.key === "ArrowDown"
? Math.min(rows.length - 1, at + 1)
: Math.max(0, at - 1);
e.preventDefault();
rows[next]?.focus();
}
+46 -9
View File
@@ -13,7 +13,8 @@
* puts `/<name>` (`/<plugin>:<name>`) at the front of the message — the same
* text the `/` autocomplete inserts, so the CLI runs it as typed. Both lists
* are read when the menu first opens (`GET /api/cc-config/skills|plugins`),
* and Manage opens the Claude config page on that tab.
* and Manage opens the Claude config page on that tab. The menu opens
* downward when the box sits too near the top to fit it above.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -77,7 +78,36 @@ function loadLists(cwd: string): Promise<Lists> {
}
const PANEL =
"absolute w-60 rounded-xl border border-border bg-surface-1 py-1 shadow-lg animate-fade-in";
"absolute z-40 w-60 rounded-xl border border-border bg-surface-1 py-1 shadow-lg animate-fade-in";
/** Room the menu and its tallest submenu (Project files: search + list) need. */
const MENU_ROOM = 380;
/** The visible band the button sits in: the viewport, narrowed by any
* scrolling/clipping ancestor (the page body scrolls inside the layout). */
function clipBand(el: HTMLElement): { top: number; bottom: number } {
let top = 0;
let bottom = window.innerHeight;
for (let p = el.parentElement; p; p = p.parentElement) {
const o = getComputedStyle(p);
if (/(auto|scroll|hidden|clip)/.test(o.overflowY + o.overflow)) {
const r = p.getBoundingClientRect();
top = Math.max(top, r.top);
bottom = Math.min(bottom, r.bottom);
}
}
return { top, bottom };
}
/** Open downward when there is not room above but more below. */
function opensDown(el: HTMLElement | null): boolean {
if (!el) return false;
const r = el.getBoundingClientRect();
const band = clipBand(el);
const above = r.top - band.top;
const below = band.bottom - r.bottom;
return above < MENU_ROOM && below > above;
}
const ROW =
"flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-3 hover:text-fg-primary";
@@ -195,6 +225,7 @@ export function ChatAddMenu({
const { t } = useTranslation("run");
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [down, setDown] = useState(false);
const [sub, setSub] = useState<Sub | null>(null);
const [context, setContext] = useState<Attachment[] | null>(null);
const [recent, setRecent] = useState<Attachment[]>([]);
@@ -286,6 +317,8 @@ export function ChatAddMenu({
/>
))
);
// A submenu lines up with the menu's edge nearest the button.
const edge = down ? "top-0" : "bottom-0";
const shownPlugin = lists?.plugins.find((p) => p.name === plugin);
return (
@@ -296,13 +329,17 @@ export function ChatAddMenu({
aria-label={t("chat.add")}
title={t("chat.add")}
aria-expanded={open}
onClick={() => (open ? close() : setOpen(true))}
onClick={() => {
if (open) return close();
setDown(opensDown(ref.current));
setOpen(true);
}}
className="flex h-8 w-8 items-center justify-center rounded-full text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"
>
<Plus className="h-4 w-4" />
</button>
{open && (
<div role="menu" data-testid="chat-add-menu" className={`${PANEL} bottom-full left-0 mb-2`}>
<div role="menu" data-testid="chat-add-menu" className={`${PANEL} left-0 ${down ? "top-full mt-2" : "bottom-full mb-2"}`}>
<Row
icon={<Paperclip className="h-4 w-4" />}
label={t("chat.upload")}
@@ -376,13 +413,13 @@ export function ChatAddMenu({
)}
{sub === "project" && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1 w-72 pt-2`}>
<div role="menu" className={`${PANEL} ${edge} left-full ml-1 w-72 pt-2`}>
<ProjectFiles cwd={cwd} onPick={(rel) => attach(projectAttachment(rel))} />
</div>
)}
{(sub === "context" || sub === "recent") && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
<div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
<div className="max-h-72 overflow-y-auto">
{(sub === "context" ? context : recent)?.length ? (
(sub === "context" ? context! : recent).map((it) => (
@@ -405,7 +442,7 @@ export function ChatAddMenu({
)}
{sub === "skills" && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
<div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
<div className="max-h-72 overflow-y-auto">{entries(lists?.skills ?? [], "")}</div>
<div className="my-1 border-t border-border" />
<Row
@@ -417,7 +454,7 @@ export function ChatAddMenu({
)}
{sub === "plugins" && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
<div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
<div className="max-h-72 overflow-y-auto">
{lists && lists.plugins.length === 0 && (
<p className="px-3 py-1.5 text-xs text-fg-muted">{t("chat.none")}</p>
@@ -443,7 +480,7 @@ export function ChatAddMenu({
onClick={() => manage("plugins")}
/>
{shownPlugin && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
<div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
<div className="max-h-72 overflow-y-auto">
{entries(
shownPlugin.items,
+48 -11
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.
@@ -357,6 +359,7 @@ export function FilePreview({
onClose,
className = "",
messages,
modal = false,
}: {
path: string;
scope: PreviewScope;
@@ -364,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) : []),
@@ -424,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}`
}
@@ -522,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}
@@ -546,6 +557,32 @@ export function FilePreview({
<Body path={path} content={content} view={view} scope={scope} />
</section>
);
if (modal) {
return createPortal(
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
onClick={onClose}
onKeyDown={(e) => {
// Nothing reaches the view underneath — so Esc, which useDialog's
// document listener would never see, closes here.
e.stopPropagation();
if (e.key === "Escape") onClose();
}}
role="presentation"
>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label={displayName(path)}
onClick={(e) => e.stopPropagation()}
>
{panel}
</div>
</div>,
document.body
);
}
// Full screen: over the whole page, where a pane's edges cannot clip it.
return full ? createPortal(panel, document.body) : panel;
}
+13 -5
View File
@@ -16,8 +16,9 @@
* keys, Home and End move between tabs (one tab stop, APG). Errors are
* toasts. The `+` menu opens a new tab in the free slot the server picks
* (`slot: "auto"`) — a plain shell (`POST /api/lanes/:id/start {shell: true}`)
* or an installed, enabled AI tool started bare on its default model, no
* prompt (a prompt, proxy or worktree is still the workspace home's prompt
* or an installed, enabled AI tool started bare on its default model and
* the account's Session defaults (permission mode, Codex sandbox/approval/
* bypass), no prompt (a prompt, proxy or worktree is still the workspace home's prompt
* box) — plus a link to Settings → Agents. Ten sessions is the server's limit
* (`MAX_PANE_SLOT`). The bar is the console's only header: Home (the
* workspace's home) at its left end, `actions` (the single pane's split menus)
@@ -72,6 +73,7 @@ import {
type GroupsUpdate,
} from "./PaneGrid";
import { insertionIndex } from "../../lib/laneOrderStorage";
import { readCodexDefaults, readDefaultMode } from "../../lib/sessionDefaults";
import {
type PaneGroups,
MAX_PANES,
@@ -140,15 +142,21 @@ export function newTabTools(lane: Lane, aiTools: AiToolsState): string[] {
];
}
/** The start body of a bare tool on its default model, no prompt. Only a
* different tool is named, as the home does: the workspace's own keeps
* starting even once disabled. */
/** The start body of a bare tool on its default model, no prompt, in the
* account's Session defaults (Claude's permission mode, Codex's sandbox,
* approval and bypass) — the same ones the workspace home's prompt box starts
* from. Only a different tool is named, as the home does: the workspace's own
* keeps starting even once disabled. */
export function newTabBody(id: string, lane: Lane, aiTools: AiToolsState) {
const model = aiTools.byId(id)?.defaultModel;
const codex = id === "codex" ? readCodexDefaults() : null;
return {
prompt: "",
...(id !== lane.tool ? { tool: id } : {}),
...(model ? { model } : {}),
...(id === "claude" ? { permissionMode: readDefaultMode() } : {}),
...(codex ? { sandbox: codex.sandbox, approval: codex.approval } : {}),
...(codex?.bypass && aiTools.byId(id)?.bypass ? { bypass: true } : {}),
};
}
+28 -7
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
@@ -846,7 +858,7 @@ export const PaneChat = memo(function PaneChat({
)}
</div>
<div className="mx-4">
<div className="mx-auto w-full max-w-3xl">
<div className="mx-auto w-full max-w-5xl">
{menu && (
<PaneMenuCard
// A new question starts with its field closed.
@@ -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")}
+112 -2
View File
@@ -86,16 +86,48 @@ import {
sameFilesHere,
} from "../../lib/promptAttach";
import { attachImeInput } from "../../lib/imeInput";
import { SCROLL_SPEED, wheelReports, type WheelState } from "../../lib/wheelReports";
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";
import { openInPreview } from "../../lib/openInPreview";
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 {
@@ -423,6 +455,8 @@ export const TerminalView = memo(function TerminalView({
fontWeightBold: 700,
theme: paletteRef.current,
screenReaderMode: readFlag(SCREEN_READER_KEY),
// xterm's own scrollback scrolls at the same share as the reports below.
scrollSensitivity: SCROLL_SPEED,
});
termRef.current = term;
const fit = new FitAddon();
@@ -432,7 +466,10 @@ export const TerminalView = memo(function TerminalView({
searchRef.current = search;
term.loadAddon(new ClipboardWithProvider(undefined, clipboardProvider));
term.loadAddon(
new WebLinksAddon((_e, uri) => {
new WebLinksAddon((e, uri) => {
// A local page opens in the session panel's Preview (one rule
// for every link, `lib/openInPreview.ts`); Ctrl/Cmd keeps a tab.
if (!e.ctrlKey && !e.metaKey && !e.shiftKey && openInPreview(uri)) return;
window.open(uri, "_blank", "noopener,noreferrer");
})
);
@@ -468,7 +505,8 @@ export const TerminalView = memo(function TerminalView({
// A terminal mounted on a hidden tab sits in a 0×0 box (`display:none`):
// fitting it there shrinks xterm to its 12×6 minimum, and that size would
// reach tmux with the attach. It stays unfitted until it is shown.
const hasBox = () => !!containerRef.current?.clientWidth && !!containerRef.current.clientHeight;
const hasBox = () =>
!!containerRef.current?.clientWidth && !!containerRef.current.clientHeight;
if (hasBox()) fit.fit();
// Take the keyboard as soon as the pane is on screen: the terminal IS the
// page here, and having to click it first meant the first thing typed went
@@ -489,9 +527,38 @@ export const TerminalView = memo(function TerminalView({
// wheel report itself — psmux scrolls, or hands it to the pane's app.
// ponytail: at 1;1, which is all psmux reports to the app anyway.
let ownWheel = false;
// Tracked: the reports are this pane's own too — one per
// LINES_PER_REPORT rows of travel (lib/wheelReports.ts), where xterm
// sends one per event and damps a touchpad ×0.3. SGR, as tmux always
// asks for it; at the pointer's cell, with xterm's modifier bits.
const wheelState: WheelState = { partial: 0 };
const sendWheel = (ev: WheelEvent) => {
const screen = term.element?.querySelector(".xterm-screen");
const box = screen?.getBoundingClientRect();
if (!box || !box.height || !box.width) return;
const n = wheelReports(wheelState, ev, box.height / term.rows, term.rows);
const ws = wsRef.current;
if (!n || !ws || ws.readyState !== WebSocket.OPEN) return;
const clamp = (v: number, max: number) => Math.min(max, Math.max(1, v));
const col = clamp(
Math.floor(((ev.clientX - box.left) / box.width) * term.cols) + 1,
term.cols
);
const row = clamp(
Math.floor(((ev.clientY - box.top) / box.height) * term.rows) + 1,
term.rows
);
const button = (n < 0 ? 64 : 65) | (ev.altKey ? 8 : 0) | (ev.ctrlKey ? 16 : 0);
ws.send(`\x1b[<${button};${col};${row}M`.repeat(Math.abs(n)));
};
term.attachCustomWheelEventHandler((ev) => {
const mode = term.modes.mouseTrackingMode;
const tracked = mode !== "none" && mode !== "x10";
// Shift+wheel stays xterm's (it reports nothing for it).
if (tracked && !ev.shiftKey) {
sendWheel(ev);
return false;
}
if (!tracked && ownWheel) {
if (ev.deltaY && wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(`\x1b[<${ev.deltaY < 0 ? 64 : 65};1;1M`);
@@ -761,6 +828,44 @@ export const TerminalView = memo(function TerminalView({
refit();
};
document.fonts?.addEventListener("loadingdone", onFontLoaded);
// @xterm/addon-webgl 0.19 shares ONE glyph atlas between every terminal
// with the same font. When it fills up it merges four pages into one
// larger page and shifts the rest down, and a GPU texture is re-uploaded
// only when its page's `version` differs from the uploaded one — after a
// shift those per-page counters can coincide, so a slot keeps a stale
// page of another size and every glyph is drawn squeezed or foreign
// while the buffer stays correct (fixed upstream only in 0.20 betas).
// Any change to the page list therefore restamps every page's version
// (`restampAtlas`) synchronously, before the next draw of ANY pane on
// that atlas. `clearTextureAtlas` is no fix: it empties the SHARED atlas
// but redraws only this pane, leaving its siblings slicing reused space.
// An addon of another shape falls back to toggling the family's
// trailing space on the next frame, which makes each renderer re-acquire
// its atlas (as onFontLoaded does).
let atlasFrame = 0;
const rebuildAtlas = () => {
if (gpu && restampAtlas(gpu)) return true;
if (atlasFrame) return false;
// Never inside the render that is rasterizing the merging glyph.
atlasFrame = requestAnimationFrame(() => {
atlasFrame = 0;
const family = term.options.fontFamily ?? "";
term.options.fontFamily = family.endsWith(" ") ? family.trimEnd() : `${family} `;
});
return false;
};
const atlasMerged = gpu?.onRemoveTextureAtlasCanvas(rebuildAtlas);
const atlasGrew = gpu?.onAddTextureAtlasCanvas(rebuildAtlas);
// The browser may also drop the textures without a context-loss event
// (sleep/resume, a long-backgrounded tab): re-upload when the page is
// back, and repaint, since nothing else asks this pane to draw.
const onPageShown = () => {
if (document.visibilityState === "visible" && rebuildAtlas()) {
term.refresh(0, term.rows - 1);
}
};
document.addEventListener("visibilitychange", onPageShown);
window.addEventListener("focus", onPageShown);
// Settings → Appearance changed the terminal's font or size: an open
// pane takes it now, re-measured and re-fitted like a font landing.
const onFontPref = () => {
@@ -779,6 +884,11 @@ export const TerminalView = memo(function TerminalView({
searchRef.current = null;
resizeObserver.disconnect();
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
if (atlasFrame) cancelAnimationFrame(atlasFrame);
atlasMerged?.dispose();
atlasGrew?.dispose();
document.removeEventListener("visibilitychange", onPageShown);
window.removeEventListener("focus", onPageShown);
window.removeEventListener(FONT_SIZE_EVENT, onFontPref);
term.textarea?.removeEventListener("focus", reassert);
detachIme();
@@ -217,6 +217,17 @@ describe("the artifact view", () => {
cleanup();
});
it("shows a page as a modal over the screen, closed by Esc", async () => {
const onClose = vi.fn();
render(<FilePreview path="/w/page.html" scope={scope} onClose={onClose} modal />);
const dialog = await screen.findByRole("dialog", { name: "page.html" });
expect(dialog.parentElement?.parentElement).toBe(document.body);
expect(await screen.findByTestId("artifact-frame")).toBeInTheDocument();
expect(screen.queryByTestId("artifact-expand")).toBeNull();
fireEvent.keyDown(screen.getByTestId("artifact-tab-code"), { key: "Escape" });
expect(onClose).toHaveBeenCalled();
});
it("steps back to an older version the conversation wrote", async () => {
html(["<p>two</p>"]);
const tool = (id: string, input: Record<string, unknown>, name = "Write") => ({
@@ -21,6 +21,7 @@ import {
screen,
waitFor,
} from "@testing-library/react";
import { Terminal } from "@xterm/xterm";
import { TerminalView, altArrowSeq, loadXterm } from "../TerminalView";
import { pasteToLane, pasteToRun } from "../../../lib/panePaste";
import { __resetNativePaste, nativePasteWorks } from "../../../lib/nativePaste";
@@ -63,6 +64,9 @@ const termState = {
};
const onResizeHandlers: Array<() => void> = [];
const termOptions: { theme?: unknown } = {};
const refreshMock = vi.fn();
/** The atlas the next WebGL addon draws from; null = an addon of another shape. */
const nextAtlas: { pages: { version: number }[] | null } = { pages: null };
const ctorArgs: Array<{ theme?: unknown }> = [];
vi.mock("@xterm/xterm", () => ({
@@ -92,6 +96,7 @@ vi.mock("@xterm/xterm", () => ({
return { dispose: vi.fn() };
},
dispose: disposeMock,
refresh: refreshMock,
loadAddon: loadAddonMock,
attachCustomWheelEventHandler: wheelHandlerMock,
attachCustomKeyEventHandler: keyHandlerMock,
@@ -112,21 +117,44 @@ vi.mock("@xterm/addon-fit", () => ({
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
}));
// The newest WebGL addon, with the context-loss listener the view gave it.
const gpu: { last: { dispose: ReturnType<typeof vi.fn>; lose: () => void } | null } = {
const gpu: {
last: {
dispose: ReturnType<typeof vi.fn>;
lose: () => void;
merge: () => void;
grow: () => void;
} | null;
} = {
last: null,
};
vi.mock("@xterm/addon-webgl", () => ({
WebglAddon: vi.fn().mockImplementation(() => {
let onLoss = () => {};
let onMerge = () => {};
let onGrow = () => {};
const addon = {
__webgl: true,
...(nextAtlas.pages ? { _renderer: { _charAtlas: { pages: nextAtlas.pages } } } : {}),
dispose: vi.fn(),
onContextLoss: (fn: () => void) => {
onLoss = fn;
return { dispose: vi.fn() };
},
onRemoveTextureAtlasCanvas: (fn: () => void) => {
onMerge = fn;
return { dispose: vi.fn() };
},
onAddTextureAtlasCanvas: (fn: () => void) => {
onGrow = fn;
return { dispose: vi.fn() };
},
};
gpu.last = {
dispose: addon.dispose,
lose: () => onLoss(),
merge: () => onMerge(),
grow: () => onGrow(),
};
gpu.last = { dispose: addon.dispose, lose: () => onLoss() };
return addon;
}),
}));
@@ -299,13 +327,36 @@ describe("TerminalView", () => {
expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(false);
});
it("lets the wheel through once the pane's program tracks the mouse", () => {
// tmux with `mouse on` sets this; the wheel must reach it as a real SGR
// report, so returning false here would kill scrolling entirely.
/** Gives the mocked terminal an on-screen box: 10px per column and per row. */
const screenBox = () => {
const term = vi.mocked(Terminal).mock.results[vi.mocked(Terminal).mock.results.length - 1]!
.value as { element: HTMLElement };
const screen = document.createElement("div");
screen.className = "xterm-screen";
screen.getBoundingClientRect = () =>
({ left: 0, top: 0, width: termState.cols * 10, height: termState.rows * 10 }) as DOMRect;
term.element.appendChild(screen);
};
it("sends a tracked wheel itself, one report per LINES_PER_REPORT rows, at the pointer", () => {
// tmux with `mouse on` sets this. xterm would send ONE report per event and
// damp a touchpad ×0.3 — a swipe crawled — so the pane sends its own.
termState.modes.mouseTrackingMode = "any";
termState.buffer.active.type = "alternate";
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(true);
screenBox();
const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1]!;
ws.sent.length = 0;
const wheel = wheelHandlerMock.mock.calls[0]![0]!;
// A notch of 6 rows: one report, at the cell under the pointer.
expect(wheel(new WheelEvent("wheel", { deltaY: -60, clientX: 45, clientY: 72 }))).toBe(false);
expect(ws.sent).toEqual(["\x1b[<64;5;8M"]);
// A touchpad: 6 steps of 25px = 15 rows, ×0.7 scroll speed = 10.5 rows =
// two reports, sent as they add up.
ws.sent.length = 0;
for (let i = 0; i < 6; i++)
wheel(new WheelEvent("wheel", { deltaY: 25, clientX: 5, clientY: 5 }));
expect(ws.sent.join("")).toBe("\x1b[<65;1;1M".repeat(2));
});
it("lets the wheel through on a normal buffer, where xterm scrolls scrollback", () => {
@@ -334,10 +385,11 @@ describe("TerminalView", () => {
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(false);
expect(wheel(new WheelEvent("wheel", { deltaY: 100 }))).toBe(false);
expect(ws.sent).toEqual(["\x1b[<64;1;1M", "\x1b[<65;1;1M"]);
// Tracked again (a tmux that does report): xterm sends it, never twice.
// Tracked again (a tmux that does report): sent once, by the tracked path.
termState.modes.mouseTrackingMode = "any";
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(true);
expect(ws.sent).toHaveLength(2);
screenBox();
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(false);
expect(ws.sent).toHaveLength(3);
});
it("keeps Shift+Tab in the terminal instead of letting focus escape", () => {
@@ -474,9 +526,7 @@ describe("TerminalView", () => {
// 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 { __webgl?: boolean }).__webgl)).toBe(
true
);
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", () => {
@@ -488,6 +538,50 @@ describe("TerminalView", () => {
expect(disposeMock).not.toHaveBeenCalled(); // the terminal itself lives on
});
it("restamps every atlas page at once when the page list changes", () => {
// addon-webgl 0.19 counts versions per page, so after a merge shifts the
// pages a texture slot can keep a stale page whose counter matches. Every
// page gets a version no slot has uploaded — in every pane on the atlas.
const pages = [{ version: 3 }, { version: 7 }, { version: 1 }];
nextAtlas.pages = pages;
const raf = vi.spyOn(window, "requestAnimationFrame");
try {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
gpu.last!.merge();
const first = pages.map((p) => p.version);
expect(new Set(first).size).toBe(3);
expect(first.every((v) => v > 7)).toBe(true);
gpu.last!.grow();
expect(pages.every((p, i) => p.version > Math.max(...first) && p.version !== first[i])).toBe(
true
);
expect(raf).not.toHaveBeenCalled(); // synchronous, before any pane draws
// The page coming back re-uploads and repaints this pane.
refreshMock.mockClear();
window.dispatchEvent(new Event("focus"));
expect(refreshMock).toHaveBeenCalledWith(0, 27);
} finally {
nextAtlas.pages = null;
raf.mockRestore();
}
});
it("re-acquires the glyph atlas on the next frame after a page merge", () => {
// An addon whose atlas this cannot reach: any option change makes each
// renderer re-acquire its atlas and re-upload every page.
const raf = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 1);
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const opts = termOptions as { fontFamily?: string };
opts.fontFamily = "JetBrains Mono";
gpu.last!.merge();
gpu.last!.merge(); // coalesced into one frame
expect(raf).toHaveBeenCalledTimes(1);
expect(opts.fontFamily).toBe("JetBrains Mono"); // never inside the render that merged
(raf.mock.calls[0]![0] as FrameRequestCallback)(0);
expect(opts.fontFamily).toBe("JetBrains Mono ");
raf.mockRestore();
});
// xterm 6 dropped its own Alt+arrow → word-move mapping; the pane keeps it.
it("sends Alt+arrow as the word-move keys xterm 5 sent", () => {
expect(altArrowSeq("ArrowLeft", false)).toBe("\x1b[1;5D");
@@ -19,15 +19,33 @@
* the next one below) — and, for
* a user with their own Linux account (S3), their own HTTPS
* logins. The host account's HTTPS logins are the shared Git credentials
* table above it, so this lists them only for a member. The private key and
* table above it, so this lists them only for a member. **Commit signing**
* (under the author) is PER WORKSPACE: one picker per workspace of the user's
* (`PUT /api/me/git/signing/lane`, "Don't sign" by default), the keys that can
* sign with Delete, a way out of the old account-wide setting and of a
* signing setup the user made themselves, and **Import a key you have** takes
* a private key file or paste (`POST /api/me/git/ssh-key/import`). The private key and
* every token are write-only: the server never returns them. A host identity
* seen by a non-admin is read-only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Cloud, Copy, GitBranch, KeyRound, Plus, RefreshCw, Server, Trash2 } from "lucide-react";
import {
Cloud,
Copy,
FileKey,
GitBranch,
KeyRound,
PenLine,
Plus,
RefreshCw,
Server,
Trash2,
Upload,
} from "lucide-react";
import { api, type GitIdentity } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { confirmDialog } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
import { toast } from "../../lib/toast";
@@ -41,32 +59,9 @@ const BTN =
"inline-flex items-center gap-1.5 rounded border border-border-light px-2.5 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:cursor-not-allowed disabled:opacity-40";
const CODECOMMIT_RE = /^git-codecommit\.([a-z0-9-]+)\.amazonaws\.com$/;
// The regions AWS CodeCommit runs in.
const AWS_REGIONS = [
"ap-southeast-1",
"ap-southeast-2",
"ap-southeast-3",
"ap-northeast-1",
"ap-northeast-2",
"ap-northeast-3",
"ap-south-1",
"ap-east-1",
"us-east-1",
"us-east-2",
"us-west-1",
"us-west-2",
"ca-central-1",
"eu-west-1",
"eu-west-2",
"eu-west-3",
"eu-central-1",
"eu-north-1",
"eu-south-1",
"sa-east-1",
"me-south-1",
"af-south-1",
"il-central-1",
];
// The host a key ID is saved under when no URL names one: AWS takes the same
// key ID in every region, so the server writes the default login for all.
const AWS_DEFAULT_HOST = "git-codecommit.us-east-1.amazonaws.com";
function Step({ n, children }: { n: number; children: ReactNode }) {
return (
@@ -166,28 +161,25 @@ function KeyCard({
}
/**
* Step 3 of an AWS key: the SSH key ID AWS shows for it, in one region. The
* default key's login is the region's plain host; a named key's is saved
* under an account name (its own name unless another is typed), tied to it.
* Step 3 of an AWS key: the SSH key ID AWS shows for it — nothing else. The
* default key's login is saved for every region; a named key's under its own
* name, tied to it. A clone of any CodeCommit URL tries each saved login.
*/
function AwsLoginForm({
busy,
named,
onSave,
}: {
busy: boolean;
/** The named key's name; undefined for the default key. */
named?: string;
onSave: (v: { keyId: string; region: string; alias: string }) => Promise<boolean>;
onSave: (v: { keyId: string }) => Promise<boolean>;
}) {
const { t } = useTranslation("settings");
const [v, setV] = useState({ keyId: "", region: "ap-southeast-1", alias: "" });
const [v, setV] = useState({ keyId: "" });
return (
<form
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_auto]"
className="mt-1 flex gap-2"
onSubmit={(e) => {
e.preventDefault();
void onSave(v).then((ok) => ok && setV({ ...v, keyId: "", alias: "" }));
void onSave(v).then((ok) => ok && setV({ keyId: "" }));
}}
>
<input
@@ -198,27 +190,6 @@ function AwsLoginForm({
onChange={(e) => setV({ ...v, keyId: e.target.value })}
className={`${INPUT} font-mono`}
/>
<select
aria-label={t("gitIdentity.awsRegion")}
value={v.region}
onChange={(e) => setV({ ...v, region: e.target.value })}
className="select !py-1 !text-xs"
>
{AWS_REGIONS.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<input
aria-label={t("gitIdentity.alias")}
title={t("gitIdentity.aliasHint")}
placeholder={named ?? t("gitIdentity.aliasPlaceholder")}
autoComplete="off"
value={v.alias}
onChange={(e) => setV({ ...v, alias: e.target.value })}
className={`${INPUT} font-mono`}
/>
<button type="submit" className={BTN} disabled={busy || !v.keyId.trim()}>
{t("gitIdentity.awsSave")}
</button>
@@ -263,6 +234,339 @@ function GitHostForm({
);
}
/**
* Commit signing, PER WORKSPACE: one picker per workspace of the user's
* (`PUT /api/me/git/signing/lane` — "Don't sign" by default, so only the
* workspaces that need it sign), the keys that can sign, each with Delete,
* and Import a key the user already has. The old account-wide setting
* (every repo signed) only shows when it is on, with a button that turns it
* off; a signing setup the user made themselves in `~/.gitconfig` can be
* taken out (their key file stays).
*/
function SigningCard({
id,
keys,
keyLabel,
busy,
run,
}: {
id: GitIdentity;
keys: Key[];
keyLabel: (keyId: string | null | undefined) => string;
busy: boolean;
run: (fn: () => Promise<GitIdentity>, done?: string) => Promise<boolean>;
}) {
const { t } = useTranslation("settings");
const auth = useAuth().state;
const me = auth.mode === "server" ? auth.user : null;
const signing = id.signing ?? { state: "off" as const, key: null, path: null };
const laneSigning = id.laneSigning ?? [];
const [form, setForm] = useState<{ text: string; name: string } | null>(null);
const [lanes, setLanes] = useState<Lane[] | null>(null);
useEffect(() => {
let live = true;
api.lanes
.list()
.then((r) => live && setLanes(r.lanes))
.catch(() => live && setLanes([]));
return () => {
live = false;
};
}, []);
// The user's OWN workspaces: an admin sees everyone's, but signs only theirs.
const mine = (lanes ?? []).filter((l) => !me || l.owner_id == null || l.owner_id === me.id);
const laneName = (l: Lane) => l.title || l.cwd;
// Still signing, but its workspace is gone from the list (removed): the
// folder keeps signing until it is turned off here.
const orphans =
lanes === null
? []
: laneSigning
.filter((x) => !lanes.some((l) => l.id === x.laneId))
.map(
(x) =>
({
id: x.laneId,
title: t("gitIdentity.signLaneGone", { id: x.laneId }),
cwd: "",
}) as Lane
);
const signedWith = (laneId: number) => laneSigning.find((x) => x.laneId === laneId);
const chooseLane = async (lane: Lane, keyId: string | null) =>
run(
() => api.gitIdentity.setLaneSigning(lane.id, keyId),
keyId
? t("gitIdentity.signLaneSaved", { name: laneName(lane), key: keyLabel(keyId) })
: t("gitIdentity.signLaneStopped", { name: laneName(lane) })
);
const clearForeign = async () => {
if (
!(await confirmDialog({
title: t("gitIdentity.signForeignConfirm", { path: signing.path }),
confirmLabel: t("gitIdentity.signForeignRemove"),
}))
)
return;
await run(
() => api.gitIdentity.setSigning(null, false, true),
t("gitIdentity.signForeignRemoved")
);
};
const deleteKey = async (k: Key) => {
if (
!(await confirmDialog({
title: t("gitIdentity.signKeyDeleteConfirm", { key: keyLabel(k.id) }),
confirmLabel: t("gitIdentity.remove"),
}))
)
return;
await run(
() => api.gitIdentity.removeKeyById(k.id!),
t("gitIdentity.signKeyDeleted", { key: keyLabel(k.id) })
);
};
const doImport = async () => {
if (!form) return;
const ok = await run(
() =>
api.gitIdentity.importKey({
privateKey: form.text,
...(form.name.trim() ? { name: form.name.trim() } : {}),
}),
t("gitIdentity.imported", { name: form.name.trim() || "signing" })
);
if (ok) setForm(null);
};
const readFile = async (file: File | undefined) => {
if (!file || !form) return;
// A key file is a few KB; anything far larger is not one.
if (file.size > 32 * 1024) return toast.error(t("gitIdentity.importPaste"));
const text = await file.text();
setForm({ ...form, text });
};
const usable = keys.filter((k) => k.id);
return (
<section
className="space-y-2 rounded-lg border border-border p-3"
aria-label={t("gitIdentity.signTitle")}
>
<div className="flex items-center gap-2">
<PenLine className="h-4 w-4 text-fg-muted" aria-hidden />
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
{t("gitIdentity.signTitle")}
</h3>
{id.writable && !form && (
<button
type="button"
className={BTN}
disabled={busy}
onClick={() => setForm({ text: "", name: "" })}
>
<Upload className="h-3.5 w-3.5" />
{t("gitIdentity.importKey")}
</button>
)}
</div>
<p className="text-[11px] text-fg-muted">{t("gitIdentity.signIntro")}</p>
{signing.state === "on" && (
<div
data-testid="git-signing-legacy"
className="flex flex-wrap items-center gap-2 rounded border border-status-warning/40 bg-status-warning/10 px-2.5 py-1.5"
>
<span className="min-w-0 flex-1 text-[11px] text-fg-primary">
{t("gitIdentity.signLegacy", { key: keyLabel(signing.key) })}
</span>
{id.writable && (
<button
type="button"
className={BTN}
disabled={busy}
onClick={() =>
void run(() => api.gitIdentity.setSigning(null), t("gitIdentity.signLegacyStopped"))
}
>
{t("gitIdentity.signLegacyOff")}
</button>
)}
</div>
)}
{signing.state === "foreign" && (
<div
data-testid="git-signing-foreign"
className="flex flex-wrap items-center gap-2 rounded border border-border px-2.5 py-1.5"
>
<span className="min-w-0 flex-1 text-[11px] text-fg-secondary [overflow-wrap:anywhere]">
{t("gitIdentity.signForeign", { path: signing.path })}
</span>
{id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
onClick={() => void clearForeign()}
>
<Trash2 className="h-3.5 w-3.5" />
{t("gitIdentity.signForeignRemove")}
</button>
)}
</div>
)}
<div className="space-y-1" data-testid="git-signing-lanes">
<div className="text-[11px] font-medium text-fg-secondary">
{t("gitIdentity.signLanes")}
</div>
{usable.length === 0 ? (
<p className="text-[11px] text-fg-muted">{t("gitIdentity.signNeedKey")}</p>
) : lanes === null ? null : mine.length + orphans.length === 0 ? (
<p className="text-[11px] text-fg-muted">{t("gitIdentity.signLanesEmpty")}</p>
) : (
[...mine, ...orphans].map((l) => {
const on = signedWith(l.id);
return (
<div key={l.id} className="flex items-center gap-2">
<span
className={`min-w-0 flex-1 truncate text-xs ${
on ? "text-status-success" : "text-fg-primary"
}`}
title={l.cwd || undefined}
>
{laneName(l)}
</span>
<select
aria-label={t("gitIdentity.signLaneKey", { name: laneName(l) })}
value={on?.key ?? ""}
disabled={busy || !id.writable}
onChange={(e) => void chooseLane(l, e.target.value || null)}
className="select !w-48 !py-1 !text-xs"
>
<option value="">{t("gitIdentity.signNone")}</option>
{usable.map((k) => (
<option key={k.id} value={k.id}>
{keyLabel(k.id)}
</option>
))}
</select>
</div>
);
})
)}
</div>
{usable.length > 0 && (
<div className="space-y-1" data-testid="git-signing-keys">
<div className="text-[11px] font-medium text-fg-secondary">
{t("gitIdentity.signKeys")}
</div>
{usable.map((k) => {
const used = laneSigning.filter((x) => x.key === k.id).length;
return (
<div
key={k.id}
className="flex items-center gap-2 rounded border border-border px-2 py-1"
>
<FileKey className="h-3.5 w-3.5 shrink-0 text-fg-muted" aria-hidden />
<div className="min-w-0 flex-1">
<div className="truncate text-xs text-fg-primary">{keyLabel(k.id)}</div>
<div className="truncate font-mono text-[10px] text-fg-muted">
{k.fingerprint}
{used > 0 ? ` · ${t("gitIdentity.signKeyUsed", { count: used })}` : ""}
</div>
</div>
<button
type="button"
className={BTN}
onClick={() => void copyText(k.publicKey)}
aria-label={t("gitIdentity.signKeyCopy", { key: keyLabel(k.id) })}
title={t("gitIdentity.signHostStep")}
>
<Copy className="h-3.5 w-3.5" />
</button>
{id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
aria-label={t("gitIdentity.removeKeyNamed", { name: keyLabel(k.id) })}
onClick={() => void deleteKey(k)}
>
<Trash2 className="h-3.5 w-3.5" />
{t("gitIdentity.remove")}
</button>
)}
</div>
);
})}
<p className="text-[10px] text-fg-muted">{t("gitIdentity.signHostStep")}</p>
</div>
)}
{form && (
<form
className="space-y-2 rounded border border-border p-2.5"
aria-label={t("gitIdentity.importTitle")}
onSubmit={(e) => {
e.preventDefault();
void doImport();
}}
>
<div className="text-xs font-medium text-fg-primary">{t("gitIdentity.importTitle")}</div>
<label className={`${BTN} cursor-pointer`}>
<FileKey className="h-3.5 w-3.5" />
{t("gitIdentity.importFile")}
<input
type="file"
className="sr-only"
onChange={(e) => void readFile(e.target.files?.[0])}
/>
</label>
<textarea
aria-label={t("gitIdentity.importPaste")}
placeholder={t("gitIdentity.importPaste")}
value={form.text}
spellCheck={false}
autoComplete="off"
rows={4}
onChange={(e) => setForm({ ...form, text: e.target.value })}
className={`${INPUT} resize-y font-mono text-[10px]`}
/>
<input
aria-label={t("gitIdentity.importName")}
placeholder={t("gitIdentity.importName")}
value={form.name}
autoComplete="off"
onChange={(e) => setForm({ ...form, name: e.target.value.toLowerCase() })}
className={`${INPUT} !w-40 font-mono`}
/>
<p className="text-[10px] text-fg-muted">{t("gitIdentity.importNote")}</p>
<div className="flex gap-2">
<button
type="submit"
disabled={busy || !form.text.trim()}
className="rounded border border-accent/40 bg-accent/15 px-3 py-1 text-xs text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
>
{t("gitIdentity.importSubmit")}
</button>
<button type="button" onClick={() => setForm(null)} className={BTN}>
{t("gitCreds.cancel")}
</button>
</div>
</form>
)}
</section>
);
}
export function GitIdentitySettings() {
const { t } = useTranslation("settings");
const [id, setId] = useState<GitIdentity | null>(null);
@@ -413,28 +717,41 @@ export function GitIdentitySettings() {
const namedIds = new Set(named.map((k) => k.id));
const rowsOf = <R extends { key?: string | null }>(rows: R[], k?: Key) =>
k ? rows.filter((r) => r.key === k.id) : rows.filter((r) => !r.key || !namedIds.has(r.key));
const saveAws = (v: { keyId: string; region: string; alias: string }, k?: Key) => {
const saveAws = (v: { keyId: string }, k?: Key) => {
let user = v.keyId.trim();
let host = `git-codecommit.${v.region}.amazonaws.com`;
// A pasted `ssh://APKA…@git-codecommit.<region>…/…` URL fills both.
let host = AWS_DEFAULT_HOST;
// A pasted `ssh://APKA…@git-codecommit.<region>…/…` URL: its key ID. The
// region does not matter — the key ID logs in to every one.
const m = /^ssh:\/\/([^@/]+)@([^/]+)/i.exec(user);
if (m?.[1] && m[2]) {
user = m[1];
host = m[2].toLowerCase();
if (CODECOMMIT_RE.test(m[2].toLowerCase())) host = m[2].toLowerCase();
}
// A named key's account goes by the key's name unless another is typed.
const alias = v.alias.trim().toLowerCase() || (k?.name ?? "");
return run(
() =>
api.gitIdentity.setSshUser({
host,
user,
...(alias ? { alias } : {}),
...(k?.id ? { key: k.id } : {}),
}),
t("gitIdentity.awsSaved", { region: alias || (CODECOMMIT_RE.exec(host)?.[1] ?? host) })
k?.name
? // A named key (another AWS account): its login goes by the key's
// name; a clone of any CodeCommit URL tries it.
api.gitIdentity.setSshUser({ host, user, alias: k.name, key: k.id! })
: api.gitIdentity.setSshUser({ host, user, allRegions: true }),
k?.name ? t("gitIdentity.awsSavedNamed", { name: k.name }) : t("gitIdentity.awsSavedAll")
);
};
// The default key's logins, one row per key ID: "every region" once the
// server's whole list is covered, else the regions saved (an older save) —
// with a button that extends it to every region.
const regionCount = id?.codecommitRegions?.length ?? 0;
const awsDefaultGroups = (rows: typeof awsRows) => {
const out: { user: string; key: string | null; hosts: string[]; regions: string[] }[] = [];
for (const r of rows.filter((x) => !x.alias)) {
const g = out.find((o) => o.user === r.user && o.key === (r.key ?? null));
if (g) {
g.hosts.push(r.host);
g.regions.push(r.region);
} else out.push({ user: r.user, key: r.key ?? null, hosts: [r.host], regions: [r.region] });
}
return out;
};
const saveGitHost = (host: string, k: Key) => {
const h = /^(?:ssh:\/\/)?(?:[^@/]+@)?([^/:]+)/i.exec(host)?.[1]?.toLowerCase() ?? host;
return run(
@@ -606,6 +923,10 @@ export function GitIdentitySettings() {
)}
</form>
{id.signing !== undefined && (
<SigningCard id={id} keys={keys} keyLabel={keyLabel} busy={busy} run={run} />
)}
<div className="space-y-3 rounded-lg border border-border p-3">
<div className="flex items-center gap-2">
<KeyRound className="h-4 w-4 text-fg-muted" aria-hidden />
@@ -650,6 +971,11 @@ export function GitIdentitySettings() {
onRemove={() => removeNamed(k.id!)}
>
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
{id.signing?.state === "on" && id.signing.key === k.id && (
<div className="text-[10px] text-status-success">
{t("gitIdentity.signingBadge")}
</div>
)}
<Step n={3}>
{t("gitIdentity.namedGitStepHost", { name: k.name })}
{loginRows(rows, (r) => `ssh://${r.alias}/ → ${r.user}@${r.host}`)}
@@ -689,14 +1015,66 @@ export function GitIdentitySettings() {
{id.sshUsers === undefined ? null : (
<>
{loginRows(
rows,
(r) =>
`${r.alias ? `${r.alias} · ` : ""}${r.region} · ${r.user} · ${keyLabel(r.key)}`
rows.filter((r) => !!r.alias),
(r) => `${r.alias} · ${r.user} · ${keyLabel(r.key)}`
)}
{awsDefaultGroups(rows).map((g) => {
const all = regionCount > 0 && g.regions.length >= regionCount;
return (
<div
key={`${g.user}\0${g.key ?? ""}`}
className="mt-1 flex items-center gap-2"
>
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
{all ? t("gitIdentity.awsAllRegions") : g.regions.join(", ")}
{` · ${g.user} · ${keyLabel(g.key)}`}
</span>
{id.writable && !all && regionCount > 0 && (
<button
type="button"
className={BTN}
disabled={busy}
onClick={() =>
void run(
() =>
api.gitIdentity.setSshUser({
host: g.hosts[0]!,
user: g.user,
allRegions: true,
}),
t("gitIdentity.awsSavedAll")
)
}
>
{t("gitIdentity.awsUseAll")}
</button>
)}
{id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
aria-label={`${t("gitIdentity.remove")} ${g.user}`}
onClick={() =>
void run(() =>
g.hosts.length > 1
? api.gitIdentity.removeSshUser(g.hosts[0]!, {
allRegions: true,
user: g.user,
})
: api.gitIdentity.removeSshUser(g.hosts[0]!)
)
}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
);
})}
{id.writable && (
<AwsLoginForm
busy={busy}
named={isNamed ? k!.name! : undefined}
onSave={(v) => saveAws(v, isNamed ? k : undefined)}
/>
)}
@@ -0,0 +1,53 @@
/**
* @file MemberPolicySettings — Settings → AI tools → Tests on this server:
* the admin's server-wide switch that allows or blocks members' Claude from
* running test runners and container commands on this box
* (`GET/PUT /api/ai-tools/member-policy`, `server/lib/member-policy.js`,
* enforced by `scripts/member-guard.js`). Renders nothing outside server mode
* with per-user accounts (S3), or for anyone but an admin.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type MemberPolicyState } from "../../lib/api";
import { toast } from "../../lib/toast";
import { usePermissions } from "../AuthGate";
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
export function MemberPolicySettings() {
const { t } = useTranslation("settings");
const { isAdmin, local } = usePermissions();
const [state, setState] = useState<MemberPolicyState | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
if (local || !isAdmin) return;
api.aiTools
.memberPolicy()
.then(setState)
.catch(() => setState(null));
}, [local, isAdmin]);
if (local || !isAdmin || !state?.available) return null;
return (
<SettingsSection id="member-policy" title={t("memberPolicy.title")}>
<SettingsSwitchRow
label={t("memberPolicy.switch")}
description={t("memberPolicy.hint")}
checked={state.allowTests}
disabled={busy}
onChange={async (on) => {
setBusy(true);
try {
setState(await api.aiTools.setMemberPolicy(on));
} catch (err) {
toast.error(err);
} finally {
setBusy(false);
}
}}
/>
</SettingsSection>
);
}
@@ -262,11 +262,11 @@ function LabelEdit({
function useRunner(load: () => Promise<unknown>) {
const [busy, setBusy] = useState<string | null>(null);
const run = useCallback(
async (key: string, fn: () => Promise<unknown>, success: string) => {
async <T,>(key: string, fn: () => Promise<T>, success: string | ((r: T) => string)) => {
setBusy(key);
try {
await fn();
toast.success(success);
const r = await fn();
toast.success(typeof success === "function" ? success(r) : success);
await load();
} catch (err: unknown) {
toast.error(err);
@@ -399,7 +399,14 @@ function OtherProfiles({
void run(
`switch:${p.name}`,
() => api.toolProfiles.activate(tool, p.name),
t("toolProfiles.switchSuccess", { name: p.label, tool: label })
(r) =>
t(
r.following ? "toolProfiles.switchFollowing" : "toolProfiles.switchSuccess",
{
name: p.label,
tool: label,
}
)
)
}
className={btnCls}
@@ -800,9 +807,7 @@ function ProfileUsageLine({
/** Org / seat / plan line shared by the active card and each profile row. */
function IdentityLine({ identity }: { identity: CcAccountIdentity }) {
const parts = [identity.organization, identity.plan || identity.subscriptionType].filter(
Boolean
);
const parts = [identity.organization, identity.plan || identity.subscriptionType].filter(Boolean);
if (parts.length === 0) return null;
return <div className="text-[10px] text-fg-muted mt-0.5">{parts.join(" · ")}</div>;
}
@@ -7,7 +7,11 @@
* other host under "Other SSH hosts" — each kind of host in its own tab, so
* the sections never mix; "Add another key" makes a named key as one more
* card in its own tab, a second AWS account is saved from that card under
* the key's name with that key, and a second GitHub key is tied to its host.
* the key's name with that key, and a second GitHub key is tied to its host;
* commit signing PER WORKSPACE: an imported key signs nothing by itself, one
* workspace's picker signs that workspace only, a signing key is deleted from
* the card after a confirm, and the old account-wide setting and a setup the
* user made themselves each have a way out.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -31,6 +35,18 @@ vi.mock("../../../lib/api", () => ({
saveHttps: vi.fn(),
saveAuthor: vi.fn(),
removeHttps: vi.fn(),
importKey: vi.fn(),
setSigning: vi.fn(),
setLaneSigning: vi.fn(),
},
lanes: {
list: vi.fn(async () => ({
lanes: [
{ id: 1, title: "shop-api", cwd: "/w/shop-api" },
{ id: 2, title: "", cwd: "/w/docs" },
],
counts: {},
})),
},
},
}));
@@ -38,6 +54,7 @@ vi.mock("../../../lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn()
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(() => Promise.resolve(true)) }));
vi.mock("../../../lib/clipboard", () => ({ copyText: vi.fn(() => Promise.resolve(true)) }));
const OFF = { state: "off" as const, key: null, path: null };
const KEY = { publicKey: "ssh-ed25519 AAAAC3Nz an@agenthub", fingerprint: "SHA256:abc" };
const view = (over: Partial<GitIdentity> = {}): GitIdentity => ({
account: "member",
@@ -139,27 +156,64 @@ describe("GitIdentitySettings", () => {
await waitFor(() => expect(api.gitIdentity.removeKey).toHaveBeenCalledWith("rsa"));
});
it("saves the CodeCommit SSH key ID for a region and shows the plain clone", async () => {
it("saves the CodeCommit SSH key ID for every region — no region to pick", async () => {
const RSA = { type: "rsa" as const, publicKey: "ssh-rsa AAAAB3 x", fingerprint: "SHA256:r" };
const H = "git-codecommit.eu-west-1.amazonaws.com";
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ sshKeys: [RSA], sshUsers: [] }));
const REGIONS = ["us-east-1", "eu-west-1", "ap-northeast-1"];
const host = (r: string) => `git-codecommit.${r}.amazonaws.com`;
vi.mocked(api.gitIdentity.get).mockResolvedValue(
view({ sshKeys: [RSA], sshUsers: [], codecommitRegions: REGIONS })
);
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
view({ sshKeys: [RSA], sshUsers: [{ host: H, user: "APKA1" }] })
view({
sshKeys: [RSA],
sshUsers: REGIONS.map((r) => ({ host: host(r), user: "APKA1" })),
codecommitRegions: REGIONS,
})
);
vi.mocked(api.gitIdentity.removeSshUser).mockResolvedValue(
view({ sshKeys: [RSA], sshUsers: [] })
view({ sshKeys: [RSA], sshUsers: [], codecommitRegions: REGIONS })
);
render(<GitIdentitySettings />);
const aws = await screen.findByRole("region", { name: "AWS CodeCommit" });
expect(within(aws).queryByLabelText("Region")).toBeNull();
await userEvent.type(within(aws).getByLabelText("SSH key ID"), " APKA1 ");
await userEvent.selectOptions(within(aws).getByLabelText("Region"), "eu-west-1");
await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({ host: H, user: "APKA1" });
expect(await within(aws).findByText("eu-west-1 · APKA1 · Any key")).toBeInTheDocument();
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
host: "git-codecommit.us-east-1.amazonaws.com",
user: "APKA1",
allRegions: true,
});
// One row for the key ID, not one per region.
expect(await within(aws).findByText("Every region · APKA1 · Any key")).toBeInTheDocument();
// A CodeCommit host never shows up again under "other hosts".
expect(screen.queryByText(`APKA1@${H}`)).toBeNull();
await userEvent.click(within(aws).getByRole("button", { name: `Delete ${H}` }));
expect(api.gitIdentity.removeSshUser).toHaveBeenCalledWith(H);
expect(screen.queryByText(`APKA1@${host("eu-west-1")}`)).toBeNull();
await userEvent.click(within(aws).getByRole("button", { name: "Delete APKA1" }));
expect(api.gitIdentity.removeSshUser).toHaveBeenCalledWith(host("us-east-1"), {
allRegions: true,
user: "APKA1",
});
});
it("offers an older one-region CodeCommit login for every region", async () => {
const RSA = { type: "rsa" as const, publicKey: "ssh-rsa AAAAB3 x", fingerprint: "SHA256:r" };
const H = "git-codecommit.ap-northeast-1.amazonaws.com";
vi.mocked(api.gitIdentity.get).mockResolvedValue(
view({
sshKeys: [RSA],
sshUsers: [{ host: H, user: "APKA9" }],
codecommitRegions: ["us-east-1", "ap-northeast-1"],
})
);
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(view({ sshKeys: [RSA] }));
render(<GitIdentitySettings />);
const aws = await screen.findByRole("region", { name: "AWS CodeCommit" });
expect(await within(aws).findByText("ap-northeast-1 · APKA9 · Any key")).toBeInTheDocument();
await userEvent.click(within(aws).getByRole("button", { name: "Use in every region" }));
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
host: H,
user: "APKA9",
allRegions: true,
});
});
it("saves a user for any other SSH host under the advanced section", async () => {
@@ -247,18 +301,16 @@ describe("GitIdentitySettings", () => {
// Another key can be added below it.
expect(screen.getByRole("button", { name: "Add another key" })).toBeInTheDocument();
// Its own step 3: the account name defaults to the key's name.
// Its own step 3: only the key ID; the login goes by the key's name.
await userEvent.type(within(prod).getByLabelText("SSH key ID"), "APKA2");
await userEvent.click(within(prod).getByRole("button", { name: "Save" }));
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
host: H,
host: "git-codecommit.us-east-1.amazonaws.com",
user: "APKA2",
alias: "aws-2",
key: "id_rsa_aws-2",
});
expect(
await within(prod).findByText("aws-2 · ap-southeast-1 · APKA2 · aws-2")
).toBeInTheDocument();
expect(await within(prod).findByText("aws-2 · APKA2 · aws-2")).toBeInTheDocument();
// The default key's card does not list the named key's login.
const def = screen.getByRole("region", { name: "AWS CodeCommit" });
expect(within(def).queryByText(/APKA2/)).toBeNull();
@@ -298,8 +350,132 @@ describe("GitIdentitySettings", () => {
alias: "git-2",
key: "id_ed25519_git-2",
});
expect(await within(work).findByText("ssh://git-2/ → git@github.com")).toBeInTheDocument();
});
it("imports a key without signing anything by itself", async () => {
const signingKey = {
...KEY,
id: "id_ed25519_signing",
type: "ed25519" as const,
name: "signing",
};
vi.mocked(api.gitIdentity.get).mockResolvedValue(
view({ sshKeys: [], signing: { state: "off", key: null, path: null }, laneSigning: [] })
);
vi.mocked(api.gitIdentity.importKey).mockResolvedValue({
...view({ sshKeys: [signingKey], signing: OFF, laneSigning: [] }),
imported: signingKey,
signError: null,
});
render(<GitIdentitySettings />);
expect(await screen.findByText(/Import a key you have, or make one/)).toBeInTheDocument();
await userEvent.click(await screen.findByRole("button", { name: "Import a key you have" }));
const form = screen.getByRole("form", { name: "Import an existing private key" });
await userEvent.type(
within(form).getByLabelText(/paste the private key/),
"-----BEGIN OPENSSH PRIVATE KEY-----x"
);
await userEvent.click(within(form).getByRole("button", { name: "Import" }));
expect(api.gitIdentity.importKey).toHaveBeenCalledWith({
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----x",
});
// Now there is a key: each workspace gets its own picker, all "Don't sign".
expect(
await within(work).findByText("ssh://git-2/ → git@github.com")
).toBeInTheDocument();
await screen.findByLabelText("Signing key for shop-api", {}, { timeout: 5000 })
).toHaveValue("");
expect(screen.getByLabelText("Signing key for /w/docs")).toHaveValue("");
expect(api.gitIdentity.setSigning).not.toHaveBeenCalled();
});
it("signs one workspace only, and stops it", async () => {
const k = { ...KEY, id: "id_ed25519_signing", type: "ed25519" as const, name: "signing" };
vi.mocked(api.gitIdentity.get).mockResolvedValue(
view({ sshKeys: [k], signing: { state: "off", key: null, path: null }, laneSigning: [] })
);
vi.mocked(api.gitIdentity.setLaneSigning)
.mockResolvedValueOnce(
view({ sshKeys: [k], signing: OFF, laneSigning: [{ laneId: 1, key: k.id }] })
)
.mockResolvedValueOnce(view({ sshKeys: [k], signing: OFF, laneSigning: [] }));
render(<GitIdentitySettings />);
const pick = await screen.findByLabelText("Signing key for shop-api", {}, { timeout: 5000 });
await userEvent.selectOptions(pick, k.id);
expect(api.gitIdentity.setLaneSigning).toHaveBeenCalledWith(1, k.id);
await waitFor(() =>
expect(screen.getByLabelText("Signing key for shop-api")).toHaveValue(k.id)
);
expect(screen.getByLabelText("Signing key for /w/docs")).toHaveValue("");
expect(
within(screen.getByTestId("git-signing-keys")).getByText(/signs 1 workspace/)
).toBeTruthy();
await userEvent.selectOptions(screen.getByLabelText("Signing key for shop-api"), "");
expect(api.gitIdentity.setLaneSigning).toHaveBeenLastCalledWith(1, null);
expect(api.gitIdentity.setSigning).not.toHaveBeenCalled();
});
it("lists a removed workspace that still signs, so it can be turned off", async () => {
const k = { ...KEY, id: "id_ed25519_signing", type: "ed25519" as const, name: "signing" };
vi.mocked(api.gitIdentity.get).mockResolvedValue(
view({ sshKeys: [k], signing: OFF, laneSigning: [{ laneId: 9, key: k.id }] })
);
vi.mocked(api.gitIdentity.setLaneSigning).mockResolvedValue(
view({ sshKeys: [k], signing: OFF, laneSigning: [] })
);
render(<GitIdentitySettings />);
const pick = await screen.findByLabelText(
"Signing key for Workspace #9 (removed)",
{},
{ timeout: 5000 }
);
expect(pick).toHaveValue(k.id);
await userEvent.selectOptions(pick, "");
expect(api.gitIdentity.setLaneSigning).toHaveBeenCalledWith(9, null);
await waitFor(() =>
expect(screen.queryByLabelText("Signing key for Workspace #9 (removed)")).toBeNull()
);
});
it("deletes a signing key from the signing card, after asking", async () => {
const k = { ...KEY, id: "id_ed25519_signing", type: "ed25519" as const, name: "signing" };
vi.mocked(api.gitIdentity.get).mockResolvedValue(
view({
sshKeys: [k],
signing: { state: "off", key: null, path: null },
laneSigning: [{ laneId: 1, key: k.id }],
})
);
vi.mocked(api.gitIdentity.removeKeyById).mockResolvedValue(
view({ sshKeys: [], signing: OFF, laneSigning: [] })
);
render(<GitIdentitySettings />);
const keys = await screen.findByTestId("git-signing-keys", {}, { timeout: 5000 });
await userEvent.click(within(keys).getByRole("button", { name: "Delete key signing" }));
expect(confirmDialog).toHaveBeenCalledWith(
expect.objectContaining({ title: expect.stringMatching(/every workspace signing with it/) })
);
expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith(k.id);
await waitFor(() => expect(screen.queryByTestId("git-signing-keys")).toBeNull());
});
it("turns off the old account-wide signing and removes a setup the user made", async () => {
const k = { ...KEY, id: "id_ed25519", type: "ed25519" as const, name: null };
vi.mocked(api.gitIdentity.get).mockResolvedValue(
view({ sshKeys: [k], signing: { state: "on", key: "id_ed25519", path: null } })
);
vi.mocked(api.gitIdentity.setSigning).mockResolvedValue(
view({ sshKeys: [k], signing: { state: "foreign", key: null, path: "~/.ssh/mine" } })
);
render(<GitIdentitySettings />);
const legacy = await screen.findByTestId("git-signing-legacy");
await userEvent.click(within(legacy).getByRole("button", { name: "Sign per workspace only" }));
expect(api.gitIdentity.setSigning).toHaveBeenCalledWith(null);
const foreign = await screen.findByTestId("git-signing-foreign");
expect(foreign).toHaveTextContent("~/.ssh/mine");
await userEvent.click(within(foreign).getByRole("button", { name: "Remove" }));
expect(confirmDialog).toHaveBeenCalledWith(
expect.objectContaining({ title: expect.stringMatching(/key file itself is not deleted/) })
);
expect(api.gitIdentity.setSigning).toHaveBeenLastCalledWith(null, false, true);
});
});
@@ -0,0 +1,57 @@
/**
* @file Tests for MemberPolicySettings — the admin's "allow members to run
* tests" switch in Settings → AI tools: hidden in local mode, for a non-admin
* and outside S3, and a toggle saves through PUT /api/ai-tools/member-policy.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import "../../../i18n";
import { MemberPolicySettings } from "../MemberPolicySettings";
import { api } from "../../../lib/api";
const perms = vi.hoisted(() => ({ isAdmin: true, local: false }));
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
vi.mock("../../../lib/api", () => ({
api: { aiTools: { memberPolicy: vi.fn(), setMemberPolicy: vi.fn() } },
}));
const get = vi.mocked(api.aiTools.memberPolicy);
const set = vi.mocked(api.aiTools.setMemberPolicy);
describe("MemberPolicySettings", () => {
beforeEach(() => {
vi.clearAllMocks();
perms.isAdmin = true;
perms.local = false;
});
it("lets an admin allow tests, off by default", async () => {
get.mockResolvedValue({ available: true, allowTests: false });
set.mockResolvedValue({ available: true, allowTests: true });
render(<MemberPolicySettings />);
const sw = await screen.findByRole("switch", { name: "Allow members to run tests" });
expect(sw).toHaveAttribute("aria-checked", "false");
fireEvent.click(sw);
await waitFor(() => expect(set).toHaveBeenCalledWith(true));
await waitFor(() => expect(sw).toHaveAttribute("aria-checked", "true"));
});
it("hides outside S3", async () => {
get.mockResolvedValue({ available: false, allowTests: false });
const { container } = render(<MemberPolicySettings />);
await waitFor(() => expect(get).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
});
it("asks nothing for a non-admin or in local mode", () => {
perms.isAdmin = false;
const { container } = render(<MemberPolicySettings />);
expect(container).toBeEmptyDOMElement();
perms.isAdmin = true;
perms.local = true;
render(<MemberPolicySettings />);
expect(get).not.toHaveBeenCalled();
});
});
+9 -1
View File
@@ -13,6 +13,7 @@ import { useTranslation } from "react-i18next";
import { Search } from "lucide-react";
import type { AuthUser, ServerUserUsage } from "../../lib/api";
import { Checkbox } from "../Checkbox";
import { UserAvatar } from "./UserAvatar";
export const ROLES: AuthUser["role"][] = ["admin", "developer", "analyst"];
/** A role, every user, or the users in no pool group. */
@@ -168,7 +169,14 @@ export function MemberPicker({
checked={chosen.has(u.id)}
onChange={(on) => setMany([u.id], on)}
label={
<span>
<span className="inline-flex items-center gap-1.5">
<UserAvatar
name={u.displayName || u.username}
userId={u.id}
avatarAt={u.avatarAt}
letters={1}
className="h-5 w-5 text-[10px]"
/>
{u.username}
{u.disabled && <span className="ml-1 text-fg-muted">({t("disabled")})</span>}
{note?.(u)}
+8 -6
View File
@@ -36,6 +36,7 @@ import { toast } from "../../lib/toast";
import { ConfirmModal } from "../ConfirmModal";
import { AiToolIcon } from "../AiToolIcon";
import { MemberPicker } from "./MemberPicker";
import { UserAvatar } from "./UserAvatar";
/** Pool tools with their labels, in display order. */
export const POOL_TOOL_LABELS: { id: PoolTool; label: string }[] = [
@@ -363,12 +364,13 @@ export function PoolGroups({
} ${isDragged(drag) ? "opacity-40" : ""}`}
data-testid="pool-member"
>
<span
className="flex h-4 w-4 items-center justify-center rounded-full bg-accent/15 text-[9px] font-semibold uppercase text-accent"
aria-hidden
>
{name.charAt(0)}
</span>
<UserAvatar
name={name}
userId={id}
avatarAt={u?.avatarAt}
letters={1}
className="h-4 w-4 text-[9px]"
/>
{name}
{u && <span className="text-[10px] text-fg-muted">{t(`roles.${u.role}`)}</span>}
{isAdmin && from && (
+5 -3
View File
@@ -2,9 +2,11 @@
* @file UserAvatar.tsx
* @description An account's round picture: the signed-in account's own
* (`GET /api/me/avatar`) or, with `userId`, another's (`GET
* /api/users/:id/avatar`, staff only), versioned by `avatarAt`; without one —
* /api/users/:id/avatar`, any signed-in account), versioned by `avatarAt`; without one —
* or when the image fails to load — the first letters of its name. Used by the
* sidebar's account row, its workspace owner groups and Settings → Account.
* sidebar's account row, its workspace owner groups, Settings → Account, the
* Users page and its editor, the pool board and member picker, and System
* Health's per-user CPU/RAM card.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -21,7 +23,7 @@ export function UserAvatar({
}: {
name: string;
avatarAt?: string | null;
/** Another account's (staff only); omitted = the signed-in account's own. */
/** Another account's; omitted = the signed-in account's own. */
userId?: number;
/** How many letters of the name stand in for a missing picture. */
letters?: number;
+22 -7
View File
@@ -2,7 +2,8 @@
* @file UserEditDialog.tsx
* @description The Users page's ONE account editor (admin only): profile
* (display name, email), access (role, active/disabled — neither for your own
* account), a new password (typed or generated, copyable — someone else's
* account — and, for an admin, whether they own the host's Linux account, so
* its sessions outside every workspace are billed to them), a new password (typed or generated, copyable — someone else's
* may be anything and makes them choose their own at next sign-in; your own
* must pass every rule of `lib/passwordRules.ts`), the workspaces the
* account owns (remove, add from every workspace), its spend quota (the
@@ -33,6 +34,7 @@ import { copyText } from "../../lib/clipboard";
import { isSubmitEnter } from "../../lib/keys";
import { passwordProblems } from "../../lib/passwordRules";
import { PasswordRules } from "./PasswordRules";
import { UserAvatar } from "./UserAvatar";
import { useDialog } from "../../hooks/useDialog";
import { Switch } from "../Switch";
import { Checkbox } from "../Checkbox";
@@ -86,6 +88,7 @@ export function UserEditDialog({
const [email, setEmail] = useState(user.email ?? "");
const [role, setRole] = useState<Role>(user.role);
const [active, setActive] = useState(!user.disabled);
const [hostAccount, setHostAccount] = useState(Boolean(user.hostAccount));
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [owned, setOwned] = useState<OwnedWorkspace[] | null>(null);
@@ -138,6 +141,7 @@ export function UserEditDialog({
if (norm(email) !== (user.email ?? null)) patch.email = norm(email);
if (!self && role !== user.role) patch.role = role;
if (!self && active === user.disabled) patch.disabled = !active;
if (role === "admin" && hostAccount !== Boolean(user.hostAccount)) patch.hostAccount = hostAccount;
if (password) patch.password = password;
if (added.length) patch.addWorkspaces = added;
if (removed.size) patch.removeWorkspaces = [...removed];
@@ -243,12 +247,12 @@ export function UserEditDialog({
className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-xl"
>
<div className="flex items-start gap-3 border-b border-border px-5 py-4">
<span
aria-hidden
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-accent/15 text-sm font-semibold uppercase text-accent"
>
{shown.slice(0, 2)}
</span>
<UserAvatar
name={shown}
userId={user.id}
avatarAt={user.avatarAt}
className="h-10 w-10 text-sm"
/>
<div className="min-w-0 flex-1">
<h2 id="user-edit-title" className="text-base font-semibold text-fg-primary">
{t("edit.title", { name: user.displayName || user.username })}
@@ -363,6 +367,17 @@ export function UserEditDialog({
)}
</div>
</div>
{role === "admin" && (
<div className="mt-3">
<Checkbox
checked={hostAccount}
onChange={setHostAccount}
label={t("edit.hostAccount")}
labelClassName="text-sm text-fg-secondary"
/>
<p className="ml-6 mt-0.5 text-[11px] text-fg-muted">{t("edit.hostAccountHint")}</p>
</div>
)}
</Section>
<Section title={t("edit.password")}>
@@ -33,7 +33,7 @@ const accountPool = vi.hoisted(() => ({
updateGroup: vi.fn(),
deleteGroup: vi.fn(),
}));
const users = vi.hoisted(() => ({ list: vi.fn() }));
const users = vi.hoisted(() => ({ list: vi.fn(), avatarUrl: () => null }));
const aiTools = vi.hoisted(() => ({
list: vi.fn(),
ompSharing: vi.fn(),
@@ -13,7 +13,12 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ServerUserUsage } from "../../../lib/api";
const users = vi.hoisted(() => ({ get: vi.fn(), update: vi.fn(), remove: vi.fn() }));
const users = vi.hoisted(() => ({
get: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
avatarUrl: () => null,
}));
const confirmDialog = vi.hoisted(() => vi.fn());
vi.mock("../../../lib/confirm", () => ({ confirmDialog }));
const lanes = vi.hoisted(() => ({ list: vi.fn() }));
@@ -16,6 +16,7 @@ const users = vi.hoisted(() => ({
setQuotaDefault: vi.fn(),
update: vi.fn(),
get: vi.fn(),
avatarUrl: () => null,
}));
const lanes = vi.hoisted(() => ({ list: vi.fn() }));
vi.mock("../../../lib/api", async (importOriginal) => {
+3
View File
@@ -118,6 +118,9 @@ export const i18nReady = i18n
"admin",
"onboarding",
"artifacts",
"gitRefs",
"gitTools",
"gitHistory",
],
defaultNS: "common",
interpolation: { escapeValue: false },

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