diff --git a/.changeset/active-hunk-staging.md b/.changeset/active-hunk-staging.md new file mode 100644 index 000000000..fa6aaf7d1 --- /dev/null +++ b/.changeset/active-hunk-staging.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Stage or unstage the active diff hunk with Space or a code-line double-click, preserving other hunks and worktree content. diff --git a/.changeset/calm-bikes-march.md b/.changeset/calm-bikes-march.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/calm-bikes-march.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/complete-note-input.md b/.changeset/complete-note-input.md new file mode 100644 index 000000000..3de192262 --- /dev/null +++ b/.changeset/complete-note-input.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Preserve the final characters of a note when save follows immediately, and let queued edits settle before cancellation. diff --git a/.changeset/enter-esc-pane-focus.md b/.changeset/enter-esc-pane-focus.md new file mode 100644 index 000000000..7b218708d --- /dev/null +++ b/.changeset/enter-esc-pane-focus.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Use Enter to focus the selected file’s review from the files pane and Escape to return to the files pane on the current file. diff --git a/.changeset/exact-line-editor.md b/.changeset/exact-line-editor.md new file mode 100644 index 000000000..d019e1dd6 --- /dev/null +++ b/.changeset/exact-line-editor.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Open the external editor at the selected source line or the active hunk’s first change, mapping staged lines through later unstaged edits and refusing text-converted coordinates or unknown editor line syntax. diff --git a/.changeset/fast-dialog-answers.md b/.changeset/fast-dialog-answers.md new file mode 100644 index 000000000..ff6c3de2a --- /dev/null +++ b/.changeset/fast-dialog-answers.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Preserve the latest extension dialog input and selection when Enter follows immediately. diff --git a/.changeset/fast-discard-targets.md b/.changeset/fast-discard-targets.md new file mode 100644 index 000000000..0d8a4eaf8 --- /dev/null +++ b/.changeset/fast-discard-targets.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Honor file navigation immediately before discard or stash shortcuts while preserving the exact selected target. diff --git a/.changeset/focused-pane-indication.md b/.changeset/focused-pane-indication.md new file mode 100644 index 000000000..54bcb1894 --- /dev/null +++ b/.changeset/focused-pane-indication.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Highlight the focused pane with a brighter border, and move ↑/↓ and j/k through the file list while the files pane is focused. diff --git a/.changeset/folder-file-pane-actions.md b/.changeset/folder-file-pane-actions.md new file mode 100644 index 000000000..ab0243eb5 --- /dev/null +++ b/.changeset/folder-file-pane-actions.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Make files-pane folders selectable so staging, discard, and stash apply to their nested files in tree view or their group’s files in the compact list. diff --git a/.changeset/folder-select-keeps-stats.md b/.changeset/folder-select-keeps-stats.md new file mode 100644 index 000000000..48faa2fc3 --- /dev/null +++ b/.changeset/folder-select-keeps-stats.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Keep files-pane line counts visible on both working-tree tabs and keep folder selection on the current tab when any nested file belongs there. diff --git a/.changeset/git-status-file-colors.md b/.changeset/git-status-file-colors.md new file mode 100644 index 000000000..6ccc12ee7 --- /dev/null +++ b/.changeset/git-status-file-colors.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Show Git-style colored status columns in the file panel, with red untracked markers, green staged additions, and a full-width selected-row highlight. diff --git a/.changeset/hot-comics-battle.md b/.changeset/hot-comics-battle.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/hot-comics-battle.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/initial-row-window.md b/.changeset/initial-row-window.md new file mode 100644 index 000000000..eb7ef9277 --- /dev/null +++ b/.changeset/initial-row-window.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Keep large unwrapped diffs responsive from the first frame by windowing rows before the terminal viewport is measured. diff --git a/.changeset/little-clouds-rush.md b/.changeset/little-clouds-rush.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/little-clouds-rush.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/live-staging-targets.md b/.changeset/live-staging-targets.md new file mode 100644 index 000000000..84c85489d --- /dev/null +++ b/.changeset/live-staging-targets.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Apply staging to the latest file, folder, or hunk selected in an input burst and prevent extension writes from overlapping Git mutations. diff --git a/.changeset/micro-editor.md b/.changeset/micro-editor.md new file mode 100644 index 000000000..a2687d919 --- /dev/null +++ b/.changeset/micro-editor.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Support opening the selected source line in micro with the e shortcut when EDITOR is set to micro. diff --git a/.changeset/newer-note-hover.md b/.changeset/newer-note-hover.md new file mode 100644 index 000000000..2eed88253 --- /dev/null +++ b/.changeset/newer-note-hover.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Keep add-note controls visible when a mouse move follows navigation before the viewport update settles. diff --git a/.changeset/open-donuts-allow.md b/.changeset/open-donuts-allow.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/open-donuts-allow.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/pty-frame-probes.md b/.changeset/pty-frame-probes.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/pty-frame-probes.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/quiet-file-quick-actions.md b/.changeset/quiet-file-quick-actions.md new file mode 100644 index 000000000..5a40a4800 --- /dev/null +++ b/.changeset/quiet-file-quick-actions.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Keep stash and discard shortcuts scoped to the visible, focused file panel so they do not override built-in review shortcuts elsewhere. diff --git a/.changeset/quiet-jj-diagnostics.md b/.changeset/quiet-jj-diagnostics.md new file mode 100644 index 000000000..1f4eb2163 --- /dev/null +++ b/.changeset/quiet-jj-diagnostics.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Include the repository path when Jujutsu source reads fail to start or collect output. diff --git a/.changeset/rebase-integration.md b/.changeset/rebase-integration.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/rebase-integration.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/review-chord-conflicts.md b/.changeset/review-chord-conflicts.md new file mode 100644 index 000000000..2ca950227 --- /dev/null +++ b/.changeset/review-chord-conflicts.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Stop help and menus from advertising keys that a working-tree command currently owns, leave file quick actions after review-stream clicks, and keep Space on the file action when hunk staging is not available. diff --git a/.changeset/selected-file-discard-stash.md b/.changeset/selected-file-discard-stash.md new file mode 100644 index 000000000..f1bd73116 --- /dev/null +++ b/.changeset/selected-file-discard-stash.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Discard selected-file changes with a scoped confirmation, or stash only the selected file with a message while preserving its partial staging and unrelated work. diff --git a/.changeset/smooth-comics-fail.md b/.changeset/smooth-comics-fail.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/smooth-comics-fail.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/status-aware-staging.md b/.changeset/status-aware-staging.md new file mode 100644 index 000000000..01cd84b0a --- /dev/null +++ b/.changeset/status-aware-staging.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Add status-aware working-tree navigation with staged/unstaged stream tabs and file staging through Space or double-click. diff --git a/.changeset/sunny-worlds-design.md b/.changeset/sunny-worlds-design.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/sunny-worlds-design.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/upstream-working-tree-contract.md b/.changeset/upstream-working-tree-contract.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/upstream-working-tree-contract.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/docs/extension-architecture.md b/docs/extension-architecture.md index 28880bf0d..79d40eb73 100644 --- a/docs/extension-architecture.md +++ b/docs/extension-architecture.md @@ -318,7 +318,7 @@ key runs it, so the mouse surfaces read from it rather than restating it: the dropdown menus (`packages/hunk/src/ui/lib/appMenus.ts`) declare items as command ids plus menu-specific wording and checkbox state, and the controls help dialog (`packages/hunk/src/ui/lib/helpContent.ts`) declares curated rows the same way — both render -their key text from resolved `keyLabels` and run entries through +their key text from the chords first-match dispatch still delivers and run entries through `executeAppCommand`. A few commands ship with `defaultKeys: []` because they exist for a menu item; they never match a key but remain bindable by id. @@ -337,6 +337,25 @@ none — which is why the visible menu list is derived from the menus record ## VCS adapters +Plain working-tree reviews additionally carry an optional provider-owned status inventory beside +the active diff changeset. `packages/hunk-git/src/workingTree.ts` derives both +index/worktree status and attestations, and implements the public file-staging methods. +`packages/hunk-git/src/hunkStaging.ts` selects attested raw Git hunks for +index-only writes; sanitized render text is never applied. The conversion boundary preserves these +optional methods and normalizes their errors. `packages/hunk/src/ui/hooks/useWorkingTreeActions.ts` +binds status paths to the existing review navigation and AppHost's tracked mutation/serialized +refresh lifecycle, excluding extension writes while a Git action is active. The bundled sidebar consumes `ExtensionPaneProps.workingTree` through the public +pane contract; inactive-side files never become fabricated review-document entries. Stream tabs +replace the active canonical diff, not the sidebar inventory. Browser/session exports remain +read-only with respect to Git state. The same leased mutation controller owns exact-file +discard/stash prompts; keyboard routing remains in the App keyboard hook and mouse choices reuse +ConfirmDialog. Git builds file-only stash trees through an isolated index and publishes a normal +stash before cleaning the selected live paths. Cleanup failure retains that stash and reports +partial completion. The terminal cursor adapter distinguishes deliberate source selection from +automatic viewport rows; editor fallback consumes the shared first-change target. Git's optional +read-only line resolver validates source coordinates and maps staged addresses through later +worktree edits, while App retains editor launch and refresh ownership. + `packages/hunk/src/core/vcs/index.ts` owns provider-neutral catalog ordering, lookup, detection, and operation dispatch. `packages/hunk/src/app/vcsCatalog.ts` composes bundled registrations, while `packages/hunk/src/app/sessionBootstrap.ts` extends that catalog with diff --git a/docs/extensions.md b/docs/extensions.md index da974f9e4..1a3f05648 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -527,6 +527,60 @@ reuses one is skipped with a notice. map off entirely — produces a clear "not supported" error for that command instead of a crash. +Plain working-tree `load` results may return `workingTreeFiles`, an ordered inventory covering both +staged and unstaged files, independent of `input.staged`. Each entry supplies `path`, optional +`previousPath`, `staged`, `unstaged`, `untracked`, `conflicted`, an opaque `version` attestation, and +optionally an `unavailableReason`. Do not return this inventory for revision comparisons. + +API v25 adds optional `statusCode`, a two-character Git-style index/worktree status (`??` for +untracked files). The sidebar colors these columns independently and retains `S`/`U` indicators +for providers that omit the code. This display field does not replace mutation attestations. + +API v25 adds optional inventory `stats` (`additions` / `deletions`) covering that path's staged +and unstaged line counts. The files pane uses these when the current Unstaged/Staged stream does +not include the file, so +/- counts stay visible on both tabs. + +The working-tree operation may implement `stageFile(input, file, ctx)` and +`unstageFile(input, file, ctx)`. Revalidate the supplied attestation and exact path before writing; +unstaging must leave worktree contents unchanged. Throw `HunkExtensionUserError` on refusal or +failure. Hunk tracks started mutations through shutdown, blocks repeated actions until refresh, +and reloads authoritative state even after a failed mutation; extension workspace writes are refused while that Git action is active. + +API v25 also adds optional `stageHunk(input, file, hunk, ctx)` and +`unstageHunk(input, file, hunk, ctx)`. `hunk` is the existing `ExtensionDiffHunk` summary. +Validate its numbered identity against the canonical provider patch after checking the file +attestation, including its comparison base. Apply original patch bytes, never decoded or +text-converted source. Preserve all other hunks and worktree content; reject unsupported textual changes. +Hunk actions use the currently reviewed side rather than the file's combined staged status. + +API v25 adds optional `discardFile(input, file, scope, ctx)` and +`stashFile(input, file, message, ctx)`. Discard scope is `"all"` or `"unstaged"`; +the host obtains exact-file confirmation before calling either operation. Unstaged-only discard +preserves the index and is offered when both status sides have changes. Stash receives the entered +message (possibly empty) and must preserve the selected file's partial staging in the stash while +excluding unrelated changes from every stash tree and from live cleanup. Revalidate the attestation +before writes, retain a published stash if cleanup fails, and report partial completion explicitly. +These optional operations remain absent for read-only providers and revision comparisons. + +API v25 adds optional `stashFiles(input, files, message, ctx)` for one stash covering several +attested paths. The host confirms a selected folder before calling it. Preserve each file's partial +staging and exclude unrelated changes, the same way `stashFile` does for one path. + +API v25 adds optional `resolveWorkingTreeLine(input, file, line, ctx): Promise`. +This read-only operation validates a new-side source line and maps staged addresses into the actual +worktree before an external editor is launched. Validate the reviewed file's source attestation, account for later +unstaged edits, and refuse unmappable source transforms rather than guessing. Hunk drops a result +when its review lease has expired. This capability does not launch an editor or grant remote writes. + +Mounted panes receive optional `props.workingTree` with this inventory, `selectedPath`, +`selectedEntryId`, `staged`, `busy`, `selectFile(path)`, `selectEntry(id)`, `toggleStaged(path)`, +and `toggleEntry(id)`. These controls expire on review reload. +Selecting an inactive-side file switches stream tabs without reducing the review to one file. +File toggling stages remaining unstaged changes first, otherwise unstages the file. Folder rows use +`selectEntry` / `toggleEntry` and apply to the files shown under that row. Existing +`props.files` and review navigation still describe only the active diff stream. These host-only +actions are not exposed through browser/session commands or `ctx.commands`. + API version 19 adds the optional, read-only `history` capability used by the built-in `hunk log` surface: ```ts @@ -1878,7 +1932,7 @@ path. `canWriteDocument` checks the review and file policy without prompting or inspecting the filesystem. A later `writeDocument` can still refuse if the file -has moved or become unsafe. +has moved or become unsafe, or a Git mutation owns the host write boundary. `writeDocument` verifies the target, asks for consent through the attributed `ctx.dialogs` queue, then verifies it again before writing. The second check @@ -1890,7 +1944,7 @@ reconciliation of the review then active, and the write promise may settle before that reload finishes. A declined prompt returns `cancelled`, an ineligible or unsafe target returns -`unavailable`, and an attempted write failure returns `failed` with a +`unavailable` (also returned while a Git action is active or Hunk is shutting down), and an attempted write failure returns `failed` with a displayable `detail`. Malformed requests reject the promise. ### `hunk.transformChangeset(fn)` diff --git a/docs/keybindings.md b/docs/keybindings.md index b255b73a4..6a1b1ca0d 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -60,6 +60,44 @@ Quitting the opened review returns to the retained history selection and viewpor **Compare with first parent** and **Compare with parent…** actions compare the selected commit against an ordered provider-owned parent; they do not navigate the history selection to that parent. +In plain working-tree reviews, **Space** stages the selected file's remaining unstaged changes, +or unstages it when fully staged. Folder rows in the files pane are selectable too: Space +and the clickable action beside the stream tabs apply to the files shown under that folder — nested files +in the wide tree projection, or only the files listed under that header in the compact grouped projection. +Clicking a wide-tree folder selects it and collapses or expands it; double-clicking never stages a folder. +If any of those files still have unstaged changes, Space stages them; otherwise it unstages the fully staged ones. +File-row double-click and the clickable action beside the stream tabs do the same for a selected file. +The sidebar shows Git-style status columns: green index changes and red worktree changes, +including partially staged files. Untracked files show red `??`; staged additions show green `A` +and a green filename. The selected file or folder has a full-row highlight. A successful file action follows the file to the other tab. +Clicking a code line or navigating with `[` / `]` selects **hunk** action scope: Space then stages +that unstaged hunk, or unstages that staged hunk, without changing other hunks or disk contents. +Double-clicking a code line applies its hunk on release, provided the pointer did not move. Hunk actions keep the current stream tab; +use Tab to review the other side. Clicking a sidebar file or file header, or using `,` / `.` returns to file scope. +The action beside the tabs names the current scope. Drag-to-copy remains available; hunks without an available staging action and non-working-tree +reviews retain word/line selection on repeated clicks. Binary, text-converted, and metadata-only changes use file actions. +**e** opens `$EDITOR` at a deliberately clicked, stepped-to, or revealed source line. Otherwise it +uses the active hunk's first changed line, not its leading context. From the staged view, Git maps +that index line through further unstaged insertions, replacements, and deletions to the current +working-tree location. Missing files, stale source state, and text-converted lines produce +a notice instead of a guessed location. Supported line-jump syntax covers vi/vim/nvim, +code/code-insiders/cursor, hx, and micro; unknown editor syntax is refused instead of opening at file start. +**d** opens discard choices for the selected file or folder: Enter or **x** discards all its changes, +**u** discards only unstaged changes when both sides have changes, and Escape cancels. +**s** opens a selected-file or selected-folder stash message input; Enter stashes and Escape cancels. The stash +contains only those files' changes, including each staged/unstaged split, not unrelated staged files. +These actions require a selected actionable file or folder in the visible, focused file panel. They reject stale targets and +renames whose former path has been recreated. Stashing requires an initial commit. While reviewing a hunk, +with the file panel hidden, or outside an actionable working-tree review, **d** retains half-page scrolling and **s** toggles the files pane; +**Ctrl+d** and the View menu remain available in working-tree reviews. +**Tab** switches the complete Unstaged/Staged review stream; selecting a sidebar file that only +has changes on the other side switches automatically. Selecting a folder stays on the current +side when any file under it belongs there. The files pane keeps +/- counts on every status row, +including files that only have changes on the other tab. Use `/` to focus the filter. +Actions wait for Git and its refreshed diff before accepting another mutation. Outside this +context, Space still pages and Tab retains its files/filter focus behavior. All these commands +remain remappable, and an explicit user binding takes precedence over contextual defaults. + | Command id | Does | Default keys | | ---------------------------------------------- | ---------------------------------------------- | ---------------------------- | | `hunk.app.openAgentSkill` | Show agent skill | _(none)_ | @@ -70,8 +108,11 @@ an ordered provider-owned parent; they do not navigate the history selection to | `hunk.review.alignCurrentLineBottom` | Align current line to viewport bottom | _(none)_ | | `hunk.review.alignCurrentLineCenter` | Center current line in viewport | _(none)_ | | `hunk.review.alignCurrentLineTop` | Align current line to viewport top | _(none)_ | +| `hunk.review.discardSelectedFile` | Discard selected file or folder changes | `d` | | `hunk.review.editActiveNote` | Edit the active review note | `E` | | `hunk.review.editSelectedFile` | Open the selected file in your editor | `e` | +| `hunk.review.focusDiffPane` | Focus the selected file's review | `enter` | +| `hunk.review.focusFilesPane` | Focus the files pane | `escape` | | `hunk.review.focusFilter` | Focus the file filter | `/` | | `hunk.review.halfPageDown` | Scroll down half a page | `d`, `ctrl+d` | | `hunk.review.halfPageUp` | Scroll up half a page | `u`, `ctrl+u` | @@ -91,9 +132,13 @@ an ordered provider-owned parent; they do not navigate the history selection to | `hunk.review.scrollCodeLeft` | Scroll code left (shifted scrolls fast) | `left`, `shift+left` | | `hunk.review.scrollCodeRight` | Scroll code right (shifted scrolls fast) | `right`, `shift+right` | | `hunk.review.startNote` | Add a review note | `c` | -| `hunk.review.stepDown` | Scroll down one row | `down`, `j` | -| `hunk.review.stepUp` | Scroll up one row | `up`, `k` | +| `hunk.review.stashSelectedFile` | Stash selected file or folder | `s` | +| `hunk.review.stepDown` | Move down in the focused pane | `down`, `j` | +| `hunk.review.stepUp` | Move up in the focused pane | `up`, `k` | +| `hunk.review.toggleFileStaged` | Stage / unstage selected file or folder | `space` | | `hunk.review.toggleHunkGap` | Expand or collapse the selected context | `z` | +| `hunk.review.toggleHunkStaged` | Stage / unstage selected hunk | `space` | +| `hunk.review.toggleStagedView` | Switch unstaged / staged stream | `tab` | | `hunk.view.applyFilePresentationToAllMatching` | Apply current file presentation to all matches | _(none)_ | | `hunk.view.cursorLineNumber` | Mark the current line number | _(none)_ | | `hunk.view.cursorLineOff` | Hide the current-line marker | _(none)_ | @@ -125,7 +170,10 @@ entry. The menus and the controls help dialog (`?`) show the keys for the commands they present, so remapping something changes what they advertise. Unbinding a menu -command keeps its menu item and simply stops showing a key. +command keeps its menu item and simply stops showing a key. When two enabled +commands share a chord, only the first-match owner advertises it; remaining +aliases stay visible (`f` still pages, `Ctrl+d` still half-pages, `/` still +focuses the filter). Extension commands are named `.` and remap the same way (see [docs/extensions.md](extensions.md)). An explicitly activated extension diff --git a/docs/themes.md b/docs/themes.md index e953491ac..6232ca751 100644 --- a/docs/themes.md +++ b/docs/themes.md @@ -72,6 +72,11 @@ Custom themes appear in the selector after the built-in themes, in the order you declare them. A repo `.hunk/config.toml` overrides a user config table by table for the same id. +The file panel uses `accentMuted` for the selected row, `badgeAdded` for staged status +and filenames, and `badgeRemoved` for unstaged status. Selected note counts use `fileModified`. +Built-in themes keep these status +colors readable on both normal and selected rows; keep that contrast when overriding them. + ## Syntax scopes `syntax_scopes` uses [Shiki/TextMate scope selectors](https://shiki.style/guide/themes#token-colors) diff --git a/packages/hunk-git/src/commands.test.ts b/packages/hunk-git/src/commands.test.ts index 8cc906be7..5d6a22d52 100644 --- a/packages/hunk-git/src/commands.test.ts +++ b/packages/hunk-git/src/commands.test.ts @@ -556,12 +556,28 @@ describe("git diff stats helpers", () => { ]); }); + test("parseGitNumstat records rename and copy counts under the destination path", () => { + const text = ["3\t1\tsrc/a.ts", "1\t1\t", "alpha.txt", "renamed.txt", "2\t0\tsrc/c.ts"].join( + "\0", + ); + expect(parseGitNumstat(text)).toEqual([ + { path: "src/a.ts", additions: 3, deletions: 1 }, + { path: "renamed.txt", additions: 1, deletions: 1 }, + { path: "src/c.ts", additions: 2, deletions: 0 }, + ]); + }); + test("parseGitNumstat drops binary-file entries that report '-' counts", () => { // Git emits `-\t-\t` for binary files; the non-numeric counts fail the finite guard. const text = ["-\t-\tsrc/logo.png", "3\t1\tsrc/a.ts"].join("\0"); expect(parseGitNumstat(text)).toEqual([{ path: "src/a.ts", additions: 3, deletions: 1 }]); }); + test("parseGitNumstat drops a binary rename without consuming the next file", () => { + const text = ["-\t-\t", "old.bin", "new.bin", "3\t1\tsrc/a.ts"].join("\0"); + expect(parseGitNumstat(text)).toEqual([{ path: "src/a.ts", additions: 3, deletions: 1 }]); + }); + test("parseGitNumstat returns nothing for empty output", () => { expect(parseGitNumstat("")).toEqual([]); }); diff --git a/packages/hunk-git/src/commands.ts b/packages/hunk-git/src/commands.ts index 10625ba89..70c5388ba 100644 --- a/packages/hunk-git/src/commands.ts +++ b/packages/hunk-git/src/commands.ts @@ -37,6 +37,7 @@ export interface RunGitTextOptions { interface RunGitCommandResult { stderr: string; stdout: string; + stdoutBytes: Buffer; exitCode: number; } @@ -50,7 +51,7 @@ export interface GitColorMovedOptions { } /** Append Git pathspec arguments only when the caller requested them. */ -function appendGitPathspecs(args: string[], pathspecs?: string[]) { +export function appendGitPathspecs(args: string[], pathspecs?: string[]) { if (!pathspecs || pathspecs.length === 0) { return; } @@ -208,25 +209,40 @@ export interface GitNumstatFile { deletions: number; } -/** Parse `git diff --numstat -z` output for normal path entries. */ +/** + * Parse `git diff --numstat -z` output. + * Rename and copy records use an empty path field followed by NUL-separated + * old and new paths; counts are keyed by the destination path that status + * inventory rows use. + */ export function parseGitNumstat(text: string): GitNumstatFile[] { - return text - .split("\0") - .filter(Boolean) - .flatMap((entry) => { - const [additionsText, deletionsText, path] = entry.split("\t"); - if (!additionsText || !deletionsText || !path) { - return []; - } + const files: GitNumstatFile[] = []; + const parts = text.split("\0"); + for (let i = 0; i < parts.length; i += 1) { + const entry = parts[i]!; + if (!entry) continue; + + const [additionsText, deletionsText, pathField] = entry.split("\t"); + if (!additionsText || !deletionsText) continue; + + let path = pathField; + // Empty path field is the -z rename/copy triplet, not a dropped record. + if (pathField === "") { + const previousPath = parts[i + 1]; + const nextPath = parts[i + 2]; + i += 2; + if (!previousPath || !nextPath) continue; + path = nextPath; + } + if (!path) continue; - const additions = Number.parseInt(additionsText, 10); - const deletions = Number.parseInt(deletionsText, 10); - if (!Number.isFinite(additions) || !Number.isFinite(deletions)) { - return []; - } + const additions = Number.parseInt(additionsText, 10); + const deletions = Number.parseInt(deletionsText, 10); + if (!Number.isFinite(additions) || !Number.isFinite(deletions)) continue; - return [{ path, additions, deletions }]; - }); + files.push({ path, additions, deletions }); + } + return files; } /** Return whether tracked diff stats are too large to render by default. */ @@ -492,7 +508,8 @@ function runGitCommand({ throw translateGitSpawnFailure(input, error, gitExecutable); } - const stdout = Buffer.from(proc.stdout ?? []).toString("utf8"); + const stdoutBytes = Buffer.from(proc.stdout ?? []); + const stdout = stdoutBytes.toString("utf8"); const stderr = Buffer.from(proc.stderr ?? []).toString("utf8"); if (!acceptedExitCodes.includes(proc.exitCode)) { @@ -505,6 +522,7 @@ function runGitCommand({ return { stderr, stdout, + stdoutBytes, exitCode: proc.exitCode, }; } @@ -523,7 +541,7 @@ async function runGitCommandAsync({ preventOptionalLocks = false, signal, acceptedExitCodes = [0], -}: RunGitCommandOptions): Promise { +}: RunGitCommandOptions): Promise> { let result: Awaited>; try { result = await runAbortableCommand([gitExecutable, ...args], { @@ -549,6 +567,43 @@ export async function runGitTextAsync(options: RunGitTextOptions): Promise; +}): Promise { + try { + const child = Bun.spawn([gitExecutable, ...args], { + cwd, + env: env ? { ...process.env, ...env } : undefined, + stdin: typeof stdin === "string" ? new TextEncoder().encode(stdin) : (stdin ?? "ignore"), + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + if (exitCode !== 0) throw translateGitExitFailure(input, stderr); + return stdout; + } catch (error) { + throw translateGitSpawnFailure(input, error, gitExecutable); + } +} + const GIT_BOOLEAN_TRUE_VALUES = new Set(["true", "yes", "on", "1", "always"]); const GIT_BOOLEAN_FALSE_VALUES = new Set(["false", "no", "off", "0", "never"]); diff --git a/packages/hunk-git/src/editorLine.test.ts b/packages/hunk-git/src/editorLine.test.ts new file mode 100644 index 000000000..34cd16136 --- /dev/null +++ b/packages/hunk-git/src/editorLine.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { rmSync, writeFileSync } from "node:fs"; +import { quote } from "shell-quote"; +import { join } from "node:path"; +import { createTestWorkingTreeRepo, runTestGit } from "../../../test/helpers/working-tree"; +import { loadGitWorkingTreeFiles } from "./workingTree"; +import { resolveGitWorkingTreeLine } from "./editorLine"; + +const roots: string[] = []; +const input = { kind: "vcs" as const, staged: true, options: {} }; +/** Build a staged source with a later independent worktree edit. */ +function fixture(worktree: (source: string) => string) { + const root = createTestWorkingTreeRepo(); + roots.push(root); + const source = Array.from({ length: 10 }, (_, index) => `line ${index + 1}\n`).join(""); + writeFileSync(join(root, "alpha.txt"), source); + runTestGit(root, "add", "alpha.txt"); + writeFileSync(join(root, "alpha.txt"), worktree(source)); + const file = loadGitWorkingTreeFiles(input, { cwd: root })!.find( + (file) => file.path === "alpha.txt", + )!; + return { root, file }; +} +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("working-tree editor line mapping", () => { + test("accounts for insertions before, but not after, the selected source line", async () => { + const { root, file } = fixture( + (source) => `before\n${source.replace("line 5\n", "line 5\nafter\n")}`, + ); + expect(await resolveGitWorkingTreeLine(input, file, 5, { cwd: root })).toBe(6); + expect(await resolveGitWorkingTreeLine(input, file, 6, { cwd: root })).toBe(8); + }); + test("maps removed and replaced lines to surviving working-tree positions", async () => { + const { root, file } = fixture((source) => + source.replace("line 2\nline 3\n", "").replace("line 7\nline 8\n", "replacement\n"), + ); + expect(await resolveGitWorkingTreeLine(input, file, 3, { cwd: root })).toBe(2); + expect(await resolveGitWorkingTreeLine(input, file, 8, { cwd: root })).toBe(5); + expect(await resolveGitWorkingTreeLine(input, file, 10, { cwd: root })).toBe(7); + }); + test.each([false, true])("refuses text-converted line numbers with staged=%s", async (staged) => { + const { root, file } = fixture((source) => source.replace("line 5", "changed line 5")); + const converter = join(root, "converter.cjs"); + writeFileSync( + converter, + 'const fs = require("node:fs"); process.stdout.write("converted header\\n" + fs.readFileSync(process.argv[2], "utf8"));', + ); + writeFileSync(join(root, ".gitattributes"), "alpha.txt diff=converted\n"); + runTestGit(root, "config", "diff.converted.textconv", quote([process.execPath, converter])); + await expect( + resolveGitWorkingTreeLine({ ...input, staged }, file, 5, { cwd: root }), + ).rejects.toThrow("text-converted"); + }); + test("keeps an ordinary unstaged source line unchanged", async () => { + const { root, file } = fixture((source) => `before\n${source}`); + expect( + await resolveGitWorkingTreeLine({ ...input, staged: false }, file, 5, { cwd: root }), + ).toBe(5); + }); + test("refuses an external change after the review snapshot", async () => { + const { root, file } = fixture((source) => source); + writeFileSync(join(root, "alpha.txt"), "later edit\n"); + await expect(resolveGitWorkingTreeLine(input, file, 5, { cwd: root })).rejects.toThrow( + "changed since", + ); + }); +}); diff --git a/packages/hunk-git/src/editorLine.ts b/packages/hunk-git/src/editorLine.ts new file mode 100644 index 000000000..d6ecbd4f8 --- /dev/null +++ b/packages/hunk-git/src/editorLine.ts @@ -0,0 +1,75 @@ +/** Validate source coordinates and map staged addresses without changing Git state. */ +import { + HunkExtensionUserError, + type ExtensionVcsDiffInput, + type ExtensionWorkingTreeFile, +} from "hunkdiff/extension"; +import { buildGitDiffArgs, runGitBytes } from "./commands"; +import { verifyGitWorkingTreeFile } from "./workingTree"; + +/** Resolve insertions, replacements, and deletions to the nearest surviving working-tree line. */ +export async function resolveGitWorkingTreeLine( + input: ExtensionVcsDiffInput, + file: ExtensionWorkingTreeFile, + line: number, + context: { cwd: string; gitExecutable?: string }, +): Promise { + const { root } = verifyGitWorkingTreeFile(input, file, context); + if (!Number.isInteger(line) || line < 1) + throw new HunkExtensionUserError("An editor location requires a positive source line."); + const base = { + input, + cwd: root, + gitExecutable: context.gitExecutable, + preventOptionalLocks: true, + }; + const args = buildGitDiffArgs({ + ...input, + pathspecs: [file.path, ...(file.previousPath ? [file.previousPath] : [])].map( + (path) => `:(literal)${path}`, + ), + }); + const rawArgs = [...args]; + rawArgs.splice(rawArgs.indexOf("--"), 0, "--no-textconv"); + if (!runGitBytes({ ...base, args }).equals(runGitBytes({ ...base, args: rawArgs }))) + throw new HunkExtensionUserError("Cannot map text-converted lines to the working-tree source."); + if (!input.staged) { + verifyGitWorkingTreeFile(input, file, context); + return line; + } + const patch = runGitBytes({ + ...base, + args: [ + "--literal-pathspecs", + "diff", + "--no-ext-diff", + "--no-textconv", + "--no-color", + "--unified=0", + "--inter-hunk-context=0", + "--", + file.path, + ], + }).toString("latin1"); + let offset = 0; + let mapped: number | undefined; + for (const match of patch.matchAll(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/gm)) { + const oldStart = Number(match[1]); + const oldCount = Number(match[2] ?? 1); + const newStart = Number(match[3]); + const newCount = Number(match[4] ?? 1); + if (oldCount === 0) { + // A zero-count old span inserts after its numbered line, not before it. + if (line <= oldStart) break; + } else { + if (line < oldStart) break; + if (line < oldStart + oldCount) { + mapped = newCount === 0 ? newStart + 1 : newStart + Math.min(line - oldStart, newCount - 1); + break; + } + } + offset += newCount - oldCount; + } + verifyGitWorkingTreeFile(input, file, context); + return Math.max(1, mapped ?? line + offset); +} diff --git a/packages/hunk-git/src/fileActions.test.ts b/packages/hunk-git/src/fileActions.test.ts new file mode 100644 index 000000000..187e98ca9 --- /dev/null +++ b/packages/hunk-git/src/fileActions.test.ts @@ -0,0 +1,232 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmodSync, existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { createTestWorkingTreeRepo, runTestGit } from "../../../test/helpers/working-tree"; +import { createGitVcsAdapter } from "./index"; +import { loadGitWorkingTreeFiles } from "./workingTree"; +import { discardGitFile, stashGitFile, stashGitFiles } from "./fileActions"; + +const roots: string[] = []; +const input = { kind: "vcs" as const, staged: false, options: {} }; +/** Build selected partial changes and unrelated staged/unstaged content. */ +function fixture() { + const root = createTestWorkingTreeRepo(); + roots.push(root); + writeFileSync(join(root, "alpha.txt"), "staged alpha\n"); + writeFileSync(join(root, "beta.txt"), "staged beta\n"); + runTestGit(root, "add", "alpha.txt", "beta.txt"); + writeFileSync(join(root, "alpha.txt"), "unstaged alpha\n"); + writeFileSync(join(root, "beta.txt"), "unstaged beta\n"); + return root; +} +/** Read the provider's current attestation for an exact test path. */ +function status(root: string, path = "alpha.txt") { + return loadGitWorkingTreeFiles(input, { cwd: root })!.find((file) => file.path === path)!; +} +/** Confirm unrelated content survives both on disk and in the index. */ +function expectUnrelated(root: string) { + expect(runTestGit(root, "show", ":beta.txt")).toBe("staged beta\n"); + expect(readFileSync(join(root, "beta.txt"), "utf8")).toBe("unstaged beta\n"); +} +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("selected-file discard and stash", () => { + test("unstaged discard retains the selected index, all discard restores only the selected file", async () => { + const root = fixture(); + await discardGitFile(input, status(root), "unstaged", { cwd: root }); + expect(readFileSync(join(root, "alpha.txt"), "utf8")).toBe("staged alpha\n"); + expect(runTestGit(root, "show", ":alpha.txt")).toBe("staged alpha\n"); + await discardGitFile(input, status(root), "all", { cwd: root }); + expect(readFileSync(join(root, "alpha.txt"), "utf8")).toBe("one\ntwo\nthree\n"); + expect(runTestGit(root, "diff", "HEAD", "--", "alpha.txt")).toBe(""); + expectUnrelated(root); + }); + + test("stash captures only selected partial states, cleans that file, and accepts literal message text", async () => { + const root = fixture(); + await stashGitFile(input, status(root), "-m literal $message", { cwd: root }); + expect(runTestGit(root, "show", "stash:alpha.txt")).toBe("unstaged alpha\n"); + expect(runTestGit(root, "show", "stash^2:alpha.txt")).toBe("staged alpha\n"); + for (const ref of ["stash", "stash^2"]) { + expect(runTestGit(root, "diff", "--name-only", "stash^1", ref).trim()).toBe("alpha.txt"); + } + expect(runTestGit(root, "log", "-1", "--format=%s", "stash")).toContain("-m literal $message"); + expect(runTestGit(root, "diff", "HEAD", "--", "alpha.txt")).toBe(""); + expectUnrelated(root); + // Native stash application expects a clean destination index; creation preserved beta above. + runTestGit(root, "restore", "--source=HEAD", "--staged", "--worktree", "--", "beta.txt"); + runTestGit(root, "stash", "apply", "--index"); + expect(runTestGit(root, "show", ":alpha.txt")).toBe("staged alpha\n"); + expect(readFileSync(join(root, "alpha.txt"), "utf8")).toBe("unstaged alpha\n"); + expect(readFileSync(join(root, "beta.txt"), "utf8")).toBe("other\n"); + }); + + test("stashes several attested paths as one stash without unrelated files", async () => { + const root = fixture(); + writeFileSync(join(root, "gamma.txt"), "new gamma\n"); + await stashGitFiles(input, [status(root), status(root, "gamma.txt")], "pair", { cwd: root }); + expect(runTestGit(root, "diff", "--name-only", "stash^1", "stash").trim()).toBe("alpha.txt"); + expect(runTestGit(root, "show", "stash:alpha.txt")).toBe("unstaged alpha\n"); + expect(runTestGit(root, "show", "stash^2:alpha.txt")).toBe("staged alpha\n"); + expect(runTestGit(root, "ls-tree", "--name-only", "stash^3").trim()).toBe("gamma.txt"); + expect(existsSync(join(root, "gamma.txt"))).toBe(false); + expect(runTestGit(root, "diff", "HEAD", "--", "alpha.txt")).toBe(""); + expectUnrelated(root); + }); + + test("stashes an exact untracked path without including another untracked file", async () => { + const root = fixture(); + writeFileSync(join(root, "[new] file.txt"), "selected\n"); + writeFileSync(join(root, "other.txt"), "unrelated\n"); + await stashGitFile(input, status(root, "[new] file.txt"), "new file", { cwd: root }); + expect(runTestGit(root, "ls-tree", "--name-only", "stash^3").trim()).toBe("[new] file.txt"); + expect(existsSync(join(root, "[new] file.txt"))).toBe(false); + expect(readFileSync(join(root, "other.txt"), "utf8")).toBe("unrelated\n"); + expectUnrelated(root); + }); + + test("discards added and untracked leaves, and restores staged deletions", async () => { + const root = fixture(); + for (const staged of [false, true]) { + writeFileSync(join(root, "new.txt"), "new\n"); + if (staged) runTestGit(root, "add", "new.txt"); + await discardGitFile(input, status(root, "new.txt"), "all", { cwd: root }); + expect(existsSync(join(root, "new.txt"))).toBe(false); + } + runTestGit(root, "rm", "--force", "alpha.txt"); + await discardGitFile(input, status(root), "all", { cwd: root }); + expect(readFileSync(join(root, "alpha.txt"), "utf8")).toBe("one\ntwo\nthree\n"); + expectUnrelated(root); + }); + + test("stash preserves a staged deletion and a staged addition", async () => { + const root = fixture(); + runTestGit(root, "rm", "--force", "alpha.txt"); + await stashGitFile(input, status(root), "deleted", { cwd: root }); + expect(runTestGit(root, "diff", "--name-status", "stash^1", "stash").trim()).toBe( + "D\talpha.txt", + ); + expect(existsSync(join(root, "alpha.txt"))).toBe(true); + writeFileSync(join(root, "new.txt"), "staged new\n"); + runTestGit(root, "add", "new.txt"); + writeFileSync(join(root, "new.txt"), "unstaged new\n"); + await stashGitFile(input, status(root, "new.txt"), "added", { cwd: root }); + expect(runTestGit(root, "show", "stash^2:new.txt")).toBe("staged new\n"); + expect(runTestGit(root, "show", "stash:new.txt")).toBe("unstaged new\n"); + expect(existsSync(join(root, "new.txt"))).toBe(false); + expectUnrelated(root); + }); + + test("rename actions retain exact identities and refuse recreated source collisions", async () => { + const root = fixture(); + runTestGit(root, "restore", "--source=HEAD", "--staged", "--worktree", "alpha.txt"); + renameSync(join(root, "alpha.txt"), join(root, "renamed.txt")); + runTestGit(root, "add", "-A", "--", "alpha.txt", "renamed.txt"); + const renamed = status(root, "renamed.txt"); + expect(renamed.previousPath).toBe("alpha.txt"); + writeFileSync(join(root, "alpha.txt"), "recreated\n"); + for (const action of [ + () => discardGitFile(input, status(root, "renamed.txt"), "all", { cwd: root }), + () => stashGitFile(input, status(root, "renamed.txt"), "rename", { cwd: root }), + ]) { + await expect(action()).rejects.toThrow("recreated"); + } + rmSync(join(root, "alpha.txt")); + await stashGitFile(input, status(root, "renamed.txt"), "rename", { cwd: root }); + expect(runTestGit(root, "show", "stash:renamed.txt")).toBe("one\ntwo\nthree\n"); + expect(existsSync(join(root, "renamed.txt"))).toBe(false); + expect(existsSync(join(root, "alpha.txt"))).toBe(true); + expectUnrelated(root); + }); + + test("refuses stale targets and locked indexes without creating a stash", async () => { + const root = fixture(); + const before = status(root); + writeFileSync(join(root, "alpha.txt"), "changed after prompt\n"); + await expect(discardGitFile(input, before, "all", { cwd: root })).rejects.toThrow( + "changed since", + ); + writeFileSync(join(root, ".git", "index.lock"), "test lock"); + await expect(stashGitFile(input, status(root), "locked", { cwd: root })).rejects.toThrow( + "locked", + ); + expect(runTestGit(root, "stash", "list")).toBe(""); + expect(readFileSync(join(root, "alpha.txt"), "utf8")).toBe("changed after prompt\n"); + }); + + // Git hooks require an executable shell script, so this failure-injection case is Unix-only. + test.skipIf(process.platform === "win32")( + "retains a published stash when cleanup encounters a new index lock", + async () => { + const root = fixture(); + const hook = join(root, ".git", "hooks", "reference-transaction"); + writeFileSync( + hook, + '#!/bin/sh\nif [ "$1" = committed ]; then printf "test lock" > .git/index.lock; fi\n', + ); + chmodSync(hook, 0o755); + await expect( + stashGitFile(input, status(root), "safe recovery", { cwd: root }), + ).rejects.toThrow("stash is retained"); + expect(runTestGit(root, "show", "stash:alpha.txt")).toBe("unstaged alpha\n"); + expect(runTestGit(root, "show", "stash^2:alpha.txt")).toBe("staged alpha\n"); + expect(readFileSync(join(root, "alpha.txt"), "utf8")).toBe("unstaged alpha\n"); + expectUnrelated(root); + }, + ); + + test("preserves tracked executable and emulated symlink metadata in stash trees", async () => { + for (const mode of ["100755", "120000"]) { + const root = createTestWorkingTreeRepo(); + roots.push(root); + runTestGit(root, "config", "core.filemode", "false"); + runTestGit(root, "config", "core.symlinks", "false"); + writeFileSync(join(root, "mode.txt"), "old target"); + const blob = runTestGit(root, "hash-object", "-w", "mode.txt").trim(); + runTestGit(root, "update-index", "--add", "--cacheinfo", `${mode},${blob},mode.txt`); + runTestGit(root, "commit", "-m", "Tracked metadata"); + writeFileSync(join(root, "mode.txt"), "new target"); + await stashGitFile(input, status(root, "mode.txt"), "metadata", { cwd: root }); + expect(runTestGit(root, "ls-tree", "stash", "--", "mode.txt")).toStartWith(mode); + runTestGit(root, "stash", "apply", "--index"); + expect(readFileSync(join(root, "mode.txt"), "utf8")).toBe("new target"); + expect(runTestGit(root, "ls-files", "--stage", "--", "mode.txt")).toStartWith(mode); + } + }); + + test("stashes a tracked file even when ignore rules match it", async () => { + const root = fixture(); + writeFileSync(join(root, ".gitignore"), "alpha.txt\n"); + await stashGitFile(input, status(root), "ignored tracked file", { cwd: root }); + expect(runTestGit(root, "show", "stash:alpha.txt")).toBe("unstaged alpha\n"); + expectUnrelated(root); + }); + + test("discard and stash retain the adapter's configured Git executable", async () => { + const root = fixture(); + const operation = createGitVcsAdapter({ gitExecutable: "hunk-test-missing-custom-git" }) + .operations!["working-tree-diff"]!; + await expect(operation.discardFile!(input, status(root), "all", { cwd: root })).rejects.toThrow( + "hunk-test-missing-custom-git", + ); + await expect( + operation.stashFile!(input, status(root), "custom", { cwd: root }), + ).rejects.toThrow("hunk-test-missing-custom-git"); + expect(readFileSync(join(root, "alpha.txt"), "utf8")).toBe("unstaged alpha\n"); + }); + + test("unborn discard removes only its selected leaf and stash refuses without writes", async () => { + const root = createTestWorkingTreeRepo({ unborn: true }); + roots.push(root); + writeFileSync(join(root, "new.txt"), "new\n"); + runTestGit(root, "add", "new.txt"); + await expect( + stashGitFile(input, status(root, "new.txt"), "unborn", { cwd: root }), + ).rejects.toThrow("first commit"); + await discardGitFile(input, status(root, "new.txt"), "all", { cwd: root }); + expect(existsSync(join(root, "new.txt"))).toBe(false); + expect(runTestGit(root, "ls-files")).toBe(""); + }); +}); diff --git a/packages/hunk-git/src/fileActions.ts b/packages/hunk-git/src/fileActions.ts new file mode 100644 index 000000000..0a56ee60a --- /dev/null +++ b/packages/hunk-git/src/fileActions.ts @@ -0,0 +1,253 @@ +/** + * Discards or stashes one confirmed file without including unrelated work. + * The host owns prompts and refresh. Git owns restore semantics and stash objects; + * an isolated stash index excludes unrelated staged entries from both stash trees. + */ +import { existsSync, lstatSync, mkdtempSync, rmSync, unlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { + HunkExtensionUserError, + type ExtensionVcsDiffInput, + type ExtensionVcsDiscardScope, + type ExtensionWorkingTreeFile, +} from "hunkdiff/extension"; +import { runGitBytes, runGitMutation, runGitText } from "./commands"; +import { verifyGitWorkingTreeFile } from "./workingTree"; + +type GitFileActionContext = { cwd: string; gitExecutable?: string }; + +/** Refuse a recreated rename source rather than restoring over a separate untracked file. */ +function requireUnoccupiedRenameSource(root: string, file: ExtensionWorkingTreeFile) { + if (!file.previousPath) return; + try { + lstatSync(join(root, file.previousPath)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + throw new HunkExtensionUserError( + `Move the recreated ${file.previousPath} out of the way before acting on this rename.`, + ); +} + +/** Discard the exact attested path, retaining staged content for an unstaged-only choice. */ +export async function discardGitFile( + input: ExtensionVcsDiffInput, + expected: ExtensionWorkingTreeFile, + scope: ExtensionVcsDiscardScope, + context: GitFileActionContext, +) { + const { root, file } = verifyGitWorkingTreeFile(input, expected, context); + const base = { input, cwd: root, gitExecutable: context.gitExecutable }; + if (scope === "unstaged") { + if (!file.staged || !file.unstaged) + throw new HunkExtensionUserError( + "Unstaged-only discard requires both staged and unstaged changes.", + ); + await runGitMutation({ + ...base, + args: ["--literal-pathspecs", "restore", "--worktree", "--", file.path], + }); + return; + } + requireUnoccupiedRenameSource(root, file); + if (file.untracked) { + unlinkSync(join(root, file.path)); + return; + } + const paths = [file.path, ...(file.previousPath ? [file.previousPath] : [])]; + const head = runGitText({ ...base, args: ["rev-parse", "--revs-only", "HEAD"] }).trim(); + if (head) { + await runGitMutation({ + ...base, + args: [ + "--literal-pathspecs", + "restore", + `--source=${head}`, + "--staged", + "--worktree", + "--", + ...paths, + ], + }); + return; + } + // Unborn repositories have no source tree. Remove the index entry before deleting its leaf. + await runGitMutation({ + ...base, + args: ["--literal-pathspecs", "rm", "--cached", "--force", "--", ...paths], + }); + try { + unlinkSync(join(root, file.path)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } +} + +/** Build a file-only stash before cleaning any live content, retaining it if cleanup fails. */ +export async function stashGitFile( + input: ExtensionVcsDiffInput, + expected: ExtensionWorkingTreeFile, + message: string, + context: GitFileActionContext, +) { + await stashGitFiles(input, [expected], message, context); +} + +/** Build one stash from the attested files, retaining it if live cleanup fails. */ +export async function stashGitFiles( + input: ExtensionVcsDiffInput, + expectedFiles: readonly ExtensionWorkingTreeFile[], + message: string, + context: GitFileActionContext, +) { + if (expectedFiles.length === 0) return; + const verified = expectedFiles.map((expected) => { + const { root, file } = verifyGitWorkingTreeFile(input, expected, context); + requireUnoccupiedRenameSource(root, file); + return { root, file }; + }); + const root = verified[0]!.root; + const files = verified.map(({ file }) => file); + const base = { input, cwd: root, gitExecutable: context.gitExecutable }; + const head = runGitText({ ...base, args: ["rev-parse", "--revs-only", "HEAD"] }).trim(); + if (!head) throw new HunkExtensionUserError("Create the first commit before stashing a file."); + const indexPath = resolve( + root, + runGitText({ ...base, args: ["rev-parse", "--git-path", "index"] }).trim(), + ); + if (existsSync(`${indexPath}.lock`)) + throw new HunkExtensionUserError( + "Git's index is locked. Finish the other Git operation and try again.", + ); + const paths = [ + ...new Set( + files.flatMap((file) => [file.path, ...(file.previousPath ? [file.previousPath] : [])]), + ), + ]; + const trackedPaths = [ + ...new Set( + files + .filter((file) => !file.untracked) + .flatMap((file) => [file.path, ...(file.previousPath ? [file.previousPath] : [])]), + ), + ]; + const untrackedPaths = files.filter((file) => file.untracked).map((file) => file.path); + const entries = runGitBytes({ + ...base, + args: ["--literal-pathspecs", "ls-files", "--stage", "-z", "--", ...paths], + }); + const temporary = mkdtempSync(join(tmpdir(), "hunk-file-stash-")); + const env = { GIT_INDEX_FILE: join(temporary, "index") }; + const subject = files.length === 1 ? files[0]!.path : `${files.length} files`; + const label = message || `Hunk: ${subject}`; + try { + await runGitMutation({ ...base, env, args: ["read-tree", head] }); + const removals = Buffer.from( + paths.map((path) => `0 ${"0".repeat(head.length)}\t${path}\0`).join(""), + ); + await runGitMutation({ + ...base, + env, + args: ["update-index", "-z", "--index-info"], + stdin: Buffer.concat([removals, entries]), + }); + const indexTree = (await runGitMutation({ ...base, env, args: ["write-tree"] })).trim(); + const indexCommit = ( + await runGitMutation({ + ...base, + args: ["commit-tree", indexTree, "-p", head], + stdin: `index: ${label}\n`, + }) + ).trim(); + let untrackedCommit: string | undefined; + if (untrackedPaths.length > 0) { + await runGitMutation({ ...base, env, args: ["read-tree", "--empty"] }); + await runGitMutation({ + ...base, + env, + args: ["--literal-pathspecs", "add", "--", ...untrackedPaths], + }); + const untrackedTree = (await runGitMutation({ ...base, env, args: ["write-tree"] })).trim(); + untrackedCommit = ( + await runGitMutation({ + ...base, + args: ["commit-tree", untrackedTree], + stdin: `untracked: ${label}\n`, + }) + ).trim(); + await runGitMutation({ ...base, env, args: ["read-tree", indexTree] }); + } + if (trackedPaths.length > 0) { + // Preserve tracked type/mode, including emulated symlinks and ignored executable changes. + const presentPaths = trackedPaths.filter((path) => { + try { + lstatSync(join(root, path)); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } + }); + const absentPaths = trackedPaths.filter((path) => !presentPaths.includes(path)); + if (absentPaths.length) + await runGitMutation({ + ...base, + env, + args: ["update-index", "--force-remove", "--", ...absentPaths], + }); + if (presentPaths.length) + await runGitMutation({ + ...base, + env, + args: ["--literal-pathspecs", "add", "--force", "--", ...presentPaths], + }); + } + const worktreeTree = (await runGitMutation({ ...base, env, args: ["write-tree"] })).trim(); + // Git stash expects HEAD, index, and optional untracked parents in precisely this order. + const stash = ( + await runGitMutation({ + ...base, + args: [ + "commit-tree", + worktreeTree, + "-p", + head, + "-p", + indexCommit, + ...(untrackedCommit ? ["-p", untrackedCommit] : []), + ], + stdin: `${label}\n`, + }) + ).trim(); + for (const expected of expectedFiles) verifyGitWorkingTreeFile(input, expected, context); + await runGitMutation({ ...base, args: ["stash", "store", "-m", label, stash] }); + try { + // Object creation and publication do not consent to overwriting later external edits. + for (const expected of expectedFiles) verifyGitWorkingTreeFile(input, expected, context); + if (untrackedPaths.length > 0) { + for (const path of untrackedPaths) unlinkSync(join(root, path)); + } + if (trackedPaths.length > 0) + await runGitMutation({ + ...base, + args: [ + "--literal-pathspecs", + "restore", + `--source=${head}`, + "--staged", + "--worktree", + "--", + ...trackedPaths, + ], + }); + } catch (error) { + throw new HunkExtensionUserError( + `Stash ${stash.slice(0, 12)} was created, but ${subject} could not be cleaned: ${error instanceof Error ? error.message : String(error)}. The stash is retained; inspect Git status before retrying.`, + ); + } + } finally { + rmSync(temporary, { recursive: true, force: true }); + } +} diff --git a/packages/hunk-git/src/hunkStaging.test.ts b/packages/hunk-git/src/hunkStaging.test.ts new file mode 100644 index 000000000..aba3098e9 --- /dev/null +++ b/packages/hunk-git/src/hunkStaging.test.ts @@ -0,0 +1,182 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { quote } from "shell-quote"; +import { join } from "node:path"; +import { createTestWorkingTreeRepo, runTestGit } from "../../../test/helpers/working-tree"; +import { loadAppBootstrap } from "../../hunk/src/core/changeset/loaders"; +import { getBundledVcsCatalog } from "../../hunk/src/app/vcsCatalog"; +import { summarizeHunk } from "../../hunk/src/core/changeset/hunkSummary"; +import { mutateGitHunkStaging } from "./hunkStaging"; + +const roots: string[] = []; +/** Load precisely the hunk summaries and status attestations exposed by the production loader. */ +async function review(root: string, staged = false) { + const input = { kind: "vcs" as const, staged, options: {} }; + const bootstrap = await loadAppBootstrap(input, { + cwd: root, + vcsCatalog: getBundledVcsCatalog(), + }); + return { input, bootstrap }; +} +/** Create separated textual changes whose index outcomes can be checked independently. */ +function createRepo() { + const root = createTestWorkingTreeRepo(); + roots.push(root); + const original = Array.from({ length: 40 }, (_, index) => `line ${index + 1}\n`).join(""); + writeFileSync(join(root, "alpha.txt"), original); + runTestGit(root, "add", "alpha.txt"); + runTestGit(root, "commit", "-m", "Long file"); + return { root, original }; +} +/** Invoke the optional provider method through the same file/hunk boundary as the UI. */ +async function toggle(root: string, path: string, hunkIndex: number, staged = false) { + const { input, bootstrap } = await review(root, staged); + const file = bootstrap.changeset.files.find((file) => file.path === path)!; + const status = bootstrap.changeset.workingTreeFiles!.find((file) => file.path === path)!; + await mutateGitHunkStaging( + input, + status, + summarizeHunk(file.metadata.hunks[hunkIndex]!, hunkIndex), + { cwd: root }, + !staged, + ); +} +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("Git hunk staging", () => { + test("stages and unstages only the selected hunk, preserving disk and another staged file", async () => { + const { root, original } = createRepo(); + const changed = original + .replace("line 2\n", "first change\n") + .replace("line 35\n", "second change\n"); + writeFileSync(join(root, "alpha.txt"), changed); + writeFileSync(join(root, "beta.txt"), "unrelated staged\n"); + runTestGit(root, "add", "beta.txt"); + await toggle(root, "alpha.txt", 1); + expect(runTestGit(root, "show", ":alpha.txt")).toBe( + original.replace("line 35\n", "second change\n"), + ); + await toggle(root, "alpha.txt", 0, true); + expect(runTestGit(root, "show", ":alpha.txt")).toBe(original); + expect(runTestGit(root, "show", ":beta.txt")).toBe("unrelated staged\n"); + expect(readFileSync(join(root, "alpha.txt"), "utf8")).toBe(changed); + }); + + test("unstaging a renamed file's hunk preserves its index filename and other hunk", async () => { + const { root, original } = createRepo(); + renameSync(join(root, "alpha.txt"), join(root, "[renamed] file.txt")); + writeFileSync( + join(root, "[renamed] file.txt"), + original.replace("line 2\n", "first change\n").replace("line 35\n", "second change\n"), + ); + runTestGit(root, "add", "-A"); + await toggle(root, "[renamed] file.txt", 1, true); + expect(runTestGit(root, "show", ":[renamed] file.txt")).toBe( + original.replace("line 2\n", "first change\n"), + ); + expect(runTestGit(root, "ls-files").split("\n")).not.toContain("alpha.txt"); + }); + + test("whole added and deleted hunks preserve worktree bytes across both actions", async () => { + const { root, original } = createRepo(); + writeFileSync(join(root, "[new] file.txt"), "trailing whitespace \nno newline "); + await toggle(root, "[new] file.txt", 0); + await toggle(root, "[new] file.txt", 0, true); + expect(readFileSync(join(root, "[new] file.txt"), "utf8")).toBe( + "trailing whitespace \nno newline ", + ); + rmSync(join(root, "alpha.txt")); + await toggle(root, "alpha.txt", 0); + await toggle(root, "alpha.txt", 0, true); + expect(runTestGit(root, "show", ":alpha.txt")).toBe(original); + }); + + test("applies zero-context hunks without changing CRLF or trailing spaces", async () => { + const { root } = createRepo(); + runTestGit(root, "config", "core.autocrlf", "false"); + runTestGit(root, "config", "diff.context", "0"); + const original = "first\r\nkeep\r\nlast "; + const changed = "changed\r\nkeep\r\nlast altered "; + writeFileSync(join(root, "alpha.txt"), original); + runTestGit(root, "add", "alpha.txt"); + runTestGit(root, "commit", "-m", "CRLF text"); + writeFileSync(join(root, "alpha.txt"), changed); + await toggle(root, "alpha.txt", 1); + expect(runTestGit(root, "show", ":alpha.txt")).toBe("first\r\nkeep\r\nlast altered "); + expect(readFileSync(join(root, "alpha.txt"), "utf8")).toBe(changed); + }); + + test("preserves non-UTF-8 source bytes when staging and reversing a hunk", async () => { + const { root, original } = createRepo(); + const changed = Buffer.from(original.replace("line 35\n", "\xe9\n"), "latin1"); + writeFileSync(join(root, "alpha.txt"), changed); + await toggle(root, "alpha.txt", 0); + expect(execFileSync("git", ["show", ":alpha.txt"], { cwd: root })).toEqual(changed); + await toggle(root, "alpha.txt", 0, true); + expect(execFileSync("git", ["show", ":alpha.txt"], { cwd: root })).toEqual( + Buffer.from(original), + ); + expect(readFileSync(join(root, "alpha.txt"))).toEqual(changed); + }); + + test("refuses converted text instead of staging converter output", async () => { + const { root, original } = createRepo(); + const converter = join(root, "converter.cjs"); + writeFileSync( + converter, + `const fs = require("node:fs"); process.stdout.write(fs.readFileSync(process.argv[2], "utf8").replace("second change", "converted text"));`, + ); + writeFileSync(join(root, ".gitattributes"), "alpha.txt diff=contract\n"); + runTestGit(root, "config", "diff.contract.textconv", quote([process.execPath, converter])); + writeFileSync(join(root, "alpha.txt"), original.replace("line 35\n", "second change\n")); + await expect(toggle(root, "alpha.txt", 0)).rejects.toThrow("Text-converted hunks"); + expect(runTestGit(root, "diff", "--cached")).toBe(""); + }); + + test("refuses unstage after HEAD changes with the same status and hunk coordinates", async () => { + const { root, original } = createRepo(); + for (const replacement of ["SECOND", "THIRD"]) { + writeFileSync(join(root, "alpha.txt"), original.replace("line 2", replacement)); + runTestGit(root, "add", "alpha.txt"); + runTestGit(root, "commit", "-m", replacement); + } + runTestGit(root, "reset", "--soft", "HEAD^"); + const { input, bootstrap } = await review(root, true); + const file = bootstrap.changeset.files.find((file) => file.path === "alpha.txt")!; + const status = bootstrap.changeset.workingTreeFiles!.find((file) => file.path === "alpha.txt")!; + const before = runTestGit(root, "show", ":alpha.txt"); + runTestGit(root, "reset", "--soft", "HEAD^"); + await expect( + mutateGitHunkStaging( + input, + status, + summarizeHunk(file.metadata.hunks[0]!, 0), + { cwd: root }, + false, + ), + ).rejects.toThrow("changed since"); + expect(runTestGit(root, "show", ":alpha.txt")).toBe(before); + }); + + test("refuses a stale hunk before changing the index", async () => { + const { root, original } = createRepo(); + writeFileSync(join(root, "alpha.txt"), original.replace("line 2\n", "first change\n")); + const { input, bootstrap } = await review(root); + const file = bootstrap.changeset.files[0]!; + const status = bootstrap.changeset.workingTreeFiles![0]!; + writeFileSync(join(root, "alpha.txt"), "replaced externally\n"); + await expect( + mutateGitHunkStaging( + input, + status, + summarizeHunk(file.metadata.hunks[0]!, 0), + { cwd: root }, + true, + ), + ).rejects.toThrow("changed since"); + expect(runTestGit(root, "diff", "--cached")).toBe(""); + }); +}); diff --git a/packages/hunk-git/src/hunkStaging.ts b/packages/hunk-git/src/hunkStaging.ts new file mode 100644 index 000000000..f9b5e8c40 --- /dev/null +++ b/packages/hunk-git/src/hunkStaging.ts @@ -0,0 +1,110 @@ +/** Applies a selected canonical Git hunk to the index while leaving the worktree untouched. */ +import { + HunkExtensionUserError, + type ExtensionDiffHunk, + type ExtensionVcsDiffInput, + type ExtensionWorkingTreeFile, +} from "hunkdiff/extension"; +import { buildGitDiffArgs, runGitBytes, runGitMutation, runGitText } from "./commands"; +import { mutateGitFileStaging, verifyGitWorkingTreeFile } from "./workingTree"; + +/** Compare numbered spans independently of Git's optional count-one and function-context syntax. */ +function hunkCoordinates(header: string) { + const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(header); + return match ? [match[1], match[2] ?? "1", match[3], match[4] ?? "1"].join(":") : null; +} + +/** Extract one raw hunk without normalizing CRLF, control bytes, or trailing source whitespace. */ +function selectCanonicalHunk(patch: string, hunk: ExtensionDiffHunk, renamed: boolean) { + const starts = [...patch.matchAll(/^@@ /gm)].map((match) => match.index); + const start = starts[hunk.index]; + if (!Number.isInteger(hunk.index) || hunk.index < 0 || start === undefined) { + throw new HunkExtensionUserError( + "The selected hunk is no longer available. Refresh and try again.", + ); + } + const body = patch.slice(start, starts[hunk.index + 1] ?? patch.length); + const coordinates = hunkCoordinates(body); + if (!coordinates || coordinates !== hunkCoordinates(hunk.header)) { + throw new HunkExtensionUserError( + "The selected hunk changed since the review loaded. Refresh and try again.", + ); + } + const headers = patch.slice(0, start).split("\n"); + let oldPath = headers.find((line) => line.startsWith("--- "))?.slice(4); + const newPath = headers.find((line) => line.startsWith("+++ "))?.slice(4); + if (!oldPath || !newPath) + throw new HunkExtensionUserError("This change has no applicable text hunk."); + // Textual unstage of a rename edits its current index path; it must not undo the filename change. + if (renamed) oldPath = newPath.replace(/^("?)b\//, "$1a/"); + const diffHeader = renamed + ? `diff --git ${oldPath.replace(/\t.*$/, "")} ${newPath.replace(/\t.*$/, "")}` + : headers[0]!; + const modes = headers.filter((line) => /^(new file mode|deleted file mode) /.test(line)); + return [diffHeader, ...modes, `--- ${oldPath}`, `+++ ${newPath}`, body].join("\n"); +} + +/** Revalidate status, recover the provider's raw patch, and apply only the requested hunk. */ +export async function mutateGitHunkStaging( + input: ExtensionVcsDiffInput, + expected: ExtensionWorkingTreeFile, + hunk: ExtensionDiffHunk, + context: { cwd: string; gitExecutable?: string }, + stage: boolean, +) { + const { root, file } = verifyGitWorkingTreeFile(input, expected, context); + if (input.staged === stage || (stage ? !file.unstaged : !file.staged)) { + throw new HunkExtensionUserError( + "The selected hunk is not on the side required by this action.", + ); + } + // An untracked text file has one whole-file hunk. Git add also preserves its executable mode. + if (file.untracked && stage && hunk.index === 0) { + await mutateGitFileStaging(input, file, context, true); + return; + } + const paths = [file.path, ...(file.previousPath ? [file.previousPath] : [])]; + const args = buildGitDiffArgs({ ...input, pathspecs: paths.map((path) => `:(literal)${path}`) }); + const base = { + input, + cwd: root, + gitExecutable: context.gitExecutable, + preventOptionalLocks: true, + }; + // Latin-1 is a byte-preserving view for parsing ASCII patch framing, not a source encoding. + const patch = runGitBytes({ ...base, args }).toString("latin1"); + const rawArgs = [...args]; + rawArgs.splice(rawArgs.indexOf("--"), 0, "--no-textconv"); + const rawPatch = runGitBytes({ ...base, args: rawArgs }).toString("latin1"); + const namesArgs = [...args]; + namesArgs.splice(namesArgs.indexOf("--"), 0, "--name-only", "-z"); + const names = runGitText({ ...base, args: namesArgs }).split("\0"); + const chunks = patch.split(/(?<=\n)(?=diff --git )/); + const chunk = chunks[names.indexOf(file.path)]; + if (!chunk) throw new HunkExtensionUserError("The selected file has no applicable text patch."); + const displayedPatch = selectCanonicalHunk( + chunk, + hunk, + input.staged && Boolean(file.previousPath), + ); + const rawChunk = rawPatch.split(/(?<=\n)(?=diff --git )/)[names.indexOf(file.path)]; + if (!rawChunk || rawChunk !== chunk) { + throw new HunkExtensionUserError( + "Text-converted hunks cannot be staged here. Use the whole-file action instead.", + ); + } + // External edits during patch reads must not turn a reviewed action into an unseen one. + verifyGitWorkingTreeFile(input, expected, context); + await runGitMutation({ + ...base, + args: [ + "apply", + "--cached", + "--unidiff-zero", + "--whitespace=nowarn", + ...(stage ? [] : ["--reverse"]), + "-", + ], + stdin: Buffer.from(displayedPatch, "latin1"), + }); +} diff --git a/packages/hunk-git/src/index.ts b/packages/hunk-git/src/index.ts index 049209f22..9d977b075 100644 --- a/packages/hunk-git/src/index.ts +++ b/packages/hunk-git/src/index.ts @@ -1,3 +1,6 @@ +import { resolveGitWorkingTreeLine } from "./editorLine"; +import { discardGitFile, stashGitFile, stashGitFiles } from "./fileActions"; +import { mutateGitHunkStaging } from "./hunkStaging"; import { createHash } from "node:crypto"; import fs from "node:fs"; import { dirname, isAbsolute, join, relative, resolve } from "node:path"; @@ -25,6 +28,11 @@ import { } from "./commands"; import { openGitHistory } from "./history"; import { gitEndpointSourceSpec, readGitFileSource } from "./source"; +import { + attachGitWorkingTreeLineStats, + loadGitWorkingTreeFiles, + mutateGitFileStaging, +} from "./workingTree"; import { HUNK_VCS_DETECTION_BASELINE_PRIORITY, type ExtensionVcsAdapter, @@ -373,11 +381,15 @@ export function createGitVcsAdapter({ const largeTrackedFiles = parseGitNumstat(numstat).filter((file) => shouldSkipLargeTrackedDiff(file, repoRoot), ); + const statusFiles = loadGitWorkingTreeFiles(input, { cwd, gitExecutable }); return { repoRoot, sourceLabel: repoRoot, title, + workingTreeFiles: statusFiles + ? attachGitWorkingTreeLineStats(statusFiles, input, { cwd, gitExecutable }) + : undefined, patchText: await runGitTextAsync({ input, args: buildGitDiffArgs( @@ -406,6 +418,22 @@ export function createGitVcsAdapter({ untrackedPaths, }; }, + resolveWorkingTreeLine: (input, file, line, { cwd }) => + resolveGitWorkingTreeLine(input, file, line, { cwd, gitExecutable }), + discardFile: (input, file, scope, { cwd }) => + discardGitFile(input, file, scope, { cwd, gitExecutable }), + stashFile: (input, file, message, { cwd }) => + stashGitFile(input, file, message, { cwd, gitExecutable }), + stashFiles: (input, files, message, { cwd }) => + stashGitFiles(input, files, message, { cwd, gitExecutable }), + stageFile: (input, file, { cwd }) => + mutateGitFileStaging(input, file, { cwd, gitExecutable }, true), + unstageFile: (input, file, { cwd }) => + mutateGitFileStaging(input, file, { cwd, gitExecutable }, false), + stageHunk: (input, file, hunk, { cwd }) => + mutateGitHunkStaging(input, file, hunk, { cwd, gitExecutable }, true), + unstageHunk: (input, file, hunk, { cwd }) => + mutateGitHunkStaging(input, file, hunk, { cwd, gitExecutable }, false), watchPlan(input, { cwd }) { return buildGitWatchPlan(input, cwd, gitExecutable); }, @@ -428,7 +456,9 @@ export function createGitVcsAdapter({ gitExecutable, preventOptionalLocks: true, }).map((filePath) => `untracked:${statSignature(join(repoRoot, filePath))}`); - return [trackedPatch, ...untrackedSignatures].join("\n---\n"); + // Both sidebar sides stay live even when the active patch is unchanged. + const inventory = loadGitWorkingTreeFiles(input, { cwd, gitExecutable }); + return [trackedPatch, JSON.stringify(inventory), ...untrackedSignatures].join("\n---\n"); }, }, "revision-show": { diff --git a/packages/hunk-git/src/workingTree.test.ts b/packages/hunk-git/src/workingTree.test.ts new file mode 100644 index 000000000..7b751bd4c --- /dev/null +++ b/packages/hunk-git/src/workingTree.test.ts @@ -0,0 +1,224 @@ +import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { readFileSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { LARGE_DIFF_FILE_MAX_BYTES } from "@hunk/vcs/large-file"; +import { createTestWorkingTreeRepo, runTestGit } from "../../../test/helpers/working-tree"; +import { loadGitWorkingTreeFiles, mutateGitFileStaging } from "./workingTree"; +import { createGitVcsAdapter } from "."; +import type { ExtensionVcsDiffInput } from "hunkdiff/extension"; + +setDefaultTimeout(30_000); +const roots: string[] = []; +const input: ExtensionVcsDiffInput = { kind: "vcs", staged: false, options: {} }; +/** Register ownership of each test repository for cleanup. */ +function createRepo(unborn = false) { + const root = createTestWorkingTreeRepo({ unborn }); + roots.push(root); + return root; +} +/** Read one file's actual provider status after each irreversible action. */ +function status(root: string, path: string) { + return loadGitWorkingTreeFiles(input, { cwd: root })!.find((file) => file.path === path)!; +} +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("working-tree file staging", () => { + test("an index lock reports a user-facing error without changing Git", async () => { + const root = createRepo(); + writeFileSync(join(root, "alpha.txt"), "changed\n"); + const file = status(root, "alpha.txt"); + writeFileSync(join(root, ".git", "index.lock"), "test lock"); + await expect(mutateGitFileStaging(input, file, { cwd: root }, true)).rejects.toMatchObject({ + name: "HunkExtensionUserError", + }); + expect(runTestGit(root, "diff", "--cached")).toBe(""); + }); + test("partial file stages remaining changes, then unstages without changing disk or unrelated index entries", async () => { + const root = createRepo(); + writeFileSync(join(root, "alpha.txt"), "one\nstaged\nthree\n"); + runTestGit(root, "add", "alpha.txt"); + writeFileSync(join(root, "alpha.txt"), "one\nstaged\nremaining\n"); + writeFileSync(join(root, "beta.txt"), "unrelated\n"); + runTestGit(root, "add", "beta.txt"); + expect(status(root, "alpha.txt")).toMatchObject({ + staged: true, + unstaged: true, + statusCode: "MM", + }); + await mutateGitFileStaging(input, status(root, "alpha.txt"), { cwd: root }, true); + expect(status(root, "alpha.txt")).toMatchObject({ staged: true, unstaged: false }); + await mutateGitFileStaging(input, status(root, "alpha.txt"), { cwd: root }, false); + expect(status(root, "alpha.txt")).toMatchObject({ staged: false, unstaged: true }); + expect(runTestGit(root, "show", ":beta.txt")).toBe("unrelated\n"); + expect(readFileSync(join(root, "alpha.txt"), "utf8")).toBe("one\nstaged\nremaining\n"); + }); + + test.each([false, true])( + "new file stage/unstage preserves contents, unborn=%s", + async (unborn) => { + const root = createRepo(unborn); + writeFileSync(join(root, "new.txt"), "new file\n"); + expect(status(root, "new.txt")).toMatchObject({ untracked: true, statusCode: "??" }); + await mutateGitFileStaging(input, status(root, "new.txt"), { cwd: root }, true); + expect(status(root, "new.txt")).toMatchObject({ untracked: false, statusCode: "A " }); + await mutateGitFileStaging(input, status(root, "new.txt"), { cwd: root }, false); + expect(status(root, "new.txt")).toMatchObject({ untracked: true, statusCode: "??" }); + expect(readFileSync(join(root, "new.txt"), "utf8")).toBe("new file\n"); + }, + ); + + test("literal pathspec magic cannot stage another file", async () => { + const root = createRepo(); + const path = "[a] file.txt"; + writeFileSync(join(root, path), "literal\n"); + writeFileSync(join(root, "a file.txt"), "must remain untracked\n"); + await mutateGitFileStaging(input, status(root, path), { cwd: root }, true); + expect(status(root, "a file.txt").untracked).toBe(true); + expect(runTestGit(root, "ls-files", "--error-unmatch", "--", `:(literal)${path}`).trim()).toBe( + path, + ); + }); + + test("deleted and renamed paths unstage both index identities", async () => { + const root = createRepo(); + renameSync(join(root, "alpha.txt"), join(root, "renamed.txt")); + runTestGit(root, "add", "-A"); + expect(status(root, "renamed.txt").previousPath).toBe("alpha.txt"); + await mutateGitFileStaging(input, status(root, "renamed.txt"), { cwd: root }, false); + expect(runTestGit(root, "diff", "--cached")).toBe(""); + await mutateGitFileStaging(input, status(root, "alpha.txt"), { cwd: root }, true); + expect(runTestGit(root, "diff", "--cached", "--name-status")).toContain("D\talpha.txt"); + }); + + test.each([false, true])( + "staging a renamed file preserves its former path, recreated=%s", + async (recreated) => { + const root = createRepo(); + renameSync(join(root, "alpha.txt"), join(root, "renamed.txt")); + runTestGit(root, "add", "-A"); + writeFileSync(join(root, "renamed.txt"), "one\ntwo\nthree\nremaining\n"); + if (recreated) writeFileSync(join(root, "alpha.txt"), "separate untracked file\n"); + await mutateGitFileStaging(input, status(root, "renamed.txt"), { cwd: root }, true); + expect(runTestGit(root, "show", ":renamed.txt")).toContain("remaining"); + expect(runTestGit(root, "ls-files").split("\n")).not.toContain("alpha.txt"); + if (recreated) expect(status(root, "alpha.txt").untracked).toBe(true); + }, + ); + + test("stale content and stale index state are refused before writes", async () => { + const root = createRepo(); + writeFileSync(join(root, "alpha.txt"), "reviewed\n"); + const before = status(root, "alpha.txt"); + writeFileSync(join(root, "alpha.txt"), "not reviewed yet\n"); + await expect(mutateGitFileStaging(input, before, { cwd: root }, true)).rejects.toThrow( + "changed since", + ); + expect(runTestGit(root, "diff", "--cached")).toBe(""); + const beforeIndex = status(root, "alpha.txt"); + runTestGit(root, "add", "alpha.txt"); + await expect(mutateGitFileStaging(input, beforeIndex, { cwd: root }, true)).rejects.toThrow( + "changed since", + ); + }); + + test("revision comparisons cannot expose inventory or accept mutations", async () => { + const root = createRepo(); + writeFileSync(join(root, "alpha.txt"), "changed\n"); + const range = { ...input, range: "HEAD" } as ExtensionVcsDiffInput; + expect(loadGitWorkingTreeFiles(range, { cwd: root })).toBeUndefined(); + await expect( + mutateGitFileStaging(range, status(root, "alpha.txt"), { cwd: root }, true), + ).rejects.toThrow("revision comparisons"); + }); + + test("watch signatures include inactive-side status and content changes", async () => { + const root = createRepo(); + const operation = createGitVcsAdapter().operations["working-tree-diff"]; + writeFileSync(join(root, "beta.txt"), "staged first\n"); + runTestGit(root, "add", "beta.txt"); + const before = await operation.watchSignature!(input, { cwd: root }); + writeFileSync(join(root, "beta.txt"), "staged second\n"); + runTestGit(root, "add", "beta.txt"); + expect(runTestGit(root, "diff")).toBe(""); + expect(await operation.watchSignature!(input, { cwd: root })).not.toBe(before); + }); + + test("both tabs return the complete status inventory through the public adapter", async () => { + const root = createRepo(); + writeFileSync(join(root, "alpha.txt"), "staged\n"); + runTestGit(root, "add", "alpha.txt"); + writeFileSync(join(root, "beta.txt"), "unstaged\n"); + const operation = createGitVcsAdapter().operations["working-tree-diff"]; + for (const staged of [false, true]) { + const result = await operation.load({ ...input, staged }, { cwd: root }); + expect(result.workingTreeFiles?.map((file) => file.path)).toEqual(["alpha.txt", "beta.txt"]); + expect(result.workingTreeFiles?.find((file) => file.path === "alpha.txt")?.stats).toEqual({ + additions: 1, + deletions: 3, + }); + expect(result.workingTreeFiles?.find((file) => file.path === "beta.txt")?.stats).toEqual({ + additions: 1, + deletions: 1, + }); + expect(result.patchText).toContain(staged ? "a/alpha.txt" : "a/beta.txt"); + expect(result.patchText).not.toContain(staged ? "a/beta.txt" : "a/alpha.txt"); + } + }); + + test("staged review still reports untracked line counts on the status inventory", async () => { + const root = createRepo(); + writeFileSync(join(root, "new.txt"), "a\nb\nc\n"); + const result = await createGitVcsAdapter().operations["working-tree-diff"]!.load( + { ...input, staged: true }, + { cwd: root }, + ); + expect(result.workingTreeFiles?.find((file) => file.path === "new.txt")).toMatchObject({ + untracked: true, + stats: { additions: 3, deletions: 0 }, + }); + }); + + test("unstaged review still reports staged rename line counts on the status inventory", async () => { + const root = createRepo(); + renameSync(join(root, "alpha.txt"), join(root, "renamed.txt")); + writeFileSync(join(root, "renamed.txt"), "one\nedited\nthree\n"); + runTestGit(root, "add", "-A"); + const result = await createGitVcsAdapter().operations["working-tree-diff"]!.load(input, { + cwd: root, + }); + expect(result.workingTreeFiles?.find((file) => file.path === "renamed.txt")?.stats).toEqual({ + additions: 1, + deletions: 1, + }); + }); + + test("staged review caps untracked inventory stats at the large-file byte budget", async () => { + const root = createRepo(); + const line = `${"x".repeat(99)}\n`; + writeFileSync(join(root, "huge.txt"), line.repeat(15_000)); + const result = await createGitVcsAdapter().operations["working-tree-diff"]!.load( + { ...input, staged: true }, + { cwd: root }, + ); + expect(result.workingTreeFiles?.find((file) => file.path === "huge.txt")).toMatchObject({ + untracked: true, + stats: { additions: LARGE_DIFF_FILE_MAX_BYTES / line.length, deletions: 0 }, + }); + }); + + test("staged review counts an untracked symlink as its target path, not the target file", async () => { + const root = createRepo(); + writeFileSync(join(root, "target.txt"), "a\nb\nc\nd\ne\n"); + symlinkSync("target.txt", join(root, "link.txt")); + const result = await createGitVcsAdapter().operations["working-tree-diff"]!.load( + { ...input, staged: true }, + { cwd: root }, + ); + expect(result.workingTreeFiles?.find((file) => file.path === "link.txt")).toMatchObject({ + untracked: true, + stats: { additions: 1, deletions: 0 }, + }); + }); +}); diff --git a/packages/hunk-git/src/workingTree.ts b/packages/hunk-git/src/workingTree.ts new file mode 100644 index 000000000..be9372458 --- /dev/null +++ b/packages/hunk-git/src/workingTree.ts @@ -0,0 +1,274 @@ +/** + * Reads both index and worktree status and applies explicit file staging actions. + * The host owns selection, consent and refresh; Git owns path resolution and index locking. + * Attestations bind pending actions to the status and filesystem state the user reviewed. + */ +import { lstatSync, readFileSync, readlinkSync } from "node:fs"; +import { isAbsolute, join } from "node:path"; +import { + HunkExtensionUserError, + type ExtensionVcsDiffInput, + type ExtensionWorkingTreeFile, +} from "hunkdiff/extension"; +import { inspectLargeUntrackedFile } from "@hunk/vcs/large-file"; +import { + appendGitPathspecs, + buildGitDiffNumstatArgs, + parseGitNumstat, + resolveGitRepoRoot, + runGitMutation, + runGitText, + type GitNumstatFile, +} from "./commands"; + +interface GitWorkingTreeContext { + cwd: string; + gitExecutable?: string; +} + +/** Reject historical comparisons before any status-dependent mutation can begin. */ +function requireWorkingTree(input: ExtensionVcsDiffInput) { + if (input.range !== undefined || input.rangeEndpoints !== undefined) { + throw new HunkExtensionUserError( + "Working-tree actions are unavailable in revision comparisons.", + ); + } +} + +/** Attest a leaf without following symlinks, including the absence of a deleted file. */ +function fileStamp(root: string, path: string) { + try { + const stat = lstatSync(join(root, path)); + return { + version: `${stat.ino}:${stat.mode}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`, + directory: stat.isDirectory(), + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") + return { version: "missing", directory: false }; + throw error; + } +} + +/** Load the status inventory independently of the tab selected by the review. */ +export function loadGitWorkingTreeFiles( + input: ExtensionVcsDiffInput, + { cwd, gitExecutable = "git" }: GitWorkingTreeContext, +): readonly ExtensionWorkingTreeFile[] | undefined { + if (input.range !== undefined || input.rangeEndpoints !== undefined) return undefined; + const root = resolveGitRepoRoot(input, { cwd, gitExecutable }); + const args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"]; + appendGitPathspecs(args, input.pathspecs); + const records = runGitText({ input, args, cwd, gitExecutable, preventOptionalLocks: true }).split( + "\0", + ); + const head = runGitText({ + input, + args: ["rev-parse", "--revs-only", "HEAD"], + cwd: root, + gitExecutable, + preventOptionalLocks: true, + }).trim(); + const index = new Map(); + for (const record of runGitText({ + input, + args: ["ls-files", "--stage", "-z"], + cwd: root, + gitExecutable, + preventOptionalLocks: true, + }).split("\0")) { + const tab = record.indexOf("\t"); + if (tab < 0) continue; + const path = record.slice(tab + 1); + index.set(path, `${index.get(path) ?? ""}${record.slice(0, tab)};`); + } + const files: ExtensionWorkingTreeFile[] = []; + for (let i = 0; i < records.length; i++) { + const record = records[i]!; + if (!record) continue; + const status = record.slice(0, 2); + const path = record.slice(3); + const previousPath = /[RC]/.test(status) ? records[++i] : undefined; + const untracked = status === "??"; + if (untracked && input.options.excludeUntracked) continue; + const stat = fileStamp(root, path); + const conflicted = status.includes("U") || status === "AA" || status === "DD"; + files.push({ + path, + previousPath, + staged: ![" ", "?", "U"].includes(status[0]!), + unstaged: status[1] !== " ", + untracked, + conflicted, + statusCode: status, + unavailableReason: conflicted + ? "Resolve merge conflicts before staging here." + : stat.directory || index.get(path)?.startsWith("160000 ") + ? "Use the submodule or directory's own Git workflow." + : undefined, + version: JSON.stringify([ + head, + status, + index.get(path), + previousPath && index.get(previousPath), + stat.version, + previousPath && fileStamp(root, previousPath).version, + ]), + }); + } + return files; +} + +/** Add one numstat list into a path-keyed running total. */ +function addNumstat( + stats: Map, + files: readonly GitNumstatFile[], +) { + for (const file of files) { + const current = stats.get(file.path) ?? { additions: 0, deletions: 0 }; + stats.set(file.path, { + additions: current.additions + file.additions, + deletions: current.deletions + file.deletions, + }); + } +} + +/** Count text lines, treating a trailing newline as a terminator rather than an extra row. */ +function countTextLines(text: string) { + if (text.length === 0) return 0; + const lines = text.split("\n"); + return text.endsWith("\n") ? lines.length - 1 : lines.length; +} + +/** + * Count untracked lines for sidebar stats without following symlinks or + * reading past the large-file cap used for review synthesis. + */ +function countWorkingTreeLines(root: string, path: string) { + const absolutePath = join(root, path); + try { + if (lstatSync(absolutePath).isSymbolicLink()) { + return countTextLines(readlinkSync(absolutePath)); + } + } catch { + return 0; + } + + const largeFileCheck = inspectLargeUntrackedFile(root, path); + if (largeFileCheck.shouldSkip) { + return largeFileCheck.stats?.additions ?? 0; + } + + try { + return countTextLines(readFileSync(absolutePath, "utf8")); + } catch { + return 0; + } +} + +/** + * Attach staged plus unstaged line counts to a status inventory. + * Mutation verification keeps using the status-only load so extra numstat + * queries do not run on every stage/unstage. + */ +export function attachGitWorkingTreeLineStats( + files: readonly ExtensionWorkingTreeFile[], + input: ExtensionVcsDiffInput, + context: GitWorkingTreeContext, +): ExtensionWorkingTreeFile[] { + if (files.length === 0) return []; + const stats = new Map(); + const query = { cwd: context.cwd, gitExecutable: context.gitExecutable }; + addNumstat( + stats, + parseGitNumstat( + runGitText({ + ...query, + input, + args: buildGitDiffNumstatArgs({ ...input, staged: false }), + }), + ), + ); + addNumstat( + stats, + parseGitNumstat( + runGitText({ + ...query, + input, + args: buildGitDiffNumstatArgs({ ...input, staged: true }), + }), + ), + ); + const root = resolveGitRepoRoot(input, context); + return files.map((file) => { + const fileStats = + stats.get(file.path) ?? + (file.untracked + ? { additions: countWorkingTreeLines(root, file.path), deletions: 0 } + : undefined); + return fileStats ? { ...file, stats: fileStats } : file; + }); +} + +/** Revalidate an exact reviewed path and its source attestation before index writes. */ +export function verifyGitWorkingTreeFile( + input: ExtensionVcsDiffInput, + file: ExtensionWorkingTreeFile, + context: GitWorkingTreeContext, +) { + requireWorkingTree(input); + for (const path of [file.path, file.previousPath].filter( + (path): path is string => path !== undefined, + )) { + if ( + !path || + isAbsolute(path) || + path.includes("\0") || + path.split("/").some((part) => part === ".." || part === ".") || + (process.platform === "win32" && path.includes("\\")) + ) { + throw new HunkExtensionUserError( + "Working-tree actions require an exact repository-relative file path.", + ); + } + } + const root = resolveGitRepoRoot(input, context); + const current = loadGitWorkingTreeFiles( + { ...input, pathspecs: undefined }, + { ...context, cwd: root }, + )?.find((candidate) => candidate.path === file.path); + if (!current || current.version !== file.version || current.previousPath !== file.previousPath) { + throw new HunkExtensionUserError( + `${file.path} changed since the review loaded. Refresh and try again.`, + ); + } + if (current.unavailableReason) throw new HunkExtensionUserError(current.unavailableReason); + return { root, file: current }; +} + +/** Stage the file's current content, or restore only its index entry without changing disk content. */ +export async function mutateGitFileStaging( + input: ExtensionVcsDiffInput, + expected: ExtensionWorkingTreeFile, + context: GitWorkingTreeContext, + staged: boolean, +) { + const { root, file } = verifyGitWorkingTreeFile(input, expected, context); + if (staged ? !file.unstaged : !file.staged) + throw new HunkExtensionUserError("The selected file no longer has changes for this action."); + const base = { input, cwd: root, gitExecutable: context.gitExecutable }; + if (staged) { + await runGitMutation({ ...base, args: ["--literal-pathspecs", "add", "--", file.path] }); + return; + } + // The former path belongs to the staged rename, not to remaining worktree edits. + const paths = [file.path, ...(file.previousPath ? [file.previousPath] : [])]; + // An empty revision list also covers unborn HEAD without confusing repository errors with it. + const head = runGitText({ ...base, args: ["rev-parse", "--revs-only", "HEAD"] }).trim(); + await runGitMutation({ + ...base, + args: head + ? ["--literal-pathspecs", "restore", "--staged", "--", ...paths] + : ["--literal-pathspecs", "rm", "--cached", "--force", "--", ...paths], + }); +} diff --git a/packages/hunk-jj/src/source.test.ts b/packages/hunk-jj/src/source.test.ts index 70bf06904..8271c8e88 100644 --- a/packages/hunk-jj/src/source.test.ts +++ b/packages/hunk-jj/src/source.test.ts @@ -226,7 +226,23 @@ describe("Jujutsu source reading", () => { ]); }); - test("logs unexpected source failures with revision and path context", async () => { + test("logs missing executables with repository, revision, and path context", async () => { + const repoRoot = createTempDir("hunk-source-jj-missing-executable-"); + const loggedErrors = await captureConsoleErrors(async () => { + await expect( + readJjFileSource( + { repoRoot, commitId: "0123456789abcdef", path: "note.txt" }, + { jjExecutable: join(repoRoot, "missing-jj") }, + ), + ).resolves.toBeNull(); + }); + + expect(loggedErrors).toHaveLength(1); + expect(String(loggedErrors[0]?.[0])).toContain("0123456789abcdef:note.txt"); + expect(String(loggedErrors[0]?.[0])).toContain(repoRoot); + }); + + jjTest("logs unexpected source failures with revision and path context", async () => { const repoRoot = createTempDir("hunk-source-jj-not-repo-"); const loggedErrors = await captureConsoleErrors(async () => { await expect( diff --git a/packages/hunk-jj/src/source.ts b/packages/hunk-jj/src/source.ts index ed02d56c5..64406a284 100644 --- a/packages/hunk-jj/src/source.ts +++ b/packages/hunk-jj/src/source.ts @@ -117,7 +117,7 @@ export async function readJjFileSource( }); } catch (error) { logSourceDiagnostic( - `failed to run Jujutsu while reading source ${spec.commitId}:${spec.path}`, + `failed to run Jujutsu while reading source ${spec.commitId}:${spec.path} in ${spec.repoRoot}`, error, ); return null; @@ -146,7 +146,10 @@ export async function readJjFileSource( return tooLarge(error.maxBytes); } - logSourceDiagnostic(`failed to collect Jujutsu source ${spec.commitId}:${spec.path}`, error); + logSourceDiagnostic( + `failed to collect Jujutsu source ${spec.commitId}:${spec.path} in ${spec.repoRoot}`, + error, + ); return null; } diff --git a/packages/hunk/skills/hunk-extensions/SKILL.md b/packages/hunk/skills/hunk-extensions/SKILL.md index 2dcc21106..e8f834c06 100644 --- a/packages/hunk/skills/hunk-extensions/SKILL.md +++ b/packages/hunk/skills/hunk-extensions/SKILL.md @@ -111,7 +111,7 @@ bad or duplicate id is skipped with a startup notice. | Reload after an external agent changes reviewed inputs | `ctx.review.requestReload()` in an event | | Read user-supplied settings | `hunk.config` (`[extension.]` table) | | Snapshot stable files and every saved review note | `ctx.review.snapshot()` in a command | -| Branch on the API generation (currently `18`) | `hunk.apiVersion` | +| Branch on the host API generation | `hunk.apiVersion` | Registration is only valid while the factory runs — Hunk seals the API object afterwards. @@ -169,6 +169,10 @@ transform — gets `ctx.cwd` and `ctx.notify(message, type?)`. A file view's (with `{ side, line }` when opted in), semantic `theme`, resolved `keybindings`, and guarded navigation/notification `actions`. Availability callbacks receive the same `review` value, so a pane can consume no geometry for ordinary reviews. + Plain working-tree reviews also supply optional `workingTree`: both status sides, + exact selected path, busy state, and leased file-selection/staging actions. Keep + inactive-side entries in the sidebar inventory rather than fabricating diff files; + see `docs/extensions.md`. - **File-view `layout`** gets `file`, `width`, `signal`, `changes`, and a lazy `readDocument(side)`. - **File-view `mode` handlers** get `ctx.file` and `ctx.fileViews`. `onKey`, diff --git a/packages/hunk/src/core/changeset/loaders.test.ts b/packages/hunk/src/core/changeset/loaders.test.ts index 380ac4ba4..f9629e8ec 100644 --- a/packages/hunk/src/core/changeset/loaders.test.ts +++ b/packages/hunk/src/core/changeset/loaders.test.ts @@ -1940,7 +1940,7 @@ describe("loadAppBootstrap source fetcher attachment", () => { mutableBun.spawn = originalSpawn; } - expect(syncCalls).toEqual([]); + expect(syncCalls.some((call) => call.includes("status"))).toBe(true); expect(asyncCalls.some((call) => call.includes("rev-parse"))).toBe(true); expect(asyncCalls.some((call) => call.includes("diff"))).toBe(true); expect(asyncCalls).toContainEqual([gitExecutable, "show", ":value.txt"]); diff --git a/packages/hunk/src/core/changeset/loaders.ts b/packages/hunk/src/core/changeset/loaders.ts index ff17a33d4..1ca7a29a6 100644 --- a/packages/hunk/src/core/changeset/loaders.ts +++ b/packages/hunk/src/core/changeset/loaders.ts @@ -37,7 +37,7 @@ import type { VcsDiffCommandInput, VcsStashShowCommandInput, } from "../run/commandInputs"; -import type { SidecarContext, Changeset, DiffFile } from "./model"; +import type { SidecarContext, Changeset } from "./model"; export interface LoadAppBootstrapOptions { cwd?: string; @@ -74,7 +74,10 @@ function createSourceFetcherBuilder( } /** Reorder files to follow agent-context narrative order when a sidecar provides one. */ -export function orderDiffFiles(files: DiffFile[], sidecar: SidecarContext | null) { +export function orderDiffFiles( + files: File[], + sidecar: SidecarContext | null, +) { if (!sidecar || sidecar.files.length === 0) { return files; } @@ -253,6 +256,10 @@ async function loadVcsChangeset( changeset: { ...parsedChangeset, files: [...parsedChangeset.files, ...adapterFiles], + workingTreeFiles: result.workingTreeFiles?.map((file) => ({ + ...file, + agentSummary: findSidecarFileContext(sidecar, file.path, file.previousPath)?.summary, + })), } satisfies Changeset, repoRoot: result.repoRoot, }; @@ -326,6 +333,8 @@ export async function loadAppBootstrap( changeset = { ...changeset, files: orderDiffFiles(changeset.files, sidecar), + workingTreeFiles: + changeset.workingTreeFiles && orderDiffFiles([...changeset.workingTreeFiles], sidecar), }; return { diff --git a/packages/hunk/src/core/changeset/model.ts b/packages/hunk/src/core/changeset/model.ts index 2fa4c8750..aa075eca0 100644 --- a/packages/hunk/src/core/changeset/model.ts +++ b/packages/hunk/src/core/changeset/model.ts @@ -8,7 +8,7 @@ * app-facing types above them. */ import type { FileDiffMetadata } from "@pierre/diffs"; -import type { AgentFileContext } from "../../extension-api/types"; +import type { AgentFileContext, ExtensionWorkingTreeFile } from "../../extension-api/types"; import type { FileSourceFetcher } from "./fileSource"; /** One loaded review sidecar: the changeset summary plus every annotated file it names. */ @@ -54,4 +54,6 @@ export interface Changeset { summary?: string; agentSummary?: string; files: DiffFile[]; + /** Host-only VCS inventory; the review document contains only the active side's diff files. */ + workingTreeFiles?: readonly ExtensionWorkingTreeFile[]; } diff --git a/packages/hunk/src/core/review/geometry.test.ts b/packages/hunk/src/core/review/geometry.test.ts index 262897ef2..908547ba3 100644 --- a/packages/hunk/src/core/review/geometry.test.ts +++ b/packages/hunk/src/core/review/geometry.test.ts @@ -212,3 +212,21 @@ describe("normalizedReviewSourceLines", () => { expect(normalizedReviewSourceLines("\n")).toEqual([]); }); }); + +test("first-change targeting preserves an earlier deletion without changing note-anchor preference", () => { + const hunk = { + additionStart: 1, + additionCount: 8, + deletionStart: 1, + deletionCount: 8, + hunkContent: [ + { type: "context" as const, lines: 1 }, + { type: "change" as const, deletions: 1, additions: 0 }, + { type: "context" as const, lines: 3 }, + { type: "change" as const, deletions: 0, additions: 1 }, + { type: "context" as const, lines: 3 }, + ], + }; + expect(reviewDefaultHunkLineTarget(hunk)).toEqual({ side: "new", line: 5 }); + expect(reviewDefaultHunkLineTarget(hunk, "first-change")).toEqual({ side: "old", line: 2 }); +}); diff --git a/packages/hunk/src/core/review/geometry.ts b/packages/hunk/src/core/review/geometry.ts index 2f2f06831..3099ec0c4 100644 --- a/packages/hunk/src/core/review/geometry.ts +++ b/packages/hunk/src/core/review/geometry.ts @@ -77,10 +77,11 @@ export function reviewHunkIndexForLine( * Policy: the hunk's first added line, else its first deleted line, else the first line * of its new-side extent. Anchoring to the hunk's first line instead would usually land * on leading context — technically inside the hunk, but not on the change the note is - * about. + * about. Editor callers choose first-change to retain an earlier deletion before a later addition. */ export function reviewDefaultHunkLineTarget( hunk: ReviewHunkSpan & ReviewHunkContent, + preference: "first-addition" | "first-change" = "first-addition", ): ReviewLineAddressV1 { let deletionLine = hunk.deletionStart; let additionLine = hunk.additionStart; @@ -95,6 +96,9 @@ export function reviewDefaultHunkLineTarget( if ((content.additions ?? 0) > 0) { return { side: "new", line: additionLine }; } + if ((content.deletions ?? 0) > 0 && preference === "first-change") { + return { side: "old", line: deletionLine }; + } if ((content.deletions ?? 0) > 0 && firstDeletionLine === undefined) { firstDeletionLine = deletionLine; } diff --git a/packages/hunk/src/core/run/commandCatalog.test.ts b/packages/hunk/src/core/run/commandCatalog.test.ts index a0be55309..8fefddc79 100644 --- a/packages/hunk/src/core/run/commandCatalog.test.ts +++ b/packages/hunk/src/core/run/commandCatalog.test.ts @@ -65,6 +65,11 @@ describe("app command catalog", () => { (command) => command.id, ), ).toEqual([ + "hunk.review.discardSelectedFile", + "hunk.review.stashSelectedFile", + "hunk.review.toggleHunkStaged", + "hunk.review.toggleFileStaged", + "hunk.review.toggleStagedView", "hunk.app.quit", "hunk.app.openAgentSkill", "hunk.app.refresh", diff --git a/packages/hunk/src/core/run/commandCatalog.ts b/packages/hunk/src/core/run/commandCatalog.ts index acfd9cf5d..3a868a8c5 100644 --- a/packages/hunk/src/core/run/commandCatalog.ts +++ b/packages/hunk/src/core/run/commandCatalog.ts @@ -98,6 +98,50 @@ export interface AppCommandCatalogEntry { * from `[keybindings]` and dispatchable by id whether or not a menu presents them. */ const BUILTIN_COMMANDS = [ + { + id: "hunk.review.discardSelectedFile", + title: "Discard selected file or folder changes", + category: "review", + defaultKeys: ["d"], + locus: "host-only", + publicToExtensions: false, + closesMenu: true, + }, + { + id: "hunk.review.stashSelectedFile", + title: "Stash selected file or folder", + category: "review", + defaultKeys: ["s"], + locus: "host-only", + publicToExtensions: false, + closesMenu: true, + }, + { + id: "hunk.review.toggleHunkStaged", + title: "Stage / unstage selected hunk", + category: "review", + defaultKeys: ["space"], + locus: "host-only", + publicToExtensions: false, + }, + { + id: "hunk.review.toggleFileStaged", + title: "Stage / unstage selected file or folder", + category: "review", + defaultKeys: ["space"], + locus: "host-only", + publicToExtensions: false, + closesMenu: true, + }, + { + id: "hunk.review.toggleStagedView", + title: "Switch unstaged / staged stream", + category: "review", + defaultKeys: ["tab"], + locus: "host-only", + publicToExtensions: false, + closesMenu: true, + }, { id: "hunk.review.jumpToBottom", title: "Jump to end", @@ -158,6 +202,23 @@ const BUILTIN_COMMANDS = [ locus: "client-local", publicToExtensions: true, }, + { + id: "hunk.review.focusDiffPane", + title: "Focus the selected file's review", + category: "review", + defaultKeys: ["enter"], + locus: "client-local", + publicToExtensions: true, + }, + { + id: "hunk.review.focusFilesPane", + title: "Focus the files pane", + category: "review", + defaultKeys: ["escape"], + // Overlay, mode, and text-input Escape still outrank this command. + locus: "client-local", + publicToExtensions: true, + }, { id: "hunk.review.startNote", title: "Add a review note", @@ -226,7 +287,7 @@ const BUILTIN_COMMANDS = [ }, { id: "hunk.review.stepDown", - title: "Scroll down one row", + title: "Move down in the focused pane", category: "review", defaultKeys: ["down", "j"], locus: "client-local", @@ -235,7 +296,7 @@ const BUILTIN_COMMANDS = [ }, { id: "hunk.review.stepUp", - title: "Scroll up one row", + title: "Move up in the focused pane", category: "review", defaultKeys: ["up", "k"], locus: "client-local", diff --git a/packages/hunk/src/core/vcs/types.ts b/packages/hunk/src/core/vcs/types.ts index 7a110e22a..c132da986 100644 --- a/packages/hunk/src/core/vcs/types.ts +++ b/packages/hunk/src/core/vcs/types.ts @@ -5,6 +5,8 @@ import type { ExtensionVcsHistoryReviewAction, ExtensionVcsHistoryReviewOptions, ExtensionVcsWatchPlan, + ExtensionVcsWorkingTreeOperation, + ExtensionWorkingTreeFile, } from "../../extension-api/types"; import type { DiffFile } from "../changeset/model"; import type { @@ -42,7 +44,18 @@ export interface VcsOperation { } export interface VcsOperations { - "working-tree-diff"?: VcsOperation; + "working-tree-diff"?: VcsOperation & + Pick< + ExtensionVcsWorkingTreeOperation, + | "stageFile" + | "unstageFile" + | "stageHunk" + | "unstageHunk" + | "discardFile" + | "stashFile" + | "stashFiles" + | "resolveWorkingTreeLine" + >; "revision-show"?: VcsOperation; "stash-show"?: VcsOperation; } @@ -76,6 +89,7 @@ export interface VcsPatchResult { sourceLabel: string; title: string; patchText: string; + workingTreeFiles?: readonly ExtensionWorkingTreeFile[]; /** Repo-root-relative untracked paths Hunk synthesizes into added-file diffs. */ untrackedPaths?: string[]; /** Exact old/new content lookups, built from the result's `readFileSource`. */ diff --git a/packages/hunk/src/extension-api/index.ts b/packages/hunk/src/extension-api/index.ts index 225ee7261..fa48f72af 100644 --- a/packages/hunk/src/extension-api/index.ts +++ b/packages/hunk/src/extension-api/index.ts @@ -158,6 +158,12 @@ export type { ExtensionVcsHistorySource, ExtensionVcsLoadContext, ExtensionVcsOperation, + ExtensionVcsWorkingTreeOperation, + ExtensionVcsFileMutation, + ExtensionVcsHunkMutation, + ExtensionVcsDiscardScope, + ExtensionWorkingTreeFile, + ExtensionWorkingTreePane, ExtensionVcsOperations, ExtensionVcsPatchResult, ExtensionVcsRangeEndpoints, diff --git a/packages/hunk/src/extension-api/keys.test.ts b/packages/hunk/src/extension-api/keys.test.ts index b991d6199..a60bd99de 100644 --- a/packages/hunk/src/extension-api/keys.test.ts +++ b/packages/hunk/src/extension-api/keys.test.ts @@ -165,6 +165,9 @@ describe("matchesKeyChord", () => { test("named keys match by name with enter/return and space aliased", () => { expect(matchesKeyChord(parsed("f2"), keyEvent({ name: "f2" }))).toBe(true); expect(matchesKeyChord(parsed("enter"), keyEvent({ name: "return" }))).toBe(true); + expect(matchesKeyChord(parsed("escape"), keyEvent({ name: "escape" }))).toBe(true); + expect(matchesKeyChord(parsed("escape"), keyEvent({ name: "esc" }))).toBe(true); + expect(matchesKeyChord(parsed("escape"), keyEvent({ sequence: "\u001b" }))).toBe(true); expect(matchesKeyChord(parsed("pageup"), keyEvent({ name: "pageup" }))).toBe(true); // Space arrives named from OpenTUI's parser and as the bare character from // other input paths; both spell one key. diff --git a/packages/hunk/src/extension-api/keys.ts b/packages/hunk/src/extension-api/keys.ts index 10afa208c..884654f9f 100644 --- a/packages/hunk/src/extension-api/keys.ts +++ b/packages/hunk/src/extension-api/keys.ts @@ -187,6 +187,12 @@ function matchesNamedKey(base: string, key: ExtensionKeyEvent) { return true; } + // Escape arrives named "escape" from OpenTUI's parser, as "esc" from some + // hosts, and as the bare C0 byte from others. + if (base === "escape" && (name === "esc" || key.sequence === "\u001b")) { + return true; + } + // Space arrives named "space" from OpenTUI's parser, but as the bare // character from other input paths, so accept both spellings of one key. return base === "space" && (name === " " || key.sequence === " "); diff --git a/packages/hunk/src/extension-api/types.ts b/packages/hunk/src/extension-api/types.ts index fb1b4c0be..58594b705 100644 --- a/packages/hunk/src/extension-api/types.ts +++ b/packages/hunk/src/extension-api/types.ts @@ -21,7 +21,7 @@ * Extensions can branch on `hunk.apiVersion` so a newer Hunk can keep loading * older extensions without guessing at their expectations. */ -export const HUNK_EXTENSION_API_VERSION = 20; +export const HUNK_EXTENSION_API_VERSION = 25; export type HunkExtensionApiVersion = typeof HUNK_EXTENSION_API_VERSION; export type ExtensionNotifyType = "info" | "warning" | "error"; @@ -969,6 +969,8 @@ export interface ExtensionVcsPatchResult { sourceLabel: string; title: string; patchText: string; + /** Status inventory for both sides of a plain working-tree review, in sidebar order. */ + workingTreeFiles?: readonly ExtensionWorkingTreeFile[]; /** * Untracked files to review beside the patch, as repo-root-relative paths. * @@ -1068,11 +1070,107 @@ export interface ExtensionVcsOperation { * "not supported" error for that command instead of a crash. */ export interface ExtensionVcsOperations { - "working-tree-diff"?: ExtensionVcsOperation; + "working-tree-diff"?: ExtensionVcsWorkingTreeOperation; "revision-show"?: ExtensionVcsOperation; "stash-show"?: ExtensionVcsOperation; } +/** Describe one working-tree path independently of the currently displayed diff side. */ +export interface ExtensionWorkingTreeFile { + readonly path: string; + readonly previousPath?: string; + /** Optional review-context summary consumed by the shared file filter. */ + readonly agentSummary?: string; + readonly staged: boolean; + readonly unstaged: boolean; + readonly untracked: boolean; + readonly conflicted: boolean; + /** Optional two-column Git-style status: index then worktree, or ?? for untracked files. */ + readonly statusCode?: string; + /** + * Optional line counts covering this path's staged and unstaged changes. + * The files pane uses these when the current Unstaged/Staged stream does not + * include the file, so +/- counts stay visible on both tabs. + */ + readonly stats?: { additions: number; deletions: number }; + /** Provider-owned refusal for paths that cannot be mutated as ordinary files. */ + readonly unavailableReason?: string; + /** Opaque state attestation checked by the provider immediately before a mutation. */ + readonly version: string; +} + +/** Mutate an attested file without interpreting host IDs or renderer rows. */ +export type ExtensionVcsFileMutation = ( + input: ExtensionVcsDiffInput, + file: ExtensionWorkingTreeFile, + context: ExtensionVcsLoadContext, +) => Promise; + +/** Mutate one attested, numbered patch hunk while preserving the other hunks and worktree. */ +export type ExtensionVcsHunkMutation = ( + input: ExtensionVcsDiffInput, + file: ExtensionWorkingTreeFile, + hunk: ExtensionDiffHunk, + context: ExtensionVcsLoadContext, +) => Promise; + +/** Choose which reviewed changes to discard from one file. */ +export type ExtensionVcsDiscardScope = "all" | "unstaged"; + +/** Load a working-tree review and optionally offer explicit local mutations. */ +export interface ExtensionVcsWorkingTreeOperation extends ExtensionVcsOperation { + stageFile?: ExtensionVcsFileMutation; + unstageFile?: ExtensionVcsFileMutation; + stageHunk?: ExtensionVcsHunkMutation; + unstageHunk?: ExtensionVcsHunkMutation; + /** Validate a new-side source line and map staged addresses to the worktree without modifying files. */ + resolveWorkingTreeLine?: ( + input: ExtensionVcsDiffInput, + file: ExtensionWorkingTreeFile, + line: number, + context: ExtensionVcsLoadContext, + ) => Promise; + /** Discard only the confirmed file and scope, rejecting stale attestations. */ + discardFile?: ( + input: ExtensionVcsDiffInput, + file: ExtensionWorkingTreeFile, + scope: ExtensionVcsDiscardScope, + context: ExtensionVcsLoadContext, + ) => Promise; + /** Stash only this file, preserving its partial staging and excluding unrelated stash content. */ + stashFile?: ( + input: ExtensionVcsDiffInput, + file: ExtensionWorkingTreeFile, + message: string, + context: ExtensionVcsLoadContext, + ) => Promise; + /** Stash several attested files as one stash, preserving each file's partial staging. */ + stashFiles?: ( + input: ExtensionVcsDiffInput, + files: readonly ExtensionWorkingTreeFile[], + message: string, + context: ExtensionVcsLoadContext, + ) => Promise; +} + +/** Navigate and stage the host's current working-tree inventory from a mounted pane. */ +export interface ExtensionWorkingTreePane { + readonly files: readonly ExtensionWorkingTreeFile[]; + readonly selectedPath: string | null; + /** Highlighted files-pane row id: a file path, or a folder row id. */ + readonly selectedEntryId: string | null; + readonly staged: boolean; + readonly busy: boolean; + /** Select a path, switching diff sides if needed, without reducing the review to one file. */ + selectFile(path: string): void; + /** Select a visible files-pane row, including folder headers. */ + selectEntry(id: string): void; + /** Stage remaining unstaged changes, or unstage a fully staged file; inert after reload. */ + toggleStaged(path: string): void; + /** Stage or unstage the files visually under a files-pane row. */ + toggleEntry(id: string): void; +} + /** * An additional VCS backend contributed by an extension. * @@ -1276,6 +1374,8 @@ export interface ExtensionPaneProps { /** Immutable review-source metadata, or null for ordinary reviews. */ readonly review: ExtensionReviewDescriptor | null; readonly files: readonly ExtensionDiffFile[]; + /** Optional status-aware navigation and index actions for a plain working-tree review. */ + readonly workingTree?: ExtensionWorkingTreePane; readonly selectedFileId: string | null; readonly selectedHunkIndex: number | null; readonly placement: ExtensionPanePlacement; diff --git a/packages/hunk/src/extensions/default/ui/sidebar/FileSidebars.tsx b/packages/hunk/src/extensions/default/ui/sidebar/FileSidebars.tsx index 19ae651ea..6d660b1c4 100644 --- a/packages/hunk/src/extensions/default/ui/sidebar/FileSidebars.tsx +++ b/packages/hunk/src/extensions/default/ui/sidebar/FileSidebars.tsx @@ -1,18 +1,24 @@ +import { mousePressSequence, recordMousePress } from "../../../../ui/lib/mousePressSequence"; import type { ScrollBoxRenderable } from "@opentui/core"; -import { useTerminalDimensions } from "@opentui/react"; +import { useRenderer, useTerminalDimensions } from "@opentui/react"; import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import type { ExtensionPaneProps } from "../../../../extension-api/types"; import { + buildFilePaneEntries, buildFlatSidebarEntries, buildTreeSidebarEntries, collapseTreeSidebarEntries, expandCollapsedDirectoryPaths, + fileSidebarContentWidth, resolveFileSidebarMode, sidebarDirectoryPaths, sidebarEntryStatsWidth, toggleCollapsedDirectoryPath, + workingTreeSidebarSources, type SidebarEntry, + type SidebarFileSource, } from "../../../../ui/lib/files"; +import { filesVisuallyUnderSidebarEntry } from "../../../../ui/lib/filePaneSelection"; import { fileRowId } from "../../../../ui/lib/ids"; import { buildSidebarRenderWindow } from "../../../../ui/lib/sidebarRenderWindow"; import { @@ -27,11 +33,10 @@ export type BuiltInSidebarProps = Omit< > & Partial>; -type FileSidebarVariantProps = Pick< - BuiltInSidebarProps, - "actions" | "files" | "selectedFileId" | "theme" -> & { +type FileSidebarVariantProps = Pick & { + files: readonly SidebarFileSource[]; estimatedViewportRows: number; + onSelectEntry: (entryId: string) => void; scrollTop: number; textWidth: number; viewportHeight: number; @@ -49,12 +54,12 @@ interface VirtualizedFileSidebarRowsProps extends Omit ); } @@ -108,9 +116,11 @@ export function VirtualizedFileSidebarRows({ entry={entry} onToggleDirectory={onToggleDirectory ?? ignoreDirectoryToggle} paddingLeft={paddingLeft} + selected={entry.id === selectedFileId} statsWidth={statsWidth} textWidth={textWidth} theme={theme} + onSelect={onSelectEntry} /> ); } @@ -124,7 +134,7 @@ export function VirtualizedFileSidebarRows({ statsWidth={statsWidth} textWidth={textWidth} theme={theme} - onSelectFile={actions.selectFile} + onSelectFile={onSelectEntry} /> ); })} @@ -171,12 +181,65 @@ export function TreeFileSidebar({ * the same review-stream behavior. */ export function FlexFileSidebar({ - files, - selectedFileId, + files: reviewFiles, + selectedFileId: reviewSelectedFileId, theme, width, actions, + workingTree, }: BuiltInSidebarProps): ReactNode { + const renderer = useRenderer(); + const statusFiles = workingTree?.files; + const files = useMemo( + () => (statusFiles ? workingTreeSidebarSources(reviewFiles, statusFiles) : reviewFiles), + [reviewFiles, statusFiles], + ); + const lastClickRef = useRef<{ id: string; time: number; press: number } | null>(null); + const [localEntryId, setLocalEntryId] = useState(null); + const paneEntries = useMemo( + () => buildFilePaneEntries(files, fileSidebarContentWidth(width)), + [files, width], + ); + /** Only consecutive clicks on the same row may mutate the index. */ + const selectEntry = (entryId: string) => { + const now = Date.now(); + const previous = lastClickRef.current; + const press = mousePressSequence(renderer); + if ( + workingTree && + paneEntries.find((entry) => entry.id === entryId)?.kind === "file" && + previous?.id === entryId && + press === previous.press + 1 && + now - previous.time < 350 + ) { + lastClickRef.current = null; + workingTree.toggleEntry(entryId); + return; + } + lastClickRef.current = { id: entryId, time: now, press }; + if (workingTree) { + workingTree.selectEntry(entryId); + return; + } + setLocalEntryId(entryId); + const file = files.find((candidate) => candidate.id === entryId); + if (file) { + actions.selectFile(file.id); + return; + } + const index = paneEntries.findIndex((entry) => entry.id === entryId); + const nested = filesVisuallyUnderSidebarEntry(paneEntries, index)[0]; + if (nested) actions.selectFile(nested.id); + }; + const selectedFileId = workingTree?.selectedEntryId ?? localEntryId ?? reviewSelectedFileId; + + useEffect(() => { + if (workingTree || !localEntryId || localEntryId === reviewSelectedFileId) return; + const index = paneEntries.findIndex((entry) => entry.id === localEntryId); + const nested = filesVisuallyUnderSidebarEntry(paneEntries, index); + if (nested.some((entry) => entry.id === reviewSelectedFileId)) return; + setLocalEntryId(null); + }, [localEntryId, paneEntries, reviewSelectedFileId, workingTree]); const scrollRef = useRef(null); const previousSelectedFileIdRef = useRef(selectedFileId); const skipSelectedFileRevealRef = useRef(false); @@ -186,12 +249,12 @@ export function FlexFileSidebar({ const [scrollViewport, setScrollViewport] = useState({ top: 0, height: 0 }); const terminal = useTerminalDimensions(); // Mirrors the host layout: one column of row highlight plus row padding. - const textWidth = Math.max(8, width - 2); + const textWidth = fileSidebarContentWidth(width); const mode = resolveFileSidebarMode(textWidth); const variantProps: FileSidebarVariantProps = { - actions, estimatedViewportRows: terminal.height, files, + onSelectEntry: selectEntry, scrollTop: scrollViewport.top, selectedFileId, textWidth, @@ -289,6 +352,7 @@ export function FlexFileSidebar({ return ( recordMousePress(renderer, event)} ref={scrollRef} width="100%" height="100%" diff --git a/packages/hunk/src/extensions/runExtension.test.ts b/packages/hunk/src/extensions/runExtension.test.ts index 0e8031d25..d9ac7a201 100644 --- a/packages/hunk/src/extensions/runExtension.test.ts +++ b/packages/hunk/src/extensions/runExtension.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { resolveExtensionPanes } from "./apply"; import { runExtensionFactory, toInternalVcsAdapter } from "./runExtension"; +import { HunkExtensionUserError, type ExtensionWorkingTreeFile } from "../extension-api/types"; import { createEmptyExtensionRegistry, HUNK_EXTENSION_API_VERSION, @@ -12,6 +13,53 @@ function bundledMetadata(id: string) { return { id, sourcePath: `hunk:bundled/${id}`, origin: "bundled" as const }; } +test("working-tree mutation conversion preserves optionality, inputs and user-facing errors", async () => { + const file: ExtensionWorkingTreeFile = { + path: "alpha.txt", + staged: false, + unstaged: true, + untracked: false, + conflicted: false, + version: "reviewed", + }; + const calls: unknown[] = []; + const adapter = toInternalVcsAdapter({ + id: "test", + name: "Test", + detect: () => null, + operations: { + "working-tree-diff": { + async load() { + return { + repoRoot: "repo", + sourceLabel: "repo", + title: "Test", + patchText: "", + workingTreeFiles: [file], + }; + }, + async stageFile(input, target, context) { + calls.push([input, target, context]); + throw new HunkExtensionUserError("Index locked", { + suggestions: ["Wait for the other Git process."], + }); + }, + }, + }, + }); + const operation = adapter.operations["working-tree-diff"]!; + const input = { kind: "vcs" as const, staged: false, options: {} }; + expect((await operation.load(input, { cwd: "repo" })).workingTreeFiles).toEqual([file]); + expect(operation.unstageFile).toBeUndefined(); + expect(operation.stageHunk).toBeUndefined(); + await expect(operation.stageFile!(input, file, { cwd: "repo" })).rejects.toMatchObject({ + name: "HunkUserError", + message: "Index locked", + suggestions: ["Wait for the other Git process."], + }); + expect(calls).toEqual([[input, file, { cwd: "repo" }]]); +}); + describe("runExtensionFactory", () => { test("applies a synchronous factory before returning, with nothing to await", () => { const registry = createEmptyExtensionRegistry(); diff --git a/packages/hunk/src/extensions/runExtension.ts b/packages/hunk/src/extensions/runExtension.ts index 8f2e05d8a..c5458a355 100644 --- a/packages/hunk/src/extensions/runExtension.ts +++ b/packages/hunk/src/extensions/runExtension.ts @@ -32,6 +32,7 @@ import type { ExtensionVcsHistoryReviewAction, ExtensionVcsHistorySource, ExtensionVcsOperation, + ExtensionVcsWorkingTreeOperation, } from "../extension-api/types"; import type { VcsAdapter, VcsHistorySource, VcsOperation, VcsReviewInput } from "../core/vcs/types"; import { sanitizeTerminalLine, sanitizeTerminalText } from "../lib/terminalText"; @@ -491,6 +492,101 @@ export function toInternalVcsAdapter( } } + const workingTree = isPlainObject(operations) + ? (operations as { "working-tree-diff"?: ExtensionVcsWorkingTreeOperation })[ + "working-tree-diff" + ] + : undefined; + const internalWorkingTree = internalOperations["working-tree-diff"] as + | (VcsOperation & + Pick< + ExtensionVcsWorkingTreeOperation, + | "stageFile" + | "unstageFile" + | "stageHunk" + | "unstageHunk" + | "discardFile" + | "stashFile" + | "stashFiles" + | "resolveWorkingTreeLine" + >) + | undefined; + if (workingTree && internalWorkingTree) { + for (const name of ["stageFile", "unstageFile"] as const) { + const mutate = workingTree[name]; + if (typeof mutate !== "function") continue; + internalWorkingTree[name] = async (input, file, context) => { + try { + await mutate(input, file, context); + } catch (error) { + throw toUserFacingError(error); + } + }; + } + } + + if (workingTree && internalWorkingTree) { + for (const name of ["stageHunk", "unstageHunk"] as const) { + const mutate = workingTree[name]; + if (typeof mutate !== "function") continue; + internalWorkingTree[name] = async (input, file, hunk, context) => { + try { + await mutate(input, file, hunk, context); + } catch (error) { + throw toUserFacingError(error); + } + }; + } + } + + if (workingTree && internalWorkingTree) { + if (typeof workingTree.discardFile === "function") { + const discard = workingTree.discardFile; + internalWorkingTree.discardFile = async (input, file, scope, context) => { + try { + await discard(input, file, scope, context); + } catch (error) { + throw toUserFacingError(error); + } + }; + } + if (typeof workingTree.stashFile === "function") { + const stash = workingTree.stashFile; + internalWorkingTree.stashFile = async (input, file, message, context) => { + try { + await stash(input, file, message, context); + } catch (error) { + throw toUserFacingError(error); + } + }; + } + if (typeof workingTree.stashFiles === "function") { + const stash = workingTree.stashFiles; + internalWorkingTree.stashFiles = async (input, files, message, context) => { + try { + await stash(input, files, message, context); + } catch (error) { + throw toUserFacingError(error); + } + }; + } + } + + if ( + workingTree && + internalWorkingTree && + typeof workingTree.resolveWorkingTreeLine === "function" + ) { + const resolveLine = workingTree.resolveWorkingTreeLine; + internalWorkingTree.resolveWorkingTreeLine = async (input, file, line, context) => { + try { + return await resolveLine(input, file, line, context); + } catch (error) { + throw toUserFacingError(error); + } + }; + } + const history = adapterFields.history; const historyFields = isPlainObject(history) ? snapshotProperties(history, ["open", "planReview"]) diff --git a/packages/hunk/src/extensions/vcsPatchResult.ts b/packages/hunk/src/extensions/vcsPatchResult.ts index 540697358..6eedba3f0 100644 --- a/packages/hunk/src/extensions/vcsPatchResult.ts +++ b/packages/hunk/src/extensions/vcsPatchResult.ts @@ -144,6 +144,7 @@ export function toInternalVcsPatchResult(result: ExtensionVcsPatchResult): VcsPa sourceLabel: result.sourceLabel, title: result.title, patchText: result.patchText, + workingTreeFiles: result.workingTreeFiles?.map((file) => Object.freeze({ ...file })), untrackedPaths: result.untrackedPaths, sourceFetcherBuilder, extraFiles: result.extraFiles?.map((entry, index) => diff --git a/packages/hunk/src/opentui/HunkFileNav.tsx b/packages/hunk/src/opentui/HunkFileNav.tsx index 7acf56575..033f089a1 100644 --- a/packages/hunk/src/opentui/HunkFileNav.tsx +++ b/packages/hunk/src/opentui/HunkFileNav.tsx @@ -78,7 +78,8 @@ export function HunkFileNav({ key={entry.id} entry={entry} paddingLeft={0} - textWidth={Math.max(1, width)} + statsWidth={statsWidth} + textWidth={textWidth} theme={resolvedTheme} /> ); diff --git a/packages/hunk/src/session/broker/brokerClient.test.ts b/packages/hunk/src/session/broker/brokerClient.test.ts index fb3c6aee3..5977b673e 100644 --- a/packages/hunk/src/session/broker/brokerClient.test.ts +++ b/packages/hunk/src/session/broker/brokerClient.test.ts @@ -496,7 +496,7 @@ describe("Hunk session daemon client", () => { host: "127.0.0.1", port, }).metadataPath; - mkdirSync(join(metadataPath, ".."), { recursive: true }); + mkdirSync(join(metadataPath, ".."), { recursive: true, mode: 0o700 }); const writeMetadata = (pid: number) => writeFileSync( metadataPath, diff --git a/packages/hunk/src/ui/App.extension-command-controls.test.tsx b/packages/hunk/src/ui/App.extension-command-controls.test.tsx index b08f07dbd..4186591a8 100644 --- a/packages/hunk/src/ui/App.extension-command-controls.test.tsx +++ b/packages/hunk/src/ui/App.extension-command-controls.test.tsx @@ -65,6 +65,11 @@ describe("extension command control authority", () => { detail: "The test host does not reload reviews.", })} onWorkspaceWriteCompleted={() => {}} + onVcsMutationCompleted={async () => {}} + runVcsMutation={async (write) => { + await write(); + return true; + }} runWorkspaceWrite={async (write) => { await write(); return true; diff --git a/packages/hunk/src/ui/App.extension-runtime.test.tsx b/packages/hunk/src/ui/App.extension-runtime.test.tsx index 97156fc10..55dc62aae 100644 --- a/packages/hunk/src/ui/App.extension-runtime.test.tsx +++ b/packages/hunk/src/ui/App.extension-runtime.test.tsx @@ -39,6 +39,11 @@ function TestApp({ bootstrap }: { bootstrap: AppBootstrap }) { detail: "The test host does not reload reviews.", })} onWorkspaceWriteCompleted={() => {}} + onVcsMutationCompleted={async () => {}} + runVcsMutation={async (write) => { + await write(); + return true; + }} runWorkspaceWrite={async (write) => { await write(); return true; diff --git a/packages/hunk/src/ui/App.extension-trust.test.tsx b/packages/hunk/src/ui/App.extension-trust.test.tsx index 885869fbc..380f4f4e0 100644 --- a/packages/hunk/src/ui/App.extension-trust.test.tsx +++ b/packages/hunk/src/ui/App.extension-trust.test.tsx @@ -54,6 +54,11 @@ function createTrustHarness(initial: AppBootstrap) { detail: "The test host does not reload reviews.", })} onWorkspaceWriteCompleted={() => {}} + onVcsMutationCompleted={async () => {}} + runVcsMutation={async (write) => { + await write(); + return true; + }} runWorkspaceWrite={async (write) => { await write(); return true; diff --git a/packages/hunk/src/ui/App.tsx b/packages/hunk/src/ui/App.tsx index 3c68e7ccb..f2ccfce61 100644 --- a/packages/hunk/src/ui/App.tsx +++ b/packages/hunk/src/ui/App.tsx @@ -1,3 +1,7 @@ +import { getConfiguredVcsAdapter } from "../core/vcs"; +import { WorkingTreeDialog } from "./components/chrome/WorkingTreeDialog"; +import { useLiveState } from "./hooks/useLiveState"; +import { recordMousePress } from "./lib/mousePressSequence"; import type { BoxRenderable, MouseEvent as TuiMouseEvent, @@ -21,6 +25,7 @@ import { DEFAULT_FILE_GAP, DEFAULT_HUNK_GAP } from "../core/run/reviewGap"; import { DEFAULT_TAB_WIDTH } from "../core/run/tabWidth"; import { isVcsReviewInput } from "../core/vcs"; import type { AppBootstrap } from "../core/bootstrap"; +import type { ReviewSelectionScope } from "../core/review/navigation"; import { selectActiveEditableReviewNoteId, selectActiveReplyableReviewNoteId, @@ -45,6 +50,8 @@ import { ExtensionDialog } from "./components/chrome/ExtensionDialog"; import { ViewPreferenceQuitDialog } from "./components/chrome/ViewPreferenceQuitDialog"; import { ExtensionToast } from "./components/chrome/ExtensionToast"; import { StatusBar } from "./components/chrome/StatusBar"; +import { WorkingTreeBar } from "./components/chrome/WorkingTreeBar"; +import { hasWorkingTreeInventory, useWorkingTreeActions } from "./hooks/useWorkingTreeActions"; import { DiffPane } from "./components/panes/DiffPane"; import { ExtensionPaneHost } from "./components/panes/ExtensionPane"; import { PaneDivider } from "./components/panes/PaneDivider"; @@ -107,7 +114,7 @@ import { import { HUNK_FILES_PANE_KEY } from "../extensions/extensionIds"; import { maxFileHeaderStatsWidth } from "./lib/fileHeader"; import { setMouseCapture } from "./lib/mouseCapture"; -import { openSelectedFileInEditor } from "./lib/openInEditor"; +import { openSelectedFileInEditor, resolveSelectedEditorLine } from "./lib/openInEditor"; import { resolveResponsiveLayout } from "./lib/responsive"; import type { WorkspaceRefreshRequest } from "./currentReviewRefresh"; @@ -145,9 +152,11 @@ export function App({ onReloadSession, onRequestExtensionReviewReload, onWorkspaceWriteCompleted, + onVcsMutationCompleted, reviewProducer, runWorkspaceWrite, returnToHistory = process.env.HUNK_RETURN_TO_HISTORY === "1", + runVcsMutation, watchRuntime, workspaceFileWriter, }: { @@ -171,12 +180,14 @@ export function App({ ) => Promise; /** Reconcile the currently mounted review after a consented filesystem write succeeds. */ onWorkspaceWriteCompleted: () => void; + onVcsMutationCompleted: (follow?: { root: string; staged: boolean }) => Promise; /** The producer publishing this review's generations, when the host mounted one. */ reviewProducer?: ReviewProducer; /** Start and track one irreversible write, or refuse it once graceful shutdown begins. */ runWorkspaceWrite: WorkspaceWriteRunner; /** Present quit as returning to the owning history surface. */ returnToHistory?: boolean; + runVcsMutation: WorkspaceWriteRunner; watchRuntime?: WatchedInputRuntime; workspaceFileWriter?: WorkspaceFileWriter; }) { @@ -185,6 +196,7 @@ export function App({ const BODY_PADDING = 2; const pagerMode = Boolean(bootstrap.input.options.pager); + const workingTreeBarHeight = hasWorkingTreeInventory(bootstrap) ? 1 : 0; const tabWidth = bootstrap.initialTabWidth ?? DEFAULT_TAB_WIDTH; const fileGap = bootstrap.initialFileGap ?? DEFAULT_FILE_GAP; const hunkGap = bootstrap.initialHunkGap ?? DEFAULT_HUNK_GAP; @@ -242,7 +254,7 @@ export function App({ const [showMenuBar, setShowMenuBar] = useState(bootstrap.initialShowMenuBar ?? true); const [showHelp, setShowHelp] = useState(false); const [showAgentSkill, setShowAgentSkill] = useState(false); - const [focusArea, setFocusArea] = useState("files"); + const [focusArea, setFocusArea, getFocusArea] = useLiveState("files"); const { text: sessionNoticeText, show: showSessionNotice } = useTimedNotice(4_000); const extensions = bootstrap.extensions as ExtensionLoadResult | undefined; const pendingTrustRepoRoot = extensions?.pendingTrustRepoRoot; @@ -343,8 +355,11 @@ export function App({ getSelectedFileId, getSelection: getExtensionSelection, } = extensionRuntime; + const [hunkActionFocused, setHunkActionFocused, getHunkActionFocused] = useLiveState(false); + const [filePanelFocused, setFilePanelFocused, getFilePanelFocused] = useLiveState(false); const jumpToFile = useCallback( (fileId: string, options?: { alignFileHeaderTop?: boolean }) => { + setHunkActionFocused(false); review.selectFile(fileId, { alignFileHeaderTop: options?.alignFileHeaderTop }); }, [review.selectFile], @@ -450,7 +465,11 @@ export function App({ ); const bodyHeight = Math.max( 0, - terminal.height - (showMenuBar ? 1 : 0) - (extensionToast ? 1 : 0) - (statusBarVisible ? 1 : 0), + terminal.height - + (showMenuBar ? 1 : 0) - + workingTreeBarHeight - + (extensionToast ? 1 : 0) - + (statusBarVisible ? 1 : 0), ); const showPaneWarning = useCallback( (message: string) => extensions?.context.notify(message, "warning"), @@ -893,7 +912,7 @@ export function App({ setShowMenuBar((current) => !current); }; - const { canRefreshCurrentInput, refreshCurrentInput, triggerRefreshCurrentInput } = + const { canRefreshCurrentInput, refreshCurrentInput, triggerRefreshCurrentInput, setStagedView } = useCurrentReviewRefreshController({ input: bootstrap.input, onRegisterWorkspaceRefreshRequest, @@ -927,34 +946,70 @@ export function App({ showNotice: showSessionNotice, }); - const triggerEditSelectedFile = useCallback(() => { - const basePath = isVcsReviewInput(bootstrap.input) - ? bootstrap.changeset.sourceLabel - : undefined; - const message = openSelectedFileInEditor({ - basePath, - file: selectedFile, - lineCursor: activeLineCursor, - renderer, - selectedHunk: review.selectedHunk, - }); - - if (message) { - showSessionNotice(message); - return; - } - - if (canRefreshCurrentInput) { - triggerRefreshCurrentInput(); + const editorPendingRef = useRef<{ lease: ReturnType } | null>( + null, + ); + const triggerEditSelectedFile = useCallback(async () => { + if (editorPendingRef.current?.lease.isLive()) return; + const request = { lease: createReviewCapabilityLease() }; + editorPendingRef.current = request; + const { lease } = request; + const lineCursor = review.getExplicitLineCursor(); + const selection = review.getSelection(); + const file = bootstrap.changeset.files.find((candidate) => candidate.id === selection.fileId); + const hunk = file?.metadata.hunks[selection.hunkIndex ?? 0]; + try { + const basePath = isVcsReviewInput(bootstrap.input) + ? bootstrap.changeset.sourceLabel + : undefined; + let mappedLine: number | undefined; + if (file && bootstrap.input.kind === "vcs" && bootstrap.reloadContext.vcsCatalog) { + const status = bootstrap.changeset.workingTreeFiles?.find( + (candidate) => candidate.path === file.path, + ); + const resolveLine = getConfiguredVcsAdapter( + bootstrap.input.options.vcs, + bootstrap.reloadContext.vcsCatalog, + ).operations["working-tree-diff"]?.resolveWorkingTreeLine; + if (status && resolveLine) { + showSessionNotice(`Locating ${file.path} in the working tree…`); + mappedLine = await resolveLine( + bootstrap.input, + status, + Math.max(1, resolveSelectedEditorLine(file, hunk, lineCursor)), + { cwd: bootstrap.changeset.sourceLabel }, + ); + if (!lease.isLive()) return; + } + } + const message = openSelectedFileInEditor({ + basePath, + file, + lineCursor, + mappedLine, + renderer, + selectedHunk: hunk, + }); + if (message) { + showSessionNotice(message); + return; + } + if (canRefreshCurrentInput) triggerRefreshCurrentInput(); + } catch (error) { + if (lease.isLive()) + showSessionNotice( + `Cannot open editor: ${error instanceof Error ? error.message : String(error)}`, + ); + } finally { + if (editorPendingRef.current === request) editorPendingRef.current = null; } }, [ - activeLineCursor, - bootstrap.changeset.sourceLabel, - bootstrap.input.kind, + bootstrap, canRefreshCurrentInput, + createReviewCapabilityLease, renderer, - review.selectedHunk, - selectedFile, + review.getExplicitLineCursor, + review.getSelection, showSessionNotice, triggerRefreshCurrentInput, ]); @@ -989,8 +1044,37 @@ export function App({ /** Focus the file list/sidebar navigation area. */ const focusFiles = useCallback(() => { setFocusArea("files"); + setFilePanelFocused(true); + setHunkActionFocused(false); }, []); + const filesPaneFocused = + filesPaneVisible && focusArea === "files" && filePanelFocused && !hunkActionFocused; + const isFilesPaneFocused = () => + filesPaneVisible && + getFocusArea() === "files" && + getFilePanelFocused() && + !getHunkActionFocused(); + const filesPaneWidth = + paneLayout.panes.find((planned) => planned.pane.key === HUNK_FILES_PANE_KEY)?.bounds.width ?? 0; + const workingTree = useWorkingTreeActions({ + bootstrap, + selectedFile, + getSelection: review.getSelection, + isFilesPaneFocused, + filter: review.filter, + reviewFiles: filteredFiles, + filesPaneFocused, + filesPaneWidth, + createLease: createReviewCapabilityLease, + selectReviewFile: jumpToFile, + focusFiles, + setStagedView, + runMutation: runVcsMutation, + refreshAfterMutation: onVcsMutationCompleted, + showNotice: showSessionNotice, + }); + /** Focus the file filter input in the status bar. */ const focusFilter = useCallback(() => { setFocusArea("filter"); @@ -1004,10 +1088,12 @@ export function App({ }, onSelectHunk: (fileId: string, hunkIndex: number) => { focusFiles(); + setHunkActionFocused(true); review.selectHunk(fileId, hunkIndex); }, onRevealLine: (fileId: string, side: "old" | "new", line: number) => { focusFiles(); + setHunkActionFocused(true); return review.revealLine(fileId, side, line); }, }), @@ -1016,8 +1102,12 @@ export function App({ /** Toggle keyboard focus between the file list and the file filter. */ const toggleFocusArea = useCallback(() => { - setFocusArea((current) => (current === "files" ? "filter" : "files")); - }, []); + if (focusArea === "files") { + setFocusArea("filter"); + return; + } + focusFiles(); + }, [focusArea, focusFiles]); /** Move keyboard ownership into the draft note editor. */ const focusDraftNoteEditor = useCallback(() => setFocusArea("note"), []); @@ -1040,6 +1130,7 @@ export function App({ updateDraftNote, } = useUserNoteComposer({ draftNote: review.draftNote, + getDraftNoteId: () => review.store.getSnapshot().draftNote?.id ?? null, keyboardCursorEnabled: cursorLine !== "off", getLineCursor: review.getLineCursor, startDraft: review.startUserNote, @@ -1058,6 +1149,19 @@ export function App({ const activeEditableNoteId = selectActiveEditableReviewNoteId(review.store.getSnapshot()); const activeReplyableNoteId = selectActiveReplyableReviewNoteId(review.store.getSnapshot()); + // Files-pane keyboard ownership: vertical movement, stash, and discard. Diff + // headers can select a file for staging without giving the sidebar that ownership. + const reviewPaneFocused = filesPaneVisible && focusArea === "files" && !filesPaneFocused; + + /** Step the review selection and record which pane now owns keyboard movement. */ + const moveReviewSelection = (scope: ReviewSelectionScope, delta: number) => { + setFilePanelFocused(scope === "file"); + setHunkActionFocused(scope === "hunk" || scope === "annotated-hunk"); + if (scope === "file" && workingTree.pane) workingTree.moveFile(delta); + else review.moveSelection(scope, delta); + }; + // Hunk navigation can focus a binary or hunk-less file; Space then keeps the file action. + const hunkStagingActive = hunkActionFocused && workingTree.canToggleSelectedHunk; // One dispatch table for every app-level shortcut: the built-in commands // over App's live callbacks, then extension commands, so built-ins always @@ -1065,28 +1169,76 @@ export function App({ const appCommands = observeAppCommandDispatch( [ ...buildAppCommands({ + get canDiscardSelectedFile() { + return isFilesPaneFocused() && workingTree.canDiscardSelected; + }, + get canStashSelectedFile() { + return isFilesPaneFocused() && workingTree.canStashSelected; + }, + discardSelectedFile: workingTree.discardSelected, + stashSelectedFile: workingTree.stashSelected, + get canToggleFileStaged() { + return ( + !(getHunkActionFocused() && workingTree.canToggleSelectedHunk) && + workingTree.canToggleSelected + ); + }, + get canToggleHunkStaged() { + return getHunkActionFocused() && workingTree.canToggleSelectedHunk; + }, + toggleHunkStaged: workingTree.toggleSelectedHunk, + canSwitchStagedView: Boolean(workingTree.pane), + toggleFileStaged: workingTree.toggleSelected, + toggleStagedView: () => workingTree.switchView(!workingTree.staged), canAlignCurrentLine: cursorLine !== "off" && review.lineCursor !== null, canApplyFilePresentationToAllMatching: selectedFileViewBulkTarget !== null, + get canFocusDiffPane() { + return ( + isFilesPaneFocused() && selectedFile !== undefined && !workingTree.isSelectedFolder() + ); + }, + get canFocusFilesPane() { + return filesPaneVisible && getFocusArea() === "files" && !isFilesPaneFocused(); + }, canEditActiveNote: activeEditableNoteId !== undefined && review.draftNote === null, canReplyToActiveNote: activeReplyableNoteId !== undefined && review.draftNote === null, canRefreshCurrentInput, alignCurrentLine, applyFilePresentationToAllMatching, + focusDiffPane: () => { + if (!selectedFile) { + return; + } + setFilePanelFocused(false); + }, focusFilter, + focusFilesPane: focusFiles, editActiveNote: () => { if (activeEditableNoteId) startUserNoteEdit(activeEditableNoteId); }, replyToActiveNote: () => { if (activeReplyableNoteId) startUserNoteReply(activeReplyableNoteId); }, - moveSelection: review.moveSelection, + moveSelection: moveReviewSelection, openAgentSkill, openThemeSelector, requestQuit, resolvedKeys: resolvedCommandKeys, scrollCodeHorizontally, scrollDiff, - stepDiffLine, + stepDiffLine: (delta) => { + if (isFilesPaneFocused()) { + if (workingTree.pane) { + setFilePanelFocused(true); + setHunkActionFocused(false); + workingTree.moveEntry(delta); + return; + } + moveReviewSelection("file", delta); + return; + } + stepDiffLine(delta); + }, selectCursorLine: setCursorLine, selectLayoutMode, startUserNote: () => startUserNote(), @@ -1166,6 +1318,7 @@ export function App({ } = useMenuController(menus); useAppKeyboardShortcuts({ + workingTreeDialog: workingTree, activeMenuId, activateCurrentMenuItem, closeAgentSkill, @@ -1220,7 +1373,8 @@ export function App({ const diffHeaderStatsWidth = maxFileHeaderStatsWidth(filteredFiles); const diffHeaderLabelWidth = Math.max(0, diffContentWidth - diffHeaderStatsWidth - 1); const diffSeparatorWidth = Math.max(0, diffContentWidth - 2); - const diffPaneScreenTop = (showMenuBar ? 1 : 0) + paneLayout.reviewBounds.y; + const diffPaneScreenTop = + (showMenuBar ? 1 : 0) + workingTreeBarHeight + paneLayout.reviewBounds.y; /** Render one pane from the exact accepted host rectangle. */ const renderPane = (planned: PlannedPane) => { @@ -1242,6 +1396,7 @@ export function App({ review={bootstrap.review ?? null} files={filteredFiles} fileViews={getRenderExtensionFileViews()} + workingTree={workingTree.pane} selectedFileId={selection.file?.id ?? null} selectedHunkIndex={selection.hunkIndex} placement={pane.placement} @@ -1250,6 +1405,7 @@ export function App({ height={bounds.height} currentLine={pane.registered.pane.currentLine ? currentLinePaint : null} showTopChrome={showMenuBar} + focused={pane.key === HUNK_FILES_PANE_KEY && filesPaneFocused} keybindings={paneKeybindings} notify={(message, type) => extensions?.context.notify(message, type)} onCopyText={(text) => { @@ -1264,18 +1420,10 @@ export function App({ showTransientNotice("Copied text to clipboard"); return true; }} - onSelectFile={(fileId) => { - focusFiles(); - jumpToFile(fileId, { alignFileHeaderTop: true }); - }} - onSelectHunk={(fileId, hunkIndex) => { - focusFiles(); - review.selectHunk(fileId, hunkIndex); - }} - onRevealLine={(fileId, side, line) => { - focusFiles(); - return review.revealLine(fileId, side, line); - }} + onFocus={pane.key === HUNK_FILES_PANE_KEY ? focusFiles : undefined} + onSelectFile={extensionNavigationBindings.onSelectFile} + onSelectHunk={extensionNavigationBindings.onSelectHunk} + onRevealLine={extensionNavigationBindings.onRevealLine} onRenderFailure={ pane.key === HUNK_FILES_PANE_KEY ? undefined : () => reportPaneRenderFailure(pane) } @@ -1287,6 +1435,7 @@ export function App({ // OpenTUI normally chooses a drag target only after the pointer first moves. Capture on press // so a fast motion or a sidebar projection swap cannot transfer the gesture to a transient row. const beginCapturedPaneResize = (planned: PlannedPane, event: TuiMouseEvent) => { + recordMousePress(renderer, event); if (!beginPaneResize(planned, event)) return; if (paneResizeCaptureRef.current) { setMouseCapture(renderer, paneResizeCaptureRef.current); @@ -1311,6 +1460,7 @@ export function App({ width={planned.divider.width} height={planned.divider.height} isResizing={resizingPaneKey === planned.pane.key} + emphasized={planned.pane.key === HUNK_FILES_PANE_KEY} theme={activeTheme} onMouseDown={(event) => beginCapturedPaneResize(planned, event)} onMouseDrag={updatePaneResize} @@ -1322,6 +1472,7 @@ export function App({ return ( recordMousePress(renderer, event)} style={{ width: "100%", height: "100%", @@ -1345,6 +1496,22 @@ export function App({ /> ) : null} + {workingTree.pane && ( + + )} + setFilePanelFocused(false)} style={{ position: "absolute", left: bodyPadding / 2 + paneLayout.reviewBounds.x, @@ -1391,6 +1559,8 @@ export function App({ screenTop={diffPaneScreenTop} showTopChrome={showMenuBar} skipInitialIntermediateRender={Boolean(onFirstFrameReady)} + focused={reviewPaneFocused} + framed={filesPaneVisible} headerLabelWidth={diffHeaderLabelWidth} headerStatsWidth={diffHeaderStatsWidth} layout={resolvedLayout} @@ -1438,7 +1608,10 @@ export function App({ }} onCopyFeedback={showTransientNotice} onFileViewRowFailure={reportFileViewRowFailure} - onSelectFile={jumpToFile} + onSelectFile={(fileId) => { + setFilePanelFocused(false); + jumpToFile(fileId); + }} onToggleGap={review.toggleGap} onViewportCenteredHunkChange={(fileId, hunkIndex) => review.anchorSelection(fileId, hunkIndex) @@ -1447,6 +1620,13 @@ export function App({ currentLinePaintRequested={currentLinePaintRequested} onCurrentLinePaintChange={onCurrentLinePaintChange} onViewportLineCursorChange={review.anchorLineCursor} + onHunkFocus={() => { + setFilePanelFocused(false); + setHunkActionFocused(true); + }} + onReviewPointerDown={() => setFilePanelFocused(false)} + canToggleHunkStaged={workingTree.canToggleHunk} + onToggleHunkStaged={workingTree.pane ? workingTree.toggleHunk : undefined} /> @@ -1537,6 +1717,19 @@ export function App({ /> ) : null} + {workingTree.prompt ? ( + + ) : null} + {saveConfigPromptOpen ? ( { const bootstrap = await launchWithExtension(repo, extPath); await withAppHost(bootstrap, async (setup) => { - // The sidebar file rows carry the "M " status prefix; the diff + // The sidebar file rows carry the "M " modified-status prefix; the diff // pane's own headers do not, so the prefix marks the area's visibility. await flushUntil( setup, diff --git a/packages/hunk/src/ui/AppHost.extensions.test.tsx b/packages/hunk/src/ui/AppHost.extensions.test.tsx index c1dd465d8..66bee9134 100644 --- a/packages/hunk/src/ui/AppHost.extensions.test.tsx +++ b/packages/hunk/src/ui/AppHost.extensions.test.tsx @@ -471,7 +471,7 @@ describe("reload keeps launch extension authority", () => { ` });\n` + `}\n`, ); - useTempConfigHome(); + useTempConfigHome('[keybindings]\n"hunk.view.toggleFilesPane" = "s"\n'); const bootstrap = await launchInSubdirectory(repo, { extensionPaths: [extPath] }); bootstrap.extensions = await loadStartupExtensions({ diff --git a/packages/hunk/src/ui/AppHost.file-view-modes.test.tsx b/packages/hunk/src/ui/AppHost.file-view-modes.test.tsx index 6db9bdfb1..d99e59a79 100644 --- a/packages/hunk/src/ui/AppHost.file-view-modes.test.tsx +++ b/packages/hunk/src/ui/AppHost.file-view-modes.test.tsx @@ -151,7 +151,9 @@ function createInteractiveModeExtension() { * second while the first holds the keyboard must tear the first down. The * Escape binding is the observable for the other question — whether a second * Escape arriving in the same input flush, after the first already exited the - * mode, is still swallowed as if the mode were running. + * mode, is still swallowed as if the mode were running. Tests that use this + * probe unbind `hunk.review.focusFilesPane` so the host's default Escape chord + * does not take the key. */ function createModeHandoffExtension() { return createModeExtension( @@ -233,6 +235,7 @@ function createBrokenModeExtension() { async function renderWithExtension( { extension, root }: { extension: string; root: string }, files = [createTestDiffFile({ id: "alpha", path: "alpha.ts" })], + keybindings?: Record, ) { const extensions = await loadStartupExtensions({ cliExtensionPaths: [extension], @@ -257,6 +260,9 @@ async function renderWithExtension( vcsOptions: { extensionPaths: [extension] }, }); bootstrap.extensions = extensions; + if (keybindings) { + bootstrap.keybindings = { ...bootstrap.keybindings, ...keybindings }; + } const setup = await testRender( {}} />, { width: 120, height: 24, @@ -547,7 +553,9 @@ describe("AppHost file-view modes", () => { }); test("entering a second mode exits the first before the new one starts", async () => { - const { notices, setup } = await renderWithExtension(createModeHandoffExtension()); + const { notices, setup } = await renderWithExtension(createModeHandoffExtension(), undefined, { + "hunk.review.focusFilesPane": false, + }); try { await waitForFrame(setup, (frame) => frame.includes("alpha.ts")); @@ -576,7 +584,9 @@ describe("AppHost file-view modes", () => { }); test("an exiting key handler does not tear down the mode it handed off to", async () => { - const { notices, setup } = await renderWithExtension(createModeHandoffExtension()); + const { notices, setup } = await renderWithExtension(createModeHandoffExtension(), undefined, { + "hunk.review.focusFilesPane": false, + }); try { await waitForFrame(setup, (frame) => frame.includes("alpha.ts")); @@ -641,7 +651,9 @@ describe("AppHost file-view modes", () => { }); test("a second Escape in one input flush is routed as if no mode were running", async () => { - const { notices, setup } = await renderWithExtension(createModeHandoffExtension()); + const { notices, setup } = await renderWithExtension(createModeHandoffExtension(), undefined, { + "hunk.review.focusFilesPane": false, + }); try { await waitForFrame(setup, (frame) => frame.includes("alpha.ts")); diff --git a/packages/hunk/src/ui/AppHost.interactions.test.tsx b/packages/hunk/src/ui/AppHost.interactions.test.tsx index b00106ed5..218465f05 100644 --- a/packages/hunk/src/ui/AppHost.interactions.test.tsx +++ b/packages/hunk/src/ui/AppHost.interactions.test.tsx @@ -1,3 +1,4 @@ +import { getTestSidebarFrame } from "../../../../test/helpers/sidebar-frame"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -23,6 +24,7 @@ import { import { createEmptyExtensionLoadResult } from "../extensions/types"; import { AGENT_SKILL_COMMAND, AGENT_SKILL_PROMPT } from "./components/chrome/AgentSkillDialog"; import { App } from "./App"; +import { paneFrameBorderColor } from "./lib/paneFocus"; import { availableThemes, resolveTheme } from "./themes"; const { loadAppBootstrap } = await import("../core/changeset/loaders"); @@ -568,6 +570,45 @@ function firstCrossFileHunkNavigationHeader(frame: string) { ); } +/** Columns inside the files pane, between its left frame and the divider. */ +function sidebarColumns(line: string) { + const parts = line.split("│"); + return parts.length >= 3 ? (parts[1] ?? "") : (parts[0] ?? ""); +} + +/** Row index of a file name in the files pane. */ +function sidebarFileRow(setup: Awaited>, fileName: string) { + return setup + .captureCharFrame() + .split("\n") + .findIndex((line) => sidebarColumns(line).includes(fileName)); +} + +/** Foreground of the files pane's left frame on one rendered row. */ +function leftPaneBorderColor(setup: Awaited>, row: number) { + const line = setup.captureSpans().lines[row]; + const border = line?.spans.find((span) => span.text.includes("│")); + return capturedTestColorToHex(border?.fg); +} + +/** Wait until the files pane's left frame on `row` uses `expected`. */ +async function waitForLeftPaneBorder( + setup: Awaited>, + row: number, + expected: string, +) { + for (let attempt = 0; attempt < 8; attempt += 1) { + if (leftPaneBorderColor(setup, row) === expected) { + return; + } + await act(async () => { + await Bun.sleep(30); + await setup.renderOnce(); + }); + } + expect(leftPaneBorderColor(setup, row)).toBe(expected); +} + async function waitForSnapshot( setup: Awaited>, getSnapshot: () => HunkSessionSnapshot["state"] | null, @@ -1139,6 +1180,11 @@ describe("App interactions", () => { detail: "The test host does not reload reviews.", })} onWorkspaceWriteCompleted={() => {}} + onVcsMutationCompleted={async () => {}} + runVcsMutation={async (write) => { + await write(); + return true; + }} runWorkspaceWrite={async (write) => { await write(); return true; @@ -3142,12 +3188,7 @@ describe("App interactions", () => { await flush(setup); await act(async () => { - await setup.mockInput.typeText("Save from tmux CSI-u."); - }); - await flush(setup); - - await act(async () => { - await setup.mockInput.pressKeys(["\u001b[115;5u"]); + await setup.mockInput.typeText("Save from tmux CSI-u.\u001b[115;5u"); }); await flush(setup); @@ -3673,9 +3714,9 @@ describe("App interactions", () => { await flush(setup); let frame = setup.captureCharFrame(); - const directoryY = frame + const directoryY = getTestSidebarFrame(frame) .split("\n") - .findIndex((line) => line.split("│", 1)[0]?.includes("⌄ src/")); + .findIndex((line) => line.includes("⌄ src/")); expect(directoryY).toBeGreaterThan(0); expect((frame.match(/alpha\.ts/g) ?? []).length).toBe(2); expect((frame.match(/beta\.ts/g) ?? []).length).toBe(2); @@ -3687,7 +3728,7 @@ describe("App interactions", () => { frame = setup.captureCharFrame(); expect(frame.split("\n")[directoryY]).toContain("› src/"); - expect(frame.split("\n")[directoryY]?.split("│", 1)[0]).toContain("2 files"); + expect(getTestSidebarFrame(frame).split("\n")[directoryY]).toContain("2 files"); expect((frame.match(/alpha\.ts/g) ?? []).length).toBe(1); expect((frame.match(/beta\.ts/g) ?? []).length).toBe(1); expect((frame.match(/README\.md/g) ?? []).length).toBe(2); @@ -3725,10 +3766,7 @@ describe("App interactions", () => { await flush(setup); } - const secondFileY = setup - .captureCharFrame() - .split("\n") - .findIndex((line) => line.split("│", 1)[0]?.includes("second.ts")); + const secondFileY = sidebarFileRow(setup, "second.ts"); expect(secondFileY).toBeGreaterThan(0); await act(async () => { @@ -3751,6 +3789,257 @@ describe("App interactions", () => { } }); + test("Tab back from the filter focuses the files pane", async () => { + const { getLatestSnapshot, hostClient } = createMockHostClient(); + const setup = await testRender( + , + { + width: 220, + height: 16, + }, + ); + + try { + await flush(setup); + expect(getLatestSnapshot()?.selectedFileId).toBe("first"); + + await act(async () => { + await setup.mockInput.pressArrow("down"); + }); + await flush(setup); + expect(getLatestSnapshot()?.selectedFileId).toBe("first"); + + await act(async () => { + await setup.mockInput.pressTab(); + }); + await flush(setup); + expect(setup.captureCharFrame()).toContain("filter:"); + + await act(async () => { + await setup.mockInput.pressTab(); + }); + await flush(setup); + + await act(async () => { + await setup.mockInput.pressArrow("down"); + }); + await flush(setup); + + const snapshot = await waitForSnapshot( + setup, + getLatestSnapshot, + (nextSnapshot) => nextSnapshot.selectedFileId === "second", + ); + expect(snapshot?.selectedFileId).toBe("second"); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + + test("arrow keys move between files while the files pane is focused", async () => { + const { getLatestSnapshot, hostClient } = createMockHostClient(); + const setup = await testRender( + , + { + width: 220, + height: 16, + }, + ); + + try { + await flush(setup); + expect(getLatestSnapshot()?.selectedFileId).toBe("first"); + + const firstFileY = sidebarFileRow(setup, "first.ts"); + expect(firstFileY).toBeGreaterThan(0); + + await act(async () => { + await setup.mockMouse.click(6, firstFileY); + }); + await flush(setup); + + await act(async () => { + await setup.mockInput.pressArrow("down"); + }); + await flush(setup); + + let snapshot = await waitForSnapshot( + setup, + getLatestSnapshot, + (nextSnapshot) => nextSnapshot.selectedFileId === "second", + ); + expect(snapshot?.selectedFileId).toBe("second"); + + await act(async () => { + await setup.mockInput.pressArrow("up"); + }); + await flush(setup); + + snapshot = await waitForSnapshot( + setup, + getLatestSnapshot, + (nextSnapshot) => nextSnapshot.selectedFileId === "first", + ); + expect(snapshot?.selectedFileId).toBe("first"); + + await act(async () => { + await setup.mockMouse.click(140, 6); + }); + await flush(setup); + await act(async () => { + await setup.mockInput.pressArrow("down"); + }); + await flush(setup); + + expect(getLatestSnapshot()?.selectedFileId).toBe("first"); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + + test("Enter focuses the selected file's review and Esc returns to the files pane", async () => { + const theme = resolveTheme("github-dark-default", null); + const focusedBorder = paneFrameBorderColor(theme, true); + const unfocusedBorder = paneFrameBorderColor(theme, false); + const { getLatestSnapshot, hostClient } = createMockHostClient(); + const setup = await testRender( + , + { + width: 220, + height: 16, + }, + ); + + try { + await flush(setup); + + const firstFileY = sidebarFileRow(setup, "first.ts"); + expect(firstFileY).toBeGreaterThan(0); + + await act(async () => { + await setup.mockMouse.click(6, firstFileY); + }); + await flush(setup); + expect(leftPaneBorderColor(setup, firstFileY)).toBe(focusedBorder); + expect(getLatestSnapshot()?.selectedFileId).toBe("first"); + expect(getLatestSnapshot()?.selectedHunkIndex).toBe(0); + + await act(async () => { + await setup.mockInput.pressEnter(); + }); + await waitForLeftPaneBorder(setup, firstFileY, unfocusedBorder); + expect(getLatestSnapshot()?.selectedFileId).toBe("first"); + expect(getLatestSnapshot()?.selectedHunkIndex).toBe(0); + + await act(async () => { + await setup.mockInput.pressArrow("down"); + }); + await flush(setup); + expect(getLatestSnapshot()?.selectedFileId).toBe("first"); + + await pressHunkNavigationKey(setup, "]", 2); + const hunkSnapshot = await waitForSnapshot( + setup, + getLatestSnapshot, + (nextSnapshot) => + nextSnapshot.selectedFileId === "second" && nextSnapshot.selectedHunkIndex === 1, + ); + expect(hunkSnapshot).toMatchObject({ + selectedFileId: "second", + selectedHunkIndex: 1, + }); + + await act(async () => { + await setup.mockInput.pressEscape(); + }); + const secondFileY = sidebarFileRow(setup, "second.ts"); + expect(secondFileY).toBeGreaterThan(0); + await waitForLeftPaneBorder(setup, secondFileY, focusedBorder); + expect(getLatestSnapshot()).toMatchObject({ + selectedFileId: "second", + selectedHunkIndex: 1, + }); + + await act(async () => { + await setup.mockInput.pressEnter(); + }); + await waitForLeftPaneBorder(setup, secondFileY, unfocusedBorder); + expect(getLatestSnapshot()).toMatchObject({ + selectedFileId: "second", + selectedHunkIndex: 1, + }); + + await act(async () => { + await setup.mockInput.pressArrow("down"); + }); + await flush(setup); + expect(getLatestSnapshot()?.selectedFileId).toBe("second"); + + await act(async () => { + await setup.mockInput.pressEscape(); + }); + await waitForLeftPaneBorder(setup, secondFileY, focusedBorder); + + await act(async () => { + await setup.mockInput.pressArrow("up"); + }); + await flush(setup); + + const snapshot = await waitForSnapshot( + setup, + getLatestSnapshot, + (nextSnapshot) => nextSnapshot.selectedFileId === "first", + ); + expect(snapshot?.selectedFileId).toBe("first"); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + + test("the focused pane uses a brighter border than the other pane", async () => { + const theme = resolveTheme("github-dark-default", null); + const focusedBorder = paneFrameBorderColor(theme, true); + const unfocusedBorder = paneFrameBorderColor(theme, false); + const setup = await testRender(, { + width: 220, + height: 16, + }); + + try { + await flush(setup); + + const firstFileY = sidebarFileRow(setup, "first.ts"); + expect(firstFileY).toBeGreaterThan(0); + + await act(async () => { + await setup.mockMouse.click(6, firstFileY); + }); + await flush(setup); + + const filesFocusedColor = leftPaneBorderColor(setup, firstFileY); + expect(filesFocusedColor).toBe(focusedBorder); + + await act(async () => { + await setup.mockMouse.click(140, 6); + }); + await flush(setup); + + const filesUnfocusedColor = leftPaneBorderColor(setup, firstFileY); + expect(filesUnfocusedColor).toBe(unfocusedBorder); + expect(filesUnfocusedColor).not.toBe(focusedBorder); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + test("quit prompts to save changed view preferences", async () => { const configHome = mkdtempSync(join(tmpdir(), "hunk-save-view-config-")); const previousXdgConfigHome = process.env.XDG_CONFIG_HOME; diff --git a/packages/hunk/src/ui/AppHost.keybindings.test.tsx b/packages/hunk/src/ui/AppHost.keybindings.test.tsx index cebbe1bf2..0cf18c052 100644 --- a/packages/hunk/src/ui/AppHost.keybindings.test.tsx +++ b/packages/hunk/src/ui/AppHost.keybindings.test.tsx @@ -193,7 +193,7 @@ describe("user keybindings", () => { const repo = createTestRepo("hunk-keybindings-files-pane-alias-"); const bootstrap = await launchWithConfig( repo, - '[keybindings]\n"hunk.view.toggleSidebar" = "f6"\n', + '[keybindings]\n"hunk.view.toggleSidebar" = "f6"\n"hunk.review.stashSelectedFile" = []\n', ); const extensions = createEmptyExtensionLoadResult(repo); const seen: string[] = []; @@ -414,7 +414,7 @@ describe("user keybindings", () => { await withAppHost(bootstrap, async (setup) => { await act(async () => { - await setup.mockInput.pressTab(); + await setup.mockInput.typeText("/"); }); await flush(setup); seen.length = 0; diff --git a/packages/hunk/src/ui/AppHost.sidebar-resize.test.tsx b/packages/hunk/src/ui/AppHost.sidebar-resize.test.tsx index e771823fa..2ea49b117 100644 --- a/packages/hunk/src/ui/AppHost.sidebar-resize.test.tsx +++ b/packages/hunk/src/ui/AppHost.sidebar-resize.test.tsx @@ -1,3 +1,7 @@ +import { + getTestSidebarDividerColumn, + getTestSidebarFrame, +} from "../../../../test/helpers/sidebar-frame"; import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { act } from "react"; @@ -69,22 +73,6 @@ async function flush(setup: Awaited>) { }); } -/** Column of the vertical sidebar/diff divider on the probe row, or -1 when absent. */ -function dividerColumn(setup: Awaited>) { - const row = setup.captureCharFrame().split("\n")[PROBE_ROW] ?? ""; - return row.indexOf("│"); -} - -/** Return only the file-sidebar columns so diff headers cannot satisfy sidebar assertions. */ -function sidebarFrame(setup: Awaited>) { - const divider = dividerColumn(setup); - return setup - .captureCharFrame() - .split("\n") - .map((line) => line.slice(0, divider)) - .join("\n"); -} - /** * Press the divider, drag to a target x, then release. The resize handlers read React state * (`isResizingSidebar`), so each phase needs its own commit before the next event's closure sees @@ -145,41 +133,43 @@ describe("AppHost sidebar resize", () => { test("resizes the default sidebar with the terminal until the user drags it", async () => { setup = await testRender(, WIDE); await flush(setup); - expect(dividerColumn(setup)).toBe(INITIAL_DIVIDER_COLUMN); + expect(getTestSidebarDividerColumn(setup.captureCharFrame())).toBe(INITIAL_DIVIDER_COLUMN); await act(async () => setup!.resize(300, WIDE.height)); await flush(setup); - expect(dividerColumn(setup)).toBe(49); + expect(getTestSidebarDividerColumn(setup.captureCharFrame())).toBe(49); await act(async () => setup!.resize(220, WIDE.height)); await flush(setup); - expect(dividerColumn(setup)).toBe(36); + expect(getTestSidebarDividerColumn(setup.captureCharFrame())).toBe(36); await act(async () => setup!.resize(360, WIDE.height)); await flush(setup); - expect(dividerColumn(setup)).toBe(57); + expect(getTestSidebarDividerColumn(setup.captureCharFrame())).toBe(57); }); test("dragging the divider rightward widens the sidebar", async () => { setup = await testRender(, WIDE); await flush(setup); - expect(dividerColumn(setup)).toBe(INITIAL_DIVIDER_COLUMN); + expect(getTestSidebarDividerColumn(setup.captureCharFrame())).toBe(INITIAL_DIVIDER_COLUMN); await dragDivider(setup, INITIAL_DIVIDER_COLUMN, INITIAL_DIVIDER_COLUMN + 30); // The divider follows the new width: startWidth + (currentX - originX). - expect(dividerColumn(setup)).toBeGreaterThan(INITIAL_DIVIDER_COLUMN); + expect(getTestSidebarDividerColumn(setup.captureCharFrame())).toBeGreaterThan( + INITIAL_DIVIDER_COLUMN, + ); }); test("resizing across the content-width threshold switches the file projection", async () => { setup = await testRender(, WIDE); await flush(setup); - expect(sidebarFrame(setup)).not.toContain("src/ui/"); + expect(getTestSidebarFrame(setup.captureCharFrame())).not.toContain("src/ui/"); // Raw pane width 33 leaves 31 content columns, just below the preferred tree width. await dragDivider(setup, INITIAL_DIVIDER_COLUMN, 34); - expect(sidebarFrame(setup)).toContain("src/ui/"); + expect(getTestSidebarFrame(setup.captureCharFrame())).toContain("src/ui/"); }); test("dragging the divider far left clamps the sidebar at its minimum width", async () => { @@ -189,7 +179,7 @@ describe("AppHost sidebar resize", () => { await dragDivider(setup, INITIAL_DIVIDER_COLUMN, 2); // SIDEBAR_MIN_WIDTH is 22, plus the 1-column body padding => divider clamps at column 23. - expect(dividerColumn(setup)).toBe(23); + expect(getTestSidebarDividerColumn(setup.captureCharFrame())).toBe(23); }); test("dragging a horizontal divider resizes a top pane on the row axis", async () => { @@ -214,7 +204,7 @@ describe("AppHost sidebar resize", () => { await flush(setup); expect(setup.captureCharFrame()).toBe(before); - expect(dividerColumn(setup)).toBe(INITIAL_DIVIDER_COLUMN); + expect(getTestSidebarDividerColumn(setup.captureCharFrame())).toBe(INITIAL_DIVIDER_COLUMN); }); test("a non-left mouse button on the divider does not start a resize", async () => { @@ -235,6 +225,6 @@ describe("AppHost sidebar resize", () => { }); await flush(setup); - expect(dividerColumn(setup)).toBe(INITIAL_DIVIDER_COLUMN); + expect(getTestSidebarDividerColumn(setup.captureCharFrame())).toBe(INITIAL_DIVIDER_COLUMN); }); }); diff --git a/packages/hunk/src/ui/AppHost.sidebar-visibility.test.tsx b/packages/hunk/src/ui/AppHost.sidebar-visibility.test.tsx index 5166a3035..8614a83cc 100644 --- a/packages/hunk/src/ui/AppHost.sidebar-visibility.test.tsx +++ b/packages/hunk/src/ui/AppHost.sidebar-visibility.test.tsx @@ -76,12 +76,15 @@ async function flush(setup: Awaited>) { }); } -/** Return only the columns before the left pane divider. */ +/** Return only the columns inside the files pane, between its frame and the divider. */ function sidebarFrame(setup: Awaited>) { return setup .captureCharFrame() .split("\n") - .map((line) => line.split("│", 1)[0]) + .map((line) => { + const parts = line.split("│"); + return parts.length >= 3 ? (parts[1] ?? "") : (parts[0] ?? ""); + }) .join("\n"); } diff --git a/packages/hunk/src/ui/AppHost.tsx b/packages/hunk/src/ui/AppHost.tsx index 6d710be77..a60090b48 100644 --- a/packages/hunk/src/ui/AppHost.tsx +++ b/packages/hunk/src/ui/AppHost.tsx @@ -155,6 +155,7 @@ export function AppHost({ } | null>(null); const quitRequestedRef = useRef(false); const pendingWorkspaceWritesRef = useRef>>(new Set()); + const vcsMutationPendingRef = useRef(false); const workspaceRefreshRequestRef = useRef(undefined); const pendingReloadLifecycleRef = useRef<{ extensions: ExtensionLoadResult; @@ -217,7 +218,7 @@ export function AppHost({ ]); /** Start one irreversible write atomically with host tracking, unless quit already won. */ - const runWorkspaceWrite = useCallback(async (write) => { + const trackWorkspaceWrite = useCallback(async (write) => { if (quitRequestedRef.current) return false; const pending = write(); pendingWorkspaceWritesRef.current.add(pending); @@ -229,6 +230,29 @@ export function AppHost({ } }, []); + /** Refuse extension writes while a Git action owns the write-and-refresh boundary. */ + const runWorkspaceWrite = useCallback( + async (write) => { + if (vcsMutationPendingRef.current) return false; + return trackWorkspaceWrite(write); + }, + [trackWorkspaceWrite], + ); + + /** Hold one index-changing action through its refresh, including across App remounts. */ + const runVcsMutation = useCallback( + async (write) => { + if (vcsMutationPendingRef.current || pendingWorkspaceWritesRef.current.size > 0) return false; + vcsMutationPendingRef.current = true; + try { + return await trackWorkspaceWrite(write); + } finally { + vcsMutationPendingRef.current = false; + } + }, + [trackWorkspaceWrite], + ); + const performReloadSession = useCallback( async (nextInput: CliInput, options?: ReloadSessionOptions) => { if (quitRequestedRef.current) throw reloadRefusedDuringShutdown(); @@ -477,20 +501,29 @@ export function AppHost({ }, []); /** Reconcile a completed write against whichever review owns the queue when it reaches the front. */ - const reloadAfterWorkspaceWrite = useCallback(() => { - void enqueueReload(async () => { - if (quitRequestedRef.current) return; - const request = workspaceRefreshRequestRef.current; - if (!request) return; - await performReloadSession(request.nextInput, { - reason: "manual", - resetApp: false, - sourcePath: request.sourcePath, + const reloadAfterWorkspaceWrite = useCallback( + (follow?: { root: string; staged: boolean }) => { + return enqueueReload(async () => { + if (quitRequestedRef.current) return; + const request = workspaceRefreshRequestRef.current; + if (!request) return; + const nextInput = + follow && + request.sourcePath === follow.root && + request.nextInput.kind === "vcs" && + request.nextInput.range === undefined && + request.nextInput.rangeEndpoints === undefined + ? { ...request.nextInput, staged: follow.staged } + : request.nextInput; + await performReloadSession(nextInput, { + reason: "manual", + resetApp: false, + sourcePath: request.sourcePath, + }); }); - }).catch((error) => { - console.error("Failed to reload after an extension workspace write.", error); - }); - }, [enqueueReload, performReloadSession]); + }, + [enqueueReload, performReloadSession], + ); /** Coalesce extension requests and resolve their descriptor at the front of the host queue. */ const requestExtensionReviewReload = useCallback( @@ -580,9 +613,15 @@ export function AppHost({ onRegisterWorkspaceRefreshRequest={registerWorkspaceRefreshRequest} onReloadSession={reloadSession} onRequestExtensionReviewReload={requestExtensionReviewReload} - onWorkspaceWriteCompleted={reloadAfterWorkspaceWrite} + onWorkspaceWriteCompleted={() => { + void reloadAfterWorkspaceWrite().catch((error) => + console.error("Failed to reload after a workspace write.", error), + ); + }} + onVcsMutationCompleted={reloadAfterWorkspaceWrite} reviewProducer={producer} runWorkspaceWrite={runWorkspaceWrite} + runVcsMutation={runVcsMutation} watchRuntime={watchRuntime} workspaceFileWriter={workspaceFileWriter} /> diff --git a/packages/hunk/src/ui/AppHost.working-tree.test.tsx b/packages/hunk/src/ui/AppHost.working-tree.test.tsx new file mode 100644 index 000000000..79b8a29e4 --- /dev/null +++ b/packages/hunk/src/ui/AppHost.working-tree.test.tsx @@ -0,0 +1,597 @@ +import { afterEach, describe, expect, setDefaultTimeout, spyOn, test } from "bun:test"; +import { testRender } from "@opentui/react/test-utils"; +import { KeyEvent } from "@opentui/core"; +import { act } from "react"; +import { mkdirSync, readFileSync, rmSync, writeFileSync, renameSync } from "node:fs"; +import { join } from "node:path"; +import { createTestWorkingTreeRepo, runTestGit } from "../../../../test/helpers/working-tree"; +import { loadAppBootstrap } from "../core/changeset/loaders"; +import { getBundledVcsCatalog } from "../app/vcsCatalog"; +import { TestAppHost as AppHost } from "../../../../test/helpers/app-host"; +import { loadStartupExtensions } from "../extensions/startup"; + +setDefaultTimeout(30_000); +const roots: string[] = []; +let setup: Awaited> | undefined; + +/** Render until the mounted review reflects the operation, not merely its Git subprocess. */ +async function waitForReview(predicate: () => boolean) { + const deadline = Date.now() + 8_000; + while (Date.now() < deadline) { + await act(async () => { + await setup!.renderOnce(); + await Bun.sleep(10); + }); + if (predicate()) return; + } + throw new Error(`Review did not settle:\n${setup!.captureCharFrame()}`); +} + +/** Start a real provider-backed review with one unstaged file and one staged file. */ +async function createReview({ + beforeStage, + resolveEditorLine, + onQuit, + prepare, + staged = false, + width = 150, +}: { + beforeStage?: () => Promise; + resolveEditorLine?: () => Promise; + onQuit?: () => void; + prepare?: (root: string) => void; + staged?: boolean; + width?: number; +} = {}) { + const root = createTestWorkingTreeRepo(); + roots.push(root); + writeFileSync(join(root, "alpha.txt"), "one\nchanged alpha\nthree\n"); + writeFileSync(join(root, "beta.txt"), "staged beta\n"); + runTestGit(root, "add", "beta.txt"); + prepare?.(root); + const catalog = getBundledVcsCatalog(); + const vcsCatalog = + beforeStage || resolveEditorLine + ? { + ...catalog, + adapters: catalog.adapters.map((adapter) => { + const workingTree = adapter.operations["working-tree-diff"]; + if (!workingTree?.stageFile) return adapter; + const stageFile = workingTree.stageFile; + return { + ...adapter, + operations: { + ...adapter.operations, + "working-tree-diff": { + ...workingTree, + resolveWorkingTreeLine: resolveEditorLine ?? workingTree.resolveWorkingTreeLine, + stageFile: async (...args: Parameters) => { + await beforeStage?.(); + await stageFile(...args); + }, + }, + }, + }; + }), + } + : catalog; + const bootstrap = await loadAppBootstrap( + { kind: "vcs", staged, options: { mode: "stack", sidebar: true, extensions: false } }, + { cwd: root, vcsCatalog }, + ); + bootstrap.extensions = await loadStartupExtensions({ + cwd: root, + extensions: { enabled: false, paths: [], repoPaths: [], extensionConfigs: {} }, + }); + setup = await testRender(, { + width, + height: 30, + }); + await waitForReview(() => setup!.captureCharFrame().includes("Unstaged (")); + return root; +} + +/** Send one terminal key through App's real dispatch path. */ +async function press(key: string) { + await act(async () => { + await setup!.mockInput.typeText(key); + }); +} + +/** Deliver consecutive keys without allowing React to commit between them. */ +async function pressTestBurst(keys: string) { + await act(async () => { + for (const key of keys) { + setup!.renderer.keyInput.emit( + "keypress", + new KeyEvent({ + name: key === "\r" ? "return" : key === "\x1b" ? "escape" : key, + sequence: key, + raw: key, + ctrl: false, + meta: false, + option: false, + shift: false, + number: false, + eventType: "press", + source: "raw", + }), + ); + } + }); +} + +afterEach(async () => { + if (setup) { + const current = setup; + setup = undefined; + await act(async () => current.renderer.destroy()); + } + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("working-tree stream actions", () => { + test.each([". ", ",j "])("stages the latest file in one input burst %s", async (keys) => { + const root = await createReview({ + prepare: (root) => writeFileSync(join(root, "beta.txt"), "unstaged beta\n"), + }); + await pressTestBurst(keys); + await waitForReview(() => setup!.captureCharFrame().includes("Staged beta.txt.")); + expect(runTestGit(root, "show", ":alpha.txt")).toBe("one\ntwo\nthree\n"); + expect(runTestGit(root, "show", ":beta.txt")).toBe("unstaged beta\n"); + }); + + test.each([150, 220])( + "stages the latest folder in one input burst at width %i", + async (width) => { + const root = await createReview({ + width, + prepare: (root) => { + writeFileSync(join(root, "beta.txt"), "unstaged beta\n"); + mkdirSync(join(root, "src"), { recursive: true }); + writeFileSync(join(root, "src", "one.ts"), "one\n"); + writeFileSync(join(root, "src", "two.ts"), "two\n"); + }, + }); + await pressTestBurst(",jj "); + await waitForReview(() => setup!.captureCharFrame().includes("Staged 2 files in src/.")); + expect(runTestGit(root, "diff", "--cached", "--name-only").trim().split("\n")).toEqual([ + "beta.txt", + "src/one.ts", + "src/two.ts", + ]); + expect(runTestGit(root, "show", ":alpha.txt")).toBe("one\ntwo\nthree\n"); + }, + ); + + test.each([false, true])( + "uses live hunk selection for a staging burst, staged=%s", + async (staged) => { + const original = Array.from({ length: 40 }, (_, index) => `line ${index + 1}\n`).join(""); + const changed = original + .replace("line 2\n", "first change\n") + .replace("line 35\n", "second change\n"); + const root = await createReview({ + staged, + prepare: (root) => { + writeFileSync(join(root, "alpha.txt"), original); + runTestGit(root, "add", "alpha.txt"); + runTestGit(root, "commit", "--only", "-m", "Long alpha", "--", "alpha.txt"); + writeFileSync(join(root, "alpha.txt"), changed); + if (staged) runTestGit(root, "add", "alpha.txt"); + }, + }); + await pressTestBurst("] "); + await waitForReview(() => + setup! + .captureCharFrame() + .includes(`${staged ? "Unstaged" : "Staged"} hunk 2 in alpha.txt.`), + ); + expect(runTestGit(root, "show", ":alpha.txt")).toBe( + staged + ? original.replace("line 2\n", "first change\n") + : original.replace("line 35\n", "second change\n"), + ); + expect(readFileSync(join(root, "alpha.txt"), "utf8")).toBe(changed); + await pressTestBurst(", "); + await waitForReview(() => setup!.captureCharFrame().includes("Staged alpha.txt.")); + expect(runTestGit(root, "show", ":alpha.txt")).toBe(changed); + }, + ); + + test.each([ + [",d", "alpha.txt"], + [".d", "beta.txt"], + [".,d", "alpha.txt"], + [",jd", "beta.txt"], + ["]\x1bd", "beta.txt"], + ])("captures the current discard target for one input burst %s", async (keys, path) => { + const root = await createReview({ + prepare: (root) => writeFileSync(join(root, "beta.txt"), "unstaged beta\n"), + }); + await pressTestBurst(keys); + await waitForReview(() => setup!.captureCharFrame().includes("Discard changes")); + expect(setup!.captureCharFrame()).toContain(`"${path}"`); + await press("x"); + await waitForReview(() => + setup!.captureCharFrame().includes(`Discarded all changes in ${path}.`), + ); + expect(readFileSync(join(root, "alpha.txt"), "utf8")).toBe( + path === "alpha.txt" ? "one\ntwo\nthree\n" : "one\nchanged alpha\nthree\n", + ); + expect(readFileSync(join(root, "beta.txt"), "utf8")).toBe( + path === "beta.txt" ? "other\n" : "unstaged beta\n", + ); + }); + + test.each([",]d", ",\rd", ",\rjd"])( + "does not discard after leaving file focus in burst %s", + async (keys) => { + const root = await createReview(); + await pressTestBurst(keys); + await act(async () => setup!.renderOnce()); + expect(setup!.captureCharFrame()).not.toContain("Discard changes"); + expect(readFileSync(join(root, "alpha.txt"), "utf8")).toContain("changed alpha"); + }, + ); + + test.each([".s", "]\x1bs"])("captures the latest stash target in burst %s", async (keys) => { + await createReview({ + prepare: (root) => writeFileSync(join(root, "beta.txt"), "unstaged beta\n"), + }); + await pressTestBurst(keys); + await waitForReview(() => setup!.captureCharFrame().includes("Stash selected file")); + expect(setup!.captureCharFrame()).toContain('"beta.txt"'); + }); + + test.each([ + ["j\rd", false], + ["jk\rd", true], + ])("Enter uses the latest folder status in burst %s", async (keys, opensPrompt) => { + await createReview({ + prepare: (root) => { + mkdirSync(join(root, "docs")); + writeFileSync(join(root, "docs", "note.txt"), "new note\n"); + }, + }); + const row = setup! + .captureCharFrame() + .split("\n") + .findIndex((line) => line.includes("docs/")); + expect(row).toBeGreaterThan(0); + await act(async () => setup!.mockMouse.click(6, row)); + await waitForReview(() => setup!.captureCharFrame().includes("Stage folder")); + await pressTestBurst(keys); + await act(async () => setup!.renderOnce()); + expect(setup!.captureCharFrame().includes("Discard changes")).toBe(opensPrompt); + }); + + test("a retired editor lookup neither blocks nor overwrites a reloaded review", async () => { + let rejectRetired!: (error: Error) => void; + const retired = new Promise((_, reject) => { + rejectRetired = reject; + }); + let lookups = 0; + const originalEditor = process.env.EDITOR; + const originalSpawn = Bun.spawnSync; + const editorCalls: string[][] = []; + const spawn = spyOn(Bun, "spawnSync"); + spawn.mockImplementation(((command, options) => { + if (Array.isArray(command) && command[0] === "nvim") { + editorCalls.push(command); + return { exitCode: 0 }; + } + return originalSpawn(command, options); + }) as typeof Bun.spawnSync); + process.env.EDITOR = "nvim"; + try { + const root = await createReview({ + staged: true, + prepare: (root) => runTestGit(root, "add", "alpha.txt"), + resolveEditorLine: () => (++lookups === 1 ? retired : Promise.resolve(2)), + }); + await press("e"); + expect(lookups).toBe(1); + writeFileSync(join(root, "alpha.txt"), "replacement generation\n"); + runTestGit(root, "add", "alpha.txt"); + await press("r"); + await waitForReview(() => setup!.captureCharFrame().includes("replacement generation")); + await press("e"); + await waitForReview(() => editorCalls.length === 1); + // Reload restores the bundled catalog; its real resolver must not wait for the old one. + expect(lookups).toBe(1); + expect(editorCalls[0]).toEqual(["nvim", "+1", join(root, "alpha.txt")]); + await act(async () => { + rejectRetired(new Error("retired lookup failure")); + await Bun.sleep(0); + }); + expect(setup!.captureCharFrame()).not.toContain("retired lookup failure"); + expect(editorCalls).toHaveLength(1); + } finally { + spawn.mockRestore(); + if (originalEditor === undefined) delete process.env.EDITOR; + else process.env.EDITOR = originalEditor; + } + }); + + test("an external edit after discard consent is shown refuses the stale target", async () => { + const root = await createReview(); + const fileY = setup! + .captureCharFrame() + .split("\n") + .findIndex((line) => { + const parts = line.split("│"); + const sidebar = parts.length >= 3 ? (parts[1] ?? "") : (parts[0] ?? ""); + return sidebar.includes("alpha.txt"); + }); + expect(fileY).toBeGreaterThan(0); + // Discard is scoped to the focused files pane. + await act(async () => { + await setup!.mockMouse.click(6, fileY); + }); + await press("d"); + await waitForReview(() => setup!.captureCharFrame().includes("Discard changes")); + writeFileSync(join(root, "alpha.txt"), "changed after prompt\n"); + await press("x"); + await waitForReview(() => setup!.captureCharFrame().includes("changed since")); + expect(readFileSync(join(root, "alpha.txt"), "utf8")).toBe("changed after prompt\n"); + expect(runTestGit(root, "show", ":beta.txt")).toBe("staged beta\n"); + }); + + test("Space keeps the file action when hunk staging is not available", async () => { + const root = await createReview({ + prepare: (root) => { + writeFileSync(join(root, "alpha.txt"), "one\ntwo\nthree\n"); + writeFileSync(join(root, "blob.bin"), Buffer.from([0, 1, 0, 2, 255])); + runTestGit(root, "add", "--", "blob.bin"); + runTestGit(root, "commit", "--only", "-m", "binary", "--", "blob.bin"); + writeFileSync(join(root, "blob.bin"), Buffer.from([0, 1, 0, 3, 255])); + }, + }); + await waitForReview(() => setup!.captureCharFrame().includes("blob.bin")); + await press("]"); + await waitForReview(() => setup!.captureCharFrame().includes("Stage file")); + expect(setup!.captureCharFrame()).not.toContain("Stage hunk"); + await press(" "); + await waitForReview(() => setup!.captureCharFrame().includes("Staged blob.bin.")); + expect(runTestGit(root, "status", "--porcelain", "--", "blob.bin").trim()).toBe("M blob.bin"); + }); + + test("hunk navigation makes Space mutate only the active hunk on either stream side", async () => { + const original = Array.from({ length: 40 }, (_, index) => `line ${index + 1}\n`).join(""); + const root = await createReview({ + prepare: (root) => { + writeFileSync(join(root, "alpha.txt"), original); + runTestGit(root, "add", "alpha.txt"); + runTestGit(root, "commit", "--only", "-m", "Long alpha", "--", "alpha.txt"); + writeFileSync( + join(root, "alpha.txt"), + original.replace("line 2\n", "first change\n").replace("line 35\n", "second change\n"), + ); + }, + }); + await press("]"); + await press(" "); + await waitForReview(() => setup!.captureCharFrame().includes("Staged hunk 2 in alpha.txt.")); + expect(runTestGit(root, "show", ":alpha.txt")).toBe( + original.replace("line 35\n", "second change\n"), + ); + await press("\t"); + await waitForReview(() => setup!.captureCharFrame().includes("second change")); + await press("["); + await press(" "); + await waitForReview(() => setup!.captureCharFrame().includes("Unstaged hunk 1 in alpha.txt.")); + expect(runTestGit(root, "show", ":alpha.txt")).toBe(original); + expect(runTestGit(root, "show", ":beta.txt")).toBe("staged beta\n"); + }); + + test("a recreated rename source selects its own unstaged file before Space", async () => { + const root = await createReview({ + staged: true, + prepare: (root) => { + runTestGit(root, "restore", "alpha.txt"); + renameSync(join(root, "alpha.txt"), join(root, "renamed.txt")); + runTestGit(root, "add", "-A"); + writeFileSync(join(root, "alpha.txt"), "recreated source\n"); + }, + }); + await press("."); + await press("."); + await waitForReview(() => setup!.captureCharFrame().includes("recreated source")); + await press(" "); + await waitForReview(() => setup!.captureCharFrame().includes("Staged alpha.txt.")); + expect(runTestGit(root, "show", ":alpha.txt")).toBe("recreated source\n"); + expect(runTestGit(root, "show", ":renamed.txt")).toBe("one\ntwo\nthree\n"); + }); + + test("a file becoming clean releases selection for the next changed file", async () => { + const root = await createReview({ + prepare: (root) => { + runTestGit(root, "add", "alpha.txt"); + writeFileSync(join(root, "alpha.txt"), runTestGit(root, "show", "HEAD:alpha.txt")); + }, + }); + await press(" "); + await waitForReview(() => setup!.captureCharFrame().includes("Staged alpha.txt.")); + await press("."); + await press(" "); + await waitForReview(() => setup!.captureCharFrame().includes("Unstaged beta.txt.")); + expect(runTestGit(root, "diff", "--cached")).toBe(""); + }); + + test("repeated Space cannot replay a pending action and graceful quit waits for the write", async () => { + let release = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + let calls = 0; + let quit = false; + const root = await createReview({ + beforeStage: async () => { + calls++; + await gate; + }, + onQuit: () => { + quit = true; + }, + }); + try { + await press(" "); + await waitForReview(() => calls === 1); + await press("q"); + expect(quit).toBe(false); + expect(calls).toBe(1); + } finally { + release(); + } + await waitForReview(() => quit); + expect(runTestGit(root, "show", ":alpha.txt")).toContain("changed alpha"); + expect(calls).toBe(1); + }); + test("sidebar includes both sides and Space stages then unstages the same file", async () => { + const root = await createReview(); + expect(setup!.captureCharFrame()).toContain("beta.txt"); + await press(" "); + await waitForReview(() => setup!.captureCharFrame().includes("Staged alpha.txt.")); + expect(runTestGit(root, "show", ":alpha.txt")).toContain("changed alpha"); + expect(setup!.captureCharFrame()).toContain("Staged (2)"); + await press(" "); + await waitForReview(() => setup!.captureCharFrame().includes("Unstaged alpha.txt.")); + expect(runTestGit(root, "diff", "--cached", "--name-only").trim()).toBe("beta.txt"); + expect(setup!.captureCharFrame()).toContain("alpha.txt"); + expect(setup!.captureCharFrame()).toContain("beta.txt"); + }); + + test("unstaged review keeps +/- stats on staged-only files in the sidebar", async () => { + await createReview(); + const sidebar = setup! + .captureCharFrame() + .split("\n") + .map((line) => { + const parts = line.split("│"); + return parts.length >= 3 ? (parts[1] ?? "") : ""; + }) + .join("\n"); + expect(sidebar).toMatch(/alpha\.txt.*\+/); + expect(sidebar).toMatch(/beta\.txt.*\+1/); + }); + + test("Tab changes the full stream and does not mutate Git", async () => { + const root = await createReview(); + const before = runTestGit(root, "status", "--porcelain"); + await press("\t"); + await waitForReview(() => setup!.captureCharFrame().includes("staged beta")); + expect(runTestGit(root, "status", "--porcelain")).toBe(before); + expect(setup!.captureCharFrame()).toContain("alpha.txt"); + }); + + test("navigation to a staged-only sidebar file switches sides before Space unstages it", async () => { + const root = await createReview(); + await press("."); + await waitForReview(() => setup!.captureCharFrame().includes("staged beta")); + await press(" "); + await waitForReview(() => setup!.captureCharFrame().includes("Unstaged beta.txt.")); + expect(runTestGit(root, "diff", "--cached")).toBe(""); + }); + + test("selecting a mixed folder stays on the current side and keeps its line stats", async () => { + await createReview({ + prepare: (nextRoot) => { + mkdirSync(join(nextRoot, "docs"), { recursive: true }); + writeFileSync(join(nextRoot, "docs", "aaa-staged.md"), "staged docs\n"); + writeFileSync(join(nextRoot, "docs", "zzz-unstaged.md"), "unstaged docs\n"); + runTestGit(nextRoot, "add", "docs/aaa-staged.md"); + }, + }); + await waitForReview(() => setup!.captureCharFrame().includes("zzz-unstaged.md")); + const folderY = setup! + .captureCharFrame() + .split("\n") + .findIndex((line) => { + const parts = line.split("│"); + const sidebar = parts.length >= 3 ? (parts[1] ?? "") : (parts[0] ?? ""); + return sidebar.includes("docs/") && !sidebar.includes(".md"); + }); + expect(folderY).toBeGreaterThan(0); + await act(async () => { + await setup!.mockMouse.click(6, folderY); + }); + await waitForReview(() => { + const frame = setup!.captureCharFrame(); + return frame.includes("Unstaged (") && frame.includes("zzz-unstaged.md"); + }); + const frame = setup!.captureCharFrame(); + expect(frame).toContain("Unstaged ("); + expect(frame).toContain("Stage folder"); + expect(frame).toContain("zzz-unstaged.md"); + expect(frame).toContain("unstaged docs"); + expect(frame).not.toContain("aaa-staged.md"); + expect(frame).toMatch(/\+1/); + expect(frame).toContain("alpha.txt"); + }); + + test("Space on a compact folder stages only the files listed under that header", async () => { + const root = await createReview({ + prepare: (nextRoot) => { + mkdirSync(join(nextRoot, "src", "nested"), { recursive: true }); + writeFileSync(join(nextRoot, "src", "one.ts"), "one\n"); + writeFileSync(join(nextRoot, "src", "nested", "two.ts"), "two\n"); + }, + }); + await waitForReview(() => setup!.captureCharFrame().includes("one.ts")); + const folderY = setup! + .captureCharFrame() + .split("\n") + .findIndex((line) => { + const parts = line.split("│"); + const sidebar = parts.length >= 3 ? (parts[1] ?? "") : (parts[0] ?? ""); + return sidebar.includes("src/") && !sidebar.includes("nested") && !sidebar.includes(".ts"); + }); + expect(folderY).toBeGreaterThan(0); + await act(async () => { + await setup!.mockMouse.click(6, folderY); + }); + await press(" "); + await waitForReview(() => setup!.captureCharFrame().includes("Staged src/one.ts.")); + expect(runTestGit(root, "status", "--porcelain", "--", "src/one.ts").trim()).toBe( + "A src/one.ts", + ); + expect(runTestGit(root, "status", "--porcelain", "--", "src/nested/two.ts").trim()).toBe( + "?? src/nested/two.ts", + ); + expect(runTestGit(root, "status", "--porcelain", "--", "alpha.txt").trim()).toMatch(/^\s*M/); + }); + + test("Space on a tree folder stages nested files shown under that row", async () => { + const root = await createReview({ + width: 220, + prepare: (nextRoot) => { + mkdirSync(join(nextRoot, "src", "nested"), { recursive: true }); + writeFileSync(join(nextRoot, "src", "one.ts"), "one\n"); + writeFileSync(join(nextRoot, "src", "nested", "two.ts"), "two\n"); + }, + }); + await waitForReview(() => setup!.captureCharFrame().includes("one.ts")); + const folderY = setup! + .captureCharFrame() + .split("\n") + .findIndex((line) => { + const parts = line.split("│"); + const sidebar = parts.length >= 3 ? (parts[1] ?? "") : (parts[0] ?? ""); + return sidebar.includes("src/") && !sidebar.includes("nested") && !sidebar.includes(".ts"); + }); + expect(folderY).toBeGreaterThan(0); + await act(async () => { + await setup!.mockMouse.click(6, folderY); + }); + await press(" "); + await waitForReview(() => setup!.captureCharFrame().includes("Staged 2 files in src/.")); + expect(runTestGit(root, "status", "--porcelain", "--", "src/one.ts").trim()).toBe( + "A src/one.ts", + ); + expect(runTestGit(root, "status", "--porcelain", "--", "src/nested/two.ts").trim()).toBe( + "A src/nested/two.ts", + ); + expect(runTestGit(root, "status", "--porcelain", "--", "alpha.txt").trim()).toMatch(/^\s*M/); + }); +}); diff --git a/packages/hunk/src/ui/AppHost.workspace.test.tsx b/packages/hunk/src/ui/AppHost.workspace.test.tsx index 382768003..8acd1700f 100644 --- a/packages/hunk/src/ui/AppHost.workspace.test.tsx +++ b/packages/hunk/src/ui/AppHost.workspace.test.tsx @@ -522,6 +522,112 @@ describe("extension workspace reads", () => { }); describe("extension workspace writes", () => { + test.each(["git-first", "extension-first"])( + "excludes overlapping Git and extension writes: %s", + async (order) => { + const repo = createTestRepo("hunk-ext-write-git-race-"); + const extDir = createTempDir("hunk-ext-write-git-race-ext-"); + const extPath = join(extDir, "ext.ts"); + const logPath = join(extDir, "probe.log"); + writeWorkspaceFixture(extPath, logPath); + const bootstrap = await launchWithExtension(repo, extPath, { + kind: "vcs", + staged: false, + options: { mode: "stack", extensionPaths: [extPath] }, + }); + let release!: () => void; + const barrier = new Promise((resolve) => { + release = resolve; + }); + let gitStarts = 0; + let extensionStarts = 0; + const catalog = bootstrap.reloadContext.vcsCatalog!; + bootstrap.reloadContext.vcsCatalog = { + ...catalog, + adapters: catalog.adapters.map((adapter) => { + const workingTree = adapter.operations["working-tree-diff"]; + if (!workingTree?.stageFile) return adapter; + const stageFile = workingTree.stageFile; + return { + ...adapter, + operations: { + ...adapter.operations, + "working-tree-diff": { + ...workingTree, + stageFile: async (...args: Parameters) => { + gitStarts += 1; + await barrier; + await stageFile(...args); + }, + }, + }, + }; + }), + }; + await withAppHost( + bootstrap, + async (setup) => { + try { + if (order === "git-first") { + await act(async () => setup.mockInput.typeText(" ")); + await flushUntil(setup, () => gitStarts === 1, "the Git action to start"); + } + await act(async () => setup.mockInput.typeText("y")); + await flushUntil( + setup, + () => setup.captureCharFrame().includes("Write alpha.txt?"), + "extension write consent", + ); + await act(async () => setup.mockInput.pressEnter()); + if (order === "git-first") { + await flushUntil( + setup, + () => readProbeLog(logPath).some((line) => line.includes('"reason":"unavailable"')), + "extension write refusal", + ); + expect(extensionStarts).toBe(0); + expect( + readProbeLog(logPath).some((line) => + line.includes("Another workspace operation is active"), + ), + ).toBe(true); + } else { + await flushUntil(setup, () => extensionStarts === 1, "the extension write to start"); + await act(async () => setup.mockInput.typeText(" ")); + await flushUntil( + setup, + () => setup.captureCharFrame().includes("Another workspace operation is active"), + "Git action refusal", + ); + expect(gitStarts).toBe(0); + } + release(); + await flushUntil( + setup, + () => + order === "git-first" + ? setup.captureCharFrame().includes("Staged alpha.txt.") + : readProbeLog(logPath).includes('result {"ok":true}'), + "the admitted operation to complete", + ); + expect(readFileSync(join(repo, "alpha.txt"), "utf8")).toBe( + order === "git-first" ? "one\ntwo\n" : "rewritten\n", + ); + } finally { + release(); + } + }, + { + workspaceFileWriter: async (absolutePath, text) => { + extensionStarts += 1; + await barrier; + writeFileSync(absolutePath, text); + }, + }, + ); + }, + ); + test("a confirmed write replaces the reviewed file and reloads the review", async () => { const repo = createTestRepo("hunk-ext-write-confirm-"); // Outside the repo, so the fixture and its log never join the review. diff --git a/packages/hunk/src/ui/components/chrome/ConfirmDialog.tsx b/packages/hunk/src/ui/components/chrome/ConfirmDialog.tsx index f09d6041d..08d0ef2e8 100644 --- a/packages/hunk/src/ui/components/chrome/ConfirmDialog.tsx +++ b/packages/hunk/src/ui/components/chrome/ConfirmDialog.tsx @@ -1,4 +1,4 @@ -import type { MouseEvent as TuiMouseEvent } from "@opentui/core"; +import { MouseButton, type MouseEvent as TuiMouseEvent } from "@opentui/core"; import type { ReactNode } from "react"; import { useState } from "react"; import { MODAL_FRAME_CHROME_ROWS, resolveModalGeometry } from "../../lib/modalGeometry"; @@ -53,7 +53,7 @@ export function DialogActionRow({ } onMouseUp={(event: TuiMouseEvent) => { event.stopPropagation(); - action.run(); + if (event.button === MouseButton.LEFT) action.run(); }} > {action.keyLabel} diff --git a/packages/hunk/src/ui/components/chrome/WorkingTreeBar.tsx b/packages/hunk/src/ui/components/chrome/WorkingTreeBar.tsx new file mode 100644 index 000000000..cbdf75826 --- /dev/null +++ b/packages/hunk/src/ui/components/chrome/WorkingTreeBar.tsx @@ -0,0 +1,70 @@ +/** Show clickable staged/unstaged stream tabs and the current file action. */ +import { MouseButton } from "@opentui/core"; +import type { ExtensionWorkingTreePane } from "../../../extension-api/types"; +import type { AppTheme } from "../../themes"; +import { fitText } from "../../lib/text"; + +export function WorkingTreeBar({ + pane, + theme, + width, + canToggle, + hunkFocused = false, + selectedIsFolder = false, + selectedWillStage = false, + switchView, + toggleSelected, +}: { + pane: ExtensionWorkingTreePane; + theme: AppTheme; + width: number; + canToggle: boolean; + hunkFocused?: boolean; + selectedIsFolder?: boolean; + selectedWillStage?: boolean; + switchView: (staged: boolean) => void; + toggleSelected: () => void; +}) { + const selected = pane.files.find((file) => file.path === pane.selectedPath); + const stage = hunkFocused + ? !pane.staged + : selectedIsFolder + ? selectedWillStage + : selected?.unstaged; + const action = pane.busy + ? "Working…" + : `${stage ? "Stage" : "Unstage"} ${hunkFocused ? "hunk" : selectedIsFolder ? "folder" : "file"}`; + const tabs = [false, true].map((staged) => ({ + staged, + label: ` ${width < 40 ? (staged ? "S" : "U") : staged ? "Staged" : "Unstaged"} (${pane.files.filter((file) => (staged ? file.staged : file.unstaged)).length}) `, + })); + const remainingWidth = Math.max( + 0, + width - tabs.reduce((total, tab) => total + tab.label.length, 0), + ); + return ( + + {tabs.map(({ staged, label }) => ( + { + if (event.button === MouseButton.LEFT) switchView(staged); + }} + > + {label} + + ))} + { + if (event.button === MouseButton.LEFT && canToggle) toggleSelected(); + }} + > + + {fitText(` ${action}`, remainingWidth)} + + + + ); +} diff --git a/packages/hunk/src/ui/components/chrome/WorkingTreeDialog.tsx b/packages/hunk/src/ui/components/chrome/WorkingTreeDialog.tsx new file mode 100644 index 000000000..dfc1ca7e9 --- /dev/null +++ b/packages/hunk/src/ui/components/chrome/WorkingTreeDialog.tsx @@ -0,0 +1,134 @@ +/** Render exact-file discard choices and stash input using the shared confirmation chrome. */ +import type { ExtensionVcsDiscardScope } from "../../../extension-api/types"; +import type { WorkingTreePrompt } from "../../hooks/useWorkingTreeActions"; +import type { AppTheme } from "../../themes"; +import { fitText, wrapTextByWidth } from "../../lib/text"; +import { resolveModalGeometry } from "../../lib/modalGeometry"; +import { ConfirmDialog, confirmDialogHeight } from "./ConfirmDialog"; + +export function WorkingTreeDialog({ + prompt, + message, + onChangeMessage, + onAccept, + onCancel, + terminalHeight, + terminalWidth, + theme, +}: { + prompt: WorkingTreePrompt; + message: string; + onChangeMessage: (value: string) => void; + onAccept: (scope?: ExtensionVcsDiscardScope) => void; + onCancel: () => void; + terminalHeight: number; + terminalWidth: number; + theme: AppTheme; +}) { + const frame = resolveModalGeometry({ + width: 88, + height: Number.MAX_SAFE_INTEGER, + terminalWidth, + terminalHeight, + }); + const width = frame.width; + const bodyWidth = Math.max(1, width - 4); + const stash = prompt.kind === "stash"; + const folder = prompt.folder; + const unstaged = prompt.files.some((file) => file.staged && file.unstaged); + const compact = bodyWidth < 60; + const availableRows = Math.max(0, frame.height - confirmDialogHeight(0)); + const fieldRows = availableRows > 0 ? 1 : 0; + const explanationRows = availableRows > fieldRows ? 1 : 0; + const pathRows = Math.max(0, availableRows - fieldRows - explanationRows); + const pathText = folder + ? `${prompt.label} (${prompt.files.length} ${prompt.files.length === 1 ? "file" : "files"})` + : JSON.stringify(prompt.files[0]?.path ?? prompt.label); + // Quote control characters and preserve every filename space instead of treating paths as prose. + const pathLines = wrapTextByWidth(pathText, bodyWidth).map((chunk) => chunk.text); + const visiblePath = + pathLines.length <= pathRows + ? pathLines + : [...pathLines.slice(0, Math.max(0, pathRows - 1)), ...(pathRows ? ["…"] : [])]; + return ( + onAccept() }, + { keyLabel: "esc", label: bodyWidth < 37 ? "" : "cancel", run: onCancel }, + ] + : [ + { + keyLabel: compact ? "x" : "enter/x", + label: compact ? (bodyWidth < 24 ? "" : "all") : "discard all", + run: () => onAccept("all"), + }, + ...(unstaged + ? [ + { + keyLabel: "u", + label: compact ? (bodyWidth < 24 ? "" : "unstaged") : "unstaged only", + run: () => onAccept("unstaged"), + }, + ] + : []), + { keyLabel: "esc", label: bodyWidth < 37 ? "" : "cancel", run: onCancel }, + ] + } + > + {visiblePath.map((line, index) => ( + + {line} + + ))} + {explanationRows > 0 ? ( + + + {fitText( + stash + ? folder + ? "Only these files. Message (optional):" + : "Only this file. Message (optional):" + : "Cannot undo discarded changes.", + bodyWidth, + )} + + + ) : null} + {fieldRows > 0 ? ( + + {stash ? ( + + ) : ( + + {fitText( + unstaged + ? folder + ? "Unstaged only keeps each file's staged changes." + : "Unstaged only keeps this file's staged changes." + : folder + ? "Unstaged-only discard is unavailable for these files." + : "Unstaged-only discard is unavailable for this file.", + bodyWidth, + )} + + )} + + ) : null} + + ); +} diff --git a/packages/hunk/src/ui/components/panes/DiffPane.tsx b/packages/hunk/src/ui/components/panes/DiffPane.tsx index d765a8164..c9604382a 100644 --- a/packages/hunk/src/ui/components/panes/DiffPane.tsx +++ b/packages/hunk/src/ui/components/panes/DiffPane.tsx @@ -1,3 +1,4 @@ +import { mousePressSequence, recordMousePress } from "../../lib/mousePressSequence"; import { MouseButton, type MouseEvent as TuiMouseEvent, @@ -85,6 +86,7 @@ import { type ViewportRowAnchor, } from "../../lib/viewportAnchor"; import type { AppTheme } from "../../themes"; +import { paneFrameBorderColor, reviewPaneFrameSides } from "../../lib/paneFocus"; import { DiffSection } from "./DiffSection"; import type { FileViewRowFailure } from "../../fileViews/types"; import type { ValidatedLineHighlight } from "../../highlights/validate"; @@ -323,6 +325,8 @@ export function DiffPane({ screenTop = 0, showTopChrome, skipInitialIntermediateRender = false, + focused = false, + framed = false, showAgentNotes, showLineNumbers, showHunkHeaders, @@ -363,6 +367,10 @@ export function DiffPane({ onCurrentLinePaintChange, onViewportCenteredHunkChange, onViewportLineCursorChange, + onHunkFocus, + onReviewPointerDown, + canToggleHunkStaged, + onToggleHunkStaged, }: { codeHorizontalOffset?: number; diffContentWidth: number; @@ -394,6 +402,10 @@ export function DiffPane({ showTopChrome?: boolean; /** Avoid clearing another surface when this pane mounts dynamically in the shared renderer. */ skipInitialIntermediateRender?: boolean; + /** True when the review pane currently owns keyboard movement. */ + focused?: boolean; + /** Draw a full pane frame when another pane is visible beside the review. */ + framed?: boolean; showAgentNotes: boolean; showLineNumbers: boolean; showHunkHeaders: boolean; @@ -435,7 +447,12 @@ export function DiffPane({ currentLinePaintRequested?: boolean; onCurrentLinePaintChange?: (update: ExtensionCurrentLinePaintUpdate) => void; onViewportCenteredHunkChange?: (fileId: string, hunkIndex: number) => void; - onViewportLineCursorChange?: (cursor: LineCursor) => void; + onViewportLineCursorChange?: (cursor: LineCursor, explicit?: boolean) => void; + onHunkFocus?: () => void; + /** Review-stream clicks that this pane consumes, so ancestors can drop file-panel scope. */ + onReviewPointerDown?: () => void; + canToggleHunkStaged?: (fileId: string, hunkIndex: number) => boolean; + onToggleHunkStaged?: (fileId: string, hunkIndex: number) => boolean; }) { const renderTopChrome = showTopChrome ?? !pagerMode; const renderer = useRenderer(); @@ -451,6 +468,7 @@ export function DiffPane({ const [addNoteHoverClearSignal, setAddNoteHoverClearSignal] = useState(0); const [addNoteHoverClearFileId, setAddNoteHoverClearFileId] = useState(null); const hoveredFileIdRef = useRef(null); + const hoverScrollTopRef = useRef(null); const onActiveAddNoteAffordanceChangeRef = useRef(onActiveAddNoteAffordanceChange); onActiveAddNoteAffordanceChangeRef.current = onActiveAddNoteAffordanceChange; @@ -802,8 +820,10 @@ export function DiffPane({ const [copySelectionDrag, setCopySelectionDrag] = useState(null); // Mirror the drag state in a ref so updateCopySelection can suppress native selection // on the very first drag event, before React has re-rendered with the new state. + const hunkClickCandidateRef = useRef<{ fileId: string; hunkIndex: number } | null>(null); const copySelectionDragRef = useRef(null); const lastClickTimeRef = useRef(0); + const lastClickPressRef = useRef(0); const clickCountRef = useRef(0); const lastClickPointRef = useRef(null); const scrollbarRef = useRef(null); @@ -856,10 +876,14 @@ export function DiffPane({ }, []); /** Track the currently hover-owned file without making scroll handlers depend on render state. */ - const setHoveredFileForRowActions = useCallback((fileId: string) => { - hoveredFileIdRef.current = fileId; - setHoveredFileId(fileId); - }, []); + const setHoveredFileForRowActions = useCallback( + (fileId: string) => { + hoveredFileIdRef.current = fileId; + hoverScrollTopRef.current = scrollRef.current?.scrollTop ?? 0; + setHoveredFileId(fileId); + }, + [scrollRef], + ); /** Temporarily widen the mounted diff window while scroll input is arriving in bursts. */ const activateRapidScrollOverscan = useCallback((overscanRows: number) => { @@ -946,7 +970,11 @@ export function DiffPane({ // now sit over a different row, but only an actual mouse move should reveal row actions. const previousTop = prevScrollTopRef.current; scrollbarRef.current?.show(); - clearAddNoteHoverForScroll(); + // A mouse move after this scroll already targets the new viewport. A deferred + // observation of the older scroll must not erase that newer row action. + if (hoverScrollTopRef.current !== nextTop) { + clearAddNoteHoverForScroll(); + } const rapidOverscanRows = computeRapidScrollOverscanRows({ deltaRows: nextTop - previousTop, viewportHeight: nextHeight, @@ -1513,6 +1541,8 @@ export function DiffPane({ /** Start selecting diff text when the user drags inside the review stream. */ const beginCopySelection = useCallback( (event: TuiMouseEvent) => { + hunkClickCandidateRef.current = null; + recordMousePress(renderer, event); if (event.button !== MouseButton.LEFT) { return; } @@ -1526,6 +1556,10 @@ export function DiffPane({ return; } + // Consumed review hits never bubble to App's wrapper. Leave file quick-action + // scope here so d/s cannot discard/stash after a hunk-header or gap click. + onReviewPointerDown?.(); + // Detect double-click and triple-click for word/line selection. const now = Date.now(); const timeSinceLastClick = now - lastClickTimeRef.current; @@ -1534,18 +1568,39 @@ export function DiffPane({ previousClickPoint !== null && copySelectionPointsShareRow(previousClickPoint, point) && Math.abs(previousClickPoint.column - point.column) <= 2; + const press = mousePressSequence(renderer); + const consecutivePress = press === lastClickPressRef.current + 1; + lastClickPressRef.current = press; lastClickTimeRef.current = now; lastClickPointRef.current = point; let clickCount = 1; - if (timeSinceLastClick < 350 && timeSinceLastClick >= 0 && repeatedClickTarget) { + if ( + consecutivePress && + timeSinceLastClick < 350 && + timeSinceLastClick >= 0 && + repeatedClickTarget + ) { clickCountRef.current += 1; clickCount = Math.min(clickCountRef.current, 3); } else { clickCountRef.current = 1; } - if (clickCount >= 2 && point.kind === "review-row") { + if (clickCount === 2 && onToggleHunkStaged && !isNestedRowMouseAction(event)) { + const cursor = findLineCursorForClick({ + cursors: lineCursors, + fileSectionLayouts, + point, + sectionGeometry, + side: resolveCopySelectionSide(point.column, layout, diffContentWidth), + }); + // Unavailable mutations must leave repeated-click word/line copying intact. + if (cursor && canToggleHunkStaged?.(cursor.fileId, cursor.hunkIndex)) + hunkClickCandidateRef.current = cursor; + } + + if (!hunkClickCandidateRef.current && clickCount >= 2 && point.kind === "review-row") { const expanded = expandSelectionPoint(point, clickCount as 2 | 3, copySelectionContext); if (expanded) { const drag: CopySelectionDrag = { @@ -1570,7 +1625,20 @@ export function DiffPane({ event.preventDefault(); event.stopPropagation(); }, - [copySelectionContext, resolveCopySelectionPoint, suppressNativeSelection], + [ + copySelectionContext, + renderer, + resolveCopySelectionPoint, + suppressNativeSelection, + onReviewPointerDown, + onToggleHunkStaged, + canToggleHunkStaged, + lineCursors, + fileSectionLayouts, + sectionGeometry, + layout, + diffContentWidth, + ], ); /** Extend the active diff text selection while the pointer moves. */ @@ -1628,6 +1696,8 @@ export function DiffPane({ /** Finish a mouse gesture by selecting its clicked line or copying its deliberate drag. */ const endCopySelection = useCallback( (event?: TuiMouseEvent) => { + const hunkCandidate = hunkClickCandidateRef.current; + hunkClickCandidateRef.current = null; const pending = copySelectionDragRef.current; if (!pending) { return; @@ -1649,6 +1719,20 @@ export function DiffPane({ event?.preventDefault(); event?.stopPropagation(); + // A second press may become a copy drag. Only its unmoved release consents to staging. + if ( + event && + endPoint && + !current.moved && + hunkCandidate && + !isNestedRowMouseAction(event) && + onToggleHunkStaged?.(hunkCandidate.fileId, hunkCandidate.hunkIndex) + ) { + onHunkFocus?.(); + clickCountRef.current = 0; + return; + } + if (copySelectionDragIsClick(current)) { if (event && isNestedRowMouseAction(event)) { return; @@ -1662,7 +1746,8 @@ export function DiffPane({ side: resolveCopySelectionSide(current.anchor.column, layout, diffContentWidth), }); if (clickedCursor && onViewportLineCursorChange) { - onViewportLineCursorChange(clickedCursor); + onHunkFocus?.(); + onViewportLineCursorChange(clickedCursor, true); return; } if (!current.moved) { @@ -1688,6 +1773,8 @@ export function DiffPane({ layout, lineCursors, onViewportLineCursorChange, + onToggleHunkStaged, + onHunkFocus, resolveCopySelectionPoint, sectionGeometry, ], @@ -1905,13 +1992,8 @@ export function DiffPane({ const visibleBodyBoundsByFile = useMemo(() => { const previous = previousVisibleBodyBoundsRef.current; const next = new Map(); - if (!wrapLines && scrollViewport.height <= 0) { - previousVisibleBodyBoundsRef.current = next; - return next; - } - - // Keep this provisional height render-only. Navigation and selection effects must continue to - // wait for the exact scrollbox viewport represented by scrollViewport.height. + // Bound the first mount before Yoga measures the viewport instead of mounting the whole file. + // Keep this estimate render-only; navigation and selection still wait for measured geometry. const hasMeasuredViewport = scrollViewport.height > 0; const renderViewportHeight = hasMeasuredViewport ? scrollViewport.height @@ -2672,14 +2754,16 @@ export function DiffPane({ style={{ width, ...(height === undefined ? {} : { height }), - border: renderTopChrome ? ["top"] : [], - borderColor: theme.border, + border: framed ? reviewPaneFrameSides(renderTopChrome) : renderTopChrome ? ["top"] : [], + borderColor: paneFrameBorderColor(theme, focused), backgroundColor: theme.panel, paddingX: 0, flexDirection: "column", - ...(renderTopChrome - ? { paddingY: 1 } - : { paddingTop: 0, paddingBottom: pagerMode ? 0 : 1 }), + ...(framed + ? { paddingTop: renderTopChrome ? 1 : 0, paddingBottom: 0 } + : renderTopChrome + ? { paddingY: 1 } + : { paddingTop: 0, paddingBottom: pagerMode ? 0 : 1 }), }} onMouseDragEnd={endCopySelection} onMouseUp={endCopySelection} diff --git a/packages/hunk/src/ui/components/panes/ExtensionPane.test.tsx b/packages/hunk/src/ui/components/panes/ExtensionPane.test.tsx index 5da5cc8f9..cd8254969 100644 --- a/packages/hunk/src/ui/components/panes/ExtensionPane.test.tsx +++ b/packages/hunk/src/ui/components/panes/ExtensionPane.test.tsx @@ -172,6 +172,45 @@ describe("ExtensionPaneHost actions", () => { }); describe("ExtensionPaneHost activation", () => { + test("left click focuses the files pane before activating it", async () => { + const files = createTestFiles(); + const theme = resolveTheme("github-dark-default", null); + let focuses = 0; + const registered = registeredView(() => ); + + await withPane( + {}} + onFocus={() => { + focuses += 1; + }} + onSelectFile={() => {}} + onSelectHunk={() => {}} + onRevealLine={() => "line"} + />, + async (setup) => { + await act(async () => setup.mockMouse.click(2, 1, MouseButtons.LEFT)); + expect(focuses).toBe(1); + + await act(async () => setup.mockMouse.click(2, 1, MouseButtons.RIGHT)); + expect(focuses).toBe(1); + }, + ); + }); + test("activates once through nested content without consuming its primary press", async () => { const files = createTestFiles(); const theme = resolveTheme("github-dark-default", null); diff --git a/packages/hunk/src/ui/components/panes/ExtensionPane.tsx b/packages/hunk/src/ui/components/panes/ExtensionPane.tsx index 348a15999..ea19e624d 100644 --- a/packages/hunk/src/ui/components/panes/ExtensionPane.tsx +++ b/packages/hunk/src/ui/components/panes/ExtensionPane.tsx @@ -15,6 +15,7 @@ import { HUNK_FILES_PANE_KEY } from "../../../extensions/extensionIds"; import type { ExtensionNotifySink, RegisteredPane } from "../../../extensions/types"; import { createGuardedReviewNavigation } from "../../lib/extensionNavigation"; import { toExtensionPaintTheme } from "../../lib/extensionPaintTheme"; +import { filesPaneFrameSides, paneFrameBorderColor } from "../../lib/paneFocus"; import type { AppTheme } from "../../themes"; function describeError(error: unknown) { @@ -79,6 +80,7 @@ export interface ExtensionPaneHostProps { registered: RegisteredPane; review?: ExtensionPaneProps["review"]; files: DiffFile[]; + workingTree?: ExtensionPaneProps["workingTree"]; fileViews: ExtensionDiffFile[]; selectedFileId: string | null; selectedHunkIndex: number | null; @@ -91,6 +93,9 @@ export interface ExtensionPaneHostProps { keybindings: ExtensionPaneKeybindings; notify: ExtensionNotifySink; onCopyText?: (text: string) => boolean; + /** True when this pane currently owns keyboard movement. */ + focused?: boolean; + onFocus?: () => void; onSelectFile: (fileId: string) => void; onSelectHunk: (fileId: string, hunkIndex: number) => void; onRevealLine: (fileId: string, side: "old" | "new", line: number) => "line" | "hunk" | "none"; @@ -103,6 +108,7 @@ function ExtensionPaneHostView({ review = null, files, fileViews, + workingTree, selectedFileId, selectedHunkIndex, placement, @@ -114,6 +120,8 @@ function ExtensionPaneHostView({ keybindings, notify, onCopyText, + focused = false, + onFocus, onSelectFile, onSelectHunk, onRevealLine, @@ -152,6 +160,7 @@ function ExtensionPaneHostView({ const viewProps: ExtensionPaneProps = { review, files: fileViews, + workingTree, selectedFileId, selectedHunkIndex, placement, @@ -164,7 +173,9 @@ function ExtensionPaneHostView({ }; const filesChrome = paneKey(registered) === HUNK_FILES_PANE_KEY; const onMouseDown = (event: TuiMouseEvent) => { - if (event.button === MouseButton.LEFT) activatePane(registered, notify); + if (event.button !== MouseButton.LEFT) return; + onFocus?.(); + activatePane(registered, notify); }; const box = (children: ReactNode) => ( previous.registered === next.registered && previous.review === next.review && + previous.workingTree === next.workingTree && previous.files.length === next.files.length && previous.files.every((file, index) => file === next.files[index]) && previous.selectedFileId === next.selectedFileId && @@ -227,6 +240,8 @@ export const ExtensionPaneHost = memo( previous.width === next.width && previous.height === next.height && previous.showTopChrome === next.showTopChrome && + previous.focused === next.focused && + previous.onFocus === next.onFocus && previous.keybindings === next.keybindings && (!next.registered.pane.currentLine || previous.currentLine === next.currentLine), ); diff --git a/packages/hunk/src/ui/components/panes/FileListItem.tsx b/packages/hunk/src/ui/components/panes/FileListItem.tsx index 95f96b63b..372206b8f 100644 --- a/packages/hunk/src/ui/components/panes/FileListItem.tsx +++ b/packages/hunk/src/ui/components/panes/FileListItem.tsx @@ -1,7 +1,6 @@ import { MouseButton, type MouseEvent as TuiMouseEvent } from "@opentui/core"; import { memo } from "react"; import type { ExtensionSidebarTheme } from "../../../extension-api/types"; -import { diffRailMarker } from "../../diff/rowStyle"; import { fileRowId } from "../../lib/ids"; import { sidebarEntryStats, @@ -23,8 +22,13 @@ function getFileStateIcon( entry: FileListEntry, theme: ExtensionSidebarTheme, ): { icon: string; color: string } { + if (entry.stageStatus) + return { + icon: entry.stageStatus, + color: ["??", "!!"].includes(entry.stageStatus) ? theme.badgeRemoved : theme.badgeAdded, + }; if (entry.isUntracked) { - return { icon: "?", color: theme.fileUntracked }; + return { icon: "??", color: theme.badgeRemoved }; } switch (entry.changeType) { @@ -42,28 +46,42 @@ function getFileStateIcon( } } -/** Render one folder header in the navigation sidebar. */ +/** Render one compact folder header, leaving the shared stats column empty. */ export function FileGroupHeader({ entry, paddingLeft = 1, + selected = false, + statsWidth = 0, textWidth, theme, + onSelect, }: { entry: FileGroupEntry; paddingLeft?: number; + selected?: boolean; + statsWidth?: number; textWidth: number; theme: ExtensionSidebarTheme; + onSelect?: (entryId: string) => void; }) { + const rowBackground = selected ? theme.accentMuted : theme.panel; + const statsSectionWidth = statsWidth > 0 ? statsWidth + 1 : 0; + const labelWidth = Math.max(1, textWidth - statsSectionWidth); return ( { + if (event.button !== MouseButton.LEFT || !onSelect) return; + onSelect(entry.id); }} > - {fitText(entry.label, Math.max(1, textWidth))} + {fitText(entry.label, labelWidth, "…")} ); } @@ -79,18 +97,23 @@ export function FileDirectoryRow({ entry, onToggleDirectory, paddingLeft = 1, + selected = false, statsWidth = 0, textWidth, theme, + onSelect, }: { collapsed: boolean; entry: FileDirectoryEntry; onToggleDirectory: (path: string) => void; paddingLeft?: number; + selected?: boolean; statsWidth?: number; textWidth: number; theme: ExtensionSidebarTheme; + onSelect?: (entryId: string) => void; }) { + const rowBackground = selected ? theme.accentMuted : theme.panel; const statsSectionWidth = statsWidth > 0 ? statsWidth + 1 : 0; const countText = collapsed ? `${entry.descendantFileCount} ${entry.descendantFileCount === 1 ? "file" : "files"}` @@ -106,30 +129,34 @@ export function FileDirectoryRow({ return ( { if (event.button === MouseButton.LEFT) { + onSelect?.(entry.id); onToggleDirectory(entry.path); } }} > - + {collapsed ? "› " : "⌄ "} - {padText(fitText(entry.label, labelWidth), labelWidth)} + + {padText(fitText(entry.label, labelWidth), labelWidth)} + {countText && ( void; }) { - const rowBackground = selected ? theme.panelAlt : theme.panel; + const rowBackground = selected ? theme.accentMuted : theme.panel; const stats = sidebarEntryStats(entry); const { icon, color } = getFileStateIcon(entry, theme); - const iconWidth = icon ? 2 : 0; // icon + space + const fullyStaged = entry.stageStatus?.[0] !== " " && entry.stageStatus?.[1] === " "; + const nameColor = fullyStaged ? theme.badgeAdded : theme.text; + const iconWidth = icon ? icon.length + 1 : 0; const statsSectionWidth = statsWidth > 0 ? statsWidth + 1 : 0; const indentWidth = fileSidebarIndentWidth( entry.depth, @@ -187,11 +216,18 @@ export const FileListItem = memo(function FileListItem({ backgroundColor: rowBackground, flexDirection: "row", }} - onMouseUp={() => onSelectFile(entry.id)} + onMouseUp={(event) => { + if (event.button !== MouseButton.LEFT) return; + onSelectFile(entry.id); + }} > - - {selected ? diffRailMarker() : " "} - + - {icon && {icon} } - {padText(fitText(entry.name, nameWidth, "…"), nameWidth)} + {icon && ( + + {entry.stageStatus ? ( + <> + {icon[0]} + {icon[1]} + + ) : ( + icon + )}{" "} + + )} + {padText(fitText(entry.name, nameWidth, "…"), nameWidth)} {statsSectionWidth > 0 && ( void; onMouseDrag: (event: TuiMouseEvent) => void; @@ -54,7 +58,7 @@ export function PaneDivider({ flexShrink: 0, backgroundColor: isResizing ? theme.accentMuted : theme.panel, border: orientation === "vertical" ? ["left"] : ["top"], - borderColor: isResizing ? theme.accent : theme.border, + borderColor: isResizing ? theme.accent : paneFrameBorderColor(theme, emphasized), }} customBorderChars={{ topLeft: orientation === "vertical" ? "│" : "─", diff --git a/packages/hunk/src/ui/components/scrollbar/VerticalScrollbar.tsx b/packages/hunk/src/ui/components/scrollbar/VerticalScrollbar.tsx index 55e98bd8e..724635d40 100644 --- a/packages/hunk/src/ui/components/scrollbar/VerticalScrollbar.tsx +++ b/packages/hunk/src/ui/components/scrollbar/VerticalScrollbar.tsx @@ -1,3 +1,5 @@ +import { useRenderer } from "@opentui/react"; +import { recordMousePress } from "../../lib/mousePressSequence"; import type { MouseEvent as TuiMouseEvent } from "@opentui/core"; import { forwardRef, @@ -60,6 +62,7 @@ export const VerticalScrollbar = forwardRef { + recordMousePress(renderer, event); if (event.button !== 0) return; const currentScrollTop = scrollRef.current?.scrollTop ?? 0; @@ -156,6 +160,7 @@ export const VerticalScrollbar = forwardRef { + recordMousePress(renderer, event); if (event.button !== 0) return; // Calculate where on the track was clicked diff --git a/packages/hunk/src/ui/components/ui-components.test.tsx b/packages/hunk/src/ui/components/ui-components.test.tsx index e7748025f..cdfc07a5c 100644 --- a/packages/hunk/src/ui/components/ui-components.test.tsx +++ b/packages/hunk/src/ui/components/ui-components.test.tsx @@ -546,7 +546,7 @@ describe("UI components", () => { expect(frame).toContain("./"); expect(frame).toContain(" zzz-root.ts"); expect(frame.indexOf("src/ui/")).toBeLessThan(frame.indexOf("./")); - expect(frame).toContain("▌ M App.tsx"); + expect(frame).toContain(" M App.tsx"); expect(frame).toContain(" MenuDropdown.tsx"); expect(frame).toContain(" signature.ts"); expect(frame).toContain("*1 +2 -1"); @@ -557,6 +557,222 @@ describe("UI components", () => { expect(frame).not.toContain("M +2 -1 AI"); }); + test("selecting a compact folder keeps line stats on files outside that folder", async () => { + const theme = resolveTheme("github-dark-default", null); + const files = toReadOnlyFileViews([ + createTestDiffFile("docs-a", "docs/extensions.md", "a\n", "aa\n"), + createTestDiffFile("docs-b", "docs/keybindings.md", "b\n", "bb\n"), + createTestDiffFile( + "hook", + "src/ui/hooks/useWorkingTreeActions.ts", + "export const value = 1;\n", + `${"export const extra = true;\n".repeat(267)}export const value = 1;\n`, + ), + ]); + const entries = files.map((file) => ({ + path: file.path, + staged: false, + unstaged: true, + untracked: false, + conflicted: false, + version: "test", + })); + const frame = await captureFrame( + false, getKeys: () => [] }} + actions={{ + copyText: () => false, + selectFile: () => {}, + selectHunk: () => {}, + revealLine: () => {}, + notify: () => {}, + }} + workingTree={{ + files: entries, + selectedPath: "docs/extensions.md", + selectedEntryId: "group:docs:0", + staged: false, + busy: false, + selectFile: () => {}, + selectEntry: () => {}, + toggleStaged: () => {}, + toggleEntry: () => {}, + }} + />, + 36, + 12, + ); + + expect(frame).toContain("docs/"); + expect(frame).toContain("+1"); + expect(frame).toContain("+267"); + }); + + test.each([30, 42])( + "working-tree sidebar colors status and the entire selected row at width %s", + async (width) => { + const theme = resolveTheme("github-dark-default", null); + const setup = await testRender( + false, getKeys: () => [] }} + actions={{ + copyText: () => false, + selectFile: () => {}, + selectHunk: () => {}, + revealLine: () => {}, + notify: () => {}, + }} + workingTree={{ + files: [ + { + path: "new.txt", + staged: false, + unstaged: true, + untracked: true, + conflicted: false, + statusCode: "??", + version: "new", + }, + { + path: "added.txt", + staged: true, + unstaged: false, + untracked: false, + conflicted: false, + statusCode: "A ", + version: "added", + }, + { + path: "mixed.txt", + staged: true, + unstaged: true, + untracked: false, + conflicted: false, + statusCode: "MM", + version: "mixed", + }, + ], + selectedPath: "added.txt", + selectedEntryId: "added.txt", + staged: false, + busy: false, + selectFile: () => {}, + selectEntry: () => {}, + toggleStaged: () => {}, + toggleEntry: () => {}, + }} + />, + { width, height: 8 }, + ); + try { + await act(async () => setup.renderOnce()); + const rows = setup.captureSpans().lines; + const cellsFor = (name: string) => { + const row = rows.find((row) => row.spans.some((span) => span.text.includes(name)))!; + return row.spans.flatMap((span) => + [...span.text].map((text) => ({ + text, + fg: capturedTestColorToHex(span.fg), + bg: capturedTestColorToHex(span.bg), + })), + ); + }; + const untracked = cellsFor("new.txt"); + expect(untracked.filter((cell) => cell.text === "?").map((cell) => cell.fg)).toEqual([ + theme.badgeRemoved, + theme.badgeRemoved, + ]); + const added = cellsFor("added.txt"); + expect(added.find((cell) => cell.text === "A")?.fg).toBe(theme.badgeAdded); + expect(added.find((cell) => cell.text === "a")?.fg).toBe(theme.badgeAdded); + expect(added.find((cell) => cell.text === "*")?.fg).toBe(theme.fileModified); + expect(added.length).toBe(width); + expect(added.every((cell) => cell.bg === theme.accentMuted)).toBe(true); + expect( + cellsFor("mixed.txt") + .filter((cell) => cell.text === "M") + .map((cell) => cell.fg), + ).toEqual([theme.badgeAdded, theme.badgeRemoved]); + } finally { + await act(async () => setup.renderer.destroy()); + } + }, + ); + + test("working-tree sidebar requires consecutive clicks on one row to stage", async () => { + const files = toReadOnlyFileViews([ + createTestDiffFile("alpha", "alpha.ts", "a\n", "aa\n"), + createTestDiffFile("beta", "beta.ts", "b\n", "bb\n"), + ]); + const selectFile = mock(() => {}); + const selectEntry = mock(() => {}); + const toggleStaged = mock(() => {}); + const toggleEntry = mock(() => {}); + const setup = await testRender( + false, getKeys: () => [] }} + actions={{ + copyText: () => false, + selectFile, + selectHunk: () => {}, + revealLine: () => {}, + notify: () => {}, + }} + workingTree={{ + files: files.map((file) => ({ + path: file.path, + staged: false, + unstaged: true, + untracked: false, + conflicted: false, + version: "test", + })), + selectedPath: "alpha.ts", + selectedEntryId: "alpha.ts", + staged: false, + busy: false, + selectFile, + selectEntry, + toggleStaged, + toggleEntry, + }} + />, + { width: 40, height: 8 }, + ); + const time = spyOn(Date, "now").mockReturnValue(1000); + try { + await act(async () => setup.renderOnce()); + const lines = setup.captureCharFrame().split("\n"); + const alpha = lines.findIndex((line) => line.includes("alpha.ts")); + const beta = lines.findIndex((line) => line.includes("beta.ts")); + for (const row of [alpha, beta, alpha]) { + await act(async () => setup.mockMouse.click(5, row)); + } + expect(selectEntry.mock.calls).toHaveLength(3); + expect(toggleEntry).not.toHaveBeenCalled(); + await act(async () => setup.mockMouse.click(5, alpha)); + expect(toggleEntry).toHaveBeenCalledWith("alpha.ts"); + } finally { + time.mockRestore(); + await act(async () => setup.renderer.destroy()); + } + }); + test("the bundled sidebar switches to its expanded tree at 32 content columns", async () => { const theme = resolveTheme("github-dark-default", null); const files = toReadOnlyFileViews([ @@ -715,6 +931,36 @@ describe("UI components", () => { } }); + test("DiffPane preserves double-click word copy when a hunk action is unavailable", async () => { + const file = createTestDiffFile("copy-word", "copy.ts", "old answer\n", "new answer\n"); + const copyText = mock((_text: string) => undefined); + const toggleHunk = mock(() => false); + const setup = await testRender( + false, + onToggleHunkStaged: toggleHunk, + })} + />, + { width: 80, height: 8 }, + ); + try { + await settleDiffPane(setup); + const rows = setup.captureCharFrame().split("\n"); + const y = rows.findIndex((row) => row.includes("new answer")); + const x = rows[y]!.indexOf("new answer") + 5; + await act(async () => { + await setup.mockMouse.click(x, y); + await setup.mockMouse.click(x, y); + }); + expect(copyText).toHaveBeenCalledWith("answer"); + expect(toggleHunk).not.toHaveBeenCalled(); + } finally { + await act(async () => setup.renderer.destroy()); + } + }); + test("DiffPane selects the exact split line side on click without consuming add-note clicks", async () => { const file = createTestDiffFile( "click-line", @@ -754,6 +1000,7 @@ describe("UI components", () => { }); expect(selectLine).toHaveBeenLastCalledWith( expect.objectContaining({ fileId: file.id, target: { side: "old", line: 1 } }), + true, ); await act(async () => { @@ -761,6 +1008,7 @@ describe("UI components", () => { }); expect(selectLine).toHaveBeenLastCalledWith( expect.objectContaining({ fileId: file.id, target: { side: "new", line: 1 } }), + true, ); selectLine.mockClear(); @@ -769,6 +1017,7 @@ describe("UI components", () => { }); expect(selectLine).toHaveBeenLastCalledWith( expect.objectContaining({ fileId: file.id, target: { side: "new", line: 2 } }), + true, ); await act(async () => { @@ -778,6 +1027,7 @@ describe("UI components", () => { }); expect(selectLine).toHaveBeenLastCalledWith( expect.objectContaining({ fileId: file.id, target: { side: "old", line: 1 } }), + true, ); expect(copyText).not.toHaveBeenCalled(); @@ -1379,6 +1629,9 @@ describe("UI components", () => { await act(async () => { await setup.mockMouse.moveTo(32, secondHunkY); await setup.renderOnce(); + // Let the deferred viewport observation run after the newer mouse move. + await Bun.sleep(50); + await setup.renderOnce(); }); const affordanceFrame = await waitForFrame( setup, @@ -3847,20 +4100,21 @@ describe("UI components", () => { const frame = await captureFrame( {}} />, 76, - 39, + 41, ); const expectedRows = [ "Controls help", "[Esc]", "Navigation", - "Up / Down move line-by-line", + "Up / Down move in the focused pane", + "Enter / Esc focus review / files pane", "PageDown / Space / f page down", "PageUp / b / Shift+Space page up", "d / u half page down / up", @@ -3879,7 +4133,7 @@ describe("UI components", () => { "a toggle AI notes", "z toggle unchanged context", "l / w / m / M lines / wrap / metadata / menu", - "e open file in $EDITOR", + "e edit selected line in $EDITOR", "Review", "/ focus file filter", "c create review note", @@ -3968,13 +4222,13 @@ describe("UI components", () => { const frame = await captureFrame( {}} />, 76, - 39, + 41, ); expect(frame).toContain("Ctrl+X"); diff --git a/packages/hunk/src/ui/diff/CodeRowView.tsx b/packages/hunk/src/ui/diff/CodeRowView.tsx index cada89a37..6a144df25 100644 --- a/packages/hunk/src/ui/diff/CodeRowView.tsx +++ b/packages/hunk/src/ui/diff/CodeRowView.tsx @@ -63,6 +63,7 @@ function renderAddNoteButton( { markNestedRowMouseAction(event); onStartUserNoteAtHunk?.(hunkIndex, target); diff --git a/packages/hunk/src/ui/diff/DiffMetaRowView.tsx b/packages/hunk/src/ui/diff/DiffMetaRowView.tsx index 7dffec5aa..490f122ff 100644 --- a/packages/hunk/src/ui/diff/DiffMetaRowView.tsx +++ b/packages/hunk/src/ui/diff/DiffMetaRowView.tsx @@ -133,6 +133,7 @@ export function DiffMetaRowView({ { markNestedRowMouseAction(event); badge.onClick(); diff --git a/packages/hunk/src/ui/hooks/useAppKeyboardShortcuts.ts b/packages/hunk/src/ui/hooks/useAppKeyboardShortcuts.ts index 3333e1a40..bb37f3668 100644 --- a/packages/hunk/src/ui/hooks/useAppKeyboardShortcuts.ts +++ b/packages/hunk/src/ui/hooks/useAppKeyboardShortcuts.ts @@ -1,3 +1,4 @@ +import type { useWorkingTreeActions } from "./useWorkingTreeActions"; import type { KeyEvent } from "@opentui/core"; import { useKeyboard, useRenderer } from "@opentui/react"; import { useRef } from "react"; @@ -22,6 +23,10 @@ import { handleViewPreferenceQuitPromptKey } from "../lib/viewPreferenceQuitKeys type FocusArea = "files" | "filter" | "note"; export interface UseAppKeyboardShortcutsOptions { + workingTreeDialog?: Pick< + ReturnType, + "getPrompt" | "acceptPrompt" | "cancelPrompt" + >; activeMenuId: MenuId | null; activateCurrentMenuItem: () => void; closeAgentSkill: () => void; @@ -97,6 +102,7 @@ export interface UseAppKeyboardShortcutsOptions { * full contract, including why a boolean cannot express it. */ export function useAppKeyboardShortcuts({ + workingTreeDialog, activeMenuId, activateCurrentMenuItem, closeAgentSkill, @@ -137,6 +143,8 @@ export function useAppKeyboardShortcuts({ themeSelectorOpen, }: UseAppKeyboardShortcutsOptions) { const renderer = useRenderer(); + const workingTreeDialogRef = useRef(workingTreeDialog); + workingTreeDialogRef.current = workingTreeDialog; const activeMenuIdRef = useRef(activeMenuId); const commandsRef = useRef(commands); const focusAreaRef = useRef(focusArea); @@ -300,6 +308,25 @@ export function useAppKeyboardShortcuts({ return "mine"; }; + /** Own prompt keys without allowing destructive choices to fall through to review commands. */ + const handleWorkingTreeDialogShortcut = (key: KeyEvent): KeyOwner => { + const controller = workingTreeDialogRef.current; + const prompt = controller?.getPrompt(); + if (!prompt) return "notMine"; + if (isEscapeKey(key)) { + controller!.cancelPrompt(); + return "mine"; + } + if (key.name === "return" || key.name === "enter") { + controller!.acceptPrompt("all"); + return "mine"; + } + if (prompt.kind === "stash") return "focused"; + if (key.name === "x" || key.sequence === "x") controller!.acceptPrompt("all"); + if (key.name === "u" || key.sequence === "u") controller!.acceptPrompt("unstaged"); + return "mine"; + }; + /** * Own every key while an extension dialog is up. * @@ -583,6 +610,7 @@ export function useAppKeyboardShortcuts({ [ handleExtensionTrustPromptShortcut, handleSaveConfigPromptShortcut, + handleWorkingTreeDialogShortcut, handleExtensionDialogShortcut, handleMenuToggleShortcut, handleDialogShortcut, diff --git a/packages/hunk/src/ui/hooks/useCurrentReviewRefreshController.ts b/packages/hunk/src/ui/hooks/useCurrentReviewRefreshController.ts index fcead646c..6a5b35825 100644 --- a/packages/hunk/src/ui/hooks/useCurrentReviewRefreshController.ts +++ b/packages/hunk/src/ui/hooks/useCurrentReviewRefreshController.ts @@ -31,6 +31,8 @@ export interface CurrentReviewRefreshController { triggerRefreshCurrentInput: () => void; /** Reload because watch mode observed a source change. */ refreshWatchedInput: () => Promise; + /** Switch the complete working-tree stream while retaining the mounted view preferences. */ + setStagedView: (staged: boolean) => Promise; } /** Derive, register, and refresh the current mounted review descriptor. */ @@ -99,6 +101,19 @@ export function useCurrentReviewRefreshController({ }); }, [refreshCurrentInput]); + const setStagedView = useCallback(async (staged: boolean) => { + const current = requestRef.current; + if (!current || current.nextInput.kind !== "vcs") return; + await reloadSessionRef.current( + { ...current.nextInput, staged }, + { + reason: "manual", + resetApp: false, + sourcePath: current.sourcePath, + }, + ); + }, []); + const refreshWatchedInput = useCallback( () => refreshCurrentInput({ reason: "watch" }), [refreshCurrentInput], @@ -118,5 +133,6 @@ export function useCurrentReviewRefreshController({ refreshCurrentInput, triggerRefreshCurrentInput, refreshWatchedInput, + setStagedView, }; } diff --git a/packages/hunk/src/ui/hooks/useExtensionDialogController.test.tsx b/packages/hunk/src/ui/hooks/useExtensionDialogController.test.tsx index 1f12e4a11..7773ce114 100644 --- a/packages/hunk/src/ui/hooks/useExtensionDialogController.test.tsx +++ b/packages/hunk/src/ui/hooks/useExtensionDialogController.test.tsx @@ -46,10 +46,10 @@ describe("useExtensionDialogController", () => { expect(harness.controller().request).toMatchObject({ kind: "select", title: "Target?" }); expect(harness.controller().selectedIndex).toBe(0); - await act(async () => harness.controller().moveSelection(-1)); - expect(harness.controller().selectedIndex).toBe(2); - - await act(async () => harness.controller().accept()); + await act(async () => { + harness.controller().moveSelection(-1); + harness.controller().accept(); + }); expect(await selected).toBe("three"); await flush(harness.setup); @@ -57,8 +57,10 @@ describe("useExtensionDialogController", () => { expect(harness.controller().selectedIndex).toBe(0); expect(harness.controller().inputValue).toBe("feature/base"); - await act(async () => harness.controller().updateInput("feature/typed")); - await act(async () => harness.controller().accept()); + await act(async () => { + harness.controller().updateInput("feature/typed"); + harness.controller().accept(); + }); expect(await typed).toBe("feature/typed"); await flush(harness.setup); expect(harness.controller().request).toBeNull(); diff --git a/packages/hunk/src/ui/hooks/useExtensionDialogController.ts b/packages/hunk/src/ui/hooks/useExtensionDialogController.ts index 0ac595b72..829705eaf 100644 --- a/packages/hunk/src/ui/hooks/useExtensionDialogController.ts +++ b/packages/hunk/src/ui/hooks/useExtensionDialogController.ts @@ -28,16 +28,31 @@ export function useExtensionDialogController({ }): ExtensionDialogController { const [queue] = useState(createExtensionDialogQueue); const request = useSyncExternalStore(queue.subscribe, queue.current, queue.current); - const [selectedIndex, setSelectedIndex] = useState(0); - const [inputValue, setInputValue] = useState(""); - const requestId = request?.id ?? null; - const initialInput = request?.kind === "input" ? request.initial : ""; + const [answer, setAnswer] = useState({ + requestId: request?.id, + selectedIndex: 0, + inputValue: "", + }); + const answerRef = useRef(answer); + const currentAnswer = + answer.requestId === request?.id + ? answer + : { + requestId: request?.id, + selectedIndex: 0, + inputValue: request?.kind === "input" ? request.initial : "", + }; - useEffect(() => { - // A promoted queued request must never inherit the previous request's answer state. - setSelectedIndex(0); - setInputValue(initialInput); - }, [initialInput, requestId]); + /** Read edits synchronously, even when another key arrives before React commits. */ + const readAnswer = () => + answerRef.current.requestId === request?.id ? answerRef.current : currentAnswer; + + /** Keep the visible answer and the next keypress scoped to the same request. */ + const updateAnswer = (patch: Partial>) => { + const next = { ...readAnswer(), ...patch }; + answerRef.current = next; + setAnswer(next); + }; const previousReviewGenerationRef = useRef(reviewGeneration); useLayoutEffect(() => { @@ -60,11 +75,14 @@ export function useExtensionDialogController({ if (!request) return; if (request.kind === "select") { - queue.accept(request.id, request.options[selectedIndexOverride ?? selectedIndex]); + queue.accept( + request.id, + request.options[selectedIndexOverride ?? readAnswer().selectedIndex], + ); return; } - queue.accept(request.id, request.kind === "input" ? inputValue : undefined); + queue.accept(request.id, request.kind === "input" ? readAnswer().inputValue : undefined); }; /** Dismiss the visible request with its kind-specific cancel value. */ @@ -77,18 +95,20 @@ export function useExtensionDialogController({ if (request?.kind !== "select") return; const optionCount = request.options.length; - setSelectedIndex((current) => (current + delta + optionCount) % optionCount); + updateAnswer({ + selectedIndex: (readAnswer().selectedIndex + delta + optionCount) % optionCount, + }); }; return { createDialogs: queue.createDialogs, request, - selectedIndex, - inputValue, + selectedIndex: currentAnswer.selectedIndex, + inputValue: currentAnswer.inputValue, accept, cancel, moveSelection, - pickOption: setSelectedIndex, - updateInput: setInputValue, + pickOption: (selectedIndex) => updateAnswer({ selectedIndex }), + updateInput: (inputValue) => updateAnswer({ inputValue }), }; } diff --git a/packages/hunk/src/ui/hooks/useExtensionWorkspaceControls.test.tsx b/packages/hunk/src/ui/hooks/useExtensionWorkspaceControls.test.tsx index 68ed4158d..969bd21ee 100644 --- a/packages/hunk/src/ui/hooks/useExtensionWorkspaceControls.test.tsx +++ b/packages/hunk/src/ui/hooks/useExtensionWorkspaceControls.test.tsx @@ -491,7 +491,11 @@ describe("useExtensionWorkspaceControls writes", () => { .controller() .createWorkspaceControls("probe") .writeDocument({ fileId: "alpha", text: "replacement" }), - ).resolves.toEqual(EXPIRED); + ).resolves.toEqual({ + ok: false, + reason: "unavailable", + detail: "Another workspace operation is active or Hunk is shutting down.", + }); expect(runnerCalls).toBe(1); expect(writes).toBe(0); } finally { diff --git a/packages/hunk/src/ui/hooks/useExtensionWorkspaceControls.ts b/packages/hunk/src/ui/hooks/useExtensionWorkspaceControls.ts index a3e1e7624..95967c614 100644 --- a/packages/hunk/src/ui/hooks/useExtensionWorkspaceControls.ts +++ b/packages/hunk/src/ui/hooks/useExtensionWorkspaceControls.ts @@ -71,7 +71,7 @@ export function useExtensionWorkspaceControls({ onWorkspaceWriteCompleted: () => void; /** The current repository root, or the review's working directory. */ root: string; - /** Start and track one irreversible write, or refuse it during shutdown. */ + /** Start and track one irreversible write, or refuse it during shutdown or a Git mutation. */ runWorkspaceWrite: WorkspaceWriteRunner; workspaceFileWriter?: WorkspaceFileWriter; }): ExtensionWorkspaceControlsController { @@ -154,7 +154,12 @@ export function useExtensionWorkspaceControls({ const started = await runWorkspaceWrite(() => workspaceFileWriter(target.absolutePath, text), ); - if (!started) return expiredWorkspaceWrite(); + if (!started) + return { + ok: false, + reason: "unavailable", + detail: "Another workspace operation is active or Hunk is shutting down.", + }; } catch (error) { return { ok: false, diff --git a/packages/hunk/src/ui/hooks/useLiveState.ts b/packages/hunk/src/ui/hooks/useLiveState.ts new file mode 100644 index 000000000..840fe24ca --- /dev/null +++ b/packages/hunk/src/ui/hooks/useLiveState.ts @@ -0,0 +1,15 @@ +import { useCallback, useRef, useState, type SetStateAction } from "react"; + +/** Publish input-owned state eagerly so consecutive terminal events see preceding edits. */ +export function useLiveState(initial: T) { + const [value, setValue] = useState(() => initial); + const valueRef = useRef(initial); + const getValue = useCallback(() => valueRef.current, []); + const updateValue = useCallback((next: SetStateAction) => { + const resolved = + typeof next === "function" ? (next as (previous: T) => T)(valueRef.current) : next; + valueRef.current = resolved; + setValue(() => resolved); + }, []); + return [value, updateValue, getValue] as const; +} diff --git a/packages/hunk/src/ui/hooks/useTerminalReview.test.tsx b/packages/hunk/src/ui/hooks/useTerminalReview.test.tsx index 016bee4a7..427afc572 100644 --- a/packages/hunk/src/ui/hooks/useTerminalReview.test.tsx +++ b/packages/hunk/src/ui/hooks/useTerminalReview.test.tsx @@ -1993,6 +1993,35 @@ describe("useTerminalReview", () => { } }); + test("distinguishes deliberate source selection from seeded and viewport-clamped cursor rows", async () => { + const { controllerRef, setup } = await renderTerminalReview([createTwoHunkFile()]); + try { + await flush(setup); + expect(expectValue(controllerRef.current).getExplicitLineCursor()).toBeNull(); + await act(async () => { + expectValue(controllerRef.current).moveLineCursor(1); + }); + await flush(setup); + const selected = expectValue(expectValue(controllerRef.current).getExplicitLineCursor()); + await act(async () => { + expectValue(controllerRef.current).anchorLineCursor(selected); + }); + expect(expectValue(controllerRef.current).getExplicitLineCursor()).toBeNull(); + await act(async () => { + expectValue(controllerRef.current).anchorLineCursor(selected, true); + }); + expect(expectValue(controllerRef.current).getExplicitLineCursor()).toEqual(selected); + await act(async () => { + expectValue(controllerRef.current).selectFile("alpha"); + }); + expect(expectValue(controllerRef.current).getExplicitLineCursor()).toBeNull(); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + test("carries the current line along when hunk navigation moves the selection", async () => { const { controllerRef, setup } = await renderTerminalReview([createTwoHunkFile()]); diff --git a/packages/hunk/src/ui/hooks/useTerminalReview.ts b/packages/hunk/src/ui/hooks/useTerminalReview.ts index 2c2c37968..c2a66deed 100644 --- a/packages/hunk/src/ui/hooks/useTerminalReview.ts +++ b/packages/hunk/src/ui/hooks/useTerminalReview.ts @@ -217,10 +217,12 @@ export interface TerminalReview { lineCursor: LineCursor | null; /** Read the current cursor synchronously between terminal key events. */ getLineCursor: () => LineCursor | null; + /** Return only a deliberately selected source line, not an automatically seeded viewport row. */ + getExplicitLineCursor: () => LineCursor | null; /** Read the current normalized selection synchronously with the cursor. */ getSelection: () => { fileId: string | null; hunkIndex: number | null }; lineCursorRevealRequest: LineCursorRevealRequest; - anchorLineCursor: (cursor: LineCursor) => void; + anchorLineCursor: (cursor: LineCursor, explicit?: boolean) => void; /** Adopt the hunk a viewport settled on, without asking any viewport to move. */ anchorSelection: (fileId: string, hunkIndex: number) => void; moveLineCursor: (delta: number) => void; @@ -392,10 +394,15 @@ export function useTerminalReview({ // A held key drains as one stdin chunk, so every press in the burst would otherwise read the // same pre-batch state and the cursor would advance a single row. const lineCursorRef = useRef(null); + const explicitLineCursorRef = useRef(false); const lineCursorsRef = useRef(lineCursors); lineCursorsRef.current = lineCursors; /** Read the latest cursor without waiting for React to publish a render. */ const getLineCursor = useCallback(() => lineCursorRef.current, []); + const getExplicitLineCursor = useCallback( + () => (explicitLineCursorRef.current ? lineCursorRef.current : null), + [], + ); const [lineCursorRevealRequest, setLineCursorRevealRequest] = useState({ id: 0, placement: "nearest", @@ -559,6 +566,7 @@ export function useTerminalReview({ return; } + explicitLineCursorRef.current = false; runIntent({ type: "selection/select", fileKey, @@ -596,6 +604,7 @@ export function useTerminalReview({ return; } + explicitLineCursorRef.current = false; runIntent({ type: "selection/select-file", fileKey, reveal: revealRequestFor(options) }); }, [keyByFileId, runIntent], @@ -620,7 +629,8 @@ export function useTerminalReview({ * Seeding from the selected hunk makes the marker visible from launch, not just after the * first keypress. */ - const applyLineCursor = useCallback((next: LineCursor | null) => { + const applyLineCursor = useCallback((next: LineCursor | null, explicit = false) => { + explicitLineCursorRef.current = explicit; lineCursorRef.current = next; setLineCursor(next); }, []); @@ -628,7 +638,7 @@ export function useTerminalReview({ /** Move the current line to a row the reviewer just asked to see, and scroll to it. */ const revealLineCursor = useCallback( (cursor: LineCursor, placement: LineRevealPlacement = "nearest") => { - applyLineCursor(cursor); + applyLineCursor(cursor, true); setLineCursorRevealRequest((current) => ({ id: current.id + 1, placement })); // The line cursor carries its own reveal request; the selection only follows it. anchorSelection(cursor.fileId, cursor.hunkIndex); @@ -676,7 +686,7 @@ export function useTerminalReview({ const resolved = resolveLineCursor(lineCursors, lineCursorRef.current); if (resolved?.fileId === selectedFileId && resolved?.hunkIndex === selectedHunkIndex) { - applyLineCursor(resolved); + applyLineCursor(resolved, explicitLineCursorRef.current); return; } @@ -689,8 +699,8 @@ export function useTerminalReview({ /** Adopt a current line the viewport already settled on, without scrolling back to it. */ const anchorLineCursor = useCallback( - (cursor: LineCursor) => { - applyLineCursor(cursor); + (cursor: LineCursor, explicit = false) => { + applyLineCursor(cursor, explicit); anchorSelection(cursor.fileId, cursor.hunkIndex); }, [anchorSelection, applyLineCursor], @@ -729,8 +739,10 @@ export function useTerminalReview({ * the session's comment navigation, and later a browser client all move identically. */ const moveSelection = useCallback( - (scope: ReviewSelectionScope, delta: number) => - runIntent({ type: "selection/move", scope, delta }, { annotations }), + (scope: ReviewSelectionScope, delta: number) => { + explicitLineCursorRef.current = false; + return runIntent({ type: "selection/move", scope, delta }, { annotations }); + }, [annotations, runIntent], ); @@ -1377,6 +1389,7 @@ export function useTerminalReview({ ); applyLineCursor( lineCursorAt(lineCursors, file.id, hunkIndex, { side: draft.side, line: draft.line }), + true, ); return storedDraftToDraftNote(draft, file); }, @@ -1413,6 +1426,7 @@ export function useTerminalReview({ side: draft.side, line: draft.line, }), + true, ); } return storedDraftToDraftNote(draft, file); @@ -1441,6 +1455,7 @@ export function useTerminalReview({ side: draft.side, line: draft.line, }), + true, ); } return storedDraftToDraftNote(draft, file); @@ -1579,6 +1594,7 @@ export function useTerminalReview({ liveCommentsByFileId, lineCursor, getLineCursor, + getExplicitLineCursor, getSelection, lineCursorRevealRequest, reviewNoteCount: reviewNoteSummaries.length, diff --git a/packages/hunk/src/ui/hooks/useUserNoteComposer.test.tsx b/packages/hunk/src/ui/hooks/useUserNoteComposer.test.tsx index f588393e1..9a4cea0ee 100644 --- a/packages/hunk/src/ui/hooks/useUserNoteComposer.test.tsx +++ b/packages/hunk/src/ui/hooks/useUserNoteComposer.test.tsx @@ -65,6 +65,7 @@ function baseOptions( ): UseUserNoteComposerOptions { return { draftNote: null, + getDraftNoteId: () => overrides.draftNote?.id ?? null, keyboardCursorEnabled: true, getLineCursor: () => null, startDraft: () => null, @@ -78,6 +79,70 @@ function baseOptions( } describe("useUserNoteComposer", () => { + test.each(["save", "cancel"])( + "ignores callbacks after a queued %s consumes the draft", + async (first) => { + let currentId: string | null = draftNote.id; + const calls: string[] = []; + const harness = await renderComposer( + baseOptions({ + draftNote, + getDraftNoteId: () => currentId, + saveDraft: () => { + calls.push("save"); + currentId = null; + return savedNote; + }, + cancelDraft: () => { + calls.push("cancel"); + currentId = null; + }, + updateDraft: () => calls.push("late edit"), + }), + ); + try { + await act(async () => { + if (first === "save") harness.composer().saveDraftNote(); + else harness.composer().cancelDraftNote(); + harness.composer().cancelDraftNote(); + harness.composer().saveDraftNote(); + queueMicrotask(() => harness.composer().updateDraftNote("late native publication")); + }); + expect(calls).toEqual([first]); + } finally { + await act(async () => harness.setup.renderer.destroy()); + } + }, + ); + + test("queued callbacks cannot consume or edit a replacement draft", async () => { + let currentId = draftNote.id; + const calls: string[] = []; + const harness = await renderComposer( + baseOptions({ + draftNote, + getDraftNoteId: () => currentId, + saveDraft: () => { + calls.push("save"); + return savedNote; + }, + cancelDraft: () => calls.push("cancel"), + updateDraft: () => calls.push("update"), + }), + ); + try { + await act(async () => { + harness.composer().saveDraftNote(); + harness.composer().cancelDraftNote(); + currentId = "replacement-draft"; + harness.composer().updateDraftNote("retired editor body"); + }); + expect(calls).toEqual([]); + } finally { + await act(async () => harness.setup.renderer.destroy()); + } + }); + test("prefers explicit targets, then hover, then the enabled keyboard cursor", async () => { const starts: Parameters[] = []; let cursorReads = 0; @@ -251,6 +316,7 @@ describe("useUserNoteComposer", () => { let cancelCount = 0; const harness = await renderComposer( baseOptions({ + draftNote, cancelDraft: () => { cancelCount += 1; }, diff --git a/packages/hunk/src/ui/hooks/useUserNoteComposer.ts b/packages/hunk/src/ui/hooks/useUserNoteComposer.ts index 59cdbfa04..1351bd9b6 100644 --- a/packages/hunk/src/ui/hooks/useUserNoteComposer.ts +++ b/packages/hunk/src/ui/hooks/useUserNoteComposer.ts @@ -2,7 +2,7 @@ * Coordinates terminal user-note targeting, draft focus, and public note events. * Semantic draft and saved-note transitions remain owned by the terminal review controller. */ -import { useCallback, useState } from "react"; +import { useCallback, useLayoutEffect, useRef, useState } from "react"; import type { UserNoteLineTarget } from "../../core/liveComments"; import type { ExtensionEventPayloads, ExtensionReviewNote } from "../../extensions/types"; import type { ActiveAddNoteAffordance } from "../diff/DiffSectionBody"; @@ -45,6 +45,8 @@ export function projectExtensionReviewNote( export interface UseUserNoteComposerOptions { draftNote: DraftReviewNote | null; + /** Read semantic draft identity before delivering deferred native editor events. */ + getDraftNoteId: () => string | null; /** Whether an implicit keyboard start may use the terminal's current-line cursor. */ keyboardCursorEnabled: boolean; getLineCursor: () => LineCursor | null; @@ -73,6 +75,7 @@ export interface UseUserNoteComposerOptions { /** Coordinate one terminal user-note composition flow around shared semantic actions. */ export function useUserNoteComposer({ draftNote, + getDraftNoteId, keyboardCursorEnabled, getLineCursor, startDraft, @@ -86,6 +89,18 @@ export function useUserNoteComposer({ }: UseUserNoteComposerOptions) { const [activeAddNoteTarget, setActiveAddNoteTarget] = useState(null); const { draft: focusDraft, review: focusReview, blurDraft: blurDraftFocus } = focus; + const mountedRef = useRef(true); + useLayoutEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + /** Ignore editor callbacks after their draft is consumed, replaced, or unmounted. */ + const isCurrentDraft = useCallback( + () => mountedRef.current && draftNote !== null && getDraftNoteId() === draftNote.id, + [draftNote, getDraftNoteId], + ); /** Start a draft at an explicit target, hovered affordance, or enabled line cursor. */ const startUserNote = useCallback( @@ -148,22 +163,26 @@ export function useUserNoteComposer({ blurDraftFocus(); }, [blurDraftFocus]); - /** Save the current draft, publish it once, and return to review navigation. */ + /** Save after OpenTUI publishes native edits queued earlier in this input batch. */ const saveDraftNote = useCallback(() => { - // `saveDraft` consumes the semantic draft synchronously. Retain its runtime file id - // first because the saved terminal projection is keyed by path rather than runtime id. - const priorDraft = draftNote; - const saved = saveDraft(); - if (saved && priorDraft) { - const note = projectExtensionReviewNote({ ...saved, fileId: priorDraft.fileId }, false); - publishEvent(priorDraft.kind === "edit" ? "note_edited" : "note_created", { note }); - } - focusReview(); - }, [draftNote, focusReview, publishEvent, saveDraft]); + queueMicrotask(() => { + if (!isCurrentDraft()) return; + // Native content-change events are microtasks. Consume the semantic draft only + // after those preceding edits have arrived, including a final character before Ctrl-S. + const priorDraft = draftNote; + const saved = saveDraft(); + if (saved && priorDraft) { + const note = projectExtensionReviewNote({ ...saved, fileId: priorDraft.fileId }, false); + publishEvent(priorDraft.kind === "edit" ? "note_edited" : "note_created", { note }); + } + focusReview(); + }); + }, [draftNote, focusReview, isCurrentDraft, publishEvent, saveDraft]); /** Update the semantic draft and publish the body supplied by the editor. */ const updateDraftNote = useCallback( (body: string) => { + if (!isCurrentDraft()) return; const priorDraft = draftNote; updateDraft(body); if (priorDraft) { @@ -182,14 +201,17 @@ export function useUserNoteComposer({ }); } }, - [draftNote, publishEvent, updateDraft], + [draftNote, isCurrentDraft, publishEvent, updateDraft], ); - /** Cancel the semantic draft and return to review navigation. */ + /** Let queued native edits settle before cancelling their semantic draft. */ const cancelDraftNote = useCallback(() => { - cancelDraft(); - focusReview(); - }, [cancelDraft, focusReview]); + queueMicrotask(() => { + if (!isCurrentDraft()) return; + cancelDraft(); + focusReview(); + }); + }, [cancelDraft, focusReview, isCurrentDraft]); return { blurDraftNote, diff --git a/packages/hunk/src/ui/hooks/useWorkingTreeActions.ts b/packages/hunk/src/ui/hooks/useWorkingTreeActions.ts new file mode 100644 index 000000000..4f5e272f2 --- /dev/null +++ b/packages/hunk/src/ui/hooks/useWorkingTreeActions.ts @@ -0,0 +1,658 @@ +/** + * Coordinates file and hunk mutations, scoped discard/stash prompts, and stream tabs against the mounted review. + * Sidebar paths survive tab changes; shared review navigation still owns the active diff. + * AppHost tracks writes through shutdown and serializes their authoritative refreshes. + */ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { AppBootstrap } from "../../core/bootstrap"; +import type { DiffFile } from "../../core/changeset/model"; +import { reviewFileMatchesFilter } from "../../core/review/selectors"; +import { getConfiguredVcsAdapter } from "../../core/vcs"; +import { HunkUserError } from "../../core/run/errors"; +import { summarizeHunk } from "../../core/changeset/hunkSummary"; +import type { + ExtensionVcsDiscardScope, + ExtensionWorkingTreeFile, + ExtensionWorkingTreePane, +} from "../../extension-api/types"; +import type { ExtensionCapabilityLease } from "../lib/extensionCapabilityLease"; +import type { WorkspaceWriteRunner } from "./useExtensionWorkspaceControls"; +import { + cursorFromSidebarIndex, + filesVisuallyUnderSidebarEntry, + nestedRevealPath, + sidebarEntryIdAtIndex, + sidebarIndexFromCursor, + stepSidebarIndex, + type FilePaneCursor, +} from "../lib/filePaneSelection"; +import { + buildFilePaneEntries, + fileSidebarContentWidth, + workingTreeSidebarSources, + type SidebarFileSource, +} from "../lib/files"; + +/** Capture the exact files and generation the user is being asked to change. */ +export interface WorkingTreePrompt { + kind: "discard" | "stash"; + files: readonly ExtensionWorkingTreeFile[]; + label: string; + folder: boolean; + lease: ExtensionCapabilityLease; +} + +/** Keep working-tree UI absent from historical, pager and non-provider reviews. */ +export function hasWorkingTreeInventory(bootstrap: AppBootstrap) { + return ( + bootstrap.input.kind === "vcs" && + bootstrap.input.range === undefined && + bootstrap.input.rangeEndpoints === undefined && + !bootstrap.input.options.pager && + bootstrap.changeset.workingTreeFiles !== undefined + ); +} + +/** Expose generation-bound pane controls and command handlers for the current Git status inventory. */ +export function useWorkingTreeActions({ + bootstrap, + selectedFile, + getSelection, + isFilesPaneFocused, + filter, + reviewFiles, + filesPaneFocused, + filesPaneWidth, + createLease, + selectReviewFile, + focusFiles, + setStagedView, + runMutation, + refreshAfterMutation, + showNotice, +}: { + bootstrap: AppBootstrap; + selectedFile: DiffFile | undefined; + getSelection: () => { fileId: string | null; hunkIndex: number | null }; + isFilesPaneFocused: () => boolean; + filter: string; + reviewFiles: readonly SidebarFileSource[]; + filesPaneFocused: boolean; + filesPaneWidth: number; + createLease: () => ExtensionCapabilityLease; + selectReviewFile: (fileId: string, options: { alignFileHeaderTop: true }) => void; + focusFiles: () => void; + setStagedView: (staged: boolean) => Promise; + runMutation: WorkspaceWriteRunner; + refreshAfterMutation: (follow?: { root: string; staged: boolean }) => Promise; + showNotice: (message: string) => void; +}) { + const enabled = hasWorkingTreeInventory(bootstrap); + const staged = bootstrap.input.kind === "vcs" && bootstrap.input.staged; + const allFiles = bootstrap.changeset.workingTreeFiles; + const files = useMemo( + () => (allFiles ?? []).filter((file) => reviewFileMatchesFilter(file, filter)), + [allFiles, filter], + ); + const operation = + enabled && bootstrap.reloadContext.vcsCatalog + ? getConfiguredVcsAdapter(bootstrap.input.options.vcs, bootstrap.reloadContext.vcsCatalog) + .operations["working-tree-diff"] + : undefined; + const [busy, setBusy] = useState(false); + const busyRef = useRef(false); + const switchingRef = useRef(false); + const mutatingRef = useRef(false); + const pendingCursorRef = useRef<{ + cursor: FilePaneCursor; + changeset: AppBootstrap["changeset"]; + } | null>(null); + const [chosenCursor, setChosenCursor] = useState(null); + const sources = useMemo( + () => (enabled ? workingTreeSidebarSources(reviewFiles, files) : []), + [enabled, reviewFiles, files], + ); + const entries = useMemo( + () => buildFilePaneEntries(sources, fileSidebarContentWidth(filesPaneWidth)), + [sources, filesPaneWidth], + ); + const reviewFileCursor: FilePaneCursor | null = selectedFile + ? { kind: "file", id: selectedFile.path } + : null; + const pendingCursor = pendingCursorRef.current?.cursor ?? null; + const folderCursor = filesPaneFocused && chosenCursor?.kind === "folder" ? chosenCursor : null; + const activeCursor = pendingCursor ?? folderCursor ?? reviewFileCursor ?? chosenCursor; + const selectedIndex = sidebarIndexFromCursor(entries, activeCursor); + const selectedEntryId = sidebarEntryIdAtIndex(entries, selectedIndex); + // Rendered review selection remains authoritative between input batches; sidebar + // handlers advance this cursor eagerly before a following mutation key can run. + const activeCursorRef = useRef(activeCursor); + activeCursorRef.current = activeCursor; + const cursorReviewFileIdRef = useRef(selectedFile?.id ?? null); + cursorReviewFileIdRef.current = selectedFile?.id ?? null; + /** Derive rendered and input-time mutation targets from the same sidebar entry. */ + const selectionAt = (index: number) => { + const statusFiles = filesVisuallyUnderSidebarEntry(entries, index) + .map((entry) => files.find((file) => file.path === entry.id)) + .filter((file): file is ExtensionWorkingTreeFile => Boolean(file)); + const path = statusFiles[0]?.path ?? null; + const entry = entries[index]; + const folder = Boolean(entry && entry.kind !== "file"); + return { + statusFiles, + path, + folder, + label: entry && entry.kind !== "file" ? entry.label : (path ?? ""), + }; + }; + const { + statusFiles: selectedStatusFiles, + path: selectedPath, + folder: selectedIsFolder, + } = selectionAt(selectedIndex); + /** Read the sidebar cursor after all preceding navigation in this input burst. */ + const liveCursor = () => { + const { fileId } = getSelection(); + if (fileId !== cursorReviewFileIdRef.current) { + const file = bootstrap.changeset.files.find((file) => file.id === fileId); + activeCursorRef.current = file ? { kind: "file", id: file.path } : null; + cursorReviewFileIdRef.current = fileId; + } + return activeCursorRef.current; + }; + /** Derive mutation targets after both sidebar and review-stream navigation. */ + const liveSelection = () => selectionAt(sidebarIndexFromCursor(entries, liveCursor())); + const lease = useMemo(() => createLease(), [bootstrap, createLease]); + const [promptState, setPromptState] = useState(null); + const promptRef = useRef(null); + const [message, setMessageState] = useState(""); + const messageRef = useRef(""); + const cancelPrompt = useCallback(() => { + promptRef.current = null; + setPromptState(null); + }, []); + useEffect(() => { + cancelPrompt(); + }, [bootstrap, cancelPrompt]); + const getPrompt = useCallback(() => { + const pending = promptRef.current; + return pending?.lease.isLive() ? pending : null; + }, []); + const setMessage = useCallback((value: string) => { + messageRef.current = value; + setMessageState(value); + }, []); + + useEffect(() => { + const pending = pendingCursorRef.current; + if (!pending || pending.changeset === bootstrap.changeset) return; + // Consume a follow request once its authoritative inventory arrives, including clean files. + pendingCursorRef.current = null; + setChosenCursor(pending.cursor); + const index = sidebarIndexFromCursor(entries, pending.cursor); + const followPath = + filesVisuallyUnderSidebarEntry(entries, index)[0]?.id ?? + (pending.cursor.kind === "file" ? pending.cursor.id : null); + const file = followPath + ? bootstrap.changeset.files.find((candidate) => candidate.path === followPath) + : undefined; + if (file) selectReviewFile(file.id, { alignFileHeaderTop: true }); + }, [bootstrap, entries, selectReviewFile]); + + /** Switch the full stream without letting fast repeated keys enqueue stale switches. */ + const switchView = useCallback( + async (next: boolean) => { + if (!enabled || !lease.isLive() || busyRef.current || next === staged) return; + busyRef.current = true; + switchingRef.current = true; + setBusy(true); + try { + await setStagedView(next); + } catch (error) { + pendingCursorRef.current = null; + showNotice( + `Could not switch review: ${error instanceof Error ? error.message : String(error)}`, + ); + } finally { + switchingRef.current = false; + busyRef.current = mutatingRef.current; + setBusy(busyRef.current); + } + }, + [enabled, lease, staged, setStagedView, showNotice], + ); + + const revealStatusPath = useCallback( + (path: string, cursor: FilePaneCursor) => { + if (!lease.isLive() || busyRef.current) return; + const status = files.find((file) => file.path === path); + if (!status) return; + focusFiles(); + activeCursorRef.current = cursor; + pendingCursorRef.current = null; + setChosenCursor(cursor); + // A recreated rename source is a separate status row, never an alias for the destination. + const file = (staged ? status.staged : status.unstaged) + ? bootstrap.changeset.files.find((candidate) => candidate.path === path) + : undefined; + if (file) { + selectReviewFile(file.id, { alignFileHeaderTop: true }); + cursorReviewFileIdRef.current = getSelection().fileId; + return; + } + pendingCursorRef.current = { cursor, changeset: bootstrap.changeset }; + void switchView(!staged); + }, + [ + lease, + files, + focusFiles, + bootstrap.changeset, + selectReviewFile, + getSelection, + switchView, + staged, + ], + ); + + const selectFile = useCallback( + (path: string) => { + revealStatusPath(path, { kind: "file", id: path }); + }, + [revealStatusPath], + ); + + const selectEntry = useCallback( + (id: string) => { + const index = entries.findIndex((entry) => entry.id === id); + if (index < 0) return; + const cursor = cursorFromSidebarIndex(entries, index); + if (!cursor) return; + const nested = filesVisuallyUnderSidebarEntry(entries, index); + const currentSidePaths = new Set( + files.filter((file) => (staged ? file.staged : file.unstaged)).map((file) => file.path), + ); + const revealPath = nestedRevealPath(nested, currentSidePaths); + if (revealPath) revealStatusPath(revealPath, cursor); + else { + focusFiles(); + activeCursorRef.current = cursor; + setChosenCursor(cursor); + } + }, + [entries, files, focusFiles, revealStatusPath, staged], + ); + + const canToggleFile = useCallback( + (file: ExtensionWorkingTreeFile | undefined) => + Boolean( + file && + !file.unavailableReason && + !file.conflicted && + (file.unstaged ? operation?.stageFile : file.staged && operation?.unstageFile), + ), + [operation], + ); + + const stagingTargets = useCallback( + (targets: readonly ExtensionWorkingTreeFile[]) => { + const actionable = targets.filter(canToggleFile); + const toStage = actionable.filter((file) => file.unstaged); + if (toStage.length > 0) return { stage: true, files: toStage }; + return { stage: false, files: actionable.filter((file) => file.staged) }; + }, + [canToggleFile], + ); + + /** Keep file and hunk writes on the same busy, error, refresh, and graceful-exit path. */ + const startMutation = useCallback( + (action: { + mutate: () => Promise; + progress: string; + complete: string; + followStaged?: boolean; + followCursor?: FilePaneCursor | null; + }) => { + busyRef.current = true; + mutatingRef.current = true; + setBusy(true); + showNotice(action.progress); + void runMutation(async () => { + let succeeded = false; + try { + await action.mutate(); + succeeded = true; + if (action.followStaged !== undefined && action.followCursor) + pendingCursorRef.current = { + cursor: action.followCursor, + changeset: bootstrap.changeset, + }; + } finally { + // A failed provider command can partially change state, so reconcile before unlocking. + await refreshAfterMutation( + succeeded && action.followStaged !== undefined + ? { root: bootstrap.changeset.sourceLabel, staged: action.followStaged } + : undefined, + ); + } + }) + .then((started) => + showNotice( + started + ? action.complete + : "Another workspace operation is active or Hunk is shutting down.", + ), + ) + .catch((error) => { + const detail = + error instanceof HunkUserError + ? [error.message, ...error.suggestions].join(" • ") + : error instanceof Error + ? error.message + : String(error); + showNotice(`Git action failed: ${detail}`); + }) + .finally(() => { + mutatingRef.current = false; + busyRef.current = switchingRef.current; + setBusy(busyRef.current); + }); + }, + [bootstrap.changeset, runMutation, refreshAfterMutation, showNotice], + ); + + const describeTargets = (targets: readonly ExtensionWorkingTreeFile[], label: string) => + targets.length === 1 ? targets[0]!.path : `${targets.length} files in ${label}`; + + const toggleStatusFiles = useCallback( + ( + targets: readonly ExtensionWorkingTreeFile[], + cursor: FilePaneCursor | null, + label: string, + ) => { + if (!lease.isLive() || mutatingRef.current || bootstrap.input.kind !== "vcs") return; + const { stage, files: next } = stagingTargets(targets); + if (next.length === 0) return; + const mutate = stage ? operation?.stageFile : operation?.unstageFile; + if (!mutate) return; + const input = bootstrap.input; + const context = { cwd: bootstrap.changeset.sourceLabel }; + const subject = describeTargets(next, label); + startMutation({ + mutate: async () => { + for (const file of next) await mutate(input, file, context); + }, + progress: `${stage ? "Staging" : "Unstaging"} ${subject}…`, + complete: `${stage ? "Staged" : "Unstaged"} ${subject}.`, + followStaged: stage, + followCursor: cursor, + }); + }, + [lease, bootstrap, operation, stagingTargets, startMutation], + ); + + const toggleStaged = useCallback( + (path: string) => { + // A double-click may finish while its first click switches tabs; its explicit path is attested. + const file = files.find((candidate) => candidate.path === path); + if (!file || !canToggleFile(file)) return; + toggleStatusFiles([file], { kind: "file", id: path }, file.path); + }, + [files, canToggleFile, toggleStatusFiles], + ); + + const toggleEntry = useCallback( + (id: string) => { + const index = entries.findIndex((entry) => entry.id === id); + if (index < 0) return; + const cursor = cursorFromSidebarIndex(entries, index); + const nested = filesVisuallyUnderSidebarEntry(entries, index) + .map((entry) => files.find((file) => file.path === entry.id)) + .filter((file): file is ExtensionWorkingTreeFile => Boolean(file)); + const label = + entries[index] && entries[index]!.kind !== "file" + ? entries[index]!.label + : (nested[0]?.path ?? id); + toggleStatusFiles(nested, cursor, label); + }, + [entries, files, toggleStatusFiles], + ); + + const canToggleHunk = useCallback( + (fileId: string | undefined, hunkIndex: number) => { + if (!lease.isLive() || busyRef.current) return false; + const reviewed = bootstrap.changeset.files.find((file) => file.id === fileId); + const status = files.find((file) => file.path === reviewed?.path); + return Boolean( + reviewed?.metadata.hunks[hunkIndex] && + !reviewed.isBinary && + !reviewed.isTooLarge && + status && + !status.unavailableReason && + !status.conflicted && + (staged + ? status.staged && operation?.unstageHunk + : status.unstaged && operation?.stageHunk), + ); + }, + [bootstrap.changeset.files, files, staged, operation, lease], + ); + + const toggleHunk = useCallback( + (fileId: string, hunkIndex: number) => { + if (!canToggleHunk(fileId, hunkIndex) || bootstrap.input.kind !== "vcs") return false; + const reviewed = bootstrap.changeset.files.find((file) => file.id === fileId)!; + const file = files.find((file) => file.path === reviewed.path)!; + const mutate = staged ? operation?.unstageHunk : operation?.stageHunk; + if (!mutate) return false; + const input = bootstrap.input; + const hunk = summarizeHunk(reviewed.metadata.hunks[hunkIndex]!, hunkIndex); + startMutation({ + mutate: () => mutate(input, file, hunk, { cwd: bootstrap.changeset.sourceLabel }), + progress: `${staged ? "Unstaging" : "Staging"} hunk ${hunkIndex + 1} in ${file.path}…`, + complete: `${staged ? "Unstaged" : "Staged"} hunk ${hunkIndex + 1} in ${file.path}.`, + }); + return true; + }, + [canToggleHunk, bootstrap, files, operation, staged, startMutation], + ); + + /** Resolve prompt eligibility from the cursor reached by the latest input event. */ + const promptSelection = () => { + const selection = liveSelection(); + const actionableFiles = selection.statusFiles.filter( + (file) => !file.conflicted && !file.unavailableReason, + ); + return { + ...selection, + actionableFiles, + canDiscard: Boolean(enabled && actionableFiles.length > 0 && operation?.discardFile), + canStash: Boolean( + enabled && + actionableFiles.length > 0 && + (actionableFiles.length === 1 + ? operation?.stashFile || operation?.stashFiles + : operation?.stashFiles), + ), + }; + }; + + /** Open a generation-bound prompt; rapid repeated keys cannot replace its captured target. */ + const openPrompt = (kind: WorkingTreePrompt["kind"]) => { + const selection = promptSelection(); + const { actionableFiles } = selection; + if ( + !lease.isLive() || + busyRef.current || + getPrompt() || + actionableFiles.length === 0 || + !(kind === "discard" ? selection.canDiscard : selection.canStash) + ) + return; + const pending = { + kind, + files: actionableFiles, + label: selection.label || actionableFiles[0]!.path, + folder: selection.folder, + lease, + }; + setMessage(""); + promptRef.current = pending; + setPromptState(pending); + }; + + /** Accept the shown scope once, retaining the shared mutation and authoritative refresh lifecycle. */ + const acceptPrompt = (scope: ExtensionVcsDiscardScope = "all") => { + const pending = getPrompt(); + if (!pending || busyRef.current || bootstrap.input.kind !== "vcs") return; + const { files: targets, kind, label } = pending; + if ( + kind === "discard" && + scope === "unstaged" && + !targets.some((file) => file.staged && file.unstaged) + ) + return; + const input = bootstrap.input; + const context = { cwd: bootstrap.changeset.sourceLabel }; + const discard = operation?.discardFile; + const stashOne = operation?.stashFile; + const stashMany = operation?.stashFiles; + if (kind === "discard" ? !discard : !(stashMany || stashOne)) return; + const stashMessage = messageRef.current; + const subject = describeTargets(targets, label); + cancelPrompt(); + startMutation({ + mutate: async () => { + if (kind === "discard") { + for (const file of targets) { + if (scope === "unstaged") { + if (file.staged && file.unstaged) await discard!(input, file, "unstaged", context); + else if (file.unstaged && !file.staged && !file.untracked) + await discard!(input, file, "all", context); + continue; + } + await discard!(input, file, scope, context); + } + return; + } + if (stashOne && targets.length === 1) { + await stashOne(input, targets[0]!, stashMessage, context); + return; + } + await stashMany!(input, targets, stashMessage, context); + }, + progress: `${kind === "discard" ? "Discarding" : "Stashing"} ${subject}…`, + complete: + kind === "discard" ? `Discarded ${scope} changes in ${subject}.` : `Stashed ${subject}.`, + }); + }; + + const pane = useMemo( + () => + enabled + ? Object.freeze({ + files, + selectedPath, + selectedEntryId, + staged, + busy, + selectFile, + selectEntry, + toggleStaged, + toggleEntry, + }) + : undefined, + [ + enabled, + files, + selectedPath, + selectedEntryId, + staged, + busy, + selectFile, + selectEntry, + toggleStaged, + toggleEntry, + ], + ); + + const selectedToggle = stagingTargets( + filesPaneFocused + ? selectedStatusFiles + : selectedPath + ? files.filter((file) => file.path === selectedPath) + : [], + ); + /** Read file staging targets from the latest pane focus and semantic selection. */ + const liveStagingFiles = () => { + if (isFilesPaneFocused()) return liveSelection().statusFiles; + const { fileId } = getSelection(); + const reviewed = bootstrap.changeset.files.find((file) => file.id === fileId); + return files.filter((file) => file.path === reviewed?.path); + }; + + return { + prompt: promptState?.lease.isLive() ? promptState : null, + getPrompt, + cancelPrompt, + acceptPrompt, + message, + setMessage, + get canDiscardSelected() { + return promptSelection().canDiscard; + }, + get canStashSelected() { + return promptSelection().canStash; + }, + discardSelected: () => openPrompt("discard"), + stashSelected: () => openPrompt("stash"), + pane, + busy, + staged, + selectedPath, + selectedIsFolder: Boolean(filesPaneFocused && selectedIsFolder), + isSelectedFolder: () => liveSelection().folder, + selectedWillStage: selectedToggle.stage, + get canToggleSelected() { + return enabled && stagingTargets(liveStagingFiles()).files.length > 0; + }, + get canToggleSelectedHunk() { + const { fileId, hunkIndex } = getSelection(); + return enabled && hunkIndex !== null && canToggleHunk(fileId ?? undefined, hunkIndex); + }, + canToggleHunk, + toggleHunk, + toggleSelectedHunk: () => { + const { fileId, hunkIndex } = getSelection(); + if (fileId && hunkIndex !== null) toggleHunk(fileId, hunkIndex); + }, + toggleSelected: () => { + if (busyRef.current) return; + if (isFilesPaneFocused()) { + const entryId = sidebarEntryIdAtIndex( + entries, + sidebarIndexFromCursor(entries, liveCursor()), + ); + if (entryId) toggleEntry(entryId); + return; + } + const file = liveStagingFiles()[0]; + if (file) toggleStaged(file.path); + }, + switchView: (next: boolean) => { + void switchView(next); + }, + moveFile: (delta: number) => { + const selectedPath = liveSelection().path; + const index = files.findIndex((file) => file.path === selectedPath); + const file = files[Math.max(0, Math.min(files.length - 1, index + delta))]; + if (file && file.path !== selectedPath) selectFile(file.path); + }, + moveEntry: (delta: number) => { + const selectedIndex = sidebarIndexFromCursor(entries, liveCursor()); + const nextIndex = stepSidebarIndex(entries, selectedIndex, delta); + const next = entries[nextIndex]; + if (next && next.id !== sidebarEntryIdAtIndex(entries, selectedIndex)) selectEntry(next.id); + }, + }; +} diff --git a/packages/hunk/src/ui/lib/appCommands.test.ts b/packages/hunk/src/ui/lib/appCommands.test.ts index 933fd157e..4885c29dd 100644 --- a/packages/hunk/src/ui/lib/appCommands.test.ts +++ b/packages/hunk/src/ui/lib/appCommands.test.ts @@ -3,6 +3,7 @@ import { resolve } from "node:path"; import { describe, expect, test } from "bun:test"; import { KeyEvent, type ParsedKey } from "@opentui/core"; import { + advertisedKeyLabels, buildAppCommands, builtinCommandKeyDefaults, dispatchAppCommand, @@ -35,7 +36,10 @@ function keyEvent(fields: Partial): KeyEvent { } /** Build the built-in table over recording callbacks, plus the log it writes. */ -function createTestCommands(resolvedKeys?: ResolvedCommandKeys) { +function createTestCommands( + resolvedKeys?: ResolvedCommandKeys, + overrides: Partial = {}, +) { const ran: string[] = []; const record = (name: string) => @@ -45,10 +49,14 @@ function createTestCommands(resolvedKeys?: ResolvedCommandKeys) { const options: BuildAppCommandsOptions = { canAlignCurrentLine: true, canApplyFilePresentationToAllMatching: false, + canFocusDiffPane: true, + canFocusFilesPane: true, canRefreshCurrentInput: true, alignCurrentLine: record("alignCurrentLine"), applyFilePresentationToAllMatching: record("applyFilePresentationToAllMatching"), + focusDiffPane: record("focusDiffPane"), focusFilter: record("focusFilter"), + focusFilesPane: record("focusFilesPane"), moveSelection: record("moveSelection"), openAgentSkill: record("openAgentSkill"), openThemeSelector: record("openThemeSelector"), @@ -72,12 +80,125 @@ function createTestCommands(resolvedKeys?: ResolvedCommandKeys) { toggleFilesPane: record("toggleFilesPane"), triggerEditSelectedFile: record("triggerEditSelectedFile"), triggerRefreshCurrentInput: record("triggerRefreshCurrentInput"), + ...overrides, }; return { commands: buildAppCommands(options), ran }; } describe("built-in command chords", () => { + test("contextual staging consumes Space and Tab while explicit remaps keep ownership", () => { + const actions: string[] = []; + const options = { + canToggleFileStaged: true, + canSwitchStagedView: true, + toggleFileStaged: () => actions.push("stage"), + toggleStagedView: () => actions.push("tab"), + }; + const { commands } = createTestCommands(undefined, options); + expect(dispatchAppCommand(commands, keyEvent({ name: "space" }))?.id).toBe( + "hunk.review.toggleFileStaged", + ); + expect(dispatchAppCommand(commands, keyEvent({ name: "tab" }))?.id).toBe( + "hunk.review.toggleStagedView", + ); + expect(actions).toEqual(["stage", "tab"]); + const { keys } = resolveCommandKeys({ + defaults: builtinCommandKeyDefaults(), + userBindings: { "hunk.review.pageDown": "space" }, + }); + const remapped = createTestCommands(keys, options); + expect(dispatchAppCommand(remapped.commands, keyEvent({ name: "space" }))?.id).toBe( + "hunk.review.pageDown", + ); + expect(actions).toHaveLength(2); + }); + test("file discard and stash defaults override paging and pane toggle only when actionable", () => { + const actions: string[] = []; + const options = { + canDiscardSelectedFile: true, + canStashSelectedFile: true, + discardSelectedFile: () => actions.push("discard"), + stashSelectedFile: () => actions.push("stash"), + }; + const { commands } = createTestCommands(undefined, options); + expect(dispatchAppCommand(commands, keyEvent({ name: "d", sequence: "d" }))?.id).toBe( + "hunk.review.discardSelectedFile", + ); + expect(dispatchAppCommand(commands, keyEvent({ name: "s", sequence: "s" }))?.id).toBe( + "hunk.review.stashSelectedFile", + ); + expect(actions).toEqual(["discard", "stash"]); + const { keys } = resolveCommandKeys({ + defaults: builtinCommandKeyDefaults(), + userBindings: { "hunk.review.halfPageDown": "d", "hunk.view.toggleFilesPane": "s" }, + }); + const remapped = createTestCommands(keys, options); + expect(dispatchAppCommand(remapped.commands, keyEvent({ name: "d", sequence: "d" }))?.id).toBe( + "hunk.review.halfPageDown", + ); + expect(dispatchAppCommand(remapped.commands, keyEvent({ name: "s", sequence: "s" }))?.id).toBe( + "hunk.view.toggleFilesPane", + ); + }); + + test("help and menus omit chords an earlier enabled command currently owns", () => { + const { commands } = createTestCommands(undefined, { + canDiscardSelectedFile: true, + canStashSelectedFile: true, + canToggleFileStaged: true, + canSwitchStagedView: true, + }); + const labels = (id: string) => + advertisedKeyLabels(commands, commands.find((command) => command.id === id)!); + const helpKeys = (description: string) => + buildHelpSections(commands) + .flatMap((section) => section.rows) + .find((row) => row.description === description)?.keys; + const menus = buildAppMenus({ + commands, + copyDecorations: false, + cursorLine: "row", + layoutMode: "stack", + filesPaneVisible: true, + showAgentNotes: false, + showHelp: false, + showHunkHeaders: true, + showLineNumbers: true, + showMenuBar: true, + wrapLines: false, + }); + const fileHint = (commandId: string) => + menus.file?.find((entry) => entry.kind === "item" && entry.commandId === commandId); + + expect(labels("hunk.review.pageDown")).toEqual(["PageDown", "f"]); + expect(labels("hunk.review.halfPageDown")).toEqual(["Ctrl+D"]); + expect(labels("hunk.view.toggleFilesPane")).toEqual([]); + expect(labels("hunk.app.toggleFocusArea")).toEqual([]); + expect(helpKeys("page down")).toBe("PageDown / f"); + expect(helpKeys("half page down / up")).toBe("Ctrl+D / u"); + expect(helpKeys("sidebar / theme selector")).toBe("t"); + expect(helpKeys("toggle files/filter focus")).toBeUndefined(); + expect(helpKeys("stage / unstage focused file, folder, or hunk")).toBe("Space"); + expect(helpKeys("switch unstaged / staged stream")).toBe("Tab"); + expect(fileHint("hunk.app.toggleFocusArea")).toMatchObject({ hint: undefined }); + expect(fileHint("hunk.review.toggleStagedView")).toMatchObject({ hint: "Tab" }); + }); + + test("focused hunk staging owns Space without invoking file staging", () => { + const actions: string[] = []; + const { commands } = createTestCommands(undefined, { + canToggleFileStaged: false, + canToggleHunkStaged: true, + toggleHunkStaged: () => actions.push("hunk"), + toggleFileStaged: () => actions.push("file"), + }); + expect(dispatchAppCommand(commands, keyEvent({ name: "space" }))?.id).toBe( + "hunk.review.toggleHunkStaged", + ); + expect(actions).toEqual(["hunk"]); + }); + test("every alias of the scroll shortcuts still dispatches", () => { const { commands, ran } = createTestCommands(); const press = (fields: Partial) => @@ -224,6 +345,25 @@ describe("built-in commands under user keybindings", () => { expect(press({ name: "pagedown" })).toBe("hunk.review.pageDown"); }); + test("Enter and Esc switch review and files pane focus only while enabled", () => { + const enabled = createTestCommands(); + expect(dispatchAppCommand(enabled.commands, keyEvent({ name: "return" }))?.id).toBe( + "hunk.review.focusDiffPane", + ); + expect(dispatchAppCommand(enabled.commands, keyEvent({ name: "escape" }))?.id).toBe( + "hunk.review.focusFilesPane", + ); + expect(enabled.ran).toEqual(["focusDiffPane", "focusFilesPane"]); + + const disabled = createTestCommands(undefined, { + canFocusDiffPane: false, + canFocusFilesPane: false, + }); + expect(dispatchAppCommand(disabled.commands, keyEvent({ name: "return" }))).toBeUndefined(); + expect(dispatchAppCommand(disabled.commands, keyEvent({ name: "escape" }))).toBeUndefined(); + expect(disabled.ran).toEqual([]); + }); + test("an unbound command matches nothing", () => { const { keys } = resolveCommandKeys({ defaults: builtinCommandKeyDefaults(), @@ -257,7 +397,15 @@ describe("builtinCommandKeyDefaults", () => { const { commands } = createTestCommands(); expect(defaults.map((entry) => entry.id)).toEqual(commands.map((command) => command.id)); - expect(commands.every((command) => command.publicToExtensions)).toBe(true); + expect( + commands.filter((command) => !command.publicToExtensions).map((command) => command.id), + ).toEqual([ + "hunk.review.discardSelectedFile", + "hunk.review.stashSelectedFile", + "hunk.review.toggleHunkStaged", + "hunk.review.toggleFileStaged", + "hunk.review.toggleStagedView", + ]); expect(defaults.find((entry) => entry.id === "hunk.review.pageDown")?.defaultKeys).toEqual([ "pagedown", "space", diff --git a/packages/hunk/src/ui/lib/appCommands.ts b/packages/hunk/src/ui/lib/appCommands.ts index 09160f14d..791993373 100644 --- a/packages/hunk/src/ui/lib/appCommands.ts +++ b/packages/hunk/src/ui/lib/appCommands.ts @@ -108,6 +108,16 @@ interface BuiltinCommandHandler { /** The callbacks the built-in command set drives; App supplies its own handlers. */ export interface BuildAppCommandsOptions { + canDiscardSelectedFile?: boolean; + canStashSelectedFile?: boolean; + discardSelectedFile?: () => void; + stashSelectedFile?: () => void; + canToggleFileStaged?: boolean; + canToggleHunkStaged?: boolean; + toggleHunkStaged?: () => void; + canSwitchStagedView?: boolean; + toggleFileStaged?: () => void; + toggleStagedView?: () => void; canAlignCurrentLine: boolean; canApplyFilePresentationToAllMatching: boolean; canEditActiveNote?: boolean; @@ -115,7 +125,11 @@ export interface BuildAppCommandsOptions { canRefreshCurrentInput: boolean; alignCurrentLine: (alignment: "top" | "center" | "bottom") => void; applyFilePresentationToAllMatching: () => void; + canFocusDiffPane?: boolean; + canFocusFilesPane?: boolean; + focusDiffPane: () => void; focusFilter: () => void; + focusFilesPane: () => void; editActiveNote?: () => void; replyToActiveNote?: () => void; /** Step the review selection through one scope, as the catalog entry declares it. */ @@ -175,6 +189,26 @@ function builtinCommandHandlers( options: BuildAppCommandsOptions, ): Record { return { + "hunk.review.discardSelectedFile": { + isEnabled: () => Boolean(options.canDiscardSelectedFile), + run: () => options.discardSelectedFile?.(), + }, + "hunk.review.stashSelectedFile": { + isEnabled: () => Boolean(options.canStashSelectedFile), + run: () => options.stashSelectedFile?.(), + }, + "hunk.review.toggleHunkStaged": { + isEnabled: () => Boolean(options.canToggleHunkStaged), + run: () => options.toggleHunkStaged?.(), + }, + "hunk.review.toggleFileStaged": { + isEnabled: () => Boolean(options.canToggleFileStaged), + run: () => options.toggleFileStaged?.(), + }, + "hunk.review.toggleStagedView": { + isEnabled: () => Boolean(options.canSwitchStagedView), + run: () => options.toggleStagedView?.(), + }, "hunk.review.jumpToBottom": { run: () => options.scrollDiff(1, "content") }, "hunk.review.jumpToTop": { run: () => options.scrollDiff(-1, "content") }, "hunk.app.quit": { run: () => options.requestQuit() }, @@ -182,6 +216,14 @@ function builtinCommandHandlers( "hunk.app.openAgentSkill": { run: () => options.openAgentSkill() }, "hunk.app.toggleFocusArea": { run: () => options.toggleFocusArea() }, "hunk.review.focusFilter": { run: () => options.focusFilter() }, + "hunk.review.focusDiffPane": { + isEnabled: () => Boolean(options.canFocusDiffPane), + run: () => options.focusDiffPane(), + }, + "hunk.review.focusFilesPane": { + isEnabled: () => Boolean(options.canFocusFilesPane), + run: () => options.focusFilesPane(), + }, "hunk.review.startNote": { run: () => options.startUserNote() }, "hunk.review.editActiveNote": { isEnabled: () => Boolean(options.canEditActiveNote), @@ -322,10 +364,14 @@ const NOOP_COMMAND_OPTIONS: BuildAppCommandsOptions = (() => { return { canAlignCurrentLine: false, canApplyFilePresentationToAllMatching: false, + canFocusDiffPane: true, + canFocusFilesPane: true, canRefreshCurrentInput: true, alignCurrentLine: noop, applyFilePresentationToAllMatching: noop, + focusDiffPane: noop, focusFilter: noop, + focusFilesPane: noop, moveSelection: noop, openAgentSkill: noop, openThemeSelector: noop, @@ -405,6 +451,38 @@ export function isCommandEnabled(command: AppCommand): boolean { return !command.isEnabled || command.isEnabled(); } +/** + * Labels this command should advertise given first-match dispatch. + * + * Help and menus must not show a chord that an earlier enabled command still + * answers to. Remaining aliases stay visible, so Space can become staging + * while page-down still shows `f`. + */ +export function advertisedKeyLabels( + commands: readonly AppCommand[], + command: AppCommand, +): string[] { + if (!isCommandEnabled(command)) { + return []; + } + + const index = commands.findIndex((candidate) => candidate.id === command.id); + const claimed = new Set(); + for (const candidate of commands.slice(0, Math.max(0, index))) { + if (!isCommandEnabled(candidate)) { + continue; + } + for (const key of candidate.keys) { + claimed.add(key); + } + } + + return command.keys.flatMap((key, keyIndex) => { + const label = command.keyLabels[keyIndex]; + return claimed.has(key) || !label ? [] : [label]; + }); +} + /** * Find the resolved vertical movement binding that matches one key. * diff --git a/packages/hunk/src/ui/lib/appMenus.test.ts b/packages/hunk/src/ui/lib/appMenus.test.ts index a738816da..238c7a7c6 100644 --- a/packages/hunk/src/ui/lib/appMenus.test.ts +++ b/packages/hunk/src/ui/lib/appMenus.test.ts @@ -39,10 +39,14 @@ function createTestCommands(overrides: Partial = {}) { const commands = buildAppCommands({ canAlignCurrentLine: true, canApplyFilePresentationToAllMatching: false, + canFocusDiffPane: true, + canFocusFilesPane: true, canRefreshCurrentInput: true, alignCurrentLine: record("alignCurrentLine"), applyFilePresentationToAllMatching: record("applyFilePresentationToAllMatching"), + focusDiffPane: noop, focusFilter: noop, + focusFilesPane: noop, moveSelection: record("moveSelection"), openAgentSkill: record("openAgentSkill"), openThemeSelector: noop, @@ -118,7 +122,9 @@ describe("buildAppMenus", () => { expect(items(menus.file).map((item) => item.label)).toEqual([ "Toggle files/filter focus", "Focus filter", - "Open file in editor", + "Focus selected file in review", + "Focus files pane", + "Edit selected or changed line", "Reload", "Quit", ]); @@ -157,6 +163,8 @@ describe("buildAppMenus", () => { expect(items(menus.file).map((item) => item.commandId)).toEqual([ "hunk.app.toggleFocusArea", "hunk.review.focusFilter", + "hunk.review.focusDiffPane", + "hunk.review.focusFilesPane", "hunk.review.editSelectedFile", "hunk.app.refresh", "hunk.app.quit", @@ -231,7 +239,9 @@ describe("buildAppMenus", () => { expect(items(menus.file).map((item) => item.label)).toEqual([ "Toggle files/filter focus", "Focus filter", - "Open file in editor", + "Focus selected file in review", + "Focus files pane", + "Edit selected or changed line", "Quit", ]); }); diff --git a/packages/hunk/src/ui/lib/appMenus.ts b/packages/hunk/src/ui/lib/appMenus.ts index 6a48df299..0b4664a40 100644 --- a/packages/hunk/src/ui/lib/appMenus.ts +++ b/packages/hunk/src/ui/lib/appMenus.ts @@ -1,6 +1,11 @@ import type { CursorLine, LayoutMode } from "../../core/run/commandInputs"; import type { AppMenus, MenuEntry, MenuId } from "../components/chrome/menu"; -import { executeAppCommand, isCommandEnabled, type AppCommand } from "./appCommands"; +import { + advertisedKeyLabels, + executeAppCommand, + isCommandEnabled, + type AppCommand, +} from "./appCommands"; /** * The dropdown menus, expressed as references into the command table. @@ -76,9 +81,8 @@ function toMenuEntries( kind: "item", label: spec.label ?? command.title, commandId: spec.commandId, - // The first resolved chord is the one the menu advertises; a command the - // user unbound (or that ships unbound) simply shows no key. - hint: command.keyLabels[0], + // First-match dispatch owns the hint: unbound, disabled, or fully shadowed items show no key. + hint: advertisedKeyLabels(commands, command)[0], checked: spec.checked, action: () => { executeAppCommand(commands, spec.commandId); @@ -141,7 +145,14 @@ export function buildAppMenus({ file: [ { commandId: "hunk.app.toggleFocusArea", label: "Toggle files/filter focus" }, { commandId: "hunk.review.focusFilter", label: "Focus filter" }, - { commandId: "hunk.review.editSelectedFile", label: "Open file in editor" }, + { commandId: "hunk.review.focusDiffPane", label: "Focus selected file in review" }, + { commandId: "hunk.review.focusFilesPane", label: "Focus files pane" }, + { commandId: "hunk.review.editSelectedFile", label: "Edit selected or changed line" }, + { commandId: "hunk.review.discardSelectedFile" }, + { commandId: "hunk.review.stashSelectedFile" }, + { commandId: "hunk.review.toggleFileStaged" }, + { commandId: "hunk.review.toggleHunkStaged" }, + { commandId: "hunk.review.toggleStagedView" }, { commandId: "hunk.app.refresh", label: "Reload" }, SEPARATOR, { commandId: "hunk.app.quit" }, diff --git a/packages/hunk/src/ui/lib/extensionCommands.test.ts b/packages/hunk/src/ui/lib/extensionCommands.test.ts index 4f3caef36..22b286b49 100644 --- a/packages/hunk/src/ui/lib/extensionCommands.test.ts +++ b/packages/hunk/src/ui/lib/extensionCommands.test.ts @@ -42,7 +42,7 @@ describe("buildExtensionAppCommands", () => { test("refuses chords owned by built-in shortcuts", () => { const { commands, conflicts } = buildExtensionAppCommands({ - // "s" toggles the files pane and "[" is hunk navigation; both are taken. + // "s" is the contextual stash shortcut; extensions cannot claim built-in chords. registered: [registeredCommand("meta", "steal-s", "s"), registeredCommand("meta", "ok", "y")], builtins: builtinCommandMatchProbes(), runCommand: () => {}, @@ -56,7 +56,7 @@ describe("buildExtensionAppCommands", () => { extensionId: "meta", fullId: "meta.steal-s", key: "s", - conflictingId: "hunk.view.toggleFilesPane", + conflictingId: "hunk.review.stashSelectedFile", }, ]); }); @@ -96,7 +96,7 @@ describe("buildExtensionAppCommands", () => { test("drops only the conflicting chord of a multi-key command", () => { const { commands, conflicts } = buildExtensionAppCommands({ - // "s" toggles the files pane; "y" is free. + // "s" is reserved by built-ins; "y" is free. registered: [registeredCommand("meta", "toggle", ["s", "y"])], builtins: builtinCommandMatchProbes(), runCommand: () => {}, @@ -107,7 +107,7 @@ describe("buildExtensionAppCommands", () => { extensionId: "meta", fullId: "meta.toggle", key: "s", - conflictingId: "hunk.view.toggleFilesPane", + conflictingId: "hunk.review.stashSelectedFile", }, ]); // The command stays registered and keeps the chord nobody else owns. @@ -129,9 +129,10 @@ describe("buildExtensionAppCommands", () => { }); test("a chord a built-in released is free for an extension to claim", () => { - // The user moved the files-pane toggle to "ctrl+b", so "s" belongs to nobody. + // Releasing every built-in s binding makes the chord available to extensions. const resolvedKeys = new Map([ ["hunk.view.toggleFilesPane", ["ctrl+b"]], + ["hunk.review.stashSelectedFile", []], ]); const { commands, conflicts } = buildExtensionAppCommands({ registered: [registeredCommand("meta", "steal-s", "s")], diff --git a/packages/hunk/src/ui/lib/filePaneSelection.test.ts b/packages/hunk/src/ui/lib/filePaneSelection.test.ts new file mode 100644 index 000000000..443057e69 --- /dev/null +++ b/packages/hunk/src/ui/lib/filePaneSelection.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, test } from "bun:test"; +import { createTestDiffFile } from "../../../../../test/helpers/diff-helpers"; +import { + cursorFromSidebarIndex, + filesVisuallyUnderSidebarEntry, + nestedRevealPath, + sidebarIndexFromCursor, + stepSidebarIndex, +} from "./filePaneSelection"; +import { buildFlatSidebarEntries, buildTreeSidebarEntries } from "./files"; + +describe("file pane visual selection", () => { + const files = [ + createTestDiffFile({ id: "ui-a", path: "src/ui/a.ts" }), + createTestDiffFile({ id: "ui-b", path: "src/ui/b.ts" }), + createTestDiffFile({ id: "root", path: "README.md" }), + createTestDiffFile({ id: "core", path: "src/core/c.ts" }), + createTestDiffFile({ id: "test", path: "test/d.ts" }), + createTestDiffFile({ id: "src-root", path: "src/e.ts" }), + ]; + + test("tree folders collect nested visible files and stop at a sibling branch", () => { + const entries = buildTreeSidebarEntries(files); + const srcIndex = entries.findIndex( + (entry) => entry.kind === "directory" && entry.path === "src", + ); + + expect(filesVisuallyUnderSidebarEntry(entries, srcIndex).map((entry) => entry.id)).toEqual([ + "ui-a", + "ui-b", + ]); + + const uiIndex = entries.findIndex( + (entry) => entry.kind === "directory" && entry.path === "src/ui", + ); + expect(filesVisuallyUnderSidebarEntry(entries, uiIndex).map((entry) => entry.id)).toEqual([ + "ui-a", + "ui-b", + ]); + + const secondSrc = entries.findIndex( + (entry, index) => entry.kind === "directory" && entry.path === "src" && index > srcIndex, + ); + expect(filesVisuallyUnderSidebarEntry(entries, secondSrc).map((entry) => entry.id)).toEqual([ + "core", + ]); + }); + + test("flat groups collect only the files listed under that header", () => { + const entries = buildFlatSidebarEntries(files); + const srcUi = entries.findIndex((entry) => entry.kind === "group" && entry.path === "src/ui"); + const root = entries.findIndex((entry) => entry.kind === "group" && entry.path === "."); + const src = entries.findIndex((entry) => entry.kind === "group" && entry.path === "src"); + + expect(filesVisuallyUnderSidebarEntry(entries, srcUi).map((entry) => entry.id)).toEqual([ + "ui-a", + "ui-b", + ]); + expect(filesVisuallyUnderSidebarEntry(entries, root).map((entry) => entry.id)).toEqual([ + "root", + ]); + expect(filesVisuallyUnderSidebarEntry(entries, src).map((entry) => entry.id)).toEqual([ + "src-root", + ]); + }); + + test("stepping visits folder rows as well as files", () => { + const entries = buildTreeSidebarEntries(files); + const firstFile = entries.findIndex((entry) => entry.kind === "file"); + const previous = stepSidebarIndex(entries, firstFile, -1); + + expect(entries[previous]).toMatchObject({ kind: "directory", path: "src/ui" }); + expect(stepSidebarIndex(entries, 0, -1)).toBe(0); + expect(stepSidebarIndex(entries, entries.length - 1, 1)).toBe(entries.length - 1); + }); + + test("folder cursors restore the same visual branch after a rebuild", () => { + const entries = buildTreeSidebarEntries(files); + const secondSrc = entries.findIndex( + (entry, index) => + entry.kind === "directory" && + entry.path === "src" && + index > + entries.findIndex( + (candidate) => candidate.kind === "directory" && candidate.path === "src", + ), + ); + const cursor = cursorFromSidebarIndex(entries, secondSrc); + + expect(cursor).toEqual({ kind: "folder", path: "src", firstFileId: "core" }); + expect(sidebarIndexFromCursor(entries, cursor)).toBe(secondSrc); + expect(sidebarIndexFromCursor(entries, { kind: "file", id: "root" })).toBe( + entries.findIndex((entry) => entry.kind === "file" && entry.id === "root"), + ); + }); + + test("folder reveal stays on the current side when any nested file is there", () => { + const nested = [{ id: "docs/staged.md" }, { id: "docs/unstaged.md" }, { id: "docs/both.md" }]; + + expect(nestedRevealPath(nested, new Set(["docs/unstaged.md", "docs/both.md"]))).toBe( + "docs/unstaged.md", + ); + expect(nestedRevealPath(nested, new Set(["docs/staged.md", "docs/both.md"]))).toBe( + "docs/staged.md", + ); + expect(nestedRevealPath(nested, new Set(["src/other.ts"]))).toBe("docs/staged.md"); + expect(nestedRevealPath([], new Set(["docs/unstaged.md"]))).toBeUndefined(); + }); + + test("a stale duplicate folder cursor does not bind a different branch", () => { + const entries = buildTreeSidebarEntries(files); + const firstSrc = entries.findIndex( + (entry) => entry.kind === "directory" && entry.path === "src", + ); + const secondSrc = sidebarIndexFromCursor(entries, { + kind: "folder", + path: "src", + firstFileId: "core", + }); + expect(secondSrc).not.toBe(firstSrc); + expect(filesVisuallyUnderSidebarEntry(entries, secondSrc).map((entry) => entry.id)).toEqual([ + "core", + ]); + expect( + sidebarIndexFromCursor(entries, { kind: "folder", path: "src", firstFileId: "gone" }), + ).toBe(entries.findIndex((entry) => entry.kind === "file")); + }); +}); diff --git a/packages/hunk/src/ui/lib/filePaneSelection.ts b/packages/hunk/src/ui/lib/filePaneSelection.ts new file mode 100644 index 000000000..38bd4d63a --- /dev/null +++ b/packages/hunk/src/ui/lib/filePaneSelection.ts @@ -0,0 +1,116 @@ +/** + * Resolves files-pane cursors against the visible sidebar rows. + * Folder actions follow the on-screen hierarchy: nested files in tree view, + * only the files listed under a compact group in flat view. + */ +import type { FileListEntry, SidebarEntry } from "./files"; + +/** Cursor over one visible files-pane row, restored after inventory refresh. */ +export type FilePaneCursor = + | { kind: "file"; id: string } + | { kind: "folder"; path: string; firstFileId: string }; + +/** + * Choose which nested file to reveal for a files-pane row. + * Folders stay on the current staged/unstaged side when any nested file is there; + * only a row whose files all live on the other side switches the stream. + */ +export function nestedRevealPath( + nested: readonly Pick[], + currentSidePaths: ReadonlySet, +): string | undefined { + if (nested.length === 0) return undefined; + return nested.find((entry) => currentSidePaths.has(entry.id))?.id ?? nested[0]?.id; +} + +/** Return the file rows visually nested under one sidebar entry. */ +export function filesVisuallyUnderSidebarEntry( + entries: readonly SidebarEntry[], + index: number, +): FileListEntry[] { + const selected = entries[index]; + if (!selected) return []; + if (selected.kind === "file") return [selected]; + + const files: FileListEntry[] = []; + const selectedDepth = selected.kind === "directory" ? selected.depth : -1; + for (let i = index + 1; i < entries.length; i += 1) { + const entry = entries[i]!; + if (entry.kind === "group") break; + if (entry.kind === "directory" && entry.depth <= selectedDepth) break; + if (entry.kind === "file" && entry.depth <= selectedDepth) break; + if (entry.kind === "file") files.push(entry); + } + return files; +} + +/** Capture a refresh-stable cursor for the row at `index`. */ +export function cursorFromSidebarIndex( + entries: readonly SidebarEntry[], + index: number, +): FilePaneCursor | null { + const entry = entries[index]; + if (!entry) return null; + if (entry.kind === "file") return { kind: "file", id: entry.id }; + return { + kind: "folder", + path: entry.path, + firstFileId: filesVisuallyUnderSidebarEntry(entries, index)[0]?.id ?? "", + }; +} + +/** Resolve a stored cursor back to a visible row, falling back to the nearest file. */ +export function sidebarIndexFromCursor( + entries: readonly SidebarEntry[], + cursor: FilePaneCursor | null, +): number { + if (entries.length === 0) return -1; + if (!cursor) { + const firstFile = entries.findIndex((entry) => entry.kind === "file"); + return firstFile >= 0 ? firstFile : 0; + } + if (cursor.kind === "file") { + const index = entries.findIndex((entry) => entry.kind === "file" && entry.id === cursor.id); + return index >= 0 ? index : fallbackFileIndex(entries); + } + + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]!; + if (entry.kind === "file" || entry.path !== cursor.path) continue; + const firstFile = filesVisuallyUnderSidebarEntry(entries, index)[0]; + if ((firstFile?.id ?? "") === cursor.firstFileId) return index; + } + + const samePathIndexes = entries.flatMap((entry, index) => + entry.kind !== "file" && entry.path === cursor.path ? [index] : [], + ); + if (samePathIndexes.length === 1) return samePathIndexes[0]!; + if (cursor.firstFileId) { + const fileIndex = entries.findIndex( + (entry) => entry.kind === "file" && entry.id === cursor.firstFileId, + ); + if (fileIndex >= 0) return fileIndex; + } + return fallbackFileIndex(entries); +} + +/** Step the files-pane cursor through every visible row, including folders. */ +export function stepSidebarIndex( + entries: readonly SidebarEntry[], + index: number, + delta: number, +): number { + if (entries.length === 0) return -1; + const start = index >= 0 ? index : fallbackFileIndex(entries); + return Math.max(0, Math.min(entries.length - 1, start + delta)); +} + +/** Find the row id currently highlighted in the files pane. */ +export function sidebarEntryIdAtIndex(entries: readonly SidebarEntry[], index: number) { + return entries[index]?.id ?? null; +} + +function fallbackFileIndex(entries: readonly SidebarEntry[]) { + const firstFile = entries.findIndex((entry) => entry.kind === "file"); + return firstFile >= 0 ? firstFile : 0; +} diff --git a/packages/hunk/src/ui/lib/files.test.ts b/packages/hunk/src/ui/lib/files.test.ts index efdd5bb7a..760dfc41e 100644 --- a/packages/hunk/src/ui/lib/files.test.ts +++ b/packages/hunk/src/ui/lib/files.test.ts @@ -8,6 +8,7 @@ import { fileLabelParts, resolveFileSidebarMode, sidebarDirectoryPaths, + workingTreeSidebarSources, } from "./files"; describe("files helpers", () => { @@ -153,6 +154,43 @@ describe("files helpers", () => { ]); }); + test("workingTreeSidebarSources keeps inventory stats when the current review omits a file", () => { + const reviewed = createTestDiffFile({ + id: "unstaged", + path: "docs/unstaged.md", + before: "old\n", + after: "new\n", + }); + const sources = workingTreeSidebarSources( + [reviewed], + [ + { + path: "docs/unstaged.md", + staged: false, + unstaged: true, + untracked: false, + conflicted: false, + stats: { additions: 9, deletions: 9 }, + version: "u", + }, + { + path: "src/staged.ts", + staged: true, + unstaged: false, + untracked: false, + conflicted: false, + stats: { additions: 36, deletions: 0 }, + version: "s", + }, + ], + ); + + expect(sources).toMatchObject([ + { path: "docs/unstaged.md", stats: reviewed.stats }, + { path: "src/staged.ts", stats: { additions: 36, deletions: 0 } }, + ]); + }); + test("resolveFileSidebarMode switches at the preferred sidebar width", () => { expect(resolveFileSidebarMode(31)).toBe("flat"); expect(resolveFileSidebarMode(32)).toBe("tree"); @@ -312,7 +350,7 @@ describe("files helpers", () => { stateLabel: null, }); expect(buildFlatSidebarEntries([file])).toEqual([ - { kind: "group", id: "group:src:0", label: "src/" }, + { kind: "group", id: "group:src:0", label: "src/", path: "src" }, expect.objectContaining({ kind: "file", name: "tab\\tname.ts" }), ]); }); diff --git a/packages/hunk/src/ui/lib/files.ts b/packages/hunk/src/ui/lib/files.ts index 9c9696ae8..f7660d16c 100644 --- a/packages/hunk/src/ui/lib/files.ts +++ b/packages/hunk/src/ui/lib/files.ts @@ -2,7 +2,7 @@ import { basename, dirname } from "node:path/posix"; import type { FileDiffMetadata } from "@pierre/diffs"; import { normalizeDiffPath } from "../../core/changeset/diffPaths"; import type { DiffFile } from "../../core/changeset/model"; -import type { AgentAnnotation } from "../../extension-api/types"; +import type { AgentAnnotation, ExtensionWorkingTreeFile } from "../../extension-api/types"; import { readMetadataChangeType } from "../../extensions/events"; import { formatTerminalPath } from "../../lib/terminalText"; @@ -16,6 +16,7 @@ export interface FileListEntry { deletionsText: string | null; changeType: FileDiffMetadata["type"]; isUntracked: boolean; + stageStatus?: string; } /** @@ -37,19 +38,23 @@ export interface SidebarFileSource { agent?: { annotations: readonly unknown[] } | null; changeType?: FileDiffMetadata["type"]; metadata?: unknown; + stageStatus?: string; } export interface FileGroupEntry { kind: "group"; id: string; label: string; + /** Directory this compact group represents, without a trailing slash. */ + path: string; } export interface FileDirectoryEntry { kind: "directory"; id: string; - path: string; label: string; + /** Directory this expanded row represents, without a trailing slash. */ + path: string; depth: number; descendantFileCount: number; } @@ -59,11 +64,26 @@ export type SidebarEntry = FileListEntry | FileGroupEntry | FileDirectoryEntry; export const TREE_FILE_SIDEBAR_MIN_CONTENT_WIDTH = 32; +/** Inner text width the built-in files pane uses to choose compact vs tree rows. */ +export function fileSidebarContentWidth(paneWidth: number) { + return Math.max(8, paneWidth - 2); +} + /** Choose the compact or hierarchical sidebar projection for an available content width. */ export function resolveFileSidebarMode(contentWidth: number): FileSidebarMode { return contentWidth >= TREE_FILE_SIDEBAR_MIN_CONTENT_WIDTH ? "tree" : "flat"; } +/** Build the visible files-pane rows for the current sidebar projection. */ +export function buildFilePaneEntries( + files: readonly SidebarFileSource[], + contentWidth: number, +): SidebarEntry[] { + return resolveFileSidebarMode(contentWidth) === "tree" + ? buildTreeSidebarEntries(files) + : buildFlatSidebarEntries(files); +} + /** Build the filename-first label shown inside one sidebar row. */ function sidebarFileName(file: SidebarFileSource) { const path = formatTerminalPath(normalizeDiffPath(file.path) ?? file.path); @@ -140,6 +160,30 @@ export function mergeFileAnnotationsByFileId( }); } +/** Project working-tree status rows onto the sidebar source list, keeping review stats. */ +export function workingTreeSidebarSources( + reviewFiles: readonly SidebarFileSource[], + statusFiles: readonly ExtensionWorkingTreeFile[], +): SidebarFileSource[] { + const reviewedByPath = new Map(reviewFiles.map((file) => [file.path, file])); + return statusFiles.map((status) => { + const reviewed = reviewedByPath.get(status.path); + return { + ...reviewed, + id: status.path, + path: status.path, + previousPath: status.previousPath, + stats: reviewed?.stats ?? status.stats ?? { additions: 0, deletions: 0 }, + isUntracked: status.untracked, + stageStatus: status.untracked + ? "??" + : status.conflicted + ? "!!" + : (status.statusCode ?? `${status.staged ? "S" : " "}${status.unstaged ? "U" : " "}`), + }; + }); +} + /** Build the shared file-row metadata used by both sidebar projections. */ function buildSidebarFileEntry(file: SidebarFileSource, depth: number): FileListEntry { const agentCommentCount = file.agent?.annotations.length ?? 0; @@ -154,6 +198,7 @@ function buildSidebarFileEntry(file: SidebarFileSource, depth: number): FileList deletionsText: formatSidebarStat("-", file.stats.deletions), changeType: file.changeType ?? readMetadataChangeType(file.metadata) ?? "change", isUntracked: file.isUntracked ?? false, + stageStatus: file.stageStatus, }; } @@ -172,6 +217,7 @@ export function buildFlatSidebarEntries(files: readonly SidebarFileSource[]): Si kind: "group", id: `group:${group}:${index}`, label: group === "." ? "./" : `${group}/`, + path: group, }); } diff --git a/packages/hunk/src/ui/lib/helpContent.test.ts b/packages/hunk/src/ui/lib/helpContent.test.ts index 1666a41cc..80fd9b2ee 100644 --- a/packages/hunk/src/ui/lib/helpContent.test.ts +++ b/packages/hunk/src/ui/lib/helpContent.test.ts @@ -31,7 +31,8 @@ describe("buildHelpSections", () => { ]); expect(keysFor(sections, "previous / next hunk")).toBe("[ / ]"); expect(keysFor(sections, "half page down / up")).toBe("d / u"); - expect(keysFor(sections, "move line-by-line")).toBe("Up / Down"); + expect(keysFor(sections, "move in the focused pane")).toBe("Up / Down"); + expect(keysFor(sections, "focus review / files pane")).toBe("Enter / Esc"); expect(keysFor(sections, "split / stack / auto")).toBe("1 / 2 / 0"); expect(keysFor(sections, "lines / wrap / metadata / menu")).toBe("l / w / m / M"); }); diff --git a/packages/hunk/src/ui/lib/helpContent.ts b/packages/hunk/src/ui/lib/helpContent.ts index 0bb8ae799..c1f265efd 100644 --- a/packages/hunk/src/ui/lib/helpContent.ts +++ b/packages/hunk/src/ui/lib/helpContent.ts @@ -1,13 +1,14 @@ -import { isCommandEnabled, type AppCommand } from "./appCommands"; +import { advertisedKeyLabels, type AppCommand } from "./appCommands"; /** * The curated content of the controls help dialog. * * The rows are hand-written — grouped, ordered, and worded for someone learning * the app — but the keys in them are not: each row names the commands it - * documents and the key column is rendered from whatever chords those commands - * currently answer to. Remap a command and help says so; unbind it and its row - * disappears rather than advertising a key that does nothing. + * documents and the key column is rendered from the chords those commands would + * currently receive. Remap a command and help says so; unbind it, disable it, or + * let an earlier enabled command take its chord and that key disappears rather + * than advertising a shortcut that does something else. * * A few rows document behavior that is not a command at all (the mouse wheel, * F10 opening the menus, which belong to the widgets that own them). Those @@ -42,7 +43,11 @@ const HELP_SECTIONS: readonly HelpSectionSpec[] = [ entries: [ { commandIds: ["hunk.review.stepUp", "hunk.review.stepDown"], - description: "move line-by-line", + description: "move in the focused pane", + }, + { + commandIds: ["hunk.review.focusDiffPane", "hunk.review.focusFilesPane"], + description: "focus review / files pane", }, { commandIds: ["hunk.review.pageDown"], description: "page down" }, { commandIds: ["hunk.review.pageUp"], description: "page up" }, @@ -99,12 +104,31 @@ const HELP_SECTIONS: readonly HelpSectionSpec[] = [ ], description: "lines / wrap / metadata / menu", }, - { commandIds: ["hunk.review.editSelectedFile"], description: "open file in $EDITOR" }, + { + commandIds: ["hunk.review.discardSelectedFile"], + description: "discard selected file or folder changes (confirmed)", + }, + { + commandIds: ["hunk.review.stashSelectedFile"], + description: "stash selected file or folder with a message", + }, + { + commandIds: ["hunk.review.editSelectedFile"], + description: "edit selected line in $EDITOR", + }, ], }, { title: "Review", entries: [ + { + commandIds: ["hunk.review.toggleFileStaged", "hunk.review.toggleHunkStaged"], + description: "stage / unstage focused file, folder, or hunk", + }, + { + commandIds: ["hunk.review.toggleStagedView"], + description: "switch unstaged / staged stream", + }, { commandIds: ["hunk.review.focusFilter"], description: "focus file filter" }, { commandIds: ["hunk.review.startNote"], description: "create review note" }, { @@ -132,11 +156,11 @@ export const HELP_COMMAND_IDS: readonly string[] = HELP_SECTIONS.flatMap((sectio /** * Render one entry's key column, or nothing when it documents no live key. * - * A row about one command lists every chord it answers to, since there is room + * A row about one command lists every chord it currently answers to, since there is room * for the alternates; a row covering several commands shows each one's primary - * chord instead, so the column stays readable. Either way a command that is - * disabled or unbound contributes nothing, and a row left with no keys at all - * is dropped by the caller. + * reachable chord instead, so the column stays readable. Either way a command that is + * disabled, unbound, or fully shadowed by an earlier enabled command contributes nothing, + * and a row left with no keys at all is dropped by the caller. */ function helpEntryKeys(commands: readonly AppCommand[], spec: HelpEntrySpec): string | undefined { if ("keys" in spec) { @@ -145,11 +169,12 @@ function helpEntryKeys(commands: readonly AppCommand[], spec: HelpEntrySpec): st const labels = spec.commandIds.flatMap((id) => { const command = commands.find((candidate) => candidate.id === id); - if (!command || !isCommandEnabled(command)) { + if (!command) { return []; } - return spec.commandIds.length === 1 ? [...command.keyLabels] : command.keyLabels.slice(0, 1); + const reachable = advertisedKeyLabels(commands, command); + return spec.commandIds.length === 1 ? reachable : reachable.slice(0, 1); }); return labels.length > 0 ? labels.join(" / ") : undefined; diff --git a/packages/hunk/src/ui/lib/mousePressSequence.ts b/packages/hunk/src/ui/lib/mousePressSequence.ts new file mode 100644 index 000000000..cd4717005 --- /dev/null +++ b/packages/hunk/src/ui/lib/mousePressSequence.ts @@ -0,0 +1,21 @@ +/** + * Counts mouse presses across the mounted terminal so mutation gestures require consecutive clicks. + * App observes bubbling presses; handlers that consume a press record it before stopping propagation. + * Renderer-scoped weak state keeps separate terminal instances and their gesture lifetimes isolated. + */ +import type { MouseEvent } from "@opentui/core"; + +const presses = new WeakMap(); + +/** Record a press once even when several ancestors observe the same bubbling event. */ +export function recordMousePress(renderer: object, event: MouseEvent) { + if (event.type !== "down") return; + const previous = presses.get(renderer); + if (previous?.event === event) return; + presses.set(renderer, { event, sequence: (previous?.sequence ?? 0) + 1 }); +} + +/** Read the latest press identity without subscribing to pointer movement or rendering. */ +export function mousePressSequence(renderer: object) { + return presses.get(renderer)?.sequence ?? 0; +} diff --git a/packages/hunk/src/ui/lib/openInEditor.test.ts b/packages/hunk/src/ui/lib/openInEditor.test.ts index 81512e48f..2d6a0f6e9 100644 --- a/packages/hunk/src/ui/lib/openInEditor.test.ts +++ b/packages/hunk/src/ui/lib/openInEditor.test.ts @@ -7,6 +7,7 @@ import { buildEditorCommand, openSelectedFileInEditor, resolveEditableFilePath, + resolveSelectedEditorLine, shouldSuspendForEditor, } from "./openInEditor"; @@ -69,6 +70,23 @@ describe("open in editor helpers", () => { }); }); + test.each([ + ["micro", "micro", []], + ["micro -readonly true", "micro", ["-readonly", "true"]], + [ + '"C:\\Program Files\\Micro\\micro.exe" -readonly true', + "C:\\Program Files\\Micro\\micro.exe", + ["-readonly", "true"], + ], + ])("builds micro line jumps for %s", (editor, command, flags) => { + const filePath = join("project", "file with spaces.ts"); + expect(buildEditorCommand({ editor, filePath, line: 12 })).toEqual({ + command, + args: [...flags, "+12", filePath], + }); + expect(shouldSuspendForEditor(editor)).toBe(true); + }); + test("preserves editor flags before appending the target file", () => { expect( buildEditorCommand({ @@ -95,17 +113,14 @@ describe("open in editor helpers", () => { }); }); - test("defaults unknown editors to opening the file path only", () => { + test("refuses unknown editor line syntax rather than opening at file start", () => { expect( buildEditorCommand({ editor: "zed --new-window", filePath: "/tmp/project/example.ts", line: 4, }), - ).toEqual({ - command: "zed", - args: ["--new-window", "/tmp/project/example.ts"], - }); + ).toBeNull(); }); test("does not suspend for code-style GUI editors", () => { @@ -222,6 +237,31 @@ describe("open in editor helpers", () => { expect(renderer.resume).toHaveBeenCalledTimes(1); }); + test("falls back to a changed line rather than leading context", () => { + const file = createTestDiffFile({ + before: "one\ntwo\nthree\nfour\nfive\n", + after: "one\ntwo\nchanged\nfour\nfive\n", + context: 3, + }); + expect(resolveSelectedEditorLine(file, file.metadata.hunks[0], null)).toBe(3); + expect( + resolveSelectedEditorLine(file, file.metadata.hunks[0], { + fileId: file.id, + hunkIndex: 0, + target: { side: "new", line: 1 }, + }), + ).toBe(1); + }); + + test("an earlier deletion wins over a later addition for editor fallback", () => { + const file = createTestDiffFile({ + before: "a\nb\nc\nd\ne\nf\ng\nh\n", + after: "a\nc\nd\ne\nNEW\nf\ng\nh\n", + context: 3, + }); + expect(resolveSelectedEditorLine(file, file.metadata.hunks[0], null)).toBe(2); + }); + test("opens the current line instead of the selected hunk start", () => { const basePath = createTempDir(); writeFileSync(join(basePath, "example.ts"), "const value = 1;\n"); diff --git a/packages/hunk/src/ui/lib/openInEditor.ts b/packages/hunk/src/ui/lib/openInEditor.ts index 21f0ed2dc..4d5d1ed72 100644 --- a/packages/hunk/src/ui/lib/openInEditor.ts +++ b/packages/hunk/src/ui/lib/openInEditor.ts @@ -2,6 +2,7 @@ import { existsSync } from "node:fs"; import { basename, resolve, win32 } from "node:path"; import type { CliRenderer } from "@opentui/core"; import type { DiffFile } from "../../core/changeset/model"; +import { reviewDefaultHunkLineTarget } from "../../core/review/geometry"; import type { LineCursor } from "./lineCursors"; export interface EditorCommand { @@ -49,8 +50,8 @@ function deletionLineToFileLine(hunk: DiffHunk, deletionLine: number) { return additionCursor; } -/** Prefer the current line over the selected hunk's first line. */ -function selectedLine( +/** Prefer a deliberate source line, otherwise the selected hunk's first change rather than context. */ +export function resolveSelectedEditorLine( file: DiffFile, selectedHunk: DiffHunk | undefined, lineCursor: EditorLineCursor | null | undefined, @@ -75,7 +76,9 @@ function selectedLine( return selectedHunk?.deletionStart ?? 1; } - return selectedHunk?.additionStart ?? 1; + if (!selectedHunk) return 1; + const target = reviewDefaultHunkLineTarget(selectedHunk, "first-change"); + return target.side === "new" ? target.line : deletionLineToFileLine(selectedHunk, target.line); } function splitEditorCommand(editor: string) { @@ -93,7 +96,7 @@ function editorProgram(editor: string) { .toLowerCase(); } -const VI_STYLE_EDITORS = ["vim", "nvim", "vi"]; +const PLUS_LINE_EDITORS = ["vim", "nvim", "vi", "micro"]; const CODE_STYLE_EDITORS = ["code", "code-insiders", "cursor"]; /** Suspend for terminal editors. */ @@ -115,11 +118,11 @@ export function buildEditorCommand({ editor: string; filePath: string; line: number; -}): EditorCommand { +}): EditorCommand | null { const [command = "", ...editorArgs] = splitEditorCommand(editor); const program = editorProgram(editor); - if (VI_STYLE_EDITORS.includes(program)) { + if (PLUS_LINE_EDITORS.includes(program)) { return { command, args: [...editorArgs, `+${line}`, filePath] }; } @@ -131,7 +134,7 @@ export function buildEditorCommand({ return { command, args: [...editorArgs, `${filePath}:${line}`] }; } - return { command, args: [...editorArgs, filePath] }; + return null; } /** Resolve diff paths relative to their source repo instead of the launch cwd. */ @@ -144,12 +147,15 @@ export function openSelectedFileInEditor({ basePath, file, lineCursor, + mappedLine, renderer, selectedHunk, }: { basePath?: string; file: DiffFile | undefined; lineCursor?: EditorLineCursor | null; + /** Provider-resolved working-tree location for a staged source line. */ + mappedLine?: number; renderer: Pick; selectedHunk: DiffHunk | undefined; }) { @@ -167,13 +173,16 @@ export function openSelectedFileInEditor({ return `Cannot edit ${file.path}: file does not exist on disk.`; } - const line = Math.max(1, selectedLine(file, selectedHunk, lineCursor)); + const line = Math.max(1, mappedLine ?? resolveSelectedEditorLine(file, selectedHunk, lineCursor)); const command = buildEditorCommand({ editor, filePath: absolutePath, line, }); + if (!command) + return "Cannot position this editor at a source line. Set $EDITOR to vi, vim, nvim, code, code-insiders, cursor, hx, or micro."; + const shouldSuspend = shouldSuspendForEditor(editor); if (shouldSuspend) { renderer.suspend(); diff --git a/packages/hunk/src/ui/lib/paneFocus.test.ts b/packages/hunk/src/ui/lib/paneFocus.test.ts new file mode 100644 index 000000000..1afa8e8c3 --- /dev/null +++ b/packages/hunk/src/ui/lib/paneFocus.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test"; +import { resolveTheme } from "../themes"; +import { filesPaneFrameSides, paneFrameBorderColor, reviewPaneFrameSides } from "./paneFocus"; + +describe("pane frame sides", () => { + test("omits the top edge when the menu bar is hidden", () => { + expect(filesPaneFrameSides(true)).toEqual(["top", "left", "bottom"]); + expect(filesPaneFrameSides(false)).toEqual(["left", "bottom"]); + expect(reviewPaneFrameSides(true)).toEqual(["top", "right", "bottom"]); + expect(reviewPaneFrameSides(false)).toEqual(["right", "bottom"]); + }); +}); + +describe("paneFrameBorderColor", () => { + test("keeps the dim theme border on an unfocused pane", () => { + const theme = resolveTheme("github-dark-default", null); + + expect(paneFrameBorderColor(theme, false)).toBe(theme.border); + }); + + test("uses a brighter stroke than the dim border on the focused pane", () => { + const dark = resolveTheme("github-dark-default", null); + const light = resolveTheme("github-light-default", null); + + expect(paneFrameBorderColor(dark, true)).not.toBe(dark.border); + expect(paneFrameBorderColor(light, true)).not.toBe(light.border); + expect(paneFrameBorderColor(dark, true)).not.toBe(dark.text); + }); +}); diff --git a/packages/hunk/src/ui/lib/paneFocus.ts b/packages/hunk/src/ui/lib/paneFocus.ts new file mode 100644 index 000000000..687fc329f --- /dev/null +++ b/packages/hunk/src/ui/lib/paneFocus.ts @@ -0,0 +1,26 @@ +import type { AppTheme } from "../themes"; +import { blendHex } from "./color"; + +/** Files-pane rectangle, sharing its inner edge with the pane divider. */ +export function filesPaneFrameSides(includeTop: boolean): Array<"top" | "left" | "bottom"> { + return includeTop ? ["top", "left", "bottom"] : ["left", "bottom"]; +} + +/** Review-pane rectangle, sharing its inner edge with the pane divider. */ +export function reviewPaneFrameSides(includeTop: boolean): Array<"top" | "right" | "bottom"> { + return includeTop ? ["top", "right", "bottom"] : ["right", "bottom"]; +} + +/** + * Border color for a pane frame. + * + * When more than one pane is visible, the focused pane and the shared divider + * use a brighter stroke; the other pane keeps the dim theme border. + */ +export function paneFrameBorderColor(theme: AppTheme, focused: boolean): string { + if (!focused) { + return theme.border; + } + + return blendHex(theme.text, theme.background, theme.appearance === "light" ? 0.42 : 0.5); +} diff --git a/packages/hunk/src/ui/lib/sidebarRenderWindow.test.ts b/packages/hunk/src/ui/lib/sidebarRenderWindow.test.ts index 33c6a9732..dab6b2460 100644 --- a/packages/hunk/src/ui/lib/sidebarRenderWindow.test.ts +++ b/packages/hunk/src/ui/lib/sidebarRenderWindow.test.ts @@ -6,7 +6,12 @@ import { buildSidebarRenderWindow, type SidebarRenderWindowItem } from "./sideba function createEntries(ids: string[]): SidebarEntry[] { return ids.map((id) => id.startsWith("group:") - ? { kind: "group", id, label: `${id.slice("group:".length)}/` } + ? { + kind: "group", + id, + label: `${id.slice("group:".length)}/`, + path: id.slice("group:".length), + } : { kind: "file", id, diff --git a/packages/hunk/src/ui/lib/sidebarRenderWindow.ts b/packages/hunk/src/ui/lib/sidebarRenderWindow.ts index 25ef570d4..a90ad7ca4 100644 --- a/packages/hunk/src/ui/lib/sidebarRenderWindow.ts +++ b/packages/hunk/src/ui/lib/sidebarRenderWindow.ts @@ -102,9 +102,7 @@ export function buildSidebarRenderWindow({ } if (selectedFileId) { - const selectedIndex = entries.findIndex( - (entry) => entry.kind === "file" && entry.id === selectedFileId, - ); + const selectedIndex = entries.findIndex((entry) => entry.id === selectedFileId); if (selectedIndex >= 0) { mountedIndices.add(selectedIndex); } diff --git a/packages/hunk/src/ui/lib/viewportTiming.ts b/packages/hunk/src/ui/lib/viewportTiming.ts index 83fb2103d..37e38a191 100644 --- a/packages/hunk/src/ui/lib/viewportTiming.ts +++ b/packages/hunk/src/ui/lib/viewportTiming.ts @@ -4,7 +4,7 @@ export const VIEWPORT_READ_COALESCE_MS = 16; /** * Estimate render-only viewport bounds before OpenTUI publishes exact scrollbox geometry. * Subtracts the review pane's screen-top offset from the renderer height so the first paint - * can window files without waiting for the scrollbox to report its laid-out height. A planned + * can window files and rows without waiting for the scrollbox to report its laid-out height. A planned * pane height excludes any extension pane below the review. */ export function estimateInitialRenderViewportHeight( diff --git a/packages/hunk/src/ui/themes.test.ts b/packages/hunk/src/ui/themes.test.ts index fc41aebe2..1d318c489 100644 --- a/packages/hunk/src/ui/themes.test.ts +++ b/packages/hunk/src/ui/themes.test.ts @@ -255,6 +255,21 @@ describe("themes", () => { background: theme.accentMuted, }, ...sidebarPairs, + ...( + [ + "badgeAdded", + "badgeRemoved", + "fileNew", + "fileDeleted", + "fileRenamed", + "fileModified", + "fileUntracked", + ] as const + ).map((field) => ({ + label: `${theme.id} ${field}/accentMuted`, + foreground: theme[field], + background: theme.accentMuted, + })), ]); }); diff --git a/packages/hunk/src/ui/themes.ts b/packages/hunk/src/ui/themes.ts index 8e17a5774..c7d3a24d1 100644 --- a/packages/hunk/src/ui/themes.ts +++ b/packages/hunk/src/ui/themes.ts @@ -132,10 +132,15 @@ function readableSeparatedRowBackground( } /** Keep semantic status colors readable on sidebar and menu surfaces. */ -function readableChromeColor(preferred: string, panel: string, panelAlt: string) { +function readableChromeColor( + preferred: string, + panel: string, + panelAlt: string, + selectedBackground: string, +) { + const backgrounds = [panel, panelAlt, selectedBackground]; if ( - contrastRatio(preferred, panel) >= MIN_GUTTER_CONTRAST && - contrastRatio(preferred, panelAlt) >= MIN_GUTTER_CONTRAST + backgrounds.every((background) => contrastRatio(preferred, background) >= MIN_GUTTER_CONTRAST) ) { return preferred; } @@ -145,8 +150,7 @@ function readableChromeColor(preferred: string, panel: string, panelAlt: string) for (const amount of [0.35, 0.5, 0.65, 0.8, 1]) { const candidate = blendHex(anchor, preferred, amount); if ( - contrastRatio(candidate, panel) >= MIN_GUTTER_CONTRAST && - contrastRatio(candidate, panelAlt) >= MIN_GUTTER_CONTRAST + backgrounds.every((background) => contrastRatio(candidate, background) >= MIN_GUTTER_CONTRAST) ) { return candidate; } @@ -229,9 +233,24 @@ function buildShikiTheme(themeId: BundledShikiThemeId): AppTheme { selectedTint, ); const syntaxColors = buildSyntaxColors(textForeground); - const badgeAdded = readableChromeColor(addedSignColor, neutralPanel, neutralPanelAlt); - const badgeRemoved = readableChromeColor(removedSignColor, neutralPanel, neutralPanelAlt); - const badgeModified = readableChromeColor(modifiedColor, neutralPanel, neutralPanelAlt); + const badgeAdded = readableChromeColor( + addedSignColor, + neutralPanel, + neutralPanelAlt, + accentMuted, + ); + const badgeRemoved = readableChromeColor( + removedSignColor, + neutralPanel, + neutralPanelAlt, + accentMuted, + ); + const badgeModified = readableChromeColor( + modifiedColor, + neutralPanel, + neutralPanelAlt, + accentMuted, + ); const themeBase: ThemeBase = { id: themeId, label: themeId, diff --git a/scripts/quality/hunk-vcs-package.test.ts b/scripts/quality/hunk-vcs-package.test.ts index 5e776f99b..d8cec711a 100644 --- a/scripts/quality/hunk-vcs-package.test.ts +++ b/scripts/quality/hunk-vcs-package.test.ts @@ -55,6 +55,7 @@ describe("@hunk/vcs package boundary", () => { expect(nixLock).toContain('"@hunk/vcs" = copyPathToStore ../packages/hunk-vcs;'); }); + // The isolated compiler also loads the standard libraries on contended CI workers. test("resolves its structural diff helper from an isolated package copy", () => { const consumerRoot = createTempConsumer(); const installedPackage = join(consumerRoot, "node_modules", "@hunk", "vcs"); @@ -96,5 +97,5 @@ describe("@hunk/vcs package boundary", () => { expect(new TextDecoder().decode(result.stderr)).toBe(""); expect(new TextDecoder().decode(result.stdout)).toBe(""); expect(result.exitCode).toBe(0); - }); + }, 20_000); }); diff --git a/test/cli/log.test.ts b/test/cli/log.test.ts index 3d47e8f10..666b141d1 100644 --- a/test/cli/log.test.ts +++ b/test/cli/log.test.ts @@ -103,7 +103,7 @@ describe("hunk log CLI contract", () => { expect(result.stdout).toContain("First commit"); expect(result.stdout).toContain("A detailed body."); expect(result.stdout).toContain("Author: Ada Lovelace "); - expect(result.stdout).toContain("Date: 2026-01-02 00:00:00Z"); + expect(result.stdout).toContain("Date: 2026-01-02 00:00:00+00:00"); expect(result.stdout).toMatch(/commit [0-9a-f]{40} \(HEAD -> /); expect(result.stdout).toContain("tag: v1.0.0"); expect(result.stdout).toContain("tag: v1.0.0-annotated"); diff --git a/test/helpers/sidebar-frame.ts b/test/helpers/sidebar-frame.ts new file mode 100644 index 000000000..6c21a4aeb --- /dev/null +++ b/test/helpers/sidebar-frame.ts @@ -0,0 +1,16 @@ +/** Find the sidebar divider where it crosses the top frame, excluding the outer pane borders. */ +export function getTestSidebarDividerColumn(frame: string) { + const topBorder = frame.split("\n").find((line) => line.includes("┌") && line.includes("│")); + return topBorder?.indexOf("│") ?? -1; +} + +/** Extract only sidebar columns so review headers cannot satisfy sidebar assertions. */ +export function getTestSidebarFrame(frame: string) { + const divider = getTestSidebarDividerColumn(frame); + return divider < 0 + ? "" + : frame + .split("\n") + .map((line) => line.slice(0, divider)) + .join("\n"); +} diff --git a/test/helpers/working-tree.ts b/test/helpers/working-tree.ts new file mode 100644 index 000000000..528994a75 --- /dev/null +++ b/test/helpers/working-tree.ts @@ -0,0 +1,29 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, realpathSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** Run Git against a test-created checkout without shell expansion. */ +export function runTestGit(root: string, ...args: string[]) { + return execFileSync("git", args, { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); +} + +/** Create a temporary working tree; callers own removal after their test finishes. */ +export function createTestWorkingTreeRepo({ unborn = false } = {}) { + const root = realpathSync(mkdtempSync(join(tmpdir(), "hunk-working-tree-test-"))); + runTestGit(root, "init", "--initial-branch=main"); + runTestGit(root, "config", "user.name", "Hunk Test"); + runTestGit(root, "config", "user.email", "test@example.test"); + runTestGit(root, "config", "commit.gpgsign", "false"); + if (!unborn) { + writeFileSync(join(root, "alpha.txt"), "one\ntwo\nthree\n"); + writeFileSync(join(root, "beta.txt"), "other\n"); + runTestGit(root, "add", "--", "alpha.txt", "beta.txt"); + runTestGit(root, "commit", "-m", "Initial test files"); + } + return root; +} diff --git a/test/pty/add-note-hover-integration.test.ts b/test/pty/add-note-hover-integration.test.ts new file mode 100644 index 000000000..9cdd2b825 --- /dev/null +++ b/test/pty/add-note-hover-integration.test.ts @@ -0,0 +1,33 @@ +import { afterEach, expect, test } from "bun:test"; +import { createPtyHarness, lineIndexOf, moveMouse, sleep } from "./harness"; + +const harness = createPtyHarness(); +afterEach(() => harness.cleanup()); + +test("add-note hover after navigation survives the deferred viewport read", async () => { + const fixture = harness.createMultiHunkFilePair(); + const session = await harness.launchHunk({ + args: ["diff", "--files", fixture.before, fixture.after, "--mode", "split"], + cols: 104, + rows: 13, + }); + try { + await session.waitForText(/line1 = 100/, { timeout: 15_000 }); + await harness.ensureKeyboardIsLive(session); + session.writeRaw("]"); + const navigated = await session.waitForText(/line60 = 6000/); + const row = lineIndexOf(navigated, "line60 = 6000") - 1; + await moveMouse(session, 80, row); + const hovered = await session.waitForText(/\[\+\]/); + await sleep(100); + expect(await session.text({ immediate: true })).toContain("[+]"); + const badgeRow = lineIndexOf(hovered, "[+]") - 1; + const badgeColumn = hovered.split("\n")[badgeRow + 1]!.indexOf("[+]"); + session.writeRaw( + `\x1b[<0;${badgeColumn + 2};${badgeRow + 1}M\x1b[<0;${badgeColumn + 2};${badgeRow + 1}m`, + ); + await session.waitForText(/Draft note/); + } finally { + session.close(); + } +}, 20_000); diff --git a/test/pty/chrome.test.ts b/test/pty/chrome.test.ts index d0b48a861..4d655c581 100644 --- a/test/pty/chrome.test.ts +++ b/test/pty/chrome.test.ts @@ -181,7 +181,9 @@ describe("PTY chrome", () => { expect(initial).toContain("add = true"); expect(initial).toContain("betaValue"); - await session.press("tab"); + await harness.ensureKeyboardIsLive(session); + await session.press("/"); + await session.waitForText("filter: type to filter files"); await session.type("beta"); const filtered = await harness.waitForSnapshot( session, @@ -205,7 +207,7 @@ describe("PTY chrome", () => { args: ["diff", "--mode", "split"], cwd: fixture.dir, cols: 220, - rows: 12, + rows: 13, }); try { @@ -261,12 +263,12 @@ describe("PTY chrome", () => { session, (text) => (text.includes("Keyboard help") || text.includes("Controls help")) && - text.includes("move line-by-line"), + text.includes("move in the focused pane"), 5_000, ); expect(help.includes("Keyboard help") || help.includes("Controls help")).toBe(true); - expect(help).toContain("move line-by-line"); + expect(help).toContain("move in the focused pane"); } finally { session.close(); } diff --git a/test/pty/cursor-line.test.ts b/test/pty/cursor-line.test.ts index 493e2a483..d74f17bc2 100644 --- a/test/pty/cursor-line.test.ts +++ b/test/pty/cursor-line.test.ts @@ -36,21 +36,21 @@ describe("PTY current line", () => { await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, { timeout: 15_000 }); await session.waitIdle({ timeout: 300 }); - expect(await measureKeyScroll(session, "j", 12)).toBe(0); + expect(await measureKeyScroll(session, "j", 12, "paint")).toBe(0); let stepsBeforeScrolling = 1; let firstScroll = 0; for (let step = 0; step < 40 && firstScroll === 0; step += 1) { - firstScroll = await measureKeyScroll(session, "j", 12); + firstScroll = await measureKeyScroll(session, "j", 12, "paint"); stepsBeforeScrolling += 1; } expect(stepsBeforeScrolling).toBeGreaterThan(5); expect(firstScroll).toBeGreaterThan(0); - expect(await measureKeyScroll(session, "j", 12)).toBe(1); - expect(await measureKeyScroll(session, "j", 12)).toBe(1); - expect(await measureKeyScroll(session, "k", 12)).toBe(0); + expect(await measureKeyScroll(session, "j", 12, "paint")).toBe(1); + expect(await measureKeyScroll(session, "j", 12, "paint")).toBe(1); + expect(await measureKeyScroll(session, "k", 12, "paint")).toBe(0); } finally { session.close(); } @@ -116,8 +116,9 @@ describe("PTY current line", () => { }); try { - const initial = await session.waitForText(/export const line06 = 6;/, { timeout: 15_000 }); + await session.waitForText(/export const line06 = 6;/, { timeout: 15_000 }); await session.waitIdle({ timeout: 300 }); + const initial = await session.text({ immediate: true }); const startRow = lineIndexOf(initial, "export const line02 = 2;") - 1; const endRow = lineIndexOf(initial, "export const line06 = 6;") - 1; expect(startRow).toBeGreaterThan(0); @@ -131,8 +132,12 @@ describe("PTY current line", () => { session.writeRaw(`\x1b[<32;31;${row + 1}M`); await sleep(20); } - await session.waitIdle(); - + await harness.waitForSnapshot(session, () => + rows.every((row, index) => { + const backgrounds = rowCellBackgrounds(session, row); + return backgrounds.some((color, column) => color !== before[index]?.[column]); + }), + ); const selected = rows.map((row) => rowCellBackgrounds(session, row)); for (let index = 0; index < rows.length; index += 1) { expect(selected[index]).not.toEqual(before[index]); @@ -232,7 +237,7 @@ describe("PTY current line", () => { let scrolled = 0; for (let step = 0; step < 40 && scrolled === 0; step += 1) { - scrolled = await measureKeyScroll(session, "j", 12); + scrolled = await measureKeyScroll(session, "j", 12, "paint"); } expect(scrolled).toBeGreaterThan(0); @@ -335,7 +340,7 @@ describe("PTY current line", () => { await session.press("space"); await session.waitIdle({ timeout: 400 }); - expect(await measureKeyScroll(session, "j", 12)).toBeLessThanOrEqual(1); + expect(await measureKeyScroll(session, "j", 12, "paint")).toBeLessThanOrEqual(1); } finally { session.close(); } diff --git a/test/pty/editor-integration.test.ts b/test/pty/editor-integration.test.ts new file mode 100644 index 000000000..7790e40ba --- /dev/null +++ b/test/pty/editor-integration.test.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { quote } from "shell-quote"; +import { createTestWorkingTreeRepo, runTestGit } from "../helpers/working-tree"; +import { createPtyHarness } from "./harness"; + +const harness = createPtyHarness(); +const roots: string[] = []; +setDefaultTimeout(30_000); +afterEach(() => { + harness.cleanup(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("PTY external editor positioning", () => { + test("e refuses text-converted unstaged coordinates without launching the editor", async () => { + const root = createTestWorkingTreeRepo(); + roots.push(root); + const config = harness.createIsolatedConfigHome(); + const converter = join(config, "converter.cjs"); + writeFileSync( + converter, + 'const fs = require("node:fs"); process.stdout.write("converted header\\n" + fs.readFileSync(process.argv[2], "utf8"));', + ); + writeFileSync(join(root, ".gitattributes"), "alpha.txt diff=converted\n"); + runTestGit(root, "config", "diff.converted.textconv", quote([process.execPath, converter])); + writeFileSync(join(root, "alpha.txt"), "one\nchanged source\nthree\n"); + const editor = join(config, "nvim"); + const record = join(config, "editor-launched"); + writeFileSync( + editor, + '#!/usr/bin/env bun\nimport { writeFileSync } from "node:fs"; writeFileSync(process.env.HUNK_TEST_EDITOR_LOG, "launched");\n', + ); + chmodSync(editor, 0o755); + const session = await harness.launchHunk({ + cwd: root, + args: ["diff", "--no-extensions", "--mode", "stack", "--", "alpha.txt"], + cols: 150, + rows: 30, + env: { EDITOR: editor, HUNK_TEST_EDITOR_LOG: record, XDG_CONFIG_HOME: config }, + }); + try { + await session.waitForText("changed source", { timeout: 15_000 }); + await session.press("e"); + await session.waitForText("Cannot map text-converted lines", { timeout: 5_000 }); + expect(existsSync(record)).toBe(false); + } finally { + session.close(); + } + }); + + test.each(["nvim", "micro"])( + "e maps selected lines through unstaged insertions in %s", + async (program) => { + const root = createTestWorkingTreeRepo(); + roots.push(root); + const source = Array.from({ length: 40 }, (_, index) => `line ${index + 1}\n`).join(""); + writeFileSync(join(root, "alpha.txt"), source); + runTestGit(root, "add", "alpha.txt"); + runTestGit(root, "commit", "-m", "Long source"); + const staged = source.replace("line 20\n", "selected change\n"); + writeFileSync(join(root, "alpha.txt"), staged); + runTestGit(root, "add", "alpha.txt"); + writeFileSync(join(root, "alpha.txt"), `insert one\ninsert two\ninsert three\n${staged}`); + writeFileSync(join(root, "beta.txt"), "selected beta\n"); + runTestGit(root, "add", "beta.txt"); + writeFileSync(join(root, "beta.txt"), "beta before\nbeta before two\nselected beta\n"); + const config = harness.createIsolatedConfigHome(); + const editor = join(config, program); + const record = join(config, "editor.jsonl"); + writeFileSync( + editor, + '#!/usr/bin/env bun\nimport { appendFileSync } from "node:fs"; appendFileSync(process.env.HUNK_TEST_EDITOR_LOG, JSON.stringify(process.argv.slice(2)) + "\\n");\n', + ); + chmodSync(editor, 0o755); + const session = await harness.launchHunk({ + cwd: root, + args: ["diff", "--staged", "--no-extensions", "--mode", "stack"], + cols: 150, + rows: 30, + env: { EDITOR: editor, HUNK_TEST_EDITOR_LOG: record, XDG_CONFIG_HOME: config }, + }); + const calls = () => + existsSync(record) + ? readFileSync(record, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)) + : []; + try { + await session.waitForText("selected change", { timeout: 15_000 }); + session.writeRaw("ee"); + await harness.waitForSnapshot(session, () => calls().length >= 1, 10_000); + expect(calls()).toEqual([["+23", join(root, "alpha.txt")]]); + await session.waitForText("line 17"); + const lines = session + .getTerminalData() + .lines.map((line) => line.spans.map((span) => span.text).join("")); + const row = lines.findIndex((line) => line.includes("line 17")); + const column = lines[row]!.indexOf("line 17") + 1; + session.writeRaw(`\x1b[<0;${column};${row + 1}M\x1b[<0;${column};${row + 1}m`); + await session.waitIdle(); + await session.press("e"); + await harness.waitForSnapshot(session, () => calls().length >= 2, 10_000); + expect(calls()[1]).toEqual(["+20", join(root, "alpha.txt")]); + // The editor records its arguments before the session restores and repaints the TUI. + await session.waitForText("alpha.txt"); + const headerLines = session + .getTerminalData() + .lines.map((line) => line.spans.map((span) => span.text).join("")); + const headerRow = headerLines.findIndex((line) => line.includes("alpha.txt")); + const headerCol = headerLines[headerRow]!.indexOf("alpha.txt") + 1; + session.writeRaw( + `\x1b[<0;${headerCol};${headerRow + 1}M\x1b[<0;${headerCol};${headerRow + 1}m`, + ); + await session.waitIdle(); + await session.press("e"); + await harness.waitForSnapshot(session, () => calls().length >= 3, 10_000); + expect(calls()[2]).toEqual(["+23", join(root, "alpha.txt")]); + session.writeRaw(".e"); + await harness.waitForSnapshot(session, () => calls().length >= 4, 10_000); + expect(calls()[3]).toEqual(["+3", join(root, "beta.txt")]); + } finally { + session.close(); + } + }, + ); +}); diff --git a/test/pty/extensions-integration.test.ts b/test/pty/extensions-integration.test.ts index 625847ad4..c9fde32fa 100644 --- a/test/pty/extensions-integration.test.ts +++ b/test/pty/extensions-integration.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; -import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { createPtyHarness, dragMouse, lineIndexOf } from "./harness"; @@ -681,8 +681,14 @@ describe("PTY extensions", () => { } }); - test("the files shortcut and View menu follow a named pane slot", async () => { + test("an explicit files shortcut and View menu follow a named pane slot", async () => { const configHome = harness.createIsolatedConfigHome(); + mkdirSync(join(configHome, "hunk")); + // Working-tree s defaults to stash; an explicit binding still owns its chosen key. + writeFileSync( + join(configHome, "hunk", "config.toml"), + '[keybindings]\n"hunk.view.toggleFilesPane" = "s"\n', + ); const fixture = harness.createRepoExtensionFixture(FILES_SLOT_EXTENSION_SOURCE); const session = await harness.launchHunk({ args: [ @@ -900,14 +906,14 @@ describe("PTY extensions", () => { await harness.ensureKeyboardIsLive(session); await session.press("c"); await session.waitForText(/Draft note/, { timeout: 5_000 }); - await session.type("Publish this exact note."); - await session.type("\x13"); + // The save key can share an input chunk with the final note characters. + session.writeRaw("Publish this exact note.\x13"); await session.waitForText(/Your note/, { timeout: 5_000 }); await session.press("f9"); await session.waitForText(/Export review snapshot/, { timeout: 5_000 }); - await session.type(outputPath); - await session.press("enter"); + // Input and acceptance may arrive together, before React paints the edited field. + session.writeRaw(`${outputPath}\r`); await session.waitForText(/Exported 1 saved note/, { timeout: 5_000 }); const snapshot = JSON.parse(readFileSync(outputPath, "utf8")) as { diff --git a/test/pty/harness.ts b/test/pty/harness.ts index 36cca8d6d..7fa4cedee 100644 --- a/test/pty/harness.ts +++ b/test/pty/harness.ts @@ -68,12 +68,19 @@ export function sleep(ms: number) { * Count how many rows one keypress moved the stream by following the text that * sat on a fixed screen row. * - * Positive means the content moved up (scrolled down); zero means the anchor - * row did not move at all. `press` already performs Tuistory's bounded idle - * wait, so another wait would only repeat the same readiness contract. + * Positive means the content moved up (scrolled down). Wait for the anchor to + * move: PTY output can be idle while the application still has a redraw queued. + * Cursor-mode callers wait for a repaint instead, since moving the current-line + * marker need not move the viewport. Both modes require a visible input effect. */ -export async function measureKeyScroll(session: Session, key: Key, anchorRow: number) { +export async function measureKeyScroll( + session: Session, + key: Key, + anchorRow: number, + waitFor: "scroll" | "paint" = "scroll", +) { const before = (await session.text({ immediate: true })).split("\n"); + const beforePaint = waitFor === "paint" ? JSON.stringify(session.getTerminalData().lines) : ""; const anchor = before[anchorRow]?.trim() ?? ""; if (anchor.length === 0) { throw new Error(`measureKeyScroll: anchor row ${anchorRow} is empty.`); @@ -81,7 +88,15 @@ export async function measureKeyScroll(session: Session, key: Key, anchorRow: nu await session.press(key); - const after = (await session.text({ immediate: true })).split("\n"); + const after = ( + await session.text({ + timeout: 5_000, + waitFor: (text) => + waitFor === "paint" + ? JSON.stringify(session.getTerminalData().lines) !== beforePaint + : text.split("\n").findIndex((line) => line.trim() === anchor) !== anchorRow, + }) + ).split("\n"); const movedTo = after.findIndex((line) => line.trim() === anchor); if (movedTo < 0) { throw new Error( diff --git a/test/pty/highlighting.test.ts b/test/pty/highlighting.test.ts index 93eeea5bd..15404fcf3 100644 --- a/test/pty/highlighting.test.ts +++ b/test/pty/highlighting.test.ts @@ -33,48 +33,52 @@ function visibleWorkerLineIndexes(snapshot: string) { } describe("PTY syntax highlighting", () => { - test("keeps key input responsive while a large added file highlights", async () => { - const fixture = createLargeHighlightTestFiles(); - const session = await harness.launchHunk({ - args: ["diff", "--files", fixture.before, fixture.after, "--fast", "--mode", "stack"], - cwd: fixture.dir, - cols: 120, - rows: 24, - }); - - try { - await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, { timeout: 15_000 }); - const initial = await session.waitForText(/export const workerLine\d+ = \d+;/, { - timeout: 15_000, + test.each(["stack", "split"])( + "keeps key input responsive while a large added file highlights in %s", + async (mode) => { + const fixture = createLargeHighlightTestFiles(); + const session = await harness.launchHunk({ + args: ["diff", "--files", fixture.before, fixture.after, "--fast", "--mode", mode], + cwd: fixture.dir, + cols: 120, + rows: 24, }); - const lastInitialLineIndex = Math.max(...visibleWorkerLineIndexes(initial)); - expect(lastInitialLineIndex).toBeGreaterThanOrEqual(0); - // Effects schedule highlighting after the first plain-text paint. Inject input as soon as - // source rows make that paint observable, then require Hunk to process the navigation. - // Tuistory's press() waits up to 500ms for idleness, so observe the viewport instead. - session.sendKey("pagedown"); - await harness.waitForSnapshot( - session, - (snapshot) => - visibleWorkerLineIndexes(snapshot).some((index) => index > lastInitialLineIndex), - 1_000, - ); + try { + await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, { timeout: 15_000 }); + const initial = await session.waitForText(/export const workerLine\d+ = \d+;/, { + timeout: 15_000, + }); + const lastInitialLineIndex = Math.max(...visibleWorkerLineIndexes(initial)); + expect(lastInitialLineIndex).toBeGreaterThanOrEqual(0); - let colored = ""; - for (let iteration = 0; iteration < 200; iteration += 1) { - await session.waitIdle({ timeout: 50 }); - colored = await session.text({ immediate: true, only: { foreground: "#ff7b72" } }); - if (colored.includes("export")) { - break; + // Effects schedule highlighting after the first plain-text paint. Inject input as soon as + // source rows make that paint observable, then require Hunk to process the navigation. + // The first mount must already be windowed so tearing down offscreen rows cannot stall input. + // Tuistory's press() waits up to 500ms for idleness, so observe the viewport instead. + session.sendKey("pagedown"); + await harness.waitForSnapshot( + session, + (snapshot) => + visibleWorkerLineIndexes(snapshot).some((index) => index > lastInitialLineIndex), + 1_000, + ); + + let colored = ""; + for (let iteration = 0; iteration < 200; iteration += 1) { + await session.waitIdle({ timeout: 50 }); + colored = await session.text({ immediate: true, only: { foreground: "#ff7b72" } }); + if (colored.includes("export")) { + break; + } } + expect(colored).toContain("export"); + } finally { + session.close(); + rmSync(fixture.dir, { recursive: true, force: true }); } - expect(colored).toContain("export"); - } finally { - session.close(); - rmSync(fixture.dir, { recursive: true, force: true }); - } - }); + }, + ); test("keeps code after a hidden Elixir heredoc opener out of the string token state", async () => { const fixture = harness.createElixirHeredocRepoFixture(); diff --git a/test/pty/layout.test.ts b/test/pty/layout.test.ts index 2692bed80..e56123d0e 100644 --- a/test/pty/layout.test.ts +++ b/test/pty/layout.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { getTestSidebarDividerColumn, getTestSidebarFrame } from "../helpers/sidebar-frame"; import stringWidth from "string-width"; import { createPtyHarness, dragMouse, rightmostColumnOf, sleep } from "./harness"; @@ -11,24 +12,6 @@ afterEach(() => { harness.cleanup(); }); -/** Locate the left pane divider from a rendered terminal frame. */ -function sidebarDividerColumn(frame: string) { - const columns = frame - .split("\n") - .map((line) => line.indexOf("│")) - .filter((column) => column >= 0); - return columns.length === 0 ? -1 : Math.min(...columns); -} - -/** Return the rendered columns owned by the left sidebar. */ -function sidebarFrame(frame: string) { - const divider = sidebarDividerColumn(frame); - return frame - .split("\n") - .map((line) => line.slice(0, divider)) - .join("\n"); -} - describe("PTY layout", () => { test("the first frame fills the viewport bottom with the next file section", async () => { // The first review frame must fill the whole viewport: when the leading file overflows just @@ -366,13 +349,13 @@ describe("PTY layout", () => { }); expect(harness.countMatches(wide, /alpha\.ts/g)).toBeGreaterThanOrEqual(2); - expect(sidebarFrame(wide)).not.toContain("src/ui/"); + expect(getTestSidebarFrame(wide)).not.toContain("src/ui/"); expect(wide).toMatch(/▌.*▌/); session.resize({ cols: 180, rows: 24 }); const medium = await harness.waitForSnapshot( session, - (text) => sidebarFrame(text).includes("src/ui/"), + (text) => getTestSidebarFrame(text).includes("src/ui/"), 5_000, ); expect(harness.countMatches(medium, /alpha\.ts/g)).toBeGreaterThanOrEqual(2); @@ -458,7 +441,7 @@ describe("PTY layout", () => { timeout: 15_000, }); const initialMainColumn = rightmostColumnOf(initial, "alpha.ts"); - const initialDividerColumn = sidebarDividerColumn(initial); + const initialDividerColumn = getTestSidebarDividerColumn(initial); expect(initialDividerColumn).toBeGreaterThan(0); expect(initialMainColumn).toBeGreaterThan(initialDividerColumn); @@ -491,7 +474,7 @@ describe("PTY layout", () => { timeout: 15_000, }); const initialMainColumn = rightmostColumnOf(initial, "alpha.ts"); - const initialDividerColumn = sidebarDividerColumn(initial); + const initialDividerColumn = getTestSidebarDividerColumn(initial); const pressColumn = initialDividerColumn - 2; const projectionSwitchColumn = initialDividerColumn - 4; @@ -540,7 +523,7 @@ describe("PTY layout", () => { const initial = await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, { timeout: 15_000, }); - const initialDividerColumn = sidebarDividerColumn(initial); + const initialDividerColumn = getTestSidebarDividerColumn(initial); const compactDividerColumn = initialDividerColumn - 2; const initialSidebar = initial .split("\n") @@ -555,7 +538,7 @@ describe("PTY layout", () => { .split("\n") .find((line) => line.includes("src/")) ?.indexOf("src/"), - ).toBe(4); + ).toBe(5); await dragMouse(session, initialDividerColumn - 2, 6, initialDividerColumn - 4, 6); const resized = await harness.waitForSnapshot( @@ -579,7 +562,7 @@ describe("PTY layout", () => { .split("\n") .find((line) => line.includes("src/ui/")) ?.indexOf("src/ui/"), - ).toBe(2); + ).toBe(3); expect(resizedSidebar).toContain("alpha.ts"); expect(resizedSidebar).toContain("beta.ts"); } finally { diff --git a/test/pty/nav.test.ts b/test/pty/nav.test.ts index 250af9ee2..65e0c43fd 100644 --- a/test/pty/nav.test.ts +++ b/test/pty/nav.test.ts @@ -58,7 +58,7 @@ describe("PTY navigation", () => { const session = await harness.launchHunk({ args: ["diff", "--files", fixture.before, fixture.after, "--mode", "split"], cols: 104, - rows: 12, + rows: 13, }); try { @@ -136,7 +136,7 @@ describe("PTY navigation", () => { const session = await harness.launchHunk({ args: ["diff", "--files", fixture.before, fixture.after, "--mode", "split"], cols: 104, - rows: 12, + rows: 13, }); try { @@ -220,7 +220,7 @@ describe("PTY navigation", () => { args: ["diff", "--mode", "split"], cwd: fixture.dir, cols: 220, - rows: 12, + rows: 13, }); try { diff --git a/test/pty/notes.test.ts b/test/pty/notes.test.ts index a3b6b5036..387532c4f 100644 --- a/test/pty/notes.test.ts +++ b/test/pty/notes.test.ts @@ -19,6 +19,51 @@ afterEach(() => { }); describe("PTY notes", () => { + test("trailing input after a save key cannot update a consumed draft", async () => { + const fixture = harness.createAgentFilePair(); + const session = await harness.launchHunk({ + args: ["diff", "--files", fixture.before, fixture.after, "--mode", "stack"], + cols: 140, + rows: 20, + }); + try { + await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, { timeout: 15_000 }); + await harness.ensureKeyboardIsLive(session); + await session.press("c"); + await session.waitForText(/Draft note/); + session.writeRaw("Saved note.\x13!\x13"); + await session.waitForText(/Your note/); + await session.press("?"); + const help = await session.waitForText(/Controls help/); + expect(help).not.toContain("Console (Focused)"); + } finally { + session.close(); + } + }); + + test("cancelling in the same input burst as typing does not leave an editor error", async () => { + const fixture = harness.createAgentFilePair(); + const session = await harness.launchHunk({ + args: ["diff", "--files", fixture.before, fixture.after, "--mode", "stack"], + cols: 140, + rows: 20, + }); + try { + await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, { timeout: 15_000 }); + await harness.ensureKeyboardIsLive(session); + await session.press("c"); + await session.waitForText(/Draft note/); + session.writeRaw("Discard this draft.\x1b"); + await harness.waitForSnapshot(session, (text) => !text.includes("Draft note")); + await session.press("?"); + const help = await session.waitForText(/Controls help/); + expect(help).not.toContain("Console (Focused)"); + expect(help).not.toContain("Your note"); + } finally { + session.close(); + } + }); + test("agent notes can be revealed and hidden in the live diff UI", async () => { const fixture = harness.createAgentFilePair(); const session = await harness.launchHunk({ diff --git a/test/pty/scroll.test.ts b/test/pty/scroll.test.ts index f6c4fbbf3..e921e3966 100644 --- a/test/pty/scroll.test.ts +++ b/test/pty/scroll.test.ts @@ -85,6 +85,7 @@ describe("PTY scrolling", () => { const initial = await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, { timeout: 15_000, }); + await harness.ensureKeyboardIsLive(session); await session.waitIdle({ timeout: 300 }); expect(await measureKeyScroll(session, "j", 12)).toBe(1); @@ -167,7 +168,7 @@ describe("PTY scrolling", () => { args: ["diff", "--mode", "split"], cwd: fixture.dir, cols: 220, - rows: 10, + rows: 11, }); try { @@ -329,7 +330,7 @@ describe("PTY scrolling", () => { args: ["diff", "--mode", "split"], cwd: fixture.dir, cols: 220, - rows: 10, + rows: 11, }); try { @@ -360,7 +361,7 @@ describe("PTY scrolling", () => { args: ["diff", "--mode", "split"], cwd: fixture.dir, cols: 220, - rows: 10, + rows: 11, }); try { diff --git a/test/pty/working-tree-integration.test.ts b/test/pty/working-tree-integration.test.ts new file mode 100644 index 000000000..1516d7912 --- /dev/null +++ b/test/pty/working-tree-integration.test.ts @@ -0,0 +1,581 @@ +import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { createTestWorkingTreeRepo, runTestGit } from "../helpers/working-tree"; +import { createPtyHarness } from "./harness"; +import type { Session } from "tuistory"; + +const harness = createPtyHarness(); +const roots: string[] = []; +setDefaultTimeout(30_000); +afterEach(() => { + harness.cleanup(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +/** Create a mixed-status fixture without touching the checkout under development. */ +function createFixture() { + const root = createTestWorkingTreeRepo(); + roots.push(root); + writeFileSync(join(root, "alpha.txt"), "one\nchanged alpha\nthree\n"); + writeFileSync(join(root, "beta.txt"), "staged beta\n"); + runTestGit(root, "add", "beta.txt"); + return root; +} + +/** Send a double-click as one input burst at an actual sidebar row, without text's leading newline. */ +function sidebarFileClick(session: Session, path: string) { + const lines = session + .getTerminalData() + .lines.map((line) => line.spans.map((span) => span.text).join("")); + const row = lines.findIndex((line, index) => index > 2 && line.slice(0, 35).includes(path)); + expect(row).toBeGreaterThan(2); + const col = lines[row]!.indexOf(path) + 1; + const click = `\x1b[<0;${col};${row + 1}M\x1b[<0;${col};${row + 1}m`; + return click; +} + +/** Deliver consecutive clicks without an intervening wait or render. */ +function doubleClickSidebarFile(session: Session, path: string) { + const click = sidebarFileClick(session, path); + session.writeRaw(click + click); +} + +/** Give the files pane keyboard ownership so discard and stash are in scope. */ +async function focusFilesPane(session: Session) { + await harness.ensureKeyboardIsLive(session); + await session.press(","); +} + +describe("PTY working-tree staging", () => { + test("raw navigation bursts stage the latest file and hunk without an intervening frame", async () => { + const root = createFixture(); + const original = Array.from({ length: 40 }, (_, index) => `line ${index + 1}\n`).join(""); + writeFileSync(join(root, "alpha.txt"), original); + runTestGit(root, "add", "alpha.txt"); + runTestGit(root, "commit", "--only", "-m", "Long alpha", "--", "alpha.txt"); + writeFileSync( + join(root, "alpha.txt"), + original.replace("line 2\n", "first change\n").replace("line 35\n", "second change\n"), + ); + writeFileSync(join(root, "beta.txt"), "unstaged beta\n"); + const session = await harness.launchHunk({ + cwd: root, + args: ["diff", "--sidebar", "--no-extensions", "--mode", "stack"], + cols: 150, + rows: 30, + }); + try { + await session.waitForText("first change", { timeout: 15_000 }); + await harness.ensureKeyboardIsLive(session); + session.writeRaw(",. "); + await session.waitForText("Staged beta.txt."); + expect(runTestGit(root, "show", ":beta.txt")).toBe("unstaged beta\n"); + expect(runTestGit(root, "show", ":alpha.txt")).toBe(original); + await session.press("tab"); + await session.waitForText("first change"); + await session.press(","); + session.writeRaw("] "); + await session.waitForText("Staged hunk 2 in alpha.txt."); + expect(runTestGit(root, "show", ":alpha.txt")).toBe( + original.replace("line 35\n", "second change\n"), + ); + session.writeRaw(", "); + await session.waitForText("Staged alpha.txt."); + expect(runTestGit(root, "diff", "--", "alpha.txt")).toBe(""); + } finally { + session.close(); + } + }); + + test("file quick actions yield to built-ins outside the visible file panel's scope", async () => { + const root = createFixture(); + const before = readFileSync(join(root, "alpha.txt"), "utf8"); + const session = await harness.launchHunk({ + cwd: root, + args: ["diff", "--no-sidebar", "--no-extensions", "--mode", "stack"], + cols: 150, + rows: 30, + }); + try { + await session.waitForText("changed alpha", { timeout: 15_000 }); + await session.press("d"); + expect(await session.text()).not.toContain("Discard changes"); + await session.press("s"); + await harness.waitForSnapshot(session, (text) => /M\s+alpha\.txt/.test(text), 5_000); + expect(await session.text()).not.toContain("Stash selected file"); + + // Hunk navigation leaves the sidebar visible but gives review shortcuts ownership. + await session.press("]"); + await session.press("d"); + expect(await session.text()).not.toContain("Discard changes"); + await session.press("s"); + await harness.waitForSnapshot(session, (text) => !/M\s+alpha\.txt/.test(text), 5_000); + await session.press("s"); + await harness.waitForSnapshot(session, (text) => /M\s+alpha\.txt/.test(text), 5_000); + + session.writeRaw(sidebarFileClick(session, "alpha.txt")); + await session.waitIdle(); + await session.press("s"); + await session.waitForText("Stash selected file"); + await session.press("escape"); + await session.press("d"); + await session.waitForText("Discard changes"); + await session.press("escape"); + + expect(readFileSync(join(root, "alpha.txt"), "utf8")).toBe(before); + expect(runTestGit(root, "stash", "list")).toBe(""); + } finally { + session.close(); + } + }); + + test("the sidebar changes an untracked marker into a staged addition", async () => { + const root = createTestWorkingTreeRepo(); + roots.push(root); + writeFileSync(join(root, "aaa-new.txt"), "new file content\n"); + const session = await harness.launchHunk({ + cwd: root, + args: ["diff", "--sidebar", "--no-extensions", "--mode", "stack"], + cols: 150, + rows: 30, + }); + try { + await session.waitForText("new file content", { timeout: 15_000 }); + await harness.waitForSnapshot(session, (text) => /\?\?\s+aaa-new\.txt/.test(text), 5_000); + await session.press("space"); + await harness.waitForSnapshot( + session, + (text) => /A\s+aaa-new\.txt/.test(text) && !/\?\?\s+aaa-new\.txt/.test(text), + 10_000, + ); + expect(runTestGit(root, "show", ":aaa-new.txt")).toBe("new file content\n"); + await session.press("space"); + await harness.waitForSnapshot(session, (text) => /\?\?\s+aaa-new\.txt/.test(text), 10_000); + expect(readFileSync(join(root, "aaa-new.txt"), "utf8")).toBe("new file content\n"); + } finally { + session.close(); + } + }); + + test("folder clicks toggle collapse without staging and Space stages and unstages nested files", async () => { + const root = createTestWorkingTreeRepo(); + roots.push(root); + mkdirSync(join(root, "src", "nested"), { recursive: true }); + writeFileSync(join(root, "src", "one.ts"), "one\n"); + writeFileSync(join(root, "src", "nested", "two.ts"), "two\n"); + const session = await harness.launchHunk({ + cwd: root, + args: ["diff", "--sidebar", "--no-extensions", "--mode", "stack"], + cols: 220, + rows: 30, + }); + try { + await session.waitForText("one.ts", { timeout: 15_000 }); + const lines = session + .getTerminalData() + .lines.map((line) => line.spans.map((span) => span.text).join("")); + const row = lines.findIndex((line, index) => { + if (index <= 2) return false; + const parts = line.split("│"); + const sidebar = parts.length >= 3 ? (parts[1] ?? "") : (parts[0] ?? ""); + return sidebar.includes("src/") && !sidebar.includes("nested") && !sidebar.includes(".ts"); + }); + expect(row).toBeGreaterThan(2); + const col = Math.max(2, lines[row]!.indexOf("src/") + 1); + const click = `\x1b[<0;${col};${row + 1}M\x1b[<0;${col};${row + 1}m`; + session.writeRaw(click); + await session.waitForText("Stage folder"); + await session.waitForText("2 files"); + expect(runTestGit(root, "diff", "--cached", "--name-only")).toBe(""); + session.writeRaw(click + click); + await session.waitForText("2 files"); + expect(runTestGit(root, "diff", "--cached", "--name-only")).toBe(""); + await session.press("space"); + await session.waitForText("Unstage folder"); + expect(runTestGit(root, "show", ":src/one.ts")).toBe("one\n"); + expect(runTestGit(root, "show", ":src/nested/two.ts")).toBe("two\n"); + await session.press("space"); + await session.waitForText("Stage folder"); + expect(runTestGit(root, "diff", "--cached", "--name-only")).toBe(""); + } finally { + session.close(); + } + }); + + test("discard offers exact-file choices, cancels safely, and preserves staged content with u", async () => { + const root = createFixture(); + runTestGit(root, "add", "alpha.txt"); + writeFileSync(join(root, "alpha.txt"), "later alpha\n"); + const session = await harness.launchHunk({ + cwd: root, + args: ["diff", "--sidebar", "--no-extensions", "--mode", "stack"], + cols: 150, + rows: 30, + }); + try { + await session.waitForText("later alpha", { timeout: 15_000 }); + await harness.ensureKeyboardIsLive(session); + session.writeRaw(",d"); + await session.waitForText("Discard changes"); + await session.press("escape"); + expect(readFileSync(join(root, "alpha.txt"), "utf8")).toBe("later alpha\n"); + await session.press("d"); + await session.waitForText("unstaged only"); + await session.press("u"); + await session.waitForText("Discarded unstaged changes in alpha.txt."); + expect(readFileSync(join(root, "alpha.txt"), "utf8")).toContain("changed alpha"); + await session.press("d"); + await session.waitForText("Discard changes"); + const lines = session + .getTerminalData() + .lines.map((line) => line.spans.map((span) => span.text).join("")); + const row = lines.findIndex((line) => line.includes("discard all")); + const col = lines[row]!.indexOf("discard all") + 1; + for (const button of [1, 2]) { + session.writeRaw(`\x1b[<${button};${col};${row + 1}M\x1b[<${button};${col};${row + 1}m`); + await session.waitIdle(); + expect(readFileSync(join(root, "alpha.txt"), "utf8")).toContain("changed alpha"); + expect(await session.text()).toContain("Discard changes"); + } + await session.click("discard all"); + await session.waitForText("Discarded all changes in alpha.txt."); + expect(runTestGit(root, "diff", "HEAD", "--", "alpha.txt")).toBe(""); + expect(runTestGit(root, "show", ":beta.txt")).toBe("staged beta\n"); + } finally { + session.close(); + } + }); + + test("compact discard keeps every available choice and cancellation visible", async () => { + const root = createFixture(); + runTestGit(root, "add", "alpha.txt"); + writeFileSync(join(root, "alpha.txt"), "later alpha\n"); + const session = await harness.launchHunk({ + cwd: root, + args: ["diff", "--sidebar", "--no-extensions", "--mode", "stack"], + cols: 150, + rows: 18, + }); + try { + await session.waitForText("later alpha", { timeout: 15_000 }); + await focusFilesPane(session); + await session.press("d"); + await session.waitForText("Discard changes"); + session.resize({ cols: 40, rows: 18 }); + await session.waitIdle(); + const frame = await session.waitForText("Discard changes"); + expect(frame).toContain("alpha.txt"); + expect(frame).toContain("x all"); + expect(frame).toContain("u unstaged"); + expect(frame).toContain("esc"); + await session.press("escape"); + expect(readFileSync(join(root, "alpha.txt"), "utf8")).toBe("later alpha\n"); + } finally { + session.close(); + } + }); + + test("compact confirmation preserves filename spaces and keeps the warning visible", async () => { + for (const path of ["a b.txt", `long-${"x".repeat(180)}.txt`]) { + const root = createFixture(); + writeFileSync(join(root, path), "new spaced file\n"); + const session = await harness.launchHunk({ + cwd: root, + args: ["diff", "--sidebar", "--no-extensions", "--mode", "stack", "--", path], + cols: 150, + rows: 14, + }); + try { + await session.waitForText("new spaced file", { timeout: 15_000 }); + await focusFilesPane(session); + await session.press("d"); + await session.waitForText("Discard changes"); + session.resize({ cols: 40, rows: 14 }); + await session.waitIdle(); + const frame = await session.waitForText("Discard changes"); + if (path === "a b.txt") expect(frame).toContain('"a b.txt"'); + else expect(frame).toContain("…"); + expect(frame).toContain("Cannot undo"); + await session.press("escape"); + expect(readFileSync(join(root, path), "utf8")).toBe("new spaced file\n"); + } finally { + session.close(); + } + } + }); + + test("stash message input keeps command letters as text and stashes only the selected file", async () => { + const root = createFixture(); + const session = await harness.launchHunk({ + cwd: root, + args: ["diff", "--sidebar", "--no-extensions", "--mode", "stack"], + cols: 150, + rows: 30, + }); + try { + await session.waitForText("changed alpha", { timeout: 15_000 }); + await focusFilesPane(session); + await session.press("s"); + await session.waitForText("Stash selected file"); + await session.press("escape"); + expect(runTestGit(root, "stash", "list")).toBe(""); + await session.press("s"); + await session.waitForText("Stash selected file"); + session.writeRaw("saved draft d s q"); + await session.waitForText("saved draft d s q"); + await session.press("enter"); + await session.waitForText("Stashed alpha.txt."); + expect(runTestGit(root, "log", "-1", "--format=%s", "stash")).toContain("saved draft d s q"); + expect(runTestGit(root, "diff", "--name-only", "stash^1", "stash").trim()).toBe("alpha.txt"); + expect(runTestGit(root, "show", ":beta.txt")).toBe("staged beta\n"); + } finally { + session.close(); + } + }); + + test("double-clicking a hunk without a mutation capability still copies a word", async () => { + const root = createFixture(); + const index = runTestGit(root, "ls-files", "--stage"); + const configHome = harness.createIsolatedConfigHome(); + const extension = join(configHome, "inventory.ts"); + const result = { + repoRoot: root, + sourceLabel: root, + title: "Read-only inventory", + patchText: runTestGit(root, "diff", "--", "alpha.txt"), + workingTreeFiles: [ + { + path: "alpha.txt", + staged: false, + unstaged: true, + untracked: false, + conflicted: false, + version: "test-source", + }, + ], + }; + writeFileSync( + extension, + `export default (hunk) => hunk.registerVcsAdapter({ + id: "inventory", name: "Inventory", detectionPriority: 1000, + detect: () => ({ id: "inventory", repoRoot: ${JSON.stringify(root)} }), + operations: { "working-tree-diff": { load: async () => (${JSON.stringify(result)}) } } + });`, + ); + const session = await harness.launchHunk({ + cwd: root, + args: ["diff", "--extension", extension, "--mode", "stack"], + env: { XDG_CONFIG_HOME: configHome }, + cols: 150, + rows: 30, + }); + try { + await session.waitForText("changed alpha", { timeout: 15_000 }); + const lines = session + .getTerminalData() + .lines.map((line) => line.spans.map((span) => span.text).join("")); + const row = lines.findIndex((line) => line.includes("changed alpha")); + const col = lines[row]!.indexOf("changed alpha") + 10; + const click = `\x1b[<0;${col};${row + 1}M\x1b[<0;${col};${row + 1}m`; + session.writeRaw(click + click); + await session.waitForText("Copied selection to clipboard", { timeout: 5_000 }); + expect(runTestGit(root, "ls-files", "--stage")).toBe(index); + } finally { + session.close(); + } + }); + + test("double-clicking a changed line stages only its hunk", async () => { + const root = createFixture(); + const original = Array.from({ length: 40 }, (_, index) => `line ${index + 1}\n`).join(""); + writeFileSync(join(root, "alpha.txt"), original); + runTestGit(root, "add", "alpha.txt"); + runTestGit(root, "commit", "--only", "-m", "Long alpha", "--", "alpha.txt"); + writeFileSync( + join(root, "alpha.txt"), + original.replace("line 2\n", "first change\n").replace("line 35\n", "second change\n"), + ); + const session = await harness.launchHunk({ + cwd: root, + args: ["diff", "--sidebar", "--no-extensions", "--mode", "stack"], + cols: 220, + rows: 35, + }); + try { + await session.waitForText("second change", { timeout: 15_000 }); + const lines = session + .getTerminalData() + .lines.map((line) => line.spans.map((span) => span.text).join("")); + const row = lines.findIndex((line) => line.includes("second change")); + const column = lines[row]!.indexOf("second change") + 1; + const click = `\x1b[<0;${column};${row + 1}M\x1b[<0;${column};${row + 1}m`; + // A second press must not mutate before release, and dragging must remain copy-only. + const down = `\x1b[<0;${column};${row + 1}M`; + session.writeRaw(click + down); + await session.waitIdle(); + expect(runTestGit(root, "show", ":alpha.txt")).toBe(original); + session.writeRaw(`\x1b[<32;${column + 5};${row + 1}M\x1b[<0;${column + 5};${row + 1}m`); + await session.waitIdle(); + expect(runTestGit(root, "show", ":alpha.txt")).toBe(original); + // Break the prior click sequence before the ordinary double-click. + session.writeRaw("\x1b[<0;4;2M\x1b[<0;4;2m" + click + click); + await session.waitForText("Staged hunk 2 in alpha.txt.", { timeout: 10_000 }); + expect(runTestGit(root, "show", ":alpha.txt")).toBe( + original.replace("line 35\n", "second change\n"), + ); + expect(runTestGit(root, "show", ":beta.txt")).toBe("staged beta\n"); + } finally { + session.close(); + } + }); + + test("a hunk-header click leaves file quick actions", async () => { + const root = createFixture(); + const session = await harness.launchHunk({ + cwd: root, + args: ["diff", "--sidebar", "--no-extensions", "--mode", "stack"], + cols: 150, + rows: 30, + }); + try { + await session.waitForText("changed alpha", { timeout: 15_000 }); + await focusFilesPane(session); + await session.press("d"); + await session.waitForText("Discard changes"); + await session.press("escape"); + const lines = session + .getTerminalData() + .lines.map((line) => line.spans.map((span) => span.text).join("")); + const row = lines.findIndex((line) => line.includes("@@")); + expect(row).toBeGreaterThan(1); + const column = lines[row]!.indexOf("@@") + 1; + session.writeRaw(`\x1b[<0;${column};${row + 1}M\x1b[<0;${column};${row + 1}m`); + await session.waitIdle(); + await session.press("d"); + expect(await session.text()).not.toContain("Discard changes"); + await session.press("s"); + await harness.waitForSnapshot(session, (text) => !/M\s+alpha\.txt/.test(text), 5_000); + expect(await session.text()).not.toContain("Stash selected file"); + } finally { + session.close(); + } + }); + + test("a file header returns Space from hunk scope to whole-file scope", async () => { + const root = createFixture(); + const original = Array.from({ length: 40 }, (_, index) => `line ${index + 1}\n`).join(""); + writeFileSync(join(root, "alpha.txt"), original); + runTestGit(root, "add", "alpha.txt"); + runTestGit(root, "commit", "--only", "-m", "Long alpha", "--", "alpha.txt"); + const changed = original + .replace("line 2\n", "first change\n") + .replace("line 35\n", "second change\n"); + writeFileSync(join(root, "alpha.txt"), changed); + const session = await harness.launchHunk({ + cwd: root, + args: ["diff", "--sidebar", "--no-extensions", "--mode", "stack"], + cols: 220, + rows: 35, + }); + try { + await session.waitForText("second change", { timeout: 15_000 }); + await session.press("]"); + await session.waitForText("Stage hunk"); + const lines = session + .getTerminalData() + .lines.map((line) => line.spans.map((span) => span.text).join("")); + const row = lines.findIndex((line) => line.indexOf("alpha.txt", 35) >= 35); + expect(row).toBeGreaterThan(1); + const column = lines[row]!.indexOf("alpha.txt", 35) + 1; + session.writeRaw(`\x1b[<0;${column};${row + 1}M\x1b[<0;${column};${row + 1}m`); + await session.waitForText("Stage file"); + // A diff header selects whole-file staging, not sidebar quick-action focus. + await session.press("d"); + expect(await session.text()).not.toContain("Discard changes"); + await session.press("s"); + await harness.waitForSnapshot(session, (text) => !/M\s+alpha\.txt/.test(text), 5_000); + expect(await session.text()).not.toContain("Stash selected file"); + await session.press("s"); + await harness.waitForSnapshot(session, (text) => /M\s+alpha\.txt/.test(text), 5_000); + await session.press("space"); + await session.waitForText("Staged alpha.txt."); + expect(runTestGit(root, "show", ":alpha.txt")).toBe(changed); + } finally { + session.close(); + } + }); + + test("rapid clicks on alternating files remain selection-only", async () => { + const root = createFixture(); + runTestGit(root, "restore", "--staged", "beta.txt"); + const session = await harness.launchHunk({ + cwd: root, + args: ["diff", "--sidebar", "--no-extensions", "--mode", "stack"], + cols: 220, + rows: 30, + }); + try { + await session.waitForText("Unstaged (2)", { timeout: 15_000 }); + const alpha = sidebarFileClick(session, "alpha.txt"); + const beta = sidebarFileClick(session, "beta.txt"); + session.writeRaw(alpha + beta + alpha); + await session.waitIdle(); + expect(runTestGit(root, "diff", "--cached")).toBe(""); + // The active tab is a non-file click even when it does not cause a reload. + const tab = "\x1b[<0;4;2M\x1b[<0;4;2m"; + session.writeRaw(beta + alpha + tab + alpha); + await session.waitIdle(); + expect(runTestGit(root, "diff", "--cached")).toBe(""); + const diff = "\x1b[<0;60;8M\x1b[<0;60;8m"; + session.writeRaw(beta + alpha + diff + alpha); + await session.waitIdle(); + expect(runTestGit(root, "diff", "--cached")).toBe(""); + } finally { + session.close(); + } + }); + + test("double-clicking an inactive-side file finishes staging after its selection switches tabs", async () => { + const root = createFixture(); + const session = await harness.launchHunk({ + cwd: root, + args: ["diff", "--sidebar", "--no-extensions", "--mode", "stack"], + cols: 220, + rows: 30, + }); + try { + await session.waitForText("Unstaged (1)", { timeout: 15_000 }); + doubleClickSidebarFile(session, "beta.txt"); + await session.waitForText("Unstaged beta.txt.", { timeout: 10_000 }); + expect(runTestGit(root, "diff", "--cached")).toBe(""); + } finally { + session.close(); + } + }); + test("Space and mouse double-click toggle a file and keep both status sides visible", async () => { + const root = createFixture(); + const session = await harness.launchHunk({ + cwd: root, + args: ["diff", "--sidebar", "--no-extensions", "--mode", "stack"], + cols: 220, + rows: 30, + }); + try { + await session.waitForText("Unstaged (1)", { timeout: 15_000 }); + expect(await session.text()).toContain("beta.txt"); + await session.press("space"); + await session.waitForText("Staged alpha.txt.", { timeout: 10_000 }); + expect(runTestGit(root, "show", ":alpha.txt")).toContain("changed alpha"); + doubleClickSidebarFile(session, "alpha.txt"); + await session.waitForText("Unstaged alpha.txt.", { timeout: 10_000 }); + expect(runTestGit(root, "diff", "--cached", "--name-only").trim()).toBe("beta.txt"); + await session.click(/Staged \(1\)/); + await session.waitForText("staged beta", { timeout: 10_000 }); + await session.press("tab"); + await session.waitForText("changed alpha", { timeout: 10_000 }); + } finally { + session.close(); + } + }); +}); diff --git a/test/review-conformance/consumers/coreModel.ts b/test/review-conformance/consumers/coreModel.ts index e0f317833..9a64beda1 100644 --- a/test/review-conformance/consumers/coreModel.ts +++ b/test/review-conformance/consumers/coreModel.ts @@ -96,7 +96,7 @@ export const coreModelConsumer: ReviewGeometryConsumer = { path: file.path, gaps: gapsOf(file), hunkRanges: file.hunks.map(reviewHunkRanges), - defaultNoteTargets: file.hunks.map(reviewDefaultHunkLineTarget), + defaultNoteTargets: file.hunks.map((hunk) => reviewDefaultHunkLineTarget(hunk)), ...(file.hunks.length === 0 ? { emptyDiffReason: reviewEmptyDiffReason({ diff --git a/website/src/content/docs/docs/extend/custom-sidebars.md b/website/src/content/docs/docs/extend/custom-sidebars.md index b5d8789b1..a9f1551e0 100644 --- a/website/src/content/docs/docs/extend/custom-sidebars.md +++ b/website/src/content/docs/docs/extend/custom-sidebars.md @@ -5,6 +5,13 @@ description: Render React panes around Hunk's review stream. `hunk.registerPane(pane)` renders a React component on the left, right, top, or bottom of the review. Pair it with [`registerCommand`](/docs/extend/extension-api/#hunkregistercommandcommand-handler) so a key opens it: +Mounted panes may receive optional `props.workingTree`: the complete status inventory for both staged and +unstaged files, `selectedPath`, `staged`, and `busy`. Use `selectFile(path)` to navigate a path and +switch tabs when necessary, or `toggleStaged(path)` to stage remaining changes or unstage a fully +staged file. API v25 adds `selectedEntryId`, `selectEntry(id)`, and `toggleEntry(id)` so a pane can +highlight and mutate folder rows as well as files. Retained controls expire on review reload. Ordinary `props.files` and navigation +continue to describe the active diff stream; the status inventory does not fabricate diff files. + ```tsx // ~/.config/hunk/extensions/flat-sidebar.tsx import { useMemo } from "react"; diff --git a/website/src/content/docs/docs/extend/vcs-adapters.md b/website/src/content/docs/docs/extend/vcs-adapters.md index 7a4522245..16ae90332 100644 --- a/website/src/content/docs/docs/extend/vcs-adapters.md +++ b/website/src/content/docs/docs/extend/vcs-adapters.md @@ -40,6 +40,46 @@ A `load` result is patch text plus how to label it. Everything else on it is opt `untrackedPaths` is the shorthand: list the repo-root-relative paths your VCS reports as unknown and Hunk synthesizes the added-file diffs for you, skipping binaries and files too large to render. Honor `input.options.excludeUntracked` when you do, so `--exclude-untracked` still means what it says. The other two are covered below. +## Working-tree status and staging + +Working-tree `load` results may include `workingTreeFiles`: ordered status entries with `path`, +optional `previousPath`, independent `staged`/`unstaged` flags, `untracked`, `conflicted`, an opaque +`version`, and an optional `unavailableReason`. The inventory covers both stream tabs, not only +the side selected by `input.staged`; omit it for revision comparisons. + +The same operation may implement `stageFile(input, file, ctx)` and `unstageFile(input, file, ctx)`. +Validate the exact path and attestation before writing, preserve disk contents when unstaging, +and throw `HunkExtensionUserError` for failures. The host tracks started writes through shutdown +and refreshes status and diff before allowing another action. These are terminal-host actions, +not implicit remote capabilities. + +API v25 adds `stageHunk(input, file, hunk, ctx)` and `unstageHunk(input, file, hunk, ctx)`. +The target uses `ExtensionDiffHunk`'s numbered summary. Recover and validate the canonical provider +patch and comparison base before applying original bytes, not text-converted output; leave other hunks and worktree bytes untouched. These actions address +the active stream side, even when the file has both staged and unstaged changes. + +API v25 adds `discardFile(input, file, scope, ctx)` and `stashFile(input, file, message, ctx)`. +The host confirms one exact file; discard scope is `"all"` or `"unstaged"`. Revalidate the file, +preserve its staged content for unstaged-only discard, and exclude unrelated changes from both +live cleanup and every stash tree. Retain a published stash and report partial completion if +cleanup fails. Messages may be empty. Omit unsupported operations. + +API v25 adds optional `stashFiles(input, files, message, ctx)` so a selected folder can become one +stash. Apply the same attestation, partial-staging, and unrelated-change rules as `stashFile`. + +API v25 adds optional `resolveWorkingTreeLine(input, file, line, ctx): Promise` for +read-only validation of a new-side source address and mapping staged addresses through later worktree changes. Revalidate the source +attestation and refuse unmappable transforms. The host owns editor launch and drops results after +review revocation; this method grants no remote write capability. + +API v25 adds optional inventory `statusCode`: two Git-style status columns (index, worktree), +or `??` for untracked files. The sidebar colors index changes green and worktree changes red; +providers that omit it retain the staged/unstaged letter indicators. + +API v25 adds optional inventory `stats` (`additions` / `deletions`) for that path's staged and +unstaged line counts. The files pane uses these when the current stream omits the file, so +/- +counts stay visible on both Unstaged and Staged tabs. + ## Detection order Detection prefers the **nearest** checkout: a Git repository nested inside a jj workspace is reviewed as Git, whatever the priorities say. The same rule covers your adapter — a Mercurial checkout inside a Git repository is reviewed as Mercurial. `detectionPriority` only decides which backend wins when several recognize the _same_ directory — the colocated case, where one working copy carries two sets of markers. diff --git a/website/src/content/docs/docs/start/keyboard-and-mouse.md b/website/src/content/docs/docs/start/keyboard-and-mouse.md index ffd1e031b..488c2020c 100644 --- a/website/src/content/docs/docs/start/keyboard-and-mouse.md +++ b/website/src/content/docs/docs/start/keyboard-and-mouse.md @@ -9,7 +9,9 @@ Press `?` at any time for Hunk's in-app shortcut reference. Menus and primary re | Keys | Action | | ------------------------- | ----------------------------------------------------- | -| `↑` / `↓`, `k` / `j` | Scroll one row | +| `↑` / `↓`, `k` / `j` | Move in the focused pane | +| `Enter` | Focus the selected file's review | +| `Esc` | Focus the files pane | | `Space` / `f`, `b` | Page down / up | | `Shift+Space` | Page up | | `d` / `u` | Half page down / up | @@ -21,7 +23,7 @@ Press `?` at any time for Hunk's in-app shortcut reference. Menus and primary re Hunk navigation stays review-wide: hunk and file shortcuts move through the same multi-file stream shown in the main pane. -`↑` / `↓` and `k` / `j` move a highlighted current line, and the view scrolls only far enough to keep it visible. Paging or scrolling past it moves it to the nearest line still on screen, and `c` anchors a note on it. Pick the marker from the View menu, or set [`cursor_line`](/docs/configure/layout-and-display/): `number` marks only the line number, and `off` turns the marker off and lets `↑` / `↓` and `k` / `j` scroll the view one row at a time instead. +When the files pane and review are both visible, the focused pane uses a brighter border. Click a pane to focus it. `,` / `.` also focus the files pane; `[` / `]` focus the review. With the files pane focused and a file selected, `Enter` focuses that file's review; `Esc` returns to the files pane on the current file. With the files pane focused, `↑` / `↓` and `k` / `j` move between files, and in working-tree reviews they also land on folders; with the review focused they move a highlighted current line, and the view scrolls only far enough to keep it visible. Paging or scrolling past it moves it to the nearest line still on screen, and `c` anchors a note on it. Pick the marker from the View menu, or set [`cursor_line`](/docs/configure/layout-and-display/): `number` marks only the line number, and `off` turns the marker off and lets those keys scroll the view one row at a time instead. ## Change the view @@ -51,7 +53,8 @@ Press `c` on the selected hunk or use a visible add-note affordance with the mou ## Mouse behavior -- Click a sidebar file to jump to it in the review stream. +- Click a sidebar file to jump to it in the review stream and focus the files pane. Click a folder to select it and, in the wide tree, collapse or expand it; Space then stages or unstages the files shown under that folder. A folder stays on the current Unstaged/Staged side when any file under it belongs there. The files pane keeps +/- counts on every status row, including files that only have changes on the other tab. +- Click the review stream to focus it. The focused pane uses a brighter border. - Click menus and dialog actions instead of their key equivalents. - Use the wheel or scrollbar to move through the review; hold Shift while scrolling to move horizontally through unwrapped code. - Select diff text for copy where the terminal supports it.