From 3899de06f32aafe2c597d9a84be6b83528b6d764 Mon Sep 17 00:00:00 2001 From: yusing Date: Sat, 5 Sep 2026 07:21:49 +0000 Subject: [PATCH 01/27] feat(review): add status-aware file staging and stream tabs Expose both Git status sides through the public VCS and sidebar contracts. Stage or unstage exact files with Space and consecutive mouse double-clicks, preserve unrelated changes, and reconcile writes through reload and graceful shutdown. Add provider, command, component, and PTY coverage plus extension and keybinding documentation. --- .changeset/status-aware-staging.md | 5 + docs/extension-architecture.md | 10 + docs/extensions.md | 18 ++ docs/keybindings.md | 12 + packages/hunk-git/src/commands.ts | 27 ++ packages/hunk-git/src/index.ts | 10 +- packages/hunk-git/src/workingTree.test.ts | 155 ++++++++++++ packages/hunk-git/src/workingTree.ts | 165 ++++++++++++ packages/hunk/skills/hunk-extensions/SKILL.md | 6 +- packages/hunk/src/core/changeset/loaders.ts | 13 +- packages/hunk/src/core/changeset/model.ts | 4 +- .../hunk/src/core/run/commandCatalog.test.ts | 2 + packages/hunk/src/core/run/commandCatalog.ts | 18 ++ packages/hunk/src/core/vcs/types.ts | 6 +- packages/hunk/src/extension-api/index.ts | 4 + packages/hunk/src/extension-api/types.ts | 47 +++- .../default/ui/sidebar/FileSidebars.tsx | 59 ++++- .../hunk/src/extensions/runExtension.test.ts | 47 ++++ packages/hunk/src/extensions/runExtension.ts | 22 ++ .../hunk/src/extensions/vcsPatchResult.ts | 1 + .../App.extension-command-controls.test.tsx | 5 + .../src/ui/App.extension-runtime.test.tsx | 5 + .../hunk/src/ui/App.extension-trust.test.tsx | 5 + packages/hunk/src/ui/App.tsx | 55 +++- .../src/ui/AppHost.extension-sidebar.test.tsx | 24 +- .../hunk/src/ui/AppHost.interactions.test.tsx | 5 + .../hunk/src/ui/AppHost.keybindings.test.tsx | 2 +- packages/hunk/src/ui/AppHost.tsx | 58 ++++- .../hunk/src/ui/AppHost.working-tree.test.tsx | 198 +++++++++++++++ .../ui/components/chrome/WorkingTreeBar.tsx | 57 +++++ .../hunk/src/ui/components/panes/DiffPane.tsx | 4 +- .../src/ui/components/panes/ExtensionPane.tsx | 4 + .../src/ui/components/panes/FileListItem.tsx | 9 +- .../scrollbar/VerticalScrollbar.tsx | 5 + .../src/ui/components/ui-components.test.tsx | 53 ++++ .../useCurrentReviewRefreshController.ts | 16 ++ .../src/ui/hooks/useWorkingTreeActions.ts | 239 ++++++++++++++++++ packages/hunk/src/ui/lib/appCommands.test.ts | 36 ++- packages/hunk/src/ui/lib/appCommands.ts | 12 + packages/hunk/src/ui/lib/appMenus.ts | 2 + packages/hunk/src/ui/lib/files.ts | 3 + packages/hunk/src/ui/lib/helpContent.ts | 8 + .../hunk/src/ui/lib/mousePressSequence.ts | 21 ++ test/helpers/working-tree.ts | 29 +++ test/pty/chrome.test.ts | 4 +- test/pty/nav.test.ts | 10 +- test/pty/scroll.test.ts | 4 +- test/pty/working-tree-integration.test.ts | 117 +++++++++ .../docs/docs/extend/custom-sidebars.md | 6 + .../content/docs/docs/extend/vcs-adapters.md | 13 + 50 files changed, 1580 insertions(+), 60 deletions(-) create mode 100644 .changeset/status-aware-staging.md create mode 100644 packages/hunk-git/src/workingTree.test.ts create mode 100644 packages/hunk-git/src/workingTree.ts create mode 100644 packages/hunk/src/ui/AppHost.working-tree.test.tsx create mode 100644 packages/hunk/src/ui/components/chrome/WorkingTreeBar.tsx create mode 100644 packages/hunk/src/ui/hooks/useWorkingTreeActions.ts create mode 100644 packages/hunk/src/ui/lib/mousePressSequence.ts create mode 100644 test/helpers/working-tree.ts create mode 100644 test/pty/working-tree-integration.test.ts 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/docs/extension-architecture.md b/docs/extension-architecture.md index 28880bf0d..f1f5045c8 100644 --- a/docs/extension-architecture.md +++ b/docs/extension-architecture.md @@ -337,6 +337,16 @@ 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/src/extensions/default/vcs/git/workingTree.ts` derives both +index/worktree status and attestations, and implements the public file-staging methods. 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. 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. + `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..05acaeb9d 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -527,6 +527,24 @@ 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. + +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. + +Mounted panes receive optional `props.workingTree` with this inventory, `selectedPath`, `staged`, +`busy`, `selectFile(path)`, and `toggleStaged(path)`. 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. 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 diff --git a/docs/keybindings.md b/docs/keybindings.md index b255b73a4..608e67c16 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -60,6 +60,16 @@ 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. File-row double-click and the clickable action beside the +stream tabs do the same. The sidebar shows independent `S` (staged) and `U` (unstaged) indicators, +including partially staged files. A successful file action follows the file to the other tab. +**Tab** switches the complete Unstaged/Staged review stream; selecting a sidebar file that only +has changes on the other side switches automatically. 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)_ | @@ -93,7 +103,9 @@ an ordered provider-owned parent; they do not navigate the history selection to | `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.toggleFileStaged` | Stage / unstage selected file | `space` | | `hunk.review.toggleHunkGap` | Expand or collapse the selected context | `z` | +| `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)_ | diff --git a/packages/hunk-git/src/commands.ts b/packages/hunk-git/src/commands.ts index 10625ba89..2053defb8 100644 --- a/packages/hunk-git/src/commands.ts +++ b/packages/hunk-git/src/commands.ts @@ -549,6 +549,33 @@ export async function runGitTextAsync(options: RunGitTextOptions): Promise { + try { + const process = Bun.spawn([gitExecutable, ...args], { + cwd, + stdin: stdinText === undefined ? "ignore" : new TextEncoder().encode(stdinText), + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stdout, stderr] = await Promise.all([ + process.exited, + new Response(process.stdout).text(), + new Response(process.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/index.ts b/packages/hunk-git/src/index.ts index 049209f22..72bbefb93 100644 --- a/packages/hunk-git/src/index.ts +++ b/packages/hunk-git/src/index.ts @@ -25,6 +25,7 @@ import { } from "./commands"; import { openGitHistory } from "./history"; import { gitEndpointSourceSpec, readGitFileSource } from "./source"; +import { loadGitWorkingTreeFiles, mutateGitFileStaging } from "./workingTree"; import { HUNK_VCS_DETECTION_BASELINE_PRIORITY, type ExtensionVcsAdapter, @@ -378,6 +379,7 @@ export function createGitVcsAdapter({ repoRoot, sourceLabel: repoRoot, title, + workingTreeFiles: loadGitWorkingTreeFiles(input, { cwd, gitExecutable }), patchText: await runGitTextAsync({ input, args: buildGitDiffArgs( @@ -406,6 +408,10 @@ export function createGitVcsAdapter({ untrackedPaths, }; }, + stageFile: (input, file, { cwd }) => + mutateGitFileStaging(input, file, { cwd, gitExecutable }, true), + unstageFile: (input, file, { cwd }) => + mutateGitFileStaging(input, file, { cwd, gitExecutable }, false), watchPlan(input, { cwd }) { return buildGitWatchPlan(input, cwd, gitExecutable); }, @@ -428,7 +434,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..9255f474e --- /dev/null +++ b/packages/hunk-git/src/workingTree.test.ts @@ -0,0 +1,155 @@ +import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +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 }); + 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").untracked).toBe(true); + await mutateGitFileStaging(input, status(root, "new.txt"), { cwd: root }, true); + await mutateGitFileStaging(input, status(root, "new.txt"), { cwd: root }, false); + expect(status(root, "new.txt").untracked).toBe(true); + 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.patchText).toContain(staged ? "a/alpha.txt" : "a/beta.txt"); + expect(result.patchText).not.toContain(staged ? "a/beta.txt" : "a/alpha.txt"); + } + }); +}); diff --git a/packages/hunk-git/src/workingTree.ts b/packages/hunk-git/src/workingTree.ts new file mode 100644 index 000000000..fdaedd2be --- /dev/null +++ b/packages/hunk-git/src/workingTree.ts @@ -0,0 +1,165 @@ +/** + * 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 } from "node:fs"; +import { isAbsolute, join } from "node:path"; +import { + HunkExtensionUserError, + type ExtensionVcsDiffInput, + type ExtensionWorkingTreeFile, +} from "hunkdiff/extension"; +import { appendGitPathspecs, resolveGitRepoRoot, runGitMutation, runGitText } 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 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, + 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([ + status, + index.get(path), + previousPath && index.get(previousPath), + stat.version, + previousPath && fileStamp(root, previousPath).version, + ]), + }); + } + return files; +} + +/** 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/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.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/run/commandCatalog.test.ts b/packages/hunk/src/core/run/commandCatalog.test.ts index a0be55309..d9ec98d58 100644 --- a/packages/hunk/src/core/run/commandCatalog.test.ts +++ b/packages/hunk/src/core/run/commandCatalog.test.ts @@ -65,6 +65,8 @@ describe("app command catalog", () => { (command) => command.id, ), ).toEqual([ + "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..730a7ddd0 100644 --- a/packages/hunk/src/core/run/commandCatalog.ts +++ b/packages/hunk/src/core/run/commandCatalog.ts @@ -98,6 +98,24 @@ export interface AppCommandCatalogEntry { * from `[keybindings]` and dispatchable by id whether or not a menu presents them. */ const BUILTIN_COMMANDS = [ + { + id: "hunk.review.toggleFileStaged", + title: "Stage / unstage selected file", + 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", diff --git a/packages/hunk/src/core/vcs/types.ts b/packages/hunk/src/core/vcs/types.ts index 7a110e22a..5332cff35 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,8 @@ export interface VcsOperation { } export interface VcsOperations { - "working-tree-diff"?: VcsOperation; + "working-tree-diff"?: VcsOperation & + Pick; "revision-show"?: VcsOperation; "stash-show"?: VcsOperation; } @@ -76,6 +79,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..d156203f6 100644 --- a/packages/hunk/src/extension-api/index.ts +++ b/packages/hunk/src/extension-api/index.ts @@ -158,6 +158,10 @@ export type { ExtensionVcsHistorySource, ExtensionVcsLoadContext, ExtensionVcsOperation, + ExtensionVcsWorkingTreeOperation, + ExtensionVcsFileMutation, + ExtensionWorkingTreeFile, + ExtensionWorkingTreePane, ExtensionVcsOperations, ExtensionVcsPatchResult, ExtensionVcsRangeEndpoints, diff --git a/packages/hunk/src/extension-api/types.ts b/packages/hunk/src/extension-api/types.ts index fb1b4c0be..1cf17fd95 100644 --- a/packages/hunk/src/extension-api/types.ts +++ b/packages/hunk/src/extension-api/types.ts @@ -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,52 @@ 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; + /** 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; + +/** Load a working-tree review and optionally offer explicit index mutations. */ +export interface ExtensionVcsWorkingTreeOperation extends ExtensionVcsOperation { + stageFile?: ExtensionVcsFileMutation; + unstageFile?: ExtensionVcsFileMutation; +} + +/** 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; + 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; + /** Stage remaining unstaged changes, or unstage a fully staged file; inert after reload. */ + toggleStaged(path: string): void; +} + /** * An additional VCS backend contributed by an extension. * @@ -1276,6 +1319,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..54c362bed 100644 --- a/packages/hunk/src/extensions/default/ui/sidebar/FileSidebars.tsx +++ b/packages/hunk/src/extensions/default/ui/sidebar/FileSidebars.tsx @@ -1,5 +1,6 @@ +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 { @@ -12,6 +13,7 @@ import { sidebarEntryStatsWidth, toggleCollapsedDirectoryPath, type SidebarEntry, + type SidebarFileSource, } from "../../../../ui/lib/files"; import { fileRowId } from "../../../../ui/lib/ids"; import { buildSidebarRenderWindow } from "../../../../ui/lib/sidebarRenderWindow"; @@ -27,10 +29,8 @@ export type BuiltInSidebarProps = Omit< > & Partial>; -type FileSidebarVariantProps = Pick< - BuiltInSidebarProps, - "actions" | "files" | "selectedFileId" | "theme" -> & { +type FileSidebarVariantProps = Pick & { + files: readonly SidebarFileSource[]; estimatedViewportRows: number; scrollTop: number; textWidth: number; @@ -171,12 +171,54 @@ 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(() => { + if (!statusFiles) return reviewFiles; + 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 ?? { additions: 0, deletions: 0 }, + isUntracked: status.untracked, + stageStatus: status.conflicted + ? "!!" + : `${status.staged ? "S" : " "}${status.unstaged ? "U" : " "}`, + }; + }); + }, [reviewFiles, statusFiles]); + const lastClickRef = useRef<{ path: string; time: number; press: number } | null>(null); + /** Only consecutive clicks on the same file may mutate the index. */ + const selectFile = (path: string) => { + const now = Date.now(); + const previous = lastClickRef.current; + const press = mousePressSequence(renderer); + if ( + workingTree && + previous?.path === path && + press === previous.press + 1 && + now - previous.time < 350 + ) { + lastClickRef.current = null; + workingTree.toggleStaged(path); + return; + } + lastClickRef.current = { path, time: now, press }; + if (workingTree) workingTree.selectFile(path); + else actions.selectFile(path); + }; + const selectedFileId = workingTree?.selectedPath ?? reviewSelectedFileId; const scrollRef = useRef(null); const previousSelectedFileIdRef = useRef(selectedFileId); const skipSelectedFileRevealRef = useRef(false); @@ -189,7 +231,7 @@ export function FlexFileSidebar({ const textWidth = Math.max(8, width - 2); const mode = resolveFileSidebarMode(textWidth); const variantProps: FileSidebarVariantProps = { - actions, + actions: { ...actions, selectFile }, estimatedViewportRows: terminal.height, files, scrollTop: scrollViewport.top, @@ -289,6 +331,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..e5d7a964a 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,52 @@ 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(); + 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..52f82160f 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,27 @@ 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) + | 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); + } + }; + } + } + 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/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..8f4c7b7ad 100644 --- a/packages/hunk/src/ui/App.tsx +++ b/packages/hunk/src/ui/App.tsx @@ -1,3 +1,4 @@ +import { recordMousePress } from "./lib/mousePressSequence"; import type { BoxRenderable, MouseEvent as TuiMouseEvent, @@ -45,6 +46,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"; @@ -145,9 +148,11 @@ export function App({ onReloadSession, onRequestExtensionReviewReload, onWorkspaceWriteCompleted, + onVcsMutationCompleted, reviewProducer, runWorkspaceWrite, returnToHistory = process.env.HUNK_RETURN_TO_HISTORY === "1", + runVcsMutation, watchRuntime, workspaceFileWriter, }: { @@ -171,12 +176,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 +192,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; @@ -450,7 +458,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 +905,7 @@ export function App({ setShowMenuBar((current) => !current); }; - const { canRefreshCurrentInput, refreshCurrentInput, triggerRefreshCurrentInput } = + const { canRefreshCurrentInput, refreshCurrentInput, triggerRefreshCurrentInput, setStagedView } = useCurrentReviewRefreshController({ input: bootstrap.input, onRegisterWorkspaceRefreshRequest, @@ -991,6 +1003,19 @@ export function App({ setFocusArea("files"); }, []); + const workingTree = useWorkingTreeActions({ + bootstrap, + selectedFile, + filter: review.filter, + 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"); @@ -1065,6 +1090,10 @@ export function App({ const appCommands = observeAppCommandDispatch( [ ...buildAppCommands({ + canToggleFileStaged: workingTree.canToggleSelected, + canSwitchStagedView: Boolean(workingTree.pane), + toggleFileStaged: workingTree.toggleSelected, + toggleStagedView: () => workingTree.switchView(!workingTree.staged), canAlignCurrentLine: cursorLine !== "off" && review.lineCursor !== null, canApplyFilePresentationToAllMatching: selectedFileViewBulkTarget !== null, canEditActiveNote: activeEditableNoteId !== undefined && review.draftNote === null, @@ -1079,7 +1108,10 @@ export function App({ replyToActiveNote: () => { if (activeReplyableNoteId) startUserNoteReply(activeReplyableNoteId); }, - moveSelection: review.moveSelection, + moveSelection: (scope, delta) => { + if (scope === "file" && workingTree.pane) workingTree.moveFile(delta); + else review.moveSelection(scope, delta); + }, openAgentSkill, openThemeSelector, requestQuit, @@ -1220,7 +1252,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 +1275,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} @@ -1287,6 +1321,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); @@ -1322,6 +1357,7 @@ export function App({ return ( recordMousePress(renderer, event)} style={{ width: "100%", height: "100%", @@ -1345,6 +1381,17 @@ export function App({ /> ) : null} + {workingTree.pane && ( + + )} + { 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 "U " unstaged-status prefix; the diff // pane's own headers do not, so the prefix marks the area's visibility. await flushUntil( setup, - () => setup.captureCharFrame().includes("M alpha.txt"), + () => setup.captureCharFrame().includes("U alpha.txt"), "the built-in sidebar to render", ); @@ -511,7 +511,7 @@ describe("extension sidebar views", () => { }); await flushUntil( setup, - () => !setup.captureCharFrame().includes("M alpha.txt"), + () => !setup.captureCharFrame().includes("U alpha.txt"), "the s key to hide the sidebar area", ); @@ -524,7 +524,7 @@ describe("extension sidebar views", () => { setup, () => { const frame = setup.captureCharFrame(); - return frame.includes("EXTSIDEBAR") && !frame.includes("M alpha.txt"); + return frame.includes("EXTSIDEBAR") && !frame.includes("U alpha.txt"); }, "the command to open only the extension pane", ); @@ -536,7 +536,7 @@ describe("extension sidebar views", () => { setup, () => { const frame = setup.captureCharFrame(); - return frame.includes("EXTSIDEBAR") && frame.includes("M alpha.txt"); + return frame.includes("EXTSIDEBAR") && frame.includes("U alpha.txt"); }, "the s key to reopen files without closing the extension pane", ); @@ -548,7 +548,7 @@ describe("extension sidebar views", () => { setup, () => { const frame = setup.captureCharFrame(); - return frame.includes("EXTSIDEBAR") && !frame.includes("M alpha.txt"); + return frame.includes("EXTSIDEBAR") && !frame.includes("U alpha.txt"); }, "the s key to close files without closing the extension pane", ); @@ -835,7 +835,7 @@ describe("extension sidebar views", () => { ); await flushUntil( setup, - () => setup.captureCharFrame().includes("M alpha.txt"), + () => setup.captureCharFrame().includes("U alpha.txt"), "the built-in sidebar to reopen after the crash", ); @@ -844,7 +844,7 @@ describe("extension sidebar views", () => { }); await flushUntil( setup, - () => !setup.captureCharFrame().includes("M alpha.txt"), + () => !setup.captureCharFrame().includes("U alpha.txt"), "the s key to close the visible built-in fallback", ); @@ -853,7 +853,7 @@ describe("extension sidebar views", () => { }); await flushUntil( setup, - () => setup.captureCharFrame().includes("M alpha.txt"), + () => setup.captureCharFrame().includes("U alpha.txt"), "the s key to reopen the built-in fallback", ); }); @@ -885,7 +885,7 @@ describe("extension sidebar views", () => { ); await flushUntil( setup, - () => setup.captureCharFrame().includes("M alpha.txt"), + () => setup.captureCharFrame().includes("U alpha.txt"), "the built-in files fallback to appear", ); @@ -894,7 +894,7 @@ describe("extension sidebar views", () => { }); await flushUntil( setup, - () => !setup.captureCharFrame().includes("M alpha.txt"), + () => !setup.captureCharFrame().includes("U alpha.txt"), "the files toggle to close the injected fallback", ); @@ -903,7 +903,7 @@ describe("extension sidebar views", () => { }); await flushUntil( setup, - () => setup.captureCharFrame().includes("M alpha.txt"), + () => setup.captureCharFrame().includes("U alpha.txt"), "the files toggle to reopen the built-in files pane", ); }); diff --git a/packages/hunk/src/ui/AppHost.interactions.test.tsx b/packages/hunk/src/ui/AppHost.interactions.test.tsx index b00106ed5..e4abf211e 100644 --- a/packages/hunk/src/ui/AppHost.interactions.test.tsx +++ b/packages/hunk/src/ui/AppHost.interactions.test.tsx @@ -1139,6 +1139,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; diff --git a/packages/hunk/src/ui/AppHost.keybindings.test.tsx b/packages/hunk/src/ui/AppHost.keybindings.test.tsx index cebbe1bf2..5e5b17122 100644 --- a/packages/hunk/src/ui/AppHost.keybindings.test.tsx +++ b/packages/hunk/src/ui/AppHost.keybindings.test.tsx @@ -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.tsx b/packages/hunk/src/ui/AppHost.tsx index 6d710be77..9c3c4223a 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; @@ -229,6 +230,20 @@ export function AppHost({ } }, []); + /** 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 runWorkspaceWrite(write); + } finally { + vcsMutationPendingRef.current = false; + } + }, + [runWorkspaceWrite], + ); + const performReloadSession = useCallback( async (nextInput: CliInput, options?: ReloadSessionOptions) => { if (quitRequestedRef.current) throw reloadRefusedDuringShutdown(); @@ -477,20 +492,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 +604,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..90ad639a3 --- /dev/null +++ b/packages/hunk/src/ui/AppHost.working-tree.test.tsx @@ -0,0 +1,198 @@ +import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { testRender } from "@opentui/react/test-utils"; +import { act } from "react"; +import { 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 { AppHost } from "./AppHost"; +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, + onQuit, + prepare, + staged = false, +}: { + beforeStage?: () => Promise; + onQuit?: () => void; + prepare?: (root: string) => void; + staged?: boolean; +} = {}) { + 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 + ? { + ...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) => { + 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: 150, + 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); + }); +} + +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("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("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(""); + }); +}); 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..18b52044c --- /dev/null +++ b/packages/hunk/src/ui/components/chrome/WorkingTreeBar.tsx @@ -0,0 +1,57 @@ +/** 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, + switchView, + toggleSelected, +}: { + pane: ExtensionWorkingTreePane; + theme: AppTheme; + width: number; + canToggle: boolean; + switchView: (staged: boolean) => void; + toggleSelected: () => void; +}) { + const selected = pane.files.find((file) => file.path === pane.selectedPath); + const action = pane.busy ? "Working…" : selected?.unstaged ? "Stage file" : "Unstage 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/panes/DiffPane.tsx b/packages/hunk/src/ui/components/panes/DiffPane.tsx index d765a8164..1df5b4f73 100644 --- a/packages/hunk/src/ui/components/panes/DiffPane.tsx +++ b/packages/hunk/src/ui/components/panes/DiffPane.tsx @@ -1,3 +1,4 @@ +import { recordMousePress } from "../../lib/mousePressSequence"; import { MouseButton, type MouseEvent as TuiMouseEvent, @@ -1513,6 +1514,7 @@ export function DiffPane({ /** Start selecting diff text when the user drags inside the review stream. */ const beginCopySelection = useCallback( (event: TuiMouseEvent) => { + recordMousePress(renderer, event); if (event.button !== MouseButton.LEFT) { return; } @@ -1570,7 +1572,7 @@ export function DiffPane({ event.preventDefault(); event.stopPropagation(); }, - [copySelectionContext, resolveCopySelectionPoint, suppressNativeSelection], + [copySelectionContext, renderer, resolveCopySelectionPoint, suppressNativeSelection], ); /** Extend the active diff text selection while the pointer moves. */ diff --git a/packages/hunk/src/ui/components/panes/ExtensionPane.tsx b/packages/hunk/src/ui/components/panes/ExtensionPane.tsx index 348a15999..91026039c 100644 --- a/packages/hunk/src/ui/components/panes/ExtensionPane.tsx +++ b/packages/hunk/src/ui/components/panes/ExtensionPane.tsx @@ -79,6 +79,7 @@ export interface ExtensionPaneHostProps { registered: RegisteredPane; review?: ExtensionPaneProps["review"]; files: DiffFile[]; + workingTree?: ExtensionPaneProps["workingTree"]; fileViews: ExtensionDiffFile[]; selectedFileId: string | null; selectedHunkIndex: number | null; @@ -103,6 +104,7 @@ function ExtensionPaneHostView({ review = null, files, fileViews, + workingTree, selectedFileId, selectedHunkIndex, placement, @@ -152,6 +154,7 @@ function ExtensionPaneHostView({ const viewProps: ExtensionPaneProps = { review, files: fileViews, + workingTree, selectedFileId, selectedHunkIndex, placement, @@ -218,6 +221,7 @@ export const ExtensionPaneHost = memo( (previous, next) => 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 && diff --git a/packages/hunk/src/ui/components/panes/FileListItem.tsx b/packages/hunk/src/ui/components/panes/FileListItem.tsx index 95f96b63b..4839bcdd8 100644 --- a/packages/hunk/src/ui/components/panes/FileListItem.tsx +++ b/packages/hunk/src/ui/components/panes/FileListItem.tsx @@ -1,5 +1,6 @@ import { MouseButton, type MouseEvent as TuiMouseEvent } from "@opentui/core"; import { memo } from "react"; +import { MouseButton } from "@opentui/core"; import type { ExtensionSidebarTheme } from "../../../extension-api/types"; import { diffRailMarker } from "../../diff/rowStyle"; import { fileRowId } from "../../lib/ids"; @@ -23,6 +24,7 @@ function getFileStateIcon( entry: FileListEntry, theme: ExtensionSidebarTheme, ): { icon: string; color: string } { + if (entry.stageStatus) return { icon: entry.stageStatus, color: theme.text }; if (entry.isUntracked) { return { icon: "?", color: theme.fileUntracked }; } @@ -169,7 +171,7 @@ export const FileListItem = memo(function FileListItem({ const rowBackground = selected ? theme.panelAlt : theme.panel; const stats = sidebarEntryStats(entry); const { icon, color } = getFileStateIcon(entry, theme); - const iconWidth = icon ? 2 : 0; // icon + space + const iconWidth = icon ? icon.length + 1 : 0; const statsSectionWidth = statsWidth > 0 ? statsWidth + 1 : 0; const indentWidth = fileSidebarIndentWidth( entry.depth, @@ -187,7 +189,10 @@ 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() : " "} 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..02dc7e670 100644 --- a/packages/hunk/src/ui/components/ui-components.test.tsx +++ b/packages/hunk/src/ui/components/ui-components.test.tsx @@ -557,6 +557,59 @@ describe("UI components", () => { expect(frame).not.toContain("M +2 -1 AI"); }); + 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 toggleStaged = mock(() => {}); + const setup = await testRender( + false, getKeys: () => [] }} + actions={{ 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", + staged: false, + busy: false, + selectFile, + toggleStaged, + }} + />, + { 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(selectFile.mock.calls).toHaveLength(3); + expect(toggleStaged).not.toHaveBeenCalled(); + await act(async () => setup.mockMouse.click(5, alpha)); + expect(toggleStaged).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([ 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/useWorkingTreeActions.ts b/packages/hunk/src/ui/hooks/useWorkingTreeActions.ts new file mode 100644 index 000000000..9380e52c8 --- /dev/null +++ b/packages/hunk/src/ui/hooks/useWorkingTreeActions.ts @@ -0,0 +1,239 @@ +/** + * Coordinates file staging and staged/unstaged 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 type { ExtensionWorkingTreePane } from "../../extension-api/types"; +import type { ExtensionCapabilityLease } from "../lib/extensionCapabilityLease"; +import type { WorkspaceWriteRunner } from "./useExtensionWorkspaceControls"; + +/** 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, + filter, + createLease, + selectReviewFile, + focusFiles, + setStagedView, + runMutation, + refreshAfterMutation, + showNotice, +}: { + bootstrap: AppBootstrap; + selectedFile: DiffFile | undefined; + filter: string; + 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 pendingPathRef = useRef<{ path: string; changeset: AppBootstrap["changeset"] } | null>( + null, + ); + const [chosenPath, setChosenPath] = useState(null); + const selectedPath = + [pendingPathRef.current?.path, selectedFile?.path, chosenPath].find((path) => + files.some((file) => file.path === path), + ) ?? + files[0]?.path ?? + null; + const lease = useMemo(() => createLease(), [bootstrap, createLease]); + + useEffect(() => { + const pending = pendingPathRef.current; + if (!pending || pending.changeset === bootstrap.changeset) return; + // Consume a follow request once its authoritative inventory arrives, including clean files. + pendingPathRef.current = null; + const file = bootstrap.changeset.files.find((candidate) => candidate.path === pending.path); + setChosenPath(file?.path ?? null); + if (file) selectReviewFile(file.id, { alignFileHeaderTop: true }); + }, [bootstrap, 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) { + pendingPathRef.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 selectFile = useCallback( + (path: string) => { + if (!lease.isLive() || busyRef.current) return; + const status = files.find((file) => file.path === path); + if (!status) return; + focusFiles(); + pendingPathRef.current = null; + setChosenPath(path); + // 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 }); + return; + } + pendingPathRef.current = { path, changeset: bootstrap.changeset }; + void switchView(!staged); + }, + [lease, files, focusFiles, bootstrap.changeset, selectReviewFile, switchView, staged], + ); + + const canToggle = useCallback( + (path: string | null) => { + const file = files.find((candidate) => candidate.path === path); + return Boolean( + file && + !file.unavailableReason && + !file.conflicted && + (file.unstaged ? operation?.stageFile : file.staged && operation?.unstageFile), + ); + }, + [files, operation], + ); + + const toggleStaged = useCallback( + (path: string) => { + // A file double-click may arrive while its first click is switching tabs. The path is + // explicit and attested, so let that gesture finish; keyboard repeats remain blocked. + if ( + !lease.isLive() || + mutatingRef.current || + !canToggle(path) || + bootstrap.input.kind !== "vcs" + ) + return; + const file = files.find((candidate) => candidate.path === path)!; + const mutate = file.unstaged ? operation?.stageFile : operation?.unstageFile; + if (!mutate) return; + const input = bootstrap.input; + busyRef.current = true; + mutatingRef.current = true; + setBusy(true); + showNotice(`${file.unstaged ? "Staging" : "Unstaging"} ${file.path}…`); + void runMutation(async () => { + let succeeded = false; + try { + await mutate(input, file, { cwd: bootstrap.changeset.sourceLabel }); + succeeded = true; + pendingPathRef.current = { path: file.path, changeset: bootstrap.changeset }; + } finally { + // A failed Git command may still have changed state. Always reconcile before unlocking. + await refreshAfterMutation( + succeeded + ? { root: bootstrap.changeset.sourceLabel, staged: file.unstaged } + : undefined, + ); + } + }) + .then((started) => { + showNotice( + started + ? `${file.unstaged ? "Staged" : "Unstaged"} ${file.path}.` + : "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); + }); + }, + [lease, canToggle, bootstrap, files, operation, runMutation, refreshAfterMutation, showNotice], + ); + + const pane = useMemo( + () => + enabled + ? Object.freeze({ + files, + selectedPath, + staged, + busy, + selectFile, + toggleStaged, + }) + : undefined, + [enabled, files, selectedPath, staged, busy, selectFile, toggleStaged], + ); + + return { + pane, + busy, + staged, + selectedPath, + canToggleSelected: enabled && canToggle(selectedPath), + toggleSelected: () => { + if (!busyRef.current && selectedPath) toggleStaged(selectedPath); + }, + switchView: (next: boolean) => { + void switchView(next); + }, + moveFile: (delta: number) => { + 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); + }, + }; +} diff --git a/packages/hunk/src/ui/lib/appCommands.test.ts b/packages/hunk/src/ui/lib/appCommands.test.ts index 933fd157e..097053b38 100644 --- a/packages/hunk/src/ui/lib/appCommands.test.ts +++ b/packages/hunk/src/ui/lib/appCommands.test.ts @@ -35,7 +35,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) => @@ -72,12 +75,39 @@ 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("every alias of the scroll shortcuts still dispatches", () => { const { commands, ran } = createTestCommands(); const press = (fields: Partial) => @@ -257,7 +287,9 @@ 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.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..9d4feca9a 100644 --- a/packages/hunk/src/ui/lib/appCommands.ts +++ b/packages/hunk/src/ui/lib/appCommands.ts @@ -108,6 +108,10 @@ interface BuiltinCommandHandler { /** The callbacks the built-in command set drives; App supplies its own handlers. */ export interface BuildAppCommandsOptions { + canToggleFileStaged?: boolean; + canSwitchStagedView?: boolean; + toggleFileStaged?: () => void; + toggleStagedView?: () => void; canAlignCurrentLine: boolean; canApplyFilePresentationToAllMatching: boolean; canEditActiveNote?: boolean; @@ -175,6 +179,14 @@ function builtinCommandHandlers( options: BuildAppCommandsOptions, ): Record { return { + "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() }, diff --git a/packages/hunk/src/ui/lib/appMenus.ts b/packages/hunk/src/ui/lib/appMenus.ts index 6a48df299..a89121754 100644 --- a/packages/hunk/src/ui/lib/appMenus.ts +++ b/packages/hunk/src/ui/lib/appMenus.ts @@ -142,6 +142,8 @@ export function buildAppMenus({ { 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.toggleFileStaged" }, + { commandId: "hunk.review.toggleStagedView" }, { commandId: "hunk.app.refresh", label: "Reload" }, SEPARATOR, { commandId: "hunk.app.quit" }, diff --git a/packages/hunk/src/ui/lib/files.ts b/packages/hunk/src/ui/lib/files.ts index 9c9696ae8..266e1d076 100644 --- a/packages/hunk/src/ui/lib/files.ts +++ b/packages/hunk/src/ui/lib/files.ts @@ -16,6 +16,7 @@ export interface FileListEntry { deletionsText: string | null; changeType: FileDiffMetadata["type"]; isUntracked: boolean; + stageStatus?: string; } /** @@ -37,6 +38,7 @@ export interface SidebarFileSource { agent?: { annotations: readonly unknown[] } | null; changeType?: FileDiffMetadata["type"]; metadata?: unknown; + stageStatus?: string; } export interface FileGroupEntry { @@ -154,6 +156,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, }; } diff --git a/packages/hunk/src/ui/lib/helpContent.ts b/packages/hunk/src/ui/lib/helpContent.ts index 0bb8ae799..a36eddd01 100644 --- a/packages/hunk/src/ui/lib/helpContent.ts +++ b/packages/hunk/src/ui/lib/helpContent.ts @@ -105,6 +105,14 @@ const HELP_SECTIONS: readonly HelpSectionSpec[] = [ { title: "Review", entries: [ + { + commandIds: ["hunk.review.toggleFileStaged"], + description: "stage / unstage selected file", + }, + { + 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" }, { 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/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/chrome.test.ts b/test/pty/chrome.test.ts index d0b48a861..79536e97c 100644 --- a/test/pty/chrome.test.ts +++ b/test/pty/chrome.test.ts @@ -181,7 +181,7 @@ describe("PTY chrome", () => { expect(initial).toContain("add = true"); expect(initial).toContain("betaValue"); - await session.press("tab"); + await session.press("/"); await session.type("beta"); const filtered = await harness.waitForSnapshot( session, @@ -205,7 +205,7 @@ describe("PTY chrome", () => { args: ["diff", "--mode", "split"], cwd: fixture.dir, cols: 220, - rows: 12, + rows: 13, }); try { diff --git a/test/pty/nav.test.ts b/test/pty/nav.test.ts index 250af9ee2..63e250bc6 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 { @@ -232,7 +232,7 @@ describe("PTY navigation", () => { expect(initial).toContain("betaValue = 2"); expect(initial).not.toContain("deltaOnly = true"); - await session.click(/M delta\.ts\s+\+2 -1/); + await session.click(/U delta\.ts\s+\+2 -1/); const jumped = await harness.waitForSnapshot( session, (text) => @@ -280,7 +280,7 @@ describe("PTY navigation", () => { expect(scrolled).toContain("first.ts"); - await session.click(/M second\.ts\s+\+16 -16/); + await session.click(/U second\.ts\s+\+16 -16/); const pinned = await harness.waitForSnapshot( session, (text) => diff --git a/test/pty/scroll.test.ts b/test/pty/scroll.test.ts index f6c4fbbf3..50c131a5b 100644 --- a/test/pty/scroll.test.ts +++ b/test/pty/scroll.test.ts @@ -329,7 +329,7 @@ describe("PTY scrolling", () => { args: ["diff", "--mode", "split"], cwd: fixture.dir, cols: 220, - rows: 10, + rows: 11, }); try { @@ -360,7 +360,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..d8fbb69f5 --- /dev/null +++ b/test/pty/working-tree-integration.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { 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); +} + +describe("PTY working-tree staging", () => { + 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/website/src/content/docs/docs/extend/custom-sidebars.md b/website/src/content/docs/docs/extend/custom-sidebars.md index b5d8789b1..d7c804881 100644 --- a/website/src/content/docs/docs/extend/custom-sidebars.md +++ b/website/src/content/docs/docs/extend/custom-sidebars.md @@ -5,6 +5,12 @@ 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. 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..9245bd7be 100644 --- a/website/src/content/docs/docs/extend/vcs-adapters.md +++ b/website/src/content/docs/docs/extend/vcs-adapters.md @@ -40,6 +40,19 @@ 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. + ## 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. From adb8043f52e0e6af52168ca8598e5401596eb837 Mon Sep 17 00:00:00 2001 From: yusing Date: Sat, 5 Sep 2026 08:07:54 +0000 Subject: [PATCH 02/27] feat(review): stage and unstage the active diff hunk Apply byte-preserving canonical Git hunks with status and HEAD attestation. Keep unrelated hunks and worktree content intact, refuse text-converted patches, and integrate scoped Space and release-confirmed double-click actions through the provider contract. --- .changeset/active-hunk-staging.md | 5 + docs/extension-architecture.md | 16 +- docs/extensions.md | 7 + docs/keybindings.md | 7 + packages/hunk-git/src/commands.ts | 18 +- packages/hunk-git/src/hunkStaging.test.ts | 182 ++++++++++++++++++ packages/hunk-git/src/hunkStaging.ts | 110 +++++++++++ packages/hunk-git/src/index.ts | 5 + packages/hunk-git/src/workingTree.ts | 8 + .../hunk/src/core/run/commandCatalog.test.ts | 1 + packages/hunk/src/core/run/commandCatalog.ts | 8 + packages/hunk/src/core/vcs/types.ts | 5 +- packages/hunk/src/extension-api/index.ts | 1 + packages/hunk/src/extension-api/types.ts | 10 + .../hunk/src/extensions/runExtension.test.ts | 1 + packages/hunk/src/extensions/runExtension.ts | 19 +- packages/hunk/src/ui/App.tsx | 42 ++-- .../hunk/src/ui/AppHost.working-tree.test.tsx | 28 +++ .../ui/components/chrome/WorkingTreeBar.tsx | 7 +- .../hunk/src/ui/components/panes/DiffPane.tsx | 69 ++++++- .../src/ui/components/ui-components.test.tsx | 30 +++ packages/hunk/src/ui/diff/CodeRowView.tsx | 1 + packages/hunk/src/ui/diff/DiffMetaRowView.tsx | 1 + .../src/ui/hooks/useWorkingTreeActions.ts | 123 +++++++++--- packages/hunk/src/ui/lib/appCommands.test.ts | 20 +- packages/hunk/src/ui/lib/appCommands.ts | 6 + packages/hunk/src/ui/lib/appMenus.ts | 1 + packages/hunk/src/ui/lib/helpContent.ts | 4 +- test/pty/scroll.test.ts | 2 +- test/pty/working-tree-integration.test.ts | 132 +++++++++++++ .../content/docs/docs/extend/vcs-adapters.md | 5 + 31 files changed, 808 insertions(+), 66 deletions(-) create mode 100644 .changeset/active-hunk-staging.md create mode 100644 packages/hunk-git/src/hunkStaging.test.ts create mode 100644 packages/hunk-git/src/hunkStaging.ts 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/docs/extension-architecture.md b/docs/extension-architecture.md index f1f5045c8..0c0f036bd 100644 --- a/docs/extension-architecture.md +++ b/docs/extension-architecture.md @@ -339,13 +339,15 @@ none — which is why the visible menu list is derived from the menus record Plain working-tree reviews additionally carry an optional provider-owned status inventory beside the active diff changeset. `packages/hunk/src/extensions/default/vcs/git/workingTree.ts` derives both -index/worktree status and attestations, and implements the public file-staging methods. 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. 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. +index/worktree status and attestations, and implements the public file-staging methods. +`packages/hunk/src/extensions/default/vcs/git/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. 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. `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 diff --git a/docs/extensions.md b/docs/extensions.md index 05acaeb9d..bc203f69c 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -538,6 +538,13 @@ unstaging must leave worktree contents unchanged. Throw `HunkExtensionUserError` failure. Hunk tracks started mutations through shutdown, blocks repeated actions until refresh, and reloads authoritative state even after a failed mutation. +API v18 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. + Mounted panes receive optional `props.workingTree` with this inventory, `selectedPath`, `staged`, `busy`, `selectFile(path)`, and `toggleStaged(path)`. These controls expire on review reload. Selecting an inactive-side file switches stream tabs without reducing the review to one file. diff --git a/docs/keybindings.md b/docs/keybindings.md index 608e67c16..d1e514f95 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -64,6 +64,12 @@ In plain working-tree reviews, **Space** stages the selected file's remaining un or unstages it when fully staged. File-row double-click and the clickable action beside the stream tabs do the same. The sidebar shows independent `S` (staged) and `U` (unstaged) indicators, including partially staged files. 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. **Tab** switches the complete Unstaged/Staged review stream; selecting a sidebar file that only has changes on the other side switches automatically. Use `/` to focus the filter. Actions wait for Git and its refreshed diff before accepting another mutation. Outside this @@ -105,6 +111,7 @@ remain remappable, and an explicit user binding takes precedence over contextual | `hunk.review.stepUp` | Scroll up one row | `up`, `k` | | `hunk.review.toggleFileStaged` | Stage / unstage selected file | `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)_ | diff --git a/packages/hunk-git/src/commands.ts b/packages/hunk-git/src/commands.ts index 2053defb8..df3f92049 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; } @@ -492,7 +493,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 +507,7 @@ function runGitCommand({ return { stderr, stdout, + stdoutBytes, exitCode: proc.exitCode, }; } @@ -523,7 +526,7 @@ async function runGitCommandAsync({ preventOptionalLocks = false, signal, acceptedExitCodes = [0], -}: RunGitCommandOptions): Promise { +}: RunGitCommandOptions): Promise> { let result: Awaited>; try { result = await runAbortableCommand([gitExecutable, ...args], { @@ -549,18 +552,23 @@ export async function runGitTextAsync(options: RunGitTextOptions): Promise { + stdin, +}: RunGitTextOptions & { stdin?: string | Uint8Array }): Promise { try { const process = Bun.spawn([gitExecutable, ...args], { cwd, - stdin: stdinText === undefined ? "ignore" : new TextEncoder().encode(stdinText), + stdin: typeof stdin === "string" ? new TextEncoder().encode(stdin) : (stdin ?? "ignore"), stdout: "pipe", stderr: "pipe", }); 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 72bbefb93..e6df03d48 100644 --- a/packages/hunk-git/src/index.ts +++ b/packages/hunk-git/src/index.ts @@ -1,3 +1,4 @@ +import { mutateGitHunkStaging } from "./hunkStaging"; import { createHash } from "node:crypto"; import fs from "node:fs"; import { dirname, isAbsolute, join, relative, resolve } from "node:path"; @@ -412,6 +413,10 @@ export function createGitVcsAdapter({ 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); }, diff --git a/packages/hunk-git/src/workingTree.ts b/packages/hunk-git/src/workingTree.ts index fdaedd2be..15bc5e25d 100644 --- a/packages/hunk-git/src/workingTree.ts +++ b/packages/hunk-git/src/workingTree.ts @@ -53,6 +53,13 @@ export function loadGitWorkingTreeFiles( 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, @@ -90,6 +97,7 @@ export function loadGitWorkingTreeFiles( ? "Use the submodule or directory's own Git workflow." : undefined, version: JSON.stringify([ + head, status, index.get(path), previousPath && index.get(previousPath), diff --git a/packages/hunk/src/core/run/commandCatalog.test.ts b/packages/hunk/src/core/run/commandCatalog.test.ts index d9ec98d58..f77e25ff3 100644 --- a/packages/hunk/src/core/run/commandCatalog.test.ts +++ b/packages/hunk/src/core/run/commandCatalog.test.ts @@ -65,6 +65,7 @@ describe("app command catalog", () => { (command) => command.id, ), ).toEqual([ + "hunk.review.toggleHunkStaged", "hunk.review.toggleFileStaged", "hunk.review.toggleStagedView", "hunk.app.quit", diff --git a/packages/hunk/src/core/run/commandCatalog.ts b/packages/hunk/src/core/run/commandCatalog.ts index 730a7ddd0..23c1afad3 100644 --- a/packages/hunk/src/core/run/commandCatalog.ts +++ b/packages/hunk/src/core/run/commandCatalog.ts @@ -98,6 +98,14 @@ export interface AppCommandCatalogEntry { * from `[keybindings]` and dispatchable by id whether or not a menu presents them. */ const BUILTIN_COMMANDS = [ + { + 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", diff --git a/packages/hunk/src/core/vcs/types.ts b/packages/hunk/src/core/vcs/types.ts index 5332cff35..c8610a510 100644 --- a/packages/hunk/src/core/vcs/types.ts +++ b/packages/hunk/src/core/vcs/types.ts @@ -45,7 +45,10 @@ export interface VcsOperation { export interface VcsOperations { "working-tree-diff"?: VcsOperation & - Pick; + Pick< + ExtensionVcsWorkingTreeOperation, + "stageFile" | "unstageFile" | "stageHunk" | "unstageHunk" + >; "revision-show"?: VcsOperation; "stash-show"?: VcsOperation; } diff --git a/packages/hunk/src/extension-api/index.ts b/packages/hunk/src/extension-api/index.ts index d156203f6..668058076 100644 --- a/packages/hunk/src/extension-api/index.ts +++ b/packages/hunk/src/extension-api/index.ts @@ -160,6 +160,7 @@ export type { ExtensionVcsOperation, ExtensionVcsWorkingTreeOperation, ExtensionVcsFileMutation, + ExtensionVcsHunkMutation, ExtensionWorkingTreeFile, ExtensionWorkingTreePane, ExtensionVcsOperations, diff --git a/packages/hunk/src/extension-api/types.ts b/packages/hunk/src/extension-api/types.ts index 1cf17fd95..03373d408 100644 --- a/packages/hunk/src/extension-api/types.ts +++ b/packages/hunk/src/extension-api/types.ts @@ -1098,10 +1098,20 @@ export type ExtensionVcsFileMutation = ( 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; + /** Load a working-tree review and optionally offer explicit index mutations. */ export interface ExtensionVcsWorkingTreeOperation extends ExtensionVcsOperation { stageFile?: ExtensionVcsFileMutation; unstageFile?: ExtensionVcsFileMutation; + stageHunk?: ExtensionVcsHunkMutation; + unstageHunk?: ExtensionVcsHunkMutation; } /** Navigate and stage the host's current working-tree inventory from a mounted pane. */ diff --git a/packages/hunk/src/extensions/runExtension.test.ts b/packages/hunk/src/extensions/runExtension.test.ts index e5d7a964a..d9ac7a201 100644 --- a/packages/hunk/src/extensions/runExtension.test.ts +++ b/packages/hunk/src/extensions/runExtension.test.ts @@ -51,6 +51,7 @@ test("working-tree mutation conversion preserves optionality, inputs and user-fa 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", diff --git a/packages/hunk/src/extensions/runExtension.ts b/packages/hunk/src/extensions/runExtension.ts index 52f82160f..8381b99fe 100644 --- a/packages/hunk/src/extensions/runExtension.ts +++ b/packages/hunk/src/extensions/runExtension.ts @@ -497,7 +497,10 @@ export function toInternalVcsAdapter( : undefined; const internalWorkingTree = internalOperations["working-tree-diff"] as | (VcsOperation & - Pick) + Pick< + ExtensionVcsWorkingTreeOperation, + "stageFile" | "unstageFile" | "stageHunk" | "unstageHunk" + >) | undefined; if (workingTree && internalWorkingTree) { for (const name of ["stageFile", "unstageFile"] as const) { @@ -513,6 +516,20 @@ export function toInternalVcsAdapter( } } + 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); + } + }; + } + } + const history = adapterFields.history; const historyFields = isPlainObject(history) ? snapshotProperties(history, ["open", "planReview"]) diff --git a/packages/hunk/src/ui/App.tsx b/packages/hunk/src/ui/App.tsx index 8f4c7b7ad..524896ff5 100644 --- a/packages/hunk/src/ui/App.tsx +++ b/packages/hunk/src/ui/App.tsx @@ -351,8 +351,10 @@ export function App({ getSelectedFileId, getSelection: getExtensionSelection, } = extensionRuntime; + const [hunkActionFocused, setHunkActionFocused] = useState(false); const jumpToFile = useCallback( (fileId: string, options?: { alignFileHeaderTop?: boolean }) => { + setHunkActionFocused(false); review.selectFile(fileId, { alignFileHeaderTop: options?.alignFileHeaderTop }); }, [review.selectFile], @@ -1006,10 +1008,14 @@ export function App({ const workingTree = useWorkingTreeActions({ bootstrap, selectedFile, + selectedHunkIndex, filter: review.filter, createLease: createReviewCapabilityLease, selectReviewFile: jumpToFile, - focusFiles, + focusFiles: () => { + focusFiles(); + setHunkActionFocused(false); + }, setStagedView, runMutation: runVcsMutation, refreshAfterMutation: onVcsMutationCompleted, @@ -1029,10 +1035,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); }, }), @@ -1090,7 +1098,9 @@ export function App({ const appCommands = observeAppCommandDispatch( [ ...buildAppCommands({ - canToggleFileStaged: workingTree.canToggleSelected, + canToggleFileStaged: !hunkActionFocused && workingTree.canToggleSelected, + canToggleHunkStaged: hunkActionFocused && workingTree.canToggleSelectedHunk, + toggleHunkStaged: workingTree.toggleSelectedHunk, canSwitchStagedView: Boolean(workingTree.pane), toggleFileStaged: workingTree.toggleSelected, toggleStagedView: () => workingTree.switchView(!workingTree.staged), @@ -1109,6 +1119,7 @@ export function App({ if (activeReplyableNoteId) startUserNoteReply(activeReplyableNoteId); }, moveSelection: (scope, delta) => { + setHunkActionFocused(scope === "hunk" || scope === "annotated-hunk"); if (scope === "file" && workingTree.pane) workingTree.moveFile(delta); else review.moveSelection(scope, delta); }, @@ -1298,18 +1309,9 @@ 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); - }} + onSelectFile={extensionNavigationBindings.onSelectFile} + onSelectHunk={extensionNavigationBindings.onSelectHunk} + onRevealLine={extensionNavigationBindings.onRevealLine} onRenderFailure={ pane.key === HUNK_FILES_PANE_KEY ? undefined : () => reportPaneRenderFailure(pane) } @@ -1386,9 +1388,14 @@ export function App({ pane={workingTree.pane} theme={activeTheme} width={terminal.width} - canToggle={workingTree.canToggleSelected} + hunkFocused={hunkActionFocused} + canToggle={ + hunkActionFocused ? workingTree.canToggleSelectedHunk : workingTree.canToggleSelected + } switchView={workingTree.switchView} - toggleSelected={workingTree.toggleSelected} + toggleSelected={ + hunkActionFocused ? workingTree.toggleSelectedHunk : workingTree.toggleSelected + } /> )} @@ -1494,6 +1501,9 @@ export function App({ currentLinePaintRequested={currentLinePaintRequested} onCurrentLinePaintChange={onCurrentLinePaintChange} onViewportLineCursorChange={review.anchorLineCursor} + onHunkFocus={() => setHunkActionFocused(true)} + canToggleHunkStaged={workingTree.canToggleHunk} + onToggleHunkStaged={workingTree.pane ? workingTree.toggleHunk : undefined} /> diff --git a/packages/hunk/src/ui/AppHost.working-tree.test.tsx b/packages/hunk/src/ui/AppHost.working-tree.test.tsx index 90ad639a3..228941f49 100644 --- a/packages/hunk/src/ui/AppHost.working-tree.test.tsx +++ b/packages/hunk/src/ui/AppHost.working-tree.test.tsx @@ -101,6 +101,34 @@ afterEach(async () => { }); describe("working-tree stream actions", () => { + 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, diff --git a/packages/hunk/src/ui/components/chrome/WorkingTreeBar.tsx b/packages/hunk/src/ui/components/chrome/WorkingTreeBar.tsx index 18b52044c..76022f262 100644 --- a/packages/hunk/src/ui/components/chrome/WorkingTreeBar.tsx +++ b/packages/hunk/src/ui/components/chrome/WorkingTreeBar.tsx @@ -9,6 +9,7 @@ export function WorkingTreeBar({ theme, width, canToggle, + hunkFocused = false, switchView, toggleSelected, }: { @@ -16,11 +17,15 @@ export function WorkingTreeBar({ theme: AppTheme; width: number; canToggle: boolean; + hunkFocused?: boolean; switchView: (staged: boolean) => void; toggleSelected: () => void; }) { const selected = pane.files.find((file) => file.path === pane.selectedPath); - const action = pane.busy ? "Working…" : selected?.unstaged ? "Stage file" : "Unstage file"; + const stage = hunkFocused ? !pane.staged : selected?.unstaged; + const action = pane.busy + ? "Working…" + : `${stage ? "Stage" : "Unstage"} ${hunkFocused ? "hunk" : "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}) `, diff --git a/packages/hunk/src/ui/components/panes/DiffPane.tsx b/packages/hunk/src/ui/components/panes/DiffPane.tsx index 1df5b4f73..de568b176 100644 --- a/packages/hunk/src/ui/components/panes/DiffPane.tsx +++ b/packages/hunk/src/ui/components/panes/DiffPane.tsx @@ -1,4 +1,4 @@ -import { recordMousePress } from "../../lib/mousePressSequence"; +import { mousePressSequence, recordMousePress } from "../../lib/mousePressSequence"; import { MouseButton, type MouseEvent as TuiMouseEvent, @@ -364,6 +364,9 @@ export function DiffPane({ onCurrentLinePaintChange, onViewportCenteredHunkChange, onViewportLineCursorChange, + onHunkFocus, + canToggleHunkStaged, + onToggleHunkStaged, }: { codeHorizontalOffset?: number; diffContentWidth: number; @@ -437,6 +440,9 @@ export function DiffPane({ onCurrentLinePaintChange?: (update: ExtensionCurrentLinePaintUpdate) => void; onViewportCenteredHunkChange?: (fileId: string, hunkIndex: number) => void; onViewportLineCursorChange?: (cursor: LineCursor) => void; + onHunkFocus?: () => void; + canToggleHunkStaged?: (fileId: string, hunkIndex: number) => boolean; + onToggleHunkStaged?: (fileId: string, hunkIndex: number) => boolean; }) { const renderTopChrome = showTopChrome ?? !pagerMode; const renderer = useRenderer(); @@ -803,8 +809,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); @@ -1514,6 +1522,7 @@ 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; @@ -1536,18 +1545,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 = { @@ -1572,7 +1602,19 @@ export function DiffPane({ event.preventDefault(); event.stopPropagation(); }, - [copySelectionContext, renderer, resolveCopySelectionPoint, suppressNativeSelection], + [ + copySelectionContext, + renderer, + resolveCopySelectionPoint, + suppressNativeSelection, + onToggleHunkStaged, + canToggleHunkStaged, + lineCursors, + fileSectionLayouts, + sectionGeometry, + layout, + diffContentWidth, + ], ); /** Extend the active diff text selection while the pointer moves. */ @@ -1630,6 +1672,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; @@ -1651,6 +1695,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; @@ -1664,6 +1722,7 @@ export function DiffPane({ side: resolveCopySelectionSide(current.anchor.column, layout, diffContentWidth), }); if (clickedCursor && onViewportLineCursorChange) { + onHunkFocus?.(); onViewportLineCursorChange(clickedCursor); return; } @@ -1690,6 +1749,8 @@ export function DiffPane({ layout, lineCursors, onViewportLineCursorChange, + onToggleHunkStaged, + onHunkFocus, resolveCopySelectionPoint, sectionGeometry, ], diff --git a/packages/hunk/src/ui/components/ui-components.test.tsx b/packages/hunk/src/ui/components/ui-components.test.tsx index 02dc7e670..6c82b888e 100644 --- a/packages/hunk/src/ui/components/ui-components.test.tsx +++ b/packages/hunk/src/ui/components/ui-components.test.tsx @@ -768,6 +768,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", 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/useWorkingTreeActions.ts b/packages/hunk/src/ui/hooks/useWorkingTreeActions.ts index 9380e52c8..639a7f6d6 100644 --- a/packages/hunk/src/ui/hooks/useWorkingTreeActions.ts +++ b/packages/hunk/src/ui/hooks/useWorkingTreeActions.ts @@ -9,7 +9,8 @@ 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 type { ExtensionWorkingTreePane } from "../../extension-api/types"; +import { summarizeHunk } from "../../core/changeset/hunkSummary"; +import type { ExtensionWorkingTreeFile, ExtensionWorkingTreePane } from "../../extension-api/types"; import type { ExtensionCapabilityLease } from "../lib/extensionCapabilityLease"; import type { WorkspaceWriteRunner } from "./useExtensionWorkspaceControls"; @@ -28,6 +29,7 @@ export function hasWorkingTreeInventory(bootstrap: AppBootstrap) { export function useWorkingTreeActions({ bootstrap, selectedFile, + selectedHunkIndex, filter, createLease, selectReviewFile, @@ -39,6 +41,7 @@ export function useWorkingTreeActions({ }: { bootstrap: AppBootstrap; selectedFile: DiffFile | undefined; + selectedHunkIndex: number; filter: string; createLease: () => ExtensionCapabilityLease; selectReviewFile: (fileId: string, options: { alignFileHeaderTop: true }) => void; @@ -144,47 +147,44 @@ export function useWorkingTreeActions({ [files, operation], ); - const toggleStaged = useCallback( - (path: string) => { - // A file double-click may arrive while its first click is switching tabs. The path is - // explicit and attested, so let that gesture finish; keyboard repeats remain blocked. - if ( - !lease.isLive() || - mutatingRef.current || - !canToggle(path) || - bootstrap.input.kind !== "vcs" - ) - return; - const file = files.find((candidate) => candidate.path === path)!; - const mutate = file.unstaged ? operation?.stageFile : operation?.unstageFile; - if (!mutate) return; - const input = bootstrap.input; + /** Keep file and hunk writes on the same busy, error, refresh, and graceful-exit path. */ + const startMutation = useCallback( + ( + file: ExtensionWorkingTreeFile, + action: { + mutate: () => Promise; + progress: string; + complete: string; + followStaged?: boolean; + }, + ) => { busyRef.current = true; mutatingRef.current = true; setBusy(true); - showNotice(`${file.unstaged ? "Staging" : "Unstaging"} ${file.path}…`); + showNotice(action.progress); void runMutation(async () => { let succeeded = false; try { - await mutate(input, file, { cwd: bootstrap.changeset.sourceLabel }); + await action.mutate(); succeeded = true; - pendingPathRef.current = { path: file.path, changeset: bootstrap.changeset }; + if (action.followStaged !== undefined) + pendingPathRef.current = { path: file.path, changeset: bootstrap.changeset }; } finally { - // A failed Git command may still have changed state. Always reconcile before unlocking. + // A failed provider command can partially change state, so reconcile before unlocking. await refreshAfterMutation( - succeeded - ? { root: bootstrap.changeset.sourceLabel, staged: file.unstaged } + succeeded && action.followStaged !== undefined + ? { root: bootstrap.changeset.sourceLabel, staged: action.followStaged } : undefined, ); } }) - .then((started) => { + .then((started) => showNotice( started - ? `${file.unstaged ? "Staged" : "Unstaged"} ${file.path}.` + ? action.complete : "Another workspace operation is active or Hunk is shutting down.", - ); - }) + ), + ) .catch((error) => { const detail = error instanceof HunkUserError @@ -200,7 +200,70 @@ export function useWorkingTreeActions({ setBusy(busyRef.current); }); }, - [lease, canToggle, bootstrap, files, operation, runMutation, refreshAfterMutation, showNotice], + [bootstrap.changeset, runMutation, refreshAfterMutation, showNotice], + ); + + const toggleStaged = useCallback( + (path: string) => { + // A double-click may finish while its first click switches tabs; its explicit path is attested. + if ( + !lease.isLive() || + mutatingRef.current || + !canToggle(path) || + bootstrap.input.kind !== "vcs" + ) + return; + const file = files.find((candidate) => candidate.path === path)!; + const mutate = file.unstaged ? operation?.stageFile : operation?.unstageFile; + if (!mutate) return; + const input = bootstrap.input; + startMutation(file, { + mutate: () => mutate(input, file, { cwd: bootstrap.changeset.sourceLabel }), + progress: `${file.unstaged ? "Staging" : "Unstaging"} ${file.path}…`, + complete: `${file.unstaged ? "Staged" : "Unstaged"} ${file.path}.`, + followStaged: file.unstaged, + }); + }, + [lease, canToggle, bootstrap, files, operation, startMutation], + ); + + 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(file, { + 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], ); const pane = useMemo( @@ -224,6 +287,12 @@ export function useWorkingTreeActions({ staged, selectedPath, canToggleSelected: enabled && canToggle(selectedPath), + canToggleSelectedHunk: enabled && canToggleHunk(selectedFile?.id, selectedHunkIndex), + canToggleHunk, + toggleHunk, + toggleSelectedHunk: () => { + if (selectedFile) toggleHunk(selectedFile.id, selectedHunkIndex); + }, toggleSelected: () => { if (!busyRef.current && selectedPath) toggleStaged(selectedPath); }, diff --git a/packages/hunk/src/ui/lib/appCommands.test.ts b/packages/hunk/src/ui/lib/appCommands.test.ts index 097053b38..4c12474da 100644 --- a/packages/hunk/src/ui/lib/appCommands.test.ts +++ b/packages/hunk/src/ui/lib/appCommands.test.ts @@ -108,6 +108,20 @@ describe("built-in command chords", () => { ); expect(actions).toHaveLength(2); }); + 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) => @@ -289,7 +303,11 @@ describe("builtinCommandKeyDefaults", () => { expect(defaults.map((entry) => entry.id)).toEqual(commands.map((command) => command.id)); expect( commands.filter((command) => !command.publicToExtensions).map((command) => command.id), - ).toEqual(["hunk.review.toggleFileStaged", "hunk.review.toggleStagedView"]); + ).toEqual([ + "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 9d4feca9a..389111112 100644 --- a/packages/hunk/src/ui/lib/appCommands.ts +++ b/packages/hunk/src/ui/lib/appCommands.ts @@ -109,6 +109,8 @@ interface BuiltinCommandHandler { /** The callbacks the built-in command set drives; App supplies its own handlers. */ export interface BuildAppCommandsOptions { canToggleFileStaged?: boolean; + canToggleHunkStaged?: boolean; + toggleHunkStaged?: () => void; canSwitchStagedView?: boolean; toggleFileStaged?: () => void; toggleStagedView?: () => void; @@ -179,6 +181,10 @@ function builtinCommandHandlers( options: BuildAppCommandsOptions, ): Record { return { + "hunk.review.toggleHunkStaged": { + isEnabled: () => Boolean(options.canToggleHunkStaged), + run: () => options.toggleHunkStaged?.(), + }, "hunk.review.toggleFileStaged": { isEnabled: () => Boolean(options.canToggleFileStaged), run: () => options.toggleFileStaged?.(), diff --git a/packages/hunk/src/ui/lib/appMenus.ts b/packages/hunk/src/ui/lib/appMenus.ts index a89121754..db24a0415 100644 --- a/packages/hunk/src/ui/lib/appMenus.ts +++ b/packages/hunk/src/ui/lib/appMenus.ts @@ -143,6 +143,7 @@ export function buildAppMenus({ { commandId: "hunk.review.focusFilter", label: "Focus filter" }, { commandId: "hunk.review.editSelectedFile", label: "Open file in editor" }, { commandId: "hunk.review.toggleFileStaged" }, + { commandId: "hunk.review.toggleHunkStaged" }, { commandId: "hunk.review.toggleStagedView" }, { commandId: "hunk.app.refresh", label: "Reload" }, SEPARATOR, diff --git a/packages/hunk/src/ui/lib/helpContent.ts b/packages/hunk/src/ui/lib/helpContent.ts index a36eddd01..9c55d77e1 100644 --- a/packages/hunk/src/ui/lib/helpContent.ts +++ b/packages/hunk/src/ui/lib/helpContent.ts @@ -106,8 +106,8 @@ const HELP_SECTIONS: readonly HelpSectionSpec[] = [ title: "Review", entries: [ { - commandIds: ["hunk.review.toggleFileStaged"], - description: "stage / unstage selected file", + commandIds: ["hunk.review.toggleFileStaged", "hunk.review.toggleHunkStaged"], + description: "stage / unstage focused file or hunk", }, { commandIds: ["hunk.review.toggleStagedView"], diff --git a/test/pty/scroll.test.ts b/test/pty/scroll.test.ts index 50c131a5b..920c45c78 100644 --- a/test/pty/scroll.test.ts +++ b/test/pty/scroll.test.ts @@ -167,7 +167,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 index d8fbb69f5..1f013bfbc 100644 --- a/test/pty/working-tree-integration.test.ts +++ b/test/pty/working-tree-integration.test.ts @@ -42,6 +42,138 @@ function doubleClickSidebarFile(session: Session, path: string) { } describe("PTY working-tree staging", () => { + 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 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"); + 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"); diff --git a/website/src/content/docs/docs/extend/vcs-adapters.md b/website/src/content/docs/docs/extend/vcs-adapters.md index 9245bd7be..41709c2d2 100644 --- a/website/src/content/docs/docs/extend/vcs-adapters.md +++ b/website/src/content/docs/docs/extend/vcs-adapters.md @@ -53,6 +53,11 @@ and throw `HunkExtensionUserError` for failures. The host tracks started writes and refreshes status and diff before allowing another action. These are terminal-host actions, not implicit remote capabilities. +API v18 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. + ## 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. From f3a2dba8bcb63bb1c3343641c7a501c7c4b0edc8 Mon Sep 17 00:00:00 2001 From: yusing Date: Sat, 5 Sep 2026 08:36:26 +0000 Subject: [PATCH 03/27] feat(review): discard and stash only the selected file Add exact-file confirmation and stash-message prompts with contextual keys and mouse parity. Preserve unrelated live changes and stash trees, partial staging, tracked modes, and recoverable published stashes when cleanup fails. --- .changeset/selected-file-discard-stash.md | 5 + docs/extension-architecture.md | 6 +- docs/extensions.md | 9 + docs/keybindings.md | 10 + packages/hunk-git/src/commands.ts | 15 +- packages/hunk-git/src/fileActions.test.ts | 219 ++++++++++++++++++ packages/hunk-git/src/fileActions.ts | 217 +++++++++++++++++ packages/hunk-git/src/index.ts | 5 + .../hunk/src/core/run/commandCatalog.test.ts | 2 + packages/hunk/src/core/run/commandCatalog.ts | 18 ++ packages/hunk/src/core/vcs/types.ts | 2 +- packages/hunk/src/extension-api/index.ts | 1 + packages/hunk/src/extension-api/types.ts | 21 +- packages/hunk/src/extensions/runExtension.ts | 25 +- packages/hunk/src/ui/App.tsx | 19 ++ .../src/ui/AppHost.extension-sidebar.test.tsx | 2 + .../hunk/src/ui/AppHost.extensions.test.tsx | 2 +- .../hunk/src/ui/AppHost.keybindings.test.tsx | 2 +- .../hunk/src/ui/AppHost.working-tree.test.tsx | 13 +- .../ui/components/chrome/ConfirmDialog.tsx | 4 +- .../components/chrome/WorkingTreeDialog.tsx | 124 ++++++++++ .../src/ui/hooks/useAppKeyboardShortcuts.ts | 28 +++ .../src/ui/hooks/useWorkingTreeActions.ts | 92 +++++++- packages/hunk/src/ui/lib/appCommands.test.ts | 31 +++ packages/hunk/src/ui/lib/appCommands.ts | 12 + packages/hunk/src/ui/lib/appMenus.ts | 2 + .../hunk/src/ui/lib/extensionCommands.test.ts | 11 +- packages/hunk/src/ui/lib/helpContent.ts | 8 + test/pty/extensions-integration.test.ts | 10 +- test/pty/working-tree-integration.test.ts | 123 +++++++++- .../content/docs/docs/extend/vcs-adapters.md | 6 + 31 files changed, 1019 insertions(+), 25 deletions(-) create mode 100644 .changeset/selected-file-discard-stash.md create mode 100644 packages/hunk-git/src/fileActions.test.ts create mode 100644 packages/hunk-git/src/fileActions.ts create mode 100644 packages/hunk/src/ui/components/chrome/WorkingTreeDialog.tsx 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/docs/extension-architecture.md b/docs/extension-architecture.md index 0c0f036bd..ae3d57b05 100644 --- a/docs/extension-architecture.md +++ b/docs/extension-architecture.md @@ -347,7 +347,11 @@ binds status paths to the existing review navigation and AppHost's tracked mutat refresh lifecycle. 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. +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. `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 diff --git a/docs/extensions.md b/docs/extensions.md index bc203f69c..1355256bf 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -545,6 +545,15 @@ attestation, including its comparison base. Apply original patch bytes, never de 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 v19 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. + Mounted panes receive optional `props.workingTree` with this inventory, `selectedPath`, `staged`, `busy`, `selectFile(path)`, and `toggleStaged(path)`. These controls expire on review reload. Selecting an inactive-side file switches stream tabs without reducing the review to one file. diff --git a/docs/keybindings.md b/docs/keybindings.md index d1e514f95..46ad4a8c2 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -70,6 +70,14 @@ Double-clicking a code line applies its hunk on release, provided the pointer di 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. +**d** opens discard choices for the selected file: 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 stash message input; Enter stashes and Escape cancels. The stash +contains only that file's changes, including its staged/unstaged split, not unrelated staged files. +These actions always target the selected file, even in hunk scope. They reject stale targets and +renames whose former path has been recreated. Stashing requires an initial commit. 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. Use `/` to focus the filter. Actions wait for Git and its refreshed diff before accepting another mutation. Outside this @@ -86,6 +94,7 @@ remain remappable, and an explicit user binding takes precedence over contextual | `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 changes | `d` | | `hunk.review.editActiveNote` | Edit the active review note | `E` | | `hunk.review.editSelectedFile` | Open the selected file in your editor | `e` | | `hunk.review.focusFilter` | Focus the file filter | `/` | @@ -107,6 +116,7 @@ remain remappable, and an explicit user binding takes precedence over contextual | `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.stashSelectedFile` | Stash selected file | `s` | | `hunk.review.stepDown` | Scroll down one row | `down`, `j` | | `hunk.review.stepUp` | Scroll up one row | `up`, `k` | | `hunk.review.toggleFileStaged` | Stage / unstage selected file | `space` | diff --git a/packages/hunk-git/src/commands.ts b/packages/hunk-git/src/commands.ts index df3f92049..2de5123f4 100644 --- a/packages/hunk-git/src/commands.ts +++ b/packages/hunk-git/src/commands.ts @@ -564,18 +564,23 @@ export async function runGitMutation({ cwd = process.cwd(), gitExecutable = "git", stdin, -}: RunGitTextOptions & { stdin?: string | Uint8Array }): Promise { + env, +}: RunGitTextOptions & { + stdin?: string | Uint8Array; + env?: Record; +}): Promise { try { - const process = Bun.spawn([gitExecutable, ...args], { + 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([ - process.exited, - new Response(process.stdout).text(), - new Response(process.stderr).text(), + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), ]); if (exitCode !== 0) throw translateGitExitFailure(input, stderr); return stdout; diff --git a/packages/hunk-git/src/fileActions.test.ts b/packages/hunk-git/src/fileActions.test.ts new file mode 100644 index 000000000..c0ebedd78 --- /dev/null +++ b/packages/hunk-git/src/fileActions.test.ts @@ -0,0 +1,219 @@ +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 } 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 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..c4955584b --- /dev/null +++ b/packages/hunk-git/src/fileActions.ts @@ -0,0 +1,217 @@ +/** + * 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, +) { + const { root, file } = verifyGitWorkingTreeFile(input, expected, context); + requireUnoccupiedRenameSource(root, 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 = [file.path, ...(file.previousPath ? [file.previousPath] : [])]; + 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 label = message || `Hunk: ${file.path}`; + 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 (file.untracked) { + await runGitMutation({ ...base, env, args: ["read-tree", "--empty"] }); + await runGitMutation({ ...base, env, args: ["--literal-pathspecs", "add", "--", file.path] }); + 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] }); + } else { + // Preserve tracked type/mode, including emulated symlinks and ignored executable changes. + const presentPaths = paths.filter((path) => { + try { + lstatSync(join(root, path)); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } + }); + const absentPaths = paths.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(); + 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. + verifyGitWorkingTreeFile(input, expected, context); + if (file.untracked) unlinkSync(join(root, file.path)); + else + await runGitMutation({ + ...base, + args: [ + "--literal-pathspecs", + "restore", + `--source=${head}`, + "--staged", + "--worktree", + "--", + ...paths, + ], + }); + } catch (error) { + throw new HunkExtensionUserError( + `Stash ${stash.slice(0, 12)} was created, but ${file.path} 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/index.ts b/packages/hunk-git/src/index.ts index e6df03d48..c49658d2a 100644 --- a/packages/hunk-git/src/index.ts +++ b/packages/hunk-git/src/index.ts @@ -1,3 +1,4 @@ +import { discardGitFile, stashGitFile } from "./fileActions"; import { mutateGitHunkStaging } from "./hunkStaging"; import { createHash } from "node:crypto"; import fs from "node:fs"; @@ -409,6 +410,10 @@ export function createGitVcsAdapter({ untrackedPaths, }; }, + discardFile: (input, file, scope, { cwd }) => + discardGitFile(input, file, scope, { cwd, gitExecutable }), + stashFile: (input, file, message, { cwd }) => + stashGitFile(input, file, message, { cwd, gitExecutable }), stageFile: (input, file, { cwd }) => mutateGitFileStaging(input, file, { cwd, gitExecutable }, true), unstageFile: (input, file, { cwd }) => diff --git a/packages/hunk/src/core/run/commandCatalog.test.ts b/packages/hunk/src/core/run/commandCatalog.test.ts index f77e25ff3..8fefddc79 100644 --- a/packages/hunk/src/core/run/commandCatalog.test.ts +++ b/packages/hunk/src/core/run/commandCatalog.test.ts @@ -65,6 +65,8 @@ describe("app command catalog", () => { (command) => command.id, ), ).toEqual([ + "hunk.review.discardSelectedFile", + "hunk.review.stashSelectedFile", "hunk.review.toggleHunkStaged", "hunk.review.toggleFileStaged", "hunk.review.toggleStagedView", diff --git a/packages/hunk/src/core/run/commandCatalog.ts b/packages/hunk/src/core/run/commandCatalog.ts index 23c1afad3..48253c89c 100644 --- a/packages/hunk/src/core/run/commandCatalog.ts +++ b/packages/hunk/src/core/run/commandCatalog.ts @@ -98,6 +98,24 @@ 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 changes", + category: "review", + defaultKeys: ["d"], + locus: "host-only", + publicToExtensions: false, + closesMenu: true, + }, + { + id: "hunk.review.stashSelectedFile", + title: "Stash selected file", + category: "review", + defaultKeys: ["s"], + locus: "host-only", + publicToExtensions: false, + closesMenu: true, + }, { id: "hunk.review.toggleHunkStaged", title: "Stage / unstage selected hunk", diff --git a/packages/hunk/src/core/vcs/types.ts b/packages/hunk/src/core/vcs/types.ts index c8610a510..6948aea1f 100644 --- a/packages/hunk/src/core/vcs/types.ts +++ b/packages/hunk/src/core/vcs/types.ts @@ -47,7 +47,7 @@ export interface VcsOperations { "working-tree-diff"?: VcsOperation & Pick< ExtensionVcsWorkingTreeOperation, - "stageFile" | "unstageFile" | "stageHunk" | "unstageHunk" + "stageFile" | "unstageFile" | "stageHunk" | "unstageHunk" | "discardFile" | "stashFile" >; "revision-show"?: VcsOperation; "stash-show"?: VcsOperation; diff --git a/packages/hunk/src/extension-api/index.ts b/packages/hunk/src/extension-api/index.ts index 668058076..fa48f72af 100644 --- a/packages/hunk/src/extension-api/index.ts +++ b/packages/hunk/src/extension-api/index.ts @@ -161,6 +161,7 @@ export type { ExtensionVcsWorkingTreeOperation, ExtensionVcsFileMutation, ExtensionVcsHunkMutation, + ExtensionVcsDiscardScope, ExtensionWorkingTreeFile, ExtensionWorkingTreePane, ExtensionVcsOperations, diff --git a/packages/hunk/src/extension-api/types.ts b/packages/hunk/src/extension-api/types.ts index 03373d408..a02f5a30c 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 = 21; export type HunkExtensionApiVersion = typeof HUNK_EXTENSION_API_VERSION; export type ExtensionNotifyType = "info" | "warning" | "error"; @@ -1106,12 +1106,29 @@ export type ExtensionVcsHunkMutation = ( context: ExtensionVcsLoadContext, ) => Promise; -/** Load a working-tree review and optionally offer explicit index mutations. */ +/** 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; + /** 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; } /** Navigate and stage the host's current working-tree inventory from a mounted pane. */ diff --git a/packages/hunk/src/extensions/runExtension.ts b/packages/hunk/src/extensions/runExtension.ts index 8381b99fe..4eee35794 100644 --- a/packages/hunk/src/extensions/runExtension.ts +++ b/packages/hunk/src/extensions/runExtension.ts @@ -499,7 +499,7 @@ export function toInternalVcsAdapter( | (VcsOperation & Pick< ExtensionVcsWorkingTreeOperation, - "stageFile" | "unstageFile" | "stageHunk" | "unstageHunk" + "stageFile" | "unstageFile" | "stageHunk" | "unstageHunk" | "discardFile" | "stashFile" >) | undefined; if (workingTree && internalWorkingTree) { @@ -530,6 +530,29 @@ export function toInternalVcsAdapter( } } + 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); + } + }; + } + } + const history = adapterFields.history; const historyFields = isPlainObject(history) ? snapshotProperties(history, ["open", "planReview"]) diff --git a/packages/hunk/src/ui/App.tsx b/packages/hunk/src/ui/App.tsx index 524896ff5..0ebdcfecf 100644 --- a/packages/hunk/src/ui/App.tsx +++ b/packages/hunk/src/ui/App.tsx @@ -1,3 +1,4 @@ +import { WorkingTreeDialog } from "./components/chrome/WorkingTreeDialog"; import { recordMousePress } from "./lib/mousePressSequence"; import type { BoxRenderable, @@ -1098,6 +1099,10 @@ export function App({ const appCommands = observeAppCommandDispatch( [ ...buildAppCommands({ + canDiscardSelectedFile: workingTree.canDiscardSelected, + canStashSelectedFile: workingTree.canStashSelected, + discardSelectedFile: workingTree.discardSelected, + stashSelectedFile: workingTree.stashSelected, canToggleFileStaged: !hunkActionFocused && workingTree.canToggleSelected, canToggleHunkStaged: hunkActionFocused && workingTree.canToggleSelectedHunk, toggleHunkStaged: workingTree.toggleSelectedHunk, @@ -1209,6 +1214,7 @@ export function App({ } = useMenuController(menus); useAppKeyboardShortcuts({ + workingTreeDialog: workingTree, activeMenuId, activateCurrentMenuItem, closeAgentSkill, @@ -1594,6 +1600,19 @@ export function App({ /> ) : null} + {workingTree.prompt ? ( + + ) : null} + {saveConfigPromptOpen ? ( { ` });\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.keybindings.test.tsx b/packages/hunk/src/ui/AppHost.keybindings.test.tsx index 5e5b17122..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[] = []; diff --git a/packages/hunk/src/ui/AppHost.working-tree.test.tsx b/packages/hunk/src/ui/AppHost.working-tree.test.tsx index 228941f49..d721c5c71 100644 --- a/packages/hunk/src/ui/AppHost.working-tree.test.tsx +++ b/packages/hunk/src/ui/AppHost.working-tree.test.tsx @@ -1,7 +1,7 @@ import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { act } from "react"; -import { rmSync, writeFileSync, renameSync } from "node:fs"; +import { 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"; @@ -101,6 +101,17 @@ afterEach(async () => { }); describe("working-tree stream actions", () => { + test("an external edit after discard consent is shown refuses the stale target", async () => { + const root = await createReview(); + 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("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({ 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/WorkingTreeDialog.tsx b/packages/hunk/src/ui/components/chrome/WorkingTreeDialog.tsx new file mode 100644 index 000000000..bb0f80121 --- /dev/null +++ b/packages/hunk/src/ui/components/chrome/WorkingTreeDialog.tsx @@ -0,0 +1,124 @@ +/** 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 unstaged = prompt.file.staged && prompt.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); + // Quote control characters and preserve every filename space instead of treating paths as prose. + const pathLines = wrapTextByWidth(JSON.stringify(prompt.file.path), 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 ? "Only this file. Message (optional):" : "Cannot undo discarded changes.", + bodyWidth, + )} + + + ) : null} + {fieldRows > 0 ? ( + + {stash ? ( + + ) : ( + + {fitText( + unstaged + ? "Unstaged only keeps this file's staged changes." + : "Unstaged-only discard is unavailable for this file.", + bodyWidth, + )} + + )} + + ) : null} + + ); +} 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/useWorkingTreeActions.ts b/packages/hunk/src/ui/hooks/useWorkingTreeActions.ts index 639a7f6d6..fcc39f083 100644 --- a/packages/hunk/src/ui/hooks/useWorkingTreeActions.ts +++ b/packages/hunk/src/ui/hooks/useWorkingTreeActions.ts @@ -1,5 +1,5 @@ /** - * Coordinates file staging and staged/unstaged stream tabs against the mounted review. + * 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. */ @@ -10,10 +10,21 @@ 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 { ExtensionWorkingTreeFile, ExtensionWorkingTreePane } from "../../extension-api/types"; +import type { + ExtensionVcsDiscardScope, + ExtensionWorkingTreeFile, + ExtensionWorkingTreePane, +} from "../../extension-api/types"; import type { ExtensionCapabilityLease } from "../lib/extensionCapabilityLease"; import type { WorkspaceWriteRunner } from "./useExtensionWorkspaceControls"; +/** Capture the exact file and generation the user is being asked to change. */ +export interface WorkingTreePrompt { + kind: "discard" | "stash"; + file: ExtensionWorkingTreeFile; + lease: ExtensionCapabilityLease; +} + /** Keep working-tree UI absent from historical, pager and non-provider reviews. */ export function hasWorkingTreeInventory(bootstrap: AppBootstrap) { return ( @@ -78,6 +89,25 @@ export function useWorkingTreeActions({ files[0]?.path ?? null; 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 = pendingPathRef.current; @@ -266,6 +296,54 @@ export function useWorkingTreeActions({ [canToggleHunk, bootstrap, files, operation, staged, startMutation], ); + const selectedStatus = files.find((file) => file.path === selectedPath); + const actionableFile = + enabled && selectedStatus && !selectedStatus.conflicted && !selectedStatus.unavailableReason; + const canDiscardSelected = Boolean(actionableFile && operation?.discardFile); + const canStashSelected = Boolean(actionableFile && operation?.stashFile); + + /** Open a generation-bound prompt; rapid repeated keys cannot replace its captured target. */ + const openPrompt = (kind: WorkingTreePrompt["kind"]) => { + if ( + !lease.isLive() || + busyRef.current || + getPrompt() || + !selectedStatus || + !(kind === "discard" ? canDiscardSelected : canStashSelected) + ) + return; + const pending = { kind, file: selectedStatus, 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 { file, kind } = pending; + if (kind === "discard" && scope === "unstaged" && !(file.staged && file.unstaged)) return; + const input = bootstrap.input; + const context = { cwd: bootstrap.changeset.sourceLabel }; + const discard = operation?.discardFile; + const stash = operation?.stashFile; + if (kind === "discard" ? !discard : !stash) return; + const stashMessage = messageRef.current; + cancelPrompt(); + startMutation(file, { + mutate: () => + kind === "discard" + ? discard!(input, file, scope, context) + : stash!(input, file, stashMessage, context), + progress: `${kind === "discard" ? "Discarding" : "Stashing"} ${file.path}…`, + complete: + kind === "discard" + ? `Discarded ${scope} changes in ${file.path}.` + : `Stashed ${file.path}.`, + }); + }; + const pane = useMemo( () => enabled @@ -282,6 +360,16 @@ export function useWorkingTreeActions({ ); return { + prompt: promptState?.lease.isLive() ? promptState : null, + getPrompt, + cancelPrompt, + acceptPrompt, + message, + setMessage, + canDiscardSelected, + canStashSelected, + discardSelected: () => openPrompt("discard"), + stashSelected: () => openPrompt("stash"), pane, busy, staged, diff --git a/packages/hunk/src/ui/lib/appCommands.test.ts b/packages/hunk/src/ui/lib/appCommands.test.ts index 4c12474da..403d21bbc 100644 --- a/packages/hunk/src/ui/lib/appCommands.test.ts +++ b/packages/hunk/src/ui/lib/appCommands.test.ts @@ -108,6 +108,35 @@ describe("built-in command chords", () => { ); 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("focused hunk staging owns Space without invoking file staging", () => { const actions: string[] = []; const { commands } = createTestCommands(undefined, { @@ -304,6 +333,8 @@ describe("builtinCommandKeyDefaults", () => { 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", diff --git a/packages/hunk/src/ui/lib/appCommands.ts b/packages/hunk/src/ui/lib/appCommands.ts index 389111112..95b6cc384 100644 --- a/packages/hunk/src/ui/lib/appCommands.ts +++ b/packages/hunk/src/ui/lib/appCommands.ts @@ -108,6 +108,10 @@ 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; @@ -181,6 +185,14 @@ 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?.(), diff --git a/packages/hunk/src/ui/lib/appMenus.ts b/packages/hunk/src/ui/lib/appMenus.ts index db24a0415..21584824d 100644 --- a/packages/hunk/src/ui/lib/appMenus.ts +++ b/packages/hunk/src/ui/lib/appMenus.ts @@ -142,6 +142,8 @@ export function buildAppMenus({ { 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.discardSelectedFile" }, + { commandId: "hunk.review.stashSelectedFile" }, { commandId: "hunk.review.toggleFileStaged" }, { commandId: "hunk.review.toggleHunkStaged" }, { commandId: "hunk.review.toggleStagedView" }, 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/helpContent.ts b/packages/hunk/src/ui/lib/helpContent.ts index 9c55d77e1..dbea262b4 100644 --- a/packages/hunk/src/ui/lib/helpContent.ts +++ b/packages/hunk/src/ui/lib/helpContent.ts @@ -99,6 +99,14 @@ const HELP_SECTIONS: readonly HelpSectionSpec[] = [ ], description: "lines / wrap / metadata / menu", }, + { + commandIds: ["hunk.review.discardSelectedFile"], + description: "discard selected file changes (confirmed)", + }, + { + commandIds: ["hunk.review.stashSelectedFile"], + description: "stash selected file with a message", + }, { commandIds: ["hunk.review.editSelectedFile"], description: "open file in $EDITOR" }, ], }, diff --git a/test/pty/extensions-integration.test.ts b/test/pty/extensions-integration.test.ts index 625847ad4..c76e3a4a6 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: [ diff --git a/test/pty/working-tree-integration.test.ts b/test/pty/working-tree-integration.test.ts index 1f013bfbc..c27acad60 100644 --- a/test/pty/working-tree-integration.test.ts +++ b/test/pty/working-tree-integration.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; -import { rmSync, writeFileSync } from "node:fs"; +import { readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { createTestWorkingTreeRepo, runTestGit } from "../helpers/working-tree"; import { createPtyHarness } from "./harness"; @@ -42,6 +42,127 @@ function doubleClickSidebarFile(session: Session, path: string) { } describe("PTY working-tree staging", () => { + 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 session.press("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", "--no-extensions", "--mode", "stack"], + cols: 40, + rows: 18, + }); + try { + await session.waitForText("later alpha", { timeout: 15_000 }); + await session.press("d"); + 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", "--no-extensions", "--mode", "stack", "--", path], + cols: 40, + rows: 14, + }); + try { + await session.waitForText("new spaced file", { timeout: 15_000 }); + await session.press("d"); + 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 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"); diff --git a/website/src/content/docs/docs/extend/vcs-adapters.md b/website/src/content/docs/docs/extend/vcs-adapters.md index 41709c2d2..928fd03dc 100644 --- a/website/src/content/docs/docs/extend/vcs-adapters.md +++ b/website/src/content/docs/docs/extend/vcs-adapters.md @@ -58,6 +58,12 @@ The target uses `ExtensionDiffHunk`'s numbered summary. Recover and validate the 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 v19 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. + ## 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. From e5fabaf1a94502d4a12b01a14f08ec6036055bc5 Mon Sep 17 00:00:00 2001 From: yusing Date: Sat, 5 Sep 2026 09:14:16 +0000 Subject: [PATCH 04/27] feat(review): open the editor at the selected changed line Track deliberate line selection separately from viewport seeding, fall back to the active hunk's first change, and map staged source addresses through later worktree edits. Guard asynchronous lookups by review generation and refuse unsupported editor line syntax. --- .changeset/exact-line-editor.md | 5 + docs/extension-architecture.md | 5 +- docs/extensions.md | 6 + docs/keybindings.md | 6 + packages/hunk-git/src/editorLine.test.ts | 70 ++++++++++ packages/hunk-git/src/editorLine.ts | 75 +++++++++++ packages/hunk-git/src/index.ts | 3 + .../hunk/src/core/review/geometry.test.ts | 18 +++ packages/hunk/src/core/review/geometry.ts | 6 +- packages/hunk/src/core/vcs/types.ts | 8 +- packages/hunk/src/extension-api/types.ts | 9 +- packages/hunk/src/extensions/runExtension.ts | 23 +++- packages/hunk/src/ui/App.tsx | 87 ++++++++---- .../hunk/src/ui/AppHost.working-tree.test.tsx | 96 ++++++++++---- .../hunk/src/ui/components/panes/DiffPane.tsx | 4 +- .../src/ui/components/ui-components.test.tsx | 6 +- .../src/ui/hooks/useTerminalReview.test.tsx | 29 ++++ .../hunk/src/ui/hooks/useTerminalReview.ts | 32 +++-- packages/hunk/src/ui/lib/appMenus.test.ts | 4 +- packages/hunk/src/ui/lib/appMenus.ts | 2 +- packages/hunk/src/ui/lib/helpContent.ts | 5 +- packages/hunk/src/ui/lib/openInEditor.test.ts | 33 ++++- packages/hunk/src/ui/lib/openInEditor.ts | 21 ++- test/pty/editor-integration.test.ts | 124 ++++++++++++++++++ .../review-conformance/consumers/coreModel.ts | 2 +- .../content/docs/docs/extend/vcs-adapters.md | 5 + 26 files changed, 605 insertions(+), 79 deletions(-) create mode 100644 .changeset/exact-line-editor.md create mode 100644 packages/hunk-git/src/editorLine.test.ts create mode 100644 packages/hunk-git/src/editorLine.ts create mode 100644 test/pty/editor-integration.test.ts diff --git a/.changeset/exact-line-editor.md b/.changeset/exact-line-editor.md new file mode 100644 index 000000000..ed27005d2 --- /dev/null +++ b/.changeset/exact-line-editor.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Open the external editor at the deliberately selected source line or the active hunk’s first change, mapping staged lines through later unstaged edits. Refuse text-converted source coordinates and unknown editor line syntax rather than opening at the file beginning. diff --git a/docs/extension-architecture.md b/docs/extension-architecture.md index ae3d57b05..3b9a71f56 100644 --- a/docs/extension-architecture.md +++ b/docs/extension-architecture.md @@ -351,7 +351,10 @@ read-only with respect to Git state. The same leased mutation controller owns ex 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. +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 diff --git a/docs/extensions.md b/docs/extensions.md index 1355256bf..639cc80c3 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -554,6 +554,12 @@ excluding unrelated changes from every stash tree and from live cleanup. Revalid 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 v20 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`, `staged`, `busy`, `selectFile(path)`, and `toggleStaged(path)`. These controls expire on review reload. Selecting an inactive-side file switches stream tabs without reducing the review to one file. diff --git a/docs/keybindings.md b/docs/keybindings.md index 46ad4a8c2..22238b774 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -70,6 +70,12 @@ Double-clicking a code line applies its hunk on release, provided the pointer di 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, and hx; unknown editor syntax is refused instead of opening at file start. **d** opens discard choices for the selected file: 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 stash message input; Enter stashes and Escape cancels. The stash 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/index.ts b/packages/hunk-git/src/index.ts index c49658d2a..28f788b27 100644 --- a/packages/hunk-git/src/index.ts +++ b/packages/hunk-git/src/index.ts @@ -1,3 +1,4 @@ +import { resolveGitWorkingTreeLine } from "./editorLine"; import { discardGitFile, stashGitFile } from "./fileActions"; import { mutateGitHunkStaging } from "./hunkStaging"; import { createHash } from "node:crypto"; @@ -410,6 +411,8 @@ 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 }) => 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/vcs/types.ts b/packages/hunk/src/core/vcs/types.ts index 6948aea1f..0935a763f 100644 --- a/packages/hunk/src/core/vcs/types.ts +++ b/packages/hunk/src/core/vcs/types.ts @@ -47,7 +47,13 @@ export interface VcsOperations { "working-tree-diff"?: VcsOperation & Pick< ExtensionVcsWorkingTreeOperation, - "stageFile" | "unstageFile" | "stageHunk" | "unstageHunk" | "discardFile" | "stashFile" + | "stageFile" + | "unstageFile" + | "stageHunk" + | "unstageHunk" + | "discardFile" + | "stashFile" + | "resolveWorkingTreeLine" >; "revision-show"?: VcsOperation; "stash-show"?: VcsOperation; diff --git a/packages/hunk/src/extension-api/types.ts b/packages/hunk/src/extension-api/types.ts index a02f5a30c..3fa8904d0 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 = 21; +export const HUNK_EXTENSION_API_VERSION = 22; export type HunkExtensionApiVersion = typeof HUNK_EXTENSION_API_VERSION; export type ExtensionNotifyType = "info" | "warning" | "error"; @@ -1115,6 +1115,13 @@ export interface ExtensionVcsWorkingTreeOperation extends ExtensionVcsOperation< 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, diff --git a/packages/hunk/src/extensions/runExtension.ts b/packages/hunk/src/extensions/runExtension.ts index 4eee35794..04fc307af 100644 --- a/packages/hunk/src/extensions/runExtension.ts +++ b/packages/hunk/src/extensions/runExtension.ts @@ -499,7 +499,13 @@ export function toInternalVcsAdapter( | (VcsOperation & Pick< ExtensionVcsWorkingTreeOperation, - "stageFile" | "unstageFile" | "stageHunk" | "unstageHunk" | "discardFile" | "stashFile" + | "stageFile" + | "unstageFile" + | "stageHunk" + | "unstageHunk" + | "discardFile" + | "stashFile" + | "resolveWorkingTreeLine" >) | undefined; if (workingTree && internalWorkingTree) { @@ -553,6 +559,21 @@ export function toInternalVcsAdapter( } } + 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/ui/App.tsx b/packages/hunk/src/ui/App.tsx index 0ebdcfecf..34343a3ba 100644 --- a/packages/hunk/src/ui/App.tsx +++ b/packages/hunk/src/ui/App.tsx @@ -1,3 +1,4 @@ +import { getConfiguredVcsAdapter } from "../core/vcs"; import { WorkingTreeDialog } from "./components/chrome/WorkingTreeDialog"; import { recordMousePress } from "./lib/mousePressSequence"; import type { @@ -111,7 +112,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"; @@ -942,34 +943,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, ]); diff --git a/packages/hunk/src/ui/AppHost.working-tree.test.tsx b/packages/hunk/src/ui/AppHost.working-tree.test.tsx index d721c5c71..5afaa5781 100644 --- a/packages/hunk/src/ui/AppHost.working-tree.test.tsx +++ b/packages/hunk/src/ui/AppHost.working-tree.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { afterEach, describe, expect, setDefaultTimeout, spyOn, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { act } from "react"; import { readFileSync, rmSync, writeFileSync, renameSync } from "node:fs"; @@ -29,11 +29,13 @@ async function waitForReview(predicate: () => boolean) { /** Start a real provider-backed review with one unstaged file and one staged file. */ async function createReview({ beforeStage, + resolveEditorLine, onQuit, prepare, staged = false, }: { beforeStage?: () => Promise; + resolveEditorLine?: () => Promise; onQuit?: () => void; prepare?: (root: string) => void; staged?: boolean; @@ -45,29 +47,31 @@ async function createReview({ runTestGit(root, "add", "beta.txt"); prepare?.(root); const catalog = getBundledVcsCatalog(); - const vcsCatalog = beforeStage - ? { - ...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) => { - await beforeStage(); - await stageFile(...args); + 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; + }; + }), + } + : catalog; const bootstrap = await loadAppBootstrap( { kind: "vcs", staged, options: { mode: "stack", sidebar: true, extensions: false } }, { cwd: root, vcsCatalog }, @@ -101,6 +105,54 @@ afterEach(async () => { }); describe("working-tree stream actions", () => { + 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(); await press("d"); diff --git a/packages/hunk/src/ui/components/panes/DiffPane.tsx b/packages/hunk/src/ui/components/panes/DiffPane.tsx index de568b176..1cc327372 100644 --- a/packages/hunk/src/ui/components/panes/DiffPane.tsx +++ b/packages/hunk/src/ui/components/panes/DiffPane.tsx @@ -439,7 +439,7 @@ 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; canToggleHunkStaged?: (fileId: string, hunkIndex: number) => boolean; onToggleHunkStaged?: (fileId: string, hunkIndex: number) => boolean; @@ -1723,7 +1723,7 @@ export function DiffPane({ }); if (clickedCursor && onViewportLineCursorChange) { onHunkFocus?.(); - onViewportLineCursorChange(clickedCursor); + onViewportLineCursorChange(clickedCursor, true); return; } if (!current.moved) { diff --git a/packages/hunk/src/ui/components/ui-components.test.tsx b/packages/hunk/src/ui/components/ui-components.test.tsx index 6c82b888e..0575acffe 100644 --- a/packages/hunk/src/ui/components/ui-components.test.tsx +++ b/packages/hunk/src/ui/components/ui-components.test.tsx @@ -837,6 +837,7 @@ describe("UI components", () => { }); expect(selectLine).toHaveBeenLastCalledWith( expect.objectContaining({ fileId: file.id, target: { side: "old", line: 1 } }), + true, ); await act(async () => { @@ -844,6 +845,7 @@ describe("UI components", () => { }); expect(selectLine).toHaveBeenLastCalledWith( expect.objectContaining({ fileId: file.id, target: { side: "new", line: 1 } }), + true, ); selectLine.mockClear(); @@ -852,6 +854,7 @@ describe("UI components", () => { }); expect(selectLine).toHaveBeenLastCalledWith( expect.objectContaining({ fileId: file.id, target: { side: "new", line: 2 } }), + true, ); await act(async () => { @@ -861,6 +864,7 @@ describe("UI components", () => { }); expect(selectLine).toHaveBeenLastCalledWith( expect.objectContaining({ fileId: file.id, target: { side: "old", line: 1 } }), + true, ); expect(copyText).not.toHaveBeenCalled(); @@ -3962,7 +3966,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", 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/lib/appMenus.test.ts b/packages/hunk/src/ui/lib/appMenus.test.ts index a738816da..2472c5099 100644 --- a/packages/hunk/src/ui/lib/appMenus.test.ts +++ b/packages/hunk/src/ui/lib/appMenus.test.ts @@ -118,7 +118,7 @@ describe("buildAppMenus", () => { expect(items(menus.file).map((item) => item.label)).toEqual([ "Toggle files/filter focus", "Focus filter", - "Open file in editor", + "Edit selected or changed line", "Reload", "Quit", ]); @@ -231,7 +231,7 @@ describe("buildAppMenus", () => { expect(items(menus.file).map((item) => item.label)).toEqual([ "Toggle files/filter focus", "Focus filter", - "Open file in editor", + "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 21584824d..808b832df 100644 --- a/packages/hunk/src/ui/lib/appMenus.ts +++ b/packages/hunk/src/ui/lib/appMenus.ts @@ -141,7 +141,7 @@ 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.editSelectedFile", label: "Edit selected or changed line" }, { commandId: "hunk.review.discardSelectedFile" }, { commandId: "hunk.review.stashSelectedFile" }, { commandId: "hunk.review.toggleFileStaged" }, diff --git a/packages/hunk/src/ui/lib/helpContent.ts b/packages/hunk/src/ui/lib/helpContent.ts index dbea262b4..92cbd2f1b 100644 --- a/packages/hunk/src/ui/lib/helpContent.ts +++ b/packages/hunk/src/ui/lib/helpContent.ts @@ -107,7 +107,10 @@ const HELP_SECTIONS: readonly HelpSectionSpec[] = [ commandIds: ["hunk.review.stashSelectedFile"], description: "stash selected file with a message", }, - { commandIds: ["hunk.review.editSelectedFile"], description: "open file in $EDITOR" }, + { + commandIds: ["hunk.review.editSelectedFile"], + description: "edit selected line in $EDITOR", + }, ], }, { diff --git a/packages/hunk/src/ui/lib/openInEditor.test.ts b/packages/hunk/src/ui/lib/openInEditor.test.ts index 81512e48f..abd9675a0 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"; @@ -95,17 +96,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 +220,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..dccc1c21e 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) { @@ -115,7 +118,7 @@ export function buildEditorCommand({ editor: string; filePath: string; line: number; -}): EditorCommand { +}): EditorCommand | null { const [command = "", ...editorArgs] = splitEditorCommand(editor); const program = editorProgram(editor); @@ -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, or hx."; + const shouldSuspend = shouldSuspendForEditor(editor); if (shouldSuspend) { renderer.suspend(); diff --git a/test/pty/editor-integration.test.ts b/test/pty/editor-integration.test.ts new file mode 100644 index 000000000..96df458e2 --- /dev/null +++ b/test/pty/editor-integration.test.ts @@ -0,0 +1,124 @@ +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("e maps the selected change or explicit context line through unstaged insertions", async () => { + 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, "nvim"); + 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")]); + 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/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/vcs-adapters.md b/website/src/content/docs/docs/extend/vcs-adapters.md index 928fd03dc..27593c91c 100644 --- a/website/src/content/docs/docs/extend/vcs-adapters.md +++ b/website/src/content/docs/docs/extend/vcs-adapters.md @@ -64,6 +64,11 @@ preserve its staged content for unstaged-only discard, and exclude unrelated cha 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 v20 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. + ## 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. From 39172f2888f9be92a2e8318dee8f463b3d2f464f Mon Sep 17 00:00:00 2001 From: yusing Date: Sat, 5 Sep 2026 12:52:58 +0000 Subject: [PATCH 05/27] feat(ui): show Git-style file status colors Render index and worktree status columns with semantic colors, highlight selected rows across the full sidebar width, and color fully staged filenames. Add optional `statusCode` to the v21 extension API, expose Git status codes, and document and test the new sidebar behavior and theme contrast. --- .changeset/git-status-file-colors.md | 5 + docs/extensions.md | 4 + docs/keybindings.md | 5 +- docs/themes.md | 5 + packages/hunk-git/src/workingTree.test.ts | 11 ++- packages/hunk-git/src/workingTree.ts | 1 + packages/hunk/src/extension-api/types.ts | 4 +- .../default/ui/sidebar/FileSidebars.tsx | 8 +- .../src/ui/AppHost.extension-sidebar.test.tsx | 24 ++--- .../src/ui/components/panes/FileListItem.tsx | 43 ++++++--- .../src/ui/components/ui-components.test.tsx | 92 +++++++++++++++++++ packages/hunk/src/ui/themes.test.ts | 15 +++ packages/hunk/src/ui/themes.ts | 35 +++++-- test/pty/nav.test.ts | 4 +- test/pty/working-tree-integration.test.ts | 28 ++++++ .../content/docs/docs/extend/vcs-adapters.md | 4 + 16 files changed, 246 insertions(+), 42 deletions(-) create mode 100644 .changeset/git-status-file-colors.md 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/docs/extensions.md b/docs/extensions.md index 639cc80c3..d4e43a425 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -532,6 +532,10 @@ staged and unstaged files, independent of `input.staged`. Each entry supplies `p `previousPath`, `staged`, `unstaged`, `untracked`, `conflicted`, an opaque `version` attestation, and optionally an `unavailableReason`. Do not return this inventory for revision comparisons. +API v21 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. + 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 diff --git a/docs/keybindings.md b/docs/keybindings.md index 22238b774..b7b9d37b1 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -62,8 +62,9 @@ an ordered provider-owned parent; they do not navigate the history selection to In plain working-tree reviews, **Space** stages the selected file's remaining unstaged changes, or unstages it when fully staged. File-row double-click and the clickable action beside the -stream tabs do the same. The sidebar shows independent `S` (staged) and `U` (unstaged) indicators, -including partially staged files. A successful file action follows the file to the other tab. +stream tabs do the same. 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 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; 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/workingTree.test.ts b/packages/hunk-git/src/workingTree.test.ts index 9255f474e..8013c1e5a 100644 --- a/packages/hunk-git/src/workingTree.test.ts +++ b/packages/hunk-git/src/workingTree.test.ts @@ -41,7 +41,11 @@ describe("working-tree file staging", () => { 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 }); + 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); @@ -55,10 +59,11 @@ describe("working-tree file staging", () => { async (unborn) => { const root = createRepo(unborn); writeFileSync(join(root, "new.txt"), "new file\n"); - expect(status(root, "new.txt").untracked).toBe(true); + 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").untracked).toBe(true); + expect(status(root, "new.txt")).toMatchObject({ untracked: true, statusCode: "??" }); expect(readFileSync(join(root, "new.txt"), "utf8")).toBe("new file\n"); }, ); diff --git a/packages/hunk-git/src/workingTree.ts b/packages/hunk-git/src/workingTree.ts index 15bc5e25d..21ed5b517 100644 --- a/packages/hunk-git/src/workingTree.ts +++ b/packages/hunk-git/src/workingTree.ts @@ -91,6 +91,7 @@ export function loadGitWorkingTreeFiles( unstaged: status[1] !== " ", untracked, conflicted, + statusCode: status, unavailableReason: conflicted ? "Resolve merge conflicts before staging here." : stat.directory || index.get(path)?.startsWith("160000 ") diff --git a/packages/hunk/src/extension-api/types.ts b/packages/hunk/src/extension-api/types.ts index 3fa8904d0..b0ef02973 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 = 22; +export const HUNK_EXTENSION_API_VERSION = 23; export type HunkExtensionApiVersion = typeof HUNK_EXTENSION_API_VERSION; export type ExtensionNotifyType = "info" | "warning" | "error"; @@ -1085,6 +1085,8 @@ export interface ExtensionWorkingTreeFile { 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; /** 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. */ diff --git a/packages/hunk/src/extensions/default/ui/sidebar/FileSidebars.tsx b/packages/hunk/src/extensions/default/ui/sidebar/FileSidebars.tsx index 54c362bed..3b7684ee2 100644 --- a/packages/hunk/src/extensions/default/ui/sidebar/FileSidebars.tsx +++ b/packages/hunk/src/extensions/default/ui/sidebar/FileSidebars.tsx @@ -192,9 +192,11 @@ export function FlexFileSidebar({ previousPath: status.previousPath, stats: reviewed?.stats ?? { additions: 0, deletions: 0 }, isUntracked: status.untracked, - stageStatus: status.conflicted - ? "!!" - : `${status.staged ? "S" : " "}${status.unstaged ? "U" : " "}`, + stageStatus: status.untracked + ? "??" + : status.conflicted + ? "!!" + : (status.statusCode ?? `${status.staged ? "S" : " "}${status.unstaged ? "U" : " "}`), }; }); }, [reviewFiles, statusFiles]); diff --git a/packages/hunk/src/ui/AppHost.extension-sidebar.test.tsx b/packages/hunk/src/ui/AppHost.extension-sidebar.test.tsx index 9f444f1a6..60327354d 100644 --- a/packages/hunk/src/ui/AppHost.extension-sidebar.test.tsx +++ b/packages/hunk/src/ui/AppHost.extension-sidebar.test.tsx @@ -500,11 +500,11 @@ describe("extension sidebar views", () => { const bootstrap = await launchWithExtension(repo, extPath); await withAppHost(bootstrap, async (setup) => { - // The sidebar file rows carry the "U " unstaged-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, - () => setup.captureCharFrame().includes("U alpha.txt"), + () => setup.captureCharFrame().includes("M alpha.txt"), "the built-in sidebar to render", ); @@ -513,7 +513,7 @@ describe("extension sidebar views", () => { }); await flushUntil( setup, - () => !setup.captureCharFrame().includes("U alpha.txt"), + () => !setup.captureCharFrame().includes("M alpha.txt"), "the s key to hide the sidebar area", ); @@ -526,7 +526,7 @@ describe("extension sidebar views", () => { setup, () => { const frame = setup.captureCharFrame(); - return frame.includes("EXTSIDEBAR") && !frame.includes("U alpha.txt"); + return frame.includes("EXTSIDEBAR") && !frame.includes("M alpha.txt"); }, "the command to open only the extension pane", ); @@ -538,7 +538,7 @@ describe("extension sidebar views", () => { setup, () => { const frame = setup.captureCharFrame(); - return frame.includes("EXTSIDEBAR") && frame.includes("U alpha.txt"); + return frame.includes("EXTSIDEBAR") && frame.includes("M alpha.txt"); }, "the s key to reopen files without closing the extension pane", ); @@ -550,7 +550,7 @@ describe("extension sidebar views", () => { setup, () => { const frame = setup.captureCharFrame(); - return frame.includes("EXTSIDEBAR") && !frame.includes("U alpha.txt"); + return frame.includes("EXTSIDEBAR") && !frame.includes("M alpha.txt"); }, "the s key to close files without closing the extension pane", ); @@ -837,7 +837,7 @@ describe("extension sidebar views", () => { ); await flushUntil( setup, - () => setup.captureCharFrame().includes("U alpha.txt"), + () => setup.captureCharFrame().includes("M alpha.txt"), "the built-in sidebar to reopen after the crash", ); @@ -846,7 +846,7 @@ describe("extension sidebar views", () => { }); await flushUntil( setup, - () => !setup.captureCharFrame().includes("U alpha.txt"), + () => !setup.captureCharFrame().includes("M alpha.txt"), "the s key to close the visible built-in fallback", ); @@ -855,7 +855,7 @@ describe("extension sidebar views", () => { }); await flushUntil( setup, - () => setup.captureCharFrame().includes("U alpha.txt"), + () => setup.captureCharFrame().includes("M alpha.txt"), "the s key to reopen the built-in fallback", ); }); @@ -887,7 +887,7 @@ describe("extension sidebar views", () => { ); await flushUntil( setup, - () => setup.captureCharFrame().includes("U alpha.txt"), + () => setup.captureCharFrame().includes("M alpha.txt"), "the built-in files fallback to appear", ); @@ -896,7 +896,7 @@ describe("extension sidebar views", () => { }); await flushUntil( setup, - () => !setup.captureCharFrame().includes("U alpha.txt"), + () => !setup.captureCharFrame().includes("M alpha.txt"), "the files toggle to close the injected fallback", ); @@ -905,7 +905,7 @@ describe("extension sidebar views", () => { }); await flushUntil( setup, - () => setup.captureCharFrame().includes("U alpha.txt"), + () => setup.captureCharFrame().includes("M alpha.txt"), "the files toggle to reopen the built-in files pane", ); }); diff --git a/packages/hunk/src/ui/components/panes/FileListItem.tsx b/packages/hunk/src/ui/components/panes/FileListItem.tsx index 4839bcdd8..a90cfe5fe 100644 --- a/packages/hunk/src/ui/components/panes/FileListItem.tsx +++ b/packages/hunk/src/ui/components/panes/FileListItem.tsx @@ -1,8 +1,6 @@ import { MouseButton, type MouseEvent as TuiMouseEvent } from "@opentui/core"; import { memo } from "react"; -import { MouseButton } from "@opentui/core"; import type { ExtensionSidebarTheme } from "../../../extension-api/types"; -import { diffRailMarker } from "../../diff/rowStyle"; import { fileRowId } from "../../lib/ids"; import { sidebarEntryStats, @@ -24,9 +22,13 @@ function getFileStateIcon( entry: FileListEntry, theme: ExtensionSidebarTheme, ): { icon: string; color: string } { - if (entry.stageStatus) return { icon: entry.stageStatus, color: theme.text }; + 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) { @@ -168,9 +170,11 @@ export const FileListItem = memo(function FileListItem({ theme: ExtensionSidebarTheme; onSelectFile: (fileId: string) => 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 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( @@ -194,9 +198,13 @@ export const FileListItem = memo(function FileListItem({ 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 && ( { expect(frame).not.toContain("M +2 -1 AI"); }); + 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={{ + 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", + staged: false, + busy: false, + selectFile: () => {}, + toggleStaged: () => {}, + }} + />, + { 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"), 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/test/pty/nav.test.ts b/test/pty/nav.test.ts index 63e250bc6..65e0c43fd 100644 --- a/test/pty/nav.test.ts +++ b/test/pty/nav.test.ts @@ -232,7 +232,7 @@ describe("PTY navigation", () => { expect(initial).toContain("betaValue = 2"); expect(initial).not.toContain("deltaOnly = true"); - await session.click(/U delta\.ts\s+\+2 -1/); + await session.click(/M delta\.ts\s+\+2 -1/); const jumped = await harness.waitForSnapshot( session, (text) => @@ -280,7 +280,7 @@ describe("PTY navigation", () => { expect(scrolled).toContain("first.ts"); - await session.click(/U second\.ts\s+\+16 -16/); + await session.click(/M second\.ts\s+\+16 -16/); const pinned = await harness.waitForSnapshot( session, (text) => diff --git a/test/pty/working-tree-integration.test.ts b/test/pty/working-tree-integration.test.ts index c27acad60..4bea0691c 100644 --- a/test/pty/working-tree-integration.test.ts +++ b/test/pty/working-tree-integration.test.ts @@ -42,6 +42,34 @@ function doubleClickSidebarFile(session: Session, path: string) { } describe("PTY working-tree staging", () => { + 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("discard offers exact-file choices, cancels safely, and preserves staged content with u", async () => { const root = createFixture(); runTestGit(root, "add", "alpha.txt"); diff --git a/website/src/content/docs/docs/extend/vcs-adapters.md b/website/src/content/docs/docs/extend/vcs-adapters.md index 27593c91c..4cbd3e9b0 100644 --- a/website/src/content/docs/docs/extend/vcs-adapters.md +++ b/website/src/content/docs/docs/extend/vcs-adapters.md @@ -69,6 +69,10 @@ read-only validation of a new-side source address and mapping staged addresses t attestation and refuse unmappable transforms. The host owns editor launch and drops results after review revocation; this method grants no remote write capability. +API v21 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. + ## 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. From a148e8c5db793ae4be2ecacfbc703f9fed0afd78 Mon Sep 17 00:00:00 2001 From: yusing Date: Sat, 5 Sep 2026 13:47:38 +0000 Subject: [PATCH 06/27] fix(ui): scope file quick actions to the focused sidebar --- .changeset/quiet-file-quick-actions.md | 5 ++ docs/keybindings.md | 6 +-- packages/hunk/src/ui/App.tsx | 21 ++++++-- test/pty/working-tree-integration.test.ts | 64 +++++++++++++++++++++-- 4 files changed, 85 insertions(+), 11 deletions(-) create mode 100644 .changeset/quiet-file-quick-actions.md 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/docs/keybindings.md b/docs/keybindings.md index b7b9d37b1..1e5f0d607 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -81,9 +81,9 @@ code/code-insiders/cursor, and hx; unknown editor syntax is refused instead of o **u** discards only unstaged changes when both sides have changes, and Escape cancels. **s** opens a selected-file stash message input; Enter stashes and Escape cancels. The stash contains only that file's changes, including its staged/unstaged split, not unrelated staged files. -These actions always target the selected file, even in hunk scope. They reject stale targets and -renames whose former path has been recreated. Stashing requires an initial commit. Outside an -actionable working-tree review, **d** retains half-page scrolling and **s** toggles the files pane; +These actions require a selected actionable file 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. Use `/` to focus the filter. diff --git a/packages/hunk/src/ui/App.tsx b/packages/hunk/src/ui/App.tsx index 34343a3ba..72dbd2a8a 100644 --- a/packages/hunk/src/ui/App.tsx +++ b/packages/hunk/src/ui/App.tsx @@ -354,6 +354,7 @@ export function App({ getSelection: getExtensionSelection, } = extensionRuntime; const [hunkActionFocused, setHunkActionFocused] = useState(false); + const [filePanelFocused, setFilePanelFocused] = useState(true); const jumpToFile = useCallback( (fileId: string, options?: { alignFileHeaderTop?: boolean }) => { setHunkActionFocused(false); @@ -1041,6 +1042,7 @@ export function App({ /** Focus the file list/sidebar navigation area. */ const focusFiles = useCallback(() => { setFocusArea("files"); + setFilePanelFocused(true); }, []); const workingTree = useWorkingTreeActions({ @@ -1129,6 +1131,9 @@ export function App({ const activeEditableNoteId = selectActiveEditableReviewNoteId(review.store.getSnapshot()); const activeReplyableNoteId = selectActiveReplyableReviewNoteId(review.store.getSnapshot()); + // Diff headers select file staging scope without giving the sidebar keyboard ownership. + const fileQuickActionsFocused = + filesPaneVisible && focusArea === "files" && filePanelFocused && !hunkActionFocused; // One dispatch table for every app-level shortcut: the built-in commands // over App's live callbacks, then extension commands, so built-ins always @@ -1136,8 +1141,8 @@ export function App({ const appCommands = observeAppCommandDispatch( [ ...buildAppCommands({ - canDiscardSelectedFile: workingTree.canDiscardSelected, - canStashSelectedFile: workingTree.canStashSelected, + canDiscardSelectedFile: fileQuickActionsFocused && workingTree.canDiscardSelected, + canStashSelectedFile: fileQuickActionsFocused && workingTree.canStashSelected, discardSelectedFile: workingTree.discardSelected, stashSelectedFile: workingTree.stashSelected, canToggleFileStaged: !hunkActionFocused && workingTree.canToggleSelected, @@ -1161,6 +1166,7 @@ export function App({ if (activeReplyableNoteId) startUserNoteReply(activeReplyableNoteId); }, moveSelection: (scope, delta) => { + setFilePanelFocused(scope === "file"); setHunkActionFocused(scope === "hunk" || scope === "annotated-hunk"); if (scope === "file" && workingTree.pane) workingTree.moveFile(delta); else review.moveSelection(scope, delta); @@ -1466,6 +1472,7 @@ export function App({ {paneLayout.panes.map(renderPane)} {paneLayout.panes.map(renderDivider)} setFilePanelFocused(false)} style={{ position: "absolute", left: bodyPadding / 2 + paneLayout.reviewBounds.x, @@ -1535,7 +1542,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) @@ -1544,7 +1554,10 @@ export function App({ currentLinePaintRequested={currentLinePaintRequested} onCurrentLinePaintChange={onCurrentLinePaintChange} onViewportLineCursorChange={review.anchorLineCursor} - onHunkFocus={() => setHunkActionFocused(true)} + onHunkFocus={() => { + setFilePanelFocused(false); + setHunkActionFocused(true); + }} canToggleHunkStaged={workingTree.canToggleHunk} onToggleHunkStaged={workingTree.pane ? workingTree.toggleHunk : undefined} /> diff --git a/test/pty/working-tree-integration.test.ts b/test/pty/working-tree-integration.test.ts index 4bea0691c..8f0fa7a4c 100644 --- a/test/pty/working-tree-integration.test.ts +++ b/test/pty/working-tree-integration.test.ts @@ -42,6 +42,48 @@ function doubleClickSidebarFile(session: Session, path: string) { } describe("PTY working-tree staging", () => { + 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); @@ -119,13 +161,16 @@ describe("PTY working-tree staging", () => { writeFileSync(join(root, "alpha.txt"), "later alpha\n"); const session = await harness.launchHunk({ cwd: root, - args: ["diff", "--no-extensions", "--mode", "stack"], - cols: 40, + args: ["diff", "--sidebar", "--no-extensions", "--mode", "stack"], + cols: 150, rows: 18, }); try { await session.waitForText("later alpha", { timeout: 15_000 }); 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"); @@ -144,13 +189,16 @@ describe("PTY working-tree staging", () => { writeFileSync(join(root, path), "new spaced file\n"); const session = await harness.launchHunk({ cwd: root, - args: ["diff", "--no-extensions", "--mode", "stack", "--", path], - cols: 40, + args: ["diff", "--sidebar", "--no-extensions", "--mode", "stack", "--", path], + cols: 150, rows: 14, }); try { await session.waitForText("new spaced file", { timeout: 15_000 }); 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("…"); @@ -315,6 +363,14 @@ describe("PTY working-tree staging", () => { 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); From 9d291391561400c89e6c28981e9720be365d0efb Mon Sep 17 00:00:00 2001 From: yusing Date: Sun, 6 Sep 2026 00:05:01 +0000 Subject: [PATCH 07/27] fix(ui): resolve shortcut ownership across review focus and menus Hide shadowed key hints from help and menus, keep file quick actions scoped to the focused sidebar, and fall back to file staging when hunk staging is unavailable. Add coverage for pointer focus, shortcut precedence, and working-tree behavior. --- .changeset/review-chord-conflicts.md | 5 +++ docs/extension-architecture.md | 2 +- docs/keybindings.md | 5 ++- packages/hunk/src/ui/App.tsx | 15 ++++--- .../hunk/src/ui/AppHost.working-tree.test.tsx | 19 ++++++++ .../hunk/src/ui/components/panes/DiffPane.tsx | 8 ++++ packages/hunk/src/ui/lib/appCommands.test.ts | 44 +++++++++++++++++++ packages/hunk/src/ui/lib/appCommands.ts | 32 ++++++++++++++ packages/hunk/src/ui/lib/appMenus.ts | 12 +++-- packages/hunk/src/ui/lib/helpContent.ts | 22 +++++----- test/pty/working-tree-integration.test.ts | 31 +++++++++++++ 11 files changed, 172 insertions(+), 23 deletions(-) create mode 100644 .changeset/review-chord-conflicts.md 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/docs/extension-architecture.md b/docs/extension-architecture.md index 3b9a71f56..4cccba9ff 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. diff --git a/docs/keybindings.md b/docs/keybindings.md index 1e5f0d607..2fb322c71 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -161,7 +161,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/packages/hunk/src/ui/App.tsx b/packages/hunk/src/ui/App.tsx index 72dbd2a8a..34aca80b4 100644 --- a/packages/hunk/src/ui/App.tsx +++ b/packages/hunk/src/ui/App.tsx @@ -1134,6 +1134,8 @@ export function App({ // Diff headers select file staging scope without giving the sidebar keyboard ownership. const fileQuickActionsFocused = filesPaneVisible && focusArea === "files" && filePanelFocused && !hunkActionFocused; + // 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 @@ -1145,8 +1147,8 @@ export function App({ canStashSelectedFile: fileQuickActionsFocused && workingTree.canStashSelected, discardSelectedFile: workingTree.discardSelected, stashSelectedFile: workingTree.stashSelected, - canToggleFileStaged: !hunkActionFocused && workingTree.canToggleSelected, - canToggleHunkStaged: hunkActionFocused && workingTree.canToggleSelectedHunk, + canToggleFileStaged: !hunkStagingActive && workingTree.canToggleSelected, + canToggleHunkStaged: hunkStagingActive, toggleHunkStaged: workingTree.toggleSelectedHunk, canSwitchStagedView: Boolean(workingTree.pane), toggleFileStaged: workingTree.toggleSelected, @@ -1437,13 +1439,11 @@ export function App({ pane={workingTree.pane} theme={activeTheme} width={terminal.width} - hunkFocused={hunkActionFocused} - canToggle={ - hunkActionFocused ? workingTree.canToggleSelectedHunk : workingTree.canToggleSelected - } + hunkFocused={hunkStagingActive} + canToggle={hunkStagingActive || workingTree.canToggleSelected} switchView={workingTree.switchView} toggleSelected={ - hunkActionFocused ? workingTree.toggleSelectedHunk : workingTree.toggleSelected + hunkStagingActive ? workingTree.toggleSelectedHunk : workingTree.toggleSelected } /> )} @@ -1558,6 +1558,7 @@ export function App({ setFilePanelFocused(false); setHunkActionFocused(true); }} + onReviewPointerDown={() => setFilePanelFocused(false)} canToggleHunkStaged={workingTree.canToggleHunk} onToggleHunkStaged={workingTree.pane ? workingTree.toggleHunk : undefined} /> diff --git a/packages/hunk/src/ui/AppHost.working-tree.test.tsx b/packages/hunk/src/ui/AppHost.working-tree.test.tsx index 5afaa5781..d3923f821 100644 --- a/packages/hunk/src/ui/AppHost.working-tree.test.tsx +++ b/packages/hunk/src/ui/AppHost.working-tree.test.tsx @@ -164,6 +164,25 @@ describe("working-tree stream actions", () => { 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({ diff --git a/packages/hunk/src/ui/components/panes/DiffPane.tsx b/packages/hunk/src/ui/components/panes/DiffPane.tsx index 1cc327372..b02a9a0f8 100644 --- a/packages/hunk/src/ui/components/panes/DiffPane.tsx +++ b/packages/hunk/src/ui/components/panes/DiffPane.tsx @@ -365,6 +365,7 @@ export function DiffPane({ onViewportCenteredHunkChange, onViewportLineCursorChange, onHunkFocus, + onReviewPointerDown, canToggleHunkStaged, onToggleHunkStaged, }: { @@ -441,6 +442,8 @@ export function DiffPane({ onViewportCenteredHunkChange?: (fileId: string, hunkIndex: number) => 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; }) { @@ -1537,6 +1540,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; @@ -1607,6 +1614,7 @@ export function DiffPane({ renderer, resolveCopySelectionPoint, suppressNativeSelection, + onReviewPointerDown, onToggleHunkStaged, canToggleHunkStaged, lineCursors, diff --git a/packages/hunk/src/ui/lib/appCommands.test.ts b/packages/hunk/src/ui/lib/appCommands.test.ts index 403d21bbc..9e03b40be 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, @@ -137,6 +138,49 @@ describe("built-in command chords", () => { ); }); + 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 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, { diff --git a/packages/hunk/src/ui/lib/appCommands.ts b/packages/hunk/src/ui/lib/appCommands.ts index 95b6cc384..08fc8abfc 100644 --- a/packages/hunk/src/ui/lib/appCommands.ts +++ b/packages/hunk/src/ui/lib/appCommands.ts @@ -435,6 +435,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.ts b/packages/hunk/src/ui/lib/appMenus.ts index 808b832df..76b262afc 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); diff --git a/packages/hunk/src/ui/lib/helpContent.ts b/packages/hunk/src/ui/lib/helpContent.ts index 92cbd2f1b..65d2d7b41 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 @@ -151,11 +152,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) { @@ -164,11 +165,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/test/pty/working-tree-integration.test.ts b/test/pty/working-tree-integration.test.ts index 8f0fa7a4c..0e81f013b 100644 --- a/test/pty/working-tree-integration.test.ts +++ b/test/pty/working-tree-integration.test.ts @@ -335,6 +335,37 @@ describe("PTY working-tree staging", () => { } }); + 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 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(""); From 3810379f343ed7475963e447bf1322d1547f0c0a Mon Sep 17 00:00:00 2001 From: yusing Date: Sun, 6 Sep 2026 01:57:29 +0000 Subject: [PATCH 08/27] feat(ui): indicate focused pane and scope keyboard actions to it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track focus ownership between the files pane and the review stream so pointer and command toggles move cursor ownership cleanly: `↑`/`↓`/`j`/`k` now step files when the files pane is focused, review focus stays in the diff stream, and `Space` stages the file when a selected hunk is not stageable. Review-stream clicks now drop files-pane quick-action scope, and focused panes are visually emphasized with brighter frame and divider styling. Centralize shortcut labeling for help and menus through first-match command ownership so a command only advertises keys that are actually reachable, preventing shadowed aliases from appearing while preserving visible alternatives on the real owner. Updated command descriptions, docs, and focused-pane/interaction tests to match the new focus model and selection behavior. --- .changeset/focused-pane-indication.md | 5 + docs/keybindings.md | 4 +- packages/hunk/src/core/run/commandCatalog.ts | 4 +- packages/hunk/src/ui/App.tsx | 55 ++++-- .../hunk/src/ui/AppHost.interactions.test.tsx | 177 +++++++++++++++++- .../ui/AppHost.sidebar-visibility.test.tsx | 7 +- .../hunk/src/ui/AppHost.working-tree.test.tsx | 13 ++ .../hunk/src/ui/components/panes/DiffPane.tsx | 19 +- .../components/panes/ExtensionPane.test.tsx | 39 ++++ .../src/ui/components/panes/ExtensionPane.tsx | 19 +- .../src/ui/components/panes/PaneDivider.tsx | 6 +- .../src/ui/components/ui-components.test.tsx | 2 +- packages/hunk/src/ui/lib/helpContent.test.ts | 2 +- packages/hunk/src/ui/lib/helpContent.ts | 2 +- packages/hunk/src/ui/lib/paneFocus.test.ts | 29 +++ packages/hunk/src/ui/lib/paneFocus.ts | 26 +++ test/pty/chrome.test.ts | 4 +- test/pty/working-tree-integration.test.ts | 11 ++ .../docs/docs/start/keyboard-and-mouse.md | 7 +- 19 files changed, 385 insertions(+), 46 deletions(-) create mode 100644 .changeset/focused-pane-indication.md create mode 100644 packages/hunk/src/ui/lib/paneFocus.test.ts create mode 100644 packages/hunk/src/ui/lib/paneFocus.ts 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/docs/keybindings.md b/docs/keybindings.md index 2fb322c71..021416a0b 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -124,8 +124,8 @@ remain remappable, and an explicit user binding takes precedence over contextual | `hunk.review.scrollCodeRight` | Scroll code right (shifted scrolls fast) | `right`, `shift+right` | | `hunk.review.startNote` | Add a review note | `c` | | `hunk.review.stashSelectedFile` | Stash selected file | `s` | -| `hunk.review.stepDown` | Scroll down one row | `down`, `j` | -| `hunk.review.stepUp` | Scroll up one row | `up`, `k` | +| `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 | `space` | | `hunk.review.toggleHunkGap` | Expand or collapse the selected context | `z` | | `hunk.review.toggleHunkStaged` | Stage / unstage selected hunk | `space` | diff --git a/packages/hunk/src/core/run/commandCatalog.ts b/packages/hunk/src/core/run/commandCatalog.ts index 48253c89c..894b6b678 100644 --- a/packages/hunk/src/core/run/commandCatalog.ts +++ b/packages/hunk/src/core/run/commandCatalog.ts @@ -270,7 +270,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", @@ -279,7 +279,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/ui/App.tsx b/packages/hunk/src/ui/App.tsx index 34aca80b4..8cbe75ef4 100644 --- a/packages/hunk/src/ui/App.tsx +++ b/packages/hunk/src/ui/App.tsx @@ -24,6 +24,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, @@ -354,7 +355,7 @@ export function App({ getSelection: getExtensionSelection, } = extensionRuntime; const [hunkActionFocused, setHunkActionFocused] = useState(false); - const [filePanelFocused, setFilePanelFocused] = useState(true); + const [filePanelFocused, setFilePanelFocused] = useState(false); const jumpToFile = useCallback( (fileId: string, options?: { alignFileHeaderTop?: boolean }) => { setHunkActionFocused(false); @@ -1043,6 +1044,7 @@ export function App({ const focusFiles = useCallback(() => { setFocusArea("files"); setFilePanelFocused(true); + setHunkActionFocused(false); }, []); const workingTree = useWorkingTreeActions({ @@ -1052,10 +1054,7 @@ export function App({ filter: review.filter, createLease: createReviewCapabilityLease, selectReviewFile: jumpToFile, - focusFiles: () => { - focusFiles(); - setHunkActionFocused(false); - }, + focusFiles, setStagedView, runMutation: runVcsMutation, refreshAfterMutation: onVcsMutationCompleted, @@ -1089,8 +1088,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"), []); @@ -1131,9 +1134,19 @@ export function App({ const activeEditableNoteId = selectActiveEditableReviewNoteId(review.store.getSnapshot()); const activeReplyableNoteId = selectActiveReplyableReviewNoteId(review.store.getSnapshot()); - // Diff headers select file staging scope without giving the sidebar keyboard ownership. - const fileQuickActionsFocused = + // Files-pane keyboard ownership: vertical movement, stash, and discard. Diff + // headers can select a file for staging without giving the sidebar that ownership. + const filesPaneFocused = filesPaneVisible && focusArea === "files" && filePanelFocused && !hunkActionFocused; + 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; @@ -1143,8 +1156,8 @@ export function App({ const appCommands = observeAppCommandDispatch( [ ...buildAppCommands({ - canDiscardSelectedFile: fileQuickActionsFocused && workingTree.canDiscardSelected, - canStashSelectedFile: fileQuickActionsFocused && workingTree.canStashSelected, + canDiscardSelectedFile: filesPaneFocused && workingTree.canDiscardSelected, + canStashSelectedFile: filesPaneFocused && workingTree.canStashSelected, discardSelectedFile: workingTree.discardSelected, stashSelectedFile: workingTree.stashSelected, canToggleFileStaged: !hunkStagingActive && workingTree.canToggleSelected, @@ -1167,19 +1180,20 @@ export function App({ replyToActiveNote: () => { if (activeReplyableNoteId) startUserNoteReply(activeReplyableNoteId); }, - moveSelection: (scope, delta) => { - setFilePanelFocused(scope === "file"); - setHunkActionFocused(scope === "hunk" || scope === "annotated-hunk"); - if (scope === "file" && workingTree.pane) workingTree.moveFile(delta); - else review.moveSelection(scope, delta); - }, + moveSelection: moveReviewSelection, openAgentSkill, openThemeSelector, requestQuit, resolvedKeys: resolvedCommandKeys, scrollCodeHorizontally, scrollDiff, - stepDiffLine, + stepDiffLine: (delta) => { + if (filesPaneFocused) { + moveReviewSelection("file", delta); + return; + } + stepDiffLine(delta); + }, selectCursorLine: setCursorLine, selectLayoutMode, startUserNote: () => startUserNote(), @@ -1346,6 +1360,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) => { @@ -1360,6 +1375,7 @@ export function App({ showTransientNotice("Copied text to clipboard"); return true; }} + onFocus={pane.key === HUNK_FILES_PANE_KEY ? focusFiles : undefined} onSelectFile={extensionNavigationBindings.onSelectFile} onSelectHunk={extensionNavigationBindings.onSelectHunk} onRevealLine={extensionNavigationBindings.onRevealLine} @@ -1399,6 +1415,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} @@ -1495,6 +1512,8 @@ export function App({ screenTop={diffPaneScreenTop} showTopChrome={showMenuBar} skipInitialIntermediateRender={Boolean(onFirstFrameReady)} + focused={reviewPaneFocused} + framed={filesPaneVisible} headerLabelWidth={diffHeaderLabelWidth} headerStatsWidth={diffHeaderStatsWidth} layout={resolvedLayout} diff --git a/packages/hunk/src/ui/AppHost.interactions.test.tsx b/packages/hunk/src/ui/AppHost.interactions.test.tsx index e4abf211e..15bc2bf22 100644 --- a/packages/hunk/src/ui/AppHost.interactions.test.tsx +++ b/packages/hunk/src/ui/AppHost.interactions.test.tsx @@ -23,6 +23,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 +569,27 @@ 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); +} + async function waitForSnapshot( setup: Awaited>, getSnapshot: () => HunkSessionSnapshot["state"] | null, @@ -3730,10 +3752,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 () => { @@ -3756,6 +3775,156 @@ 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("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.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.working-tree.test.tsx b/packages/hunk/src/ui/AppHost.working-tree.test.tsx index d3923f821..6e9111b8a 100644 --- a/packages/hunk/src/ui/AppHost.working-tree.test.tsx +++ b/packages/hunk/src/ui/AppHost.working-tree.test.tsx @@ -155,6 +155,19 @@ describe("working-tree stream actions", () => { 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"); diff --git a/packages/hunk/src/ui/components/panes/DiffPane.tsx b/packages/hunk/src/ui/components/panes/DiffPane.tsx index b02a9a0f8..13197616c 100644 --- a/packages/hunk/src/ui/components/panes/DiffPane.tsx +++ b/packages/hunk/src/ui/components/panes/DiffPane.tsx @@ -86,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"; @@ -324,6 +325,8 @@ export function DiffPane({ screenTop = 0, showTopChrome, skipInitialIntermediateRender = false, + focused = false, + framed = false, showAgentNotes, showLineNumbers, showHunkHeaders, @@ -399,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; @@ -2743,14 +2750,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 91026039c..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) { @@ -92,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"; @@ -116,6 +120,8 @@ function ExtensionPaneHostView({ keybindings, notify, onCopyText, + focused = false, + onFocus, onSelectFile, onSelectHunk, onRevealLine, @@ -167,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) => ( 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/ui-components.test.tsx b/packages/hunk/src/ui/components/ui-components.test.tsx index 800152554..58575785c 100644 --- a/packages/hunk/src/ui/components/ui-components.test.tsx +++ b/packages/hunk/src/ui/components/ui-components.test.tsx @@ -4039,7 +4039,7 @@ describe("UI components", () => { "Controls help", "[Esc]", "Navigation", - "Up / Down move line-by-line", + "Up / Down move in the focused pane", "PageDown / Space / f page down", "PageUp / b / Shift+Space page up", "d / u half page down / up", diff --git a/packages/hunk/src/ui/lib/helpContent.test.ts b/packages/hunk/src/ui/lib/helpContent.test.ts index 1666a41cc..2de9afc3a 100644 --- a/packages/hunk/src/ui/lib/helpContent.test.ts +++ b/packages/hunk/src/ui/lib/helpContent.test.ts @@ -31,7 +31,7 @@ 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, "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 65d2d7b41..c9d366a64 100644 --- a/packages/hunk/src/ui/lib/helpContent.ts +++ b/packages/hunk/src/ui/lib/helpContent.ts @@ -43,7 +43,7 @@ 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.pageDown"], description: "page down" }, { commandIds: ["hunk.review.pageUp"], description: "page up" }, 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/test/pty/chrome.test.ts b/test/pty/chrome.test.ts index 79536e97c..9e9044b78 100644 --- a/test/pty/chrome.test.ts +++ b/test/pty/chrome.test.ts @@ -261,12 +261,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/working-tree-integration.test.ts b/test/pty/working-tree-integration.test.ts index 0e81f013b..4a04fe200 100644 --- a/test/pty/working-tree-integration.test.ts +++ b/test/pty/working-tree-integration.test.ts @@ -41,6 +41,12 @@ function doubleClickSidebarFile(session: Session, path: string) { 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("file quick actions yield to built-ins outside the visible file panel's scope", async () => { const root = createFixture(); @@ -124,6 +130,7 @@ describe("PTY working-tree staging", () => { }); try { await session.waitForText("later alpha", { timeout: 15_000 }); + await focusFilesPane(session); await session.press("d"); await session.waitForText("Discard changes"); await session.press("escape"); @@ -167,6 +174,7 @@ describe("PTY working-tree staging", () => { }); 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 }); @@ -195,6 +203,7 @@ describe("PTY working-tree staging", () => { }); 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 }); @@ -221,6 +230,7 @@ describe("PTY working-tree staging", () => { }); 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"); @@ -345,6 +355,7 @@ describe("PTY working-tree staging", () => { }); 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"); 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..839c19413 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,7 @@ 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 | | `Space` / `f`, `b` | Page down / up | | `Shift+Space` | Page up | | `d` / `u` | Half page down / up | @@ -21,7 +21,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 `k` / `j` move between files; 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 +51,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 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. From 9d6aac891b90b5328160af840c67d01d268772f3 Mon Sep 17 00:00:00 2001 From: yusing Date: Sun, 6 Sep 2026 02:09:00 +0000 Subject: [PATCH 09/27] feat(ui): make files-pane folders actionable Add a files-pane selection model that tracks folder rows alongside files, and thread folder-aware entry IDs through sidebar rendering and extension working-tree props. Folder and file rows are now selectable/clickable with updated focus and highlighting, and the focused entry is kept coherent across tree and compact sidebar modes. Wire the new model into working-tree actions so `Space`, discard, and stash now operate on the focused files-pane row, including folders. Folder actions apply to all nested files in tree view or all files under a compact group row, with updated prompts/messages and selection behavior. Bump extension VCS API version and expose `selectedEntryId`/`selectEntry`/`toggleEntry`, plus a new provider operation `stashFiles` so folder actions can stash multiple attested files in one operation. The Git adapter now performs multi-file stashes while preserving per-file staged-untracked semantics, with docs and tests updated for the new behavior. --- .changeset/folder-file-pane-actions.md | 5 + docs/extensions.md | 12 +- docs/keybindings.md | 24 +- packages/hunk-git/src/fileActions.test.ts | 15 +- packages/hunk-git/src/fileActions.ts | 66 +++- packages/hunk-git/src/index.ts | 4 +- packages/hunk/src/core/run/commandCatalog.ts | 6 +- packages/hunk/src/core/vcs/types.ts | 1 + packages/hunk/src/extension-api/types.ts | 15 +- .../default/ui/sidebar/FileSidebars.tsx | 86 +++-- packages/hunk/src/extensions/runExtension.ts | 11 + packages/hunk/src/ui/App.tsx | 17 +- .../hunk/src/ui/AppHost.working-tree.test.tsx | 71 +++- .../ui/components/chrome/WorkingTreeBar.tsx | 12 +- .../components/chrome/WorkingTreeDialog.tsx | 26 +- .../src/ui/components/panes/FileListItem.tsx | 31 +- .../src/ui/components/ui-components.test.tsx | 14 +- .../src/ui/hooks/useWorkingTreeActions.ts | 348 ++++++++++++++---- packages/hunk/src/ui/lib/appCommands.test.ts | 2 +- .../hunk/src/ui/lib/filePaneSelection.test.ts | 114 ++++++ packages/hunk/src/ui/lib/filePaneSelection.ts | 103 ++++++ packages/hunk/src/ui/lib/files.test.ts | 2 +- packages/hunk/src/ui/lib/files.ts | 47 ++- packages/hunk/src/ui/lib/helpContent.ts | 6 +- .../src/ui/lib/sidebarRenderWindow.test.ts | 7 +- .../hunk/src/ui/lib/sidebarRenderWindow.ts | 4 +- test/pty/working-tree-integration.test.ts | 43 ++- .../docs/docs/extend/custom-sidebars.md | 3 +- .../content/docs/docs/extend/vcs-adapters.md | 3 + .../docs/docs/start/keyboard-and-mouse.md | 4 +- 30 files changed, 916 insertions(+), 186 deletions(-) create mode 100644 .changeset/folder-file-pane-actions.md create mode 100644 packages/hunk/src/ui/lib/filePaneSelection.test.ts create mode 100644 packages/hunk/src/ui/lib/filePaneSelection.ts diff --git a/.changeset/folder-file-pane-actions.md b/.changeset/folder-file-pane-actions.md new file mode 100644 index 000000000..ae2d308af --- /dev/null +++ b/.changeset/folder-file-pane-actions.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Make folders in the files pane selectable. Space, discard, and stash apply to the files shown under the selected folder — nested files in tree view, or only that group's files in the compact list. diff --git a/docs/extensions.md b/docs/extensions.md index d4e43a425..bb297b8f0 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -558,16 +558,22 @@ excluding unrelated changes from every stash tree and from live cleanup. Revalid 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 v22 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 v20 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`, `staged`, -`busy`, `selectFile(path)`, and `toggleStaged(path)`. These controls expire on review reload. +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. Existing +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`. diff --git a/docs/keybindings.md b/docs/keybindings.md index 021416a0b..c1107725d 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -61,10 +61,14 @@ Quitting the opened review returns to the retained history selection and viewpor 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. File-row double-click and the clickable action beside the -stream tabs do the same. The sidebar shows Git-style status columns: green index changes and red worktree changes, +or unstages it when fully staged. Folder rows in the files pane are selectable too: Space, double-click, +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. +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 has a full-row highlight. A successful file action follows the file to the other tab. +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; @@ -77,11 +81,11 @@ that index line through further unstaged insertions, replacements, and deletions 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, and hx; unknown editor syntax is refused instead of opening at file start. -**d** opens discard choices for the selected file: Enter or **x** discards all its changes, +**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 stash message input; Enter stashes and Escape cancels. The stash -contains only that file's changes, including its staged/unstaged split, not unrelated staged files. -These actions require a selected actionable file in the visible, focused file panel. They reject stale targets and +**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. @@ -101,7 +105,7 @@ remain remappable, and an explicit user binding takes precedence over contextual | `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 changes | `d` | +| `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.focusFilter` | Focus the file filter | `/` | @@ -123,10 +127,10 @@ remain remappable, and an explicit user binding takes precedence over contextual | `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.stashSelectedFile` | Stash selected file | `s` | +| `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 | `space` | +| `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` | diff --git a/packages/hunk-git/src/fileActions.test.ts b/packages/hunk-git/src/fileActions.test.ts index c0ebedd78..187e98ca9 100644 --- a/packages/hunk-git/src/fileActions.test.ts +++ b/packages/hunk-git/src/fileActions.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { createTestWorkingTreeRepo, runTestGit } from "../../../test/helpers/working-tree"; import { createGitVcsAdapter } from "./index"; import { loadGitWorkingTreeFiles } from "./workingTree"; -import { discardGitFile, stashGitFile } from "./fileActions"; +import { discardGitFile, stashGitFile, stashGitFiles } from "./fileActions"; const roots: string[] = []; const input = { kind: "vcs" as const, staged: false, options: {} }; @@ -63,6 +63,19 @@ describe("selected-file discard and stash", () => { 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"); diff --git a/packages/hunk-git/src/fileActions.ts b/packages/hunk-git/src/fileActions.ts index c4955584b..0a56ee60a 100644 --- a/packages/hunk-git/src/fileActions.ts +++ b/packages/hunk-git/src/fileActions.ts @@ -92,8 +92,24 @@ export async function stashGitFile( message: string, context: GitFileActionContext, ) { - const { root, file } = verifyGitWorkingTreeFile(input, expected, context); - requireUnoccupiedRenameSource(root, file); + 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."); @@ -105,14 +121,27 @@ export async function stashGitFile( throw new HunkExtensionUserError( "Git's index is locked. Finish the other Git operation and try again.", ); - const paths = [file.path, ...(file.previousPath ? [file.previousPath] : [])]; + 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 label = message || `Hunk: ${file.path}`; + 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( @@ -133,9 +162,13 @@ export async function stashGitFile( }) ).trim(); let untrackedCommit: string | undefined; - if (file.untracked) { + if (untrackedPaths.length > 0) { await runGitMutation({ ...base, env, args: ["read-tree", "--empty"] }); - await runGitMutation({ ...base, env, args: ["--literal-pathspecs", "add", "--", file.path] }); + await runGitMutation({ + ...base, + env, + args: ["--literal-pathspecs", "add", "--", ...untrackedPaths], + }); const untrackedTree = (await runGitMutation({ ...base, env, args: ["write-tree"] })).trim(); untrackedCommit = ( await runGitMutation({ @@ -145,9 +178,10 @@ export async function stashGitFile( }) ).trim(); await runGitMutation({ ...base, env, args: ["read-tree", indexTree] }); - } else { + } + if (trackedPaths.length > 0) { // Preserve tracked type/mode, including emulated symlinks and ignored executable changes. - const presentPaths = paths.filter((path) => { + const presentPaths = trackedPaths.filter((path) => { try { lstatSync(join(root, path)); return true; @@ -156,7 +190,7 @@ export async function stashGitFile( throw error; } }); - const absentPaths = paths.filter((path) => !presentPaths.includes(path)); + const absentPaths = trackedPaths.filter((path) => !presentPaths.includes(path)); if (absentPaths.length) await runGitMutation({ ...base, @@ -187,13 +221,15 @@ export async function stashGitFile( stdin: `${label}\n`, }) ).trim(); - verifyGitWorkingTreeFile(input, expected, context); + 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. - verifyGitWorkingTreeFile(input, expected, context); - if (file.untracked) unlinkSync(join(root, file.path)); - else + 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: [ @@ -203,12 +239,12 @@ export async function stashGitFile( "--staged", "--worktree", "--", - ...paths, + ...trackedPaths, ], }); } catch (error) { throw new HunkExtensionUserError( - `Stash ${stash.slice(0, 12)} was created, but ${file.path} could not be cleaned: ${error instanceof Error ? error.message : String(error)}. The stash is retained; inspect Git status before retrying.`, + `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 { diff --git a/packages/hunk-git/src/index.ts b/packages/hunk-git/src/index.ts index 28f788b27..ef446530c 100644 --- a/packages/hunk-git/src/index.ts +++ b/packages/hunk-git/src/index.ts @@ -1,5 +1,5 @@ import { resolveGitWorkingTreeLine } from "./editorLine"; -import { discardGitFile, stashGitFile } from "./fileActions"; +import { discardGitFile, stashGitFile, stashGitFiles } from "./fileActions"; import { mutateGitHunkStaging } from "./hunkStaging"; import { createHash } from "node:crypto"; import fs from "node:fs"; @@ -417,6 +417,8 @@ export function createGitVcsAdapter({ 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 }) => diff --git a/packages/hunk/src/core/run/commandCatalog.ts b/packages/hunk/src/core/run/commandCatalog.ts index 894b6b678..a0677251c 100644 --- a/packages/hunk/src/core/run/commandCatalog.ts +++ b/packages/hunk/src/core/run/commandCatalog.ts @@ -100,7 +100,7 @@ export interface AppCommandCatalogEntry { const BUILTIN_COMMANDS = [ { id: "hunk.review.discardSelectedFile", - title: "Discard selected file changes", + title: "Discard selected file or folder changes", category: "review", defaultKeys: ["d"], locus: "host-only", @@ -109,7 +109,7 @@ const BUILTIN_COMMANDS = [ }, { id: "hunk.review.stashSelectedFile", - title: "Stash selected file", + title: "Stash selected file or folder", category: "review", defaultKeys: ["s"], locus: "host-only", @@ -126,7 +126,7 @@ const BUILTIN_COMMANDS = [ }, { id: "hunk.review.toggleFileStaged", - title: "Stage / unstage selected file", + title: "Stage / unstage selected file or folder", category: "review", defaultKeys: ["space"], locus: "host-only", diff --git a/packages/hunk/src/core/vcs/types.ts b/packages/hunk/src/core/vcs/types.ts index 0935a763f..c132da986 100644 --- a/packages/hunk/src/core/vcs/types.ts +++ b/packages/hunk/src/core/vcs/types.ts @@ -53,6 +53,7 @@ export interface VcsOperations { | "unstageHunk" | "discardFile" | "stashFile" + | "stashFiles" | "resolveWorkingTreeLine" >; "revision-show"?: VcsOperation; diff --git a/packages/hunk/src/extension-api/types.ts b/packages/hunk/src/extension-api/types.ts index b0ef02973..736a4e93c 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 = 23; +export const HUNK_EXTENSION_API_VERSION = 24; export type HunkExtensionApiVersion = typeof HUNK_EXTENSION_API_VERSION; export type ExtensionNotifyType = "info" | "warning" | "error"; @@ -1138,18 +1138,31 @@ export interface ExtensionVcsWorkingTreeOperation extends ExtensionVcsOperation< 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; } /** diff --git a/packages/hunk/src/extensions/default/ui/sidebar/FileSidebars.tsx b/packages/hunk/src/extensions/default/ui/sidebar/FileSidebars.tsx index 3b7684ee2..340500161 100644 --- a/packages/hunk/src/extensions/default/ui/sidebar/FileSidebars.tsx +++ b/packages/hunk/src/extensions/default/ui/sidebar/FileSidebars.tsx @@ -4,17 +4,21 @@ 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 { @@ -29,9 +33,10 @@ export type BuiltInSidebarProps = Omit< > & Partial>; -type FileSidebarVariantProps = Pick & { +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 +115,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 +133,7 @@ export function VirtualizedFileSidebarRows({ statsWidth={statsWidth} textWidth={textWidth} theme={theme} - onSelectFile={actions.selectFile} + onSelectFile={onSelectEntry} /> ); })} @@ -180,47 +189,56 @@ export function FlexFileSidebar({ }: BuiltInSidebarProps): ReactNode { const renderer = useRenderer(); const statusFiles = workingTree?.files; - const files = useMemo(() => { - if (!statusFiles) return reviewFiles; - 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 ?? { additions: 0, deletions: 0 }, - isUntracked: status.untracked, - stageStatus: status.untracked - ? "??" - : status.conflicted - ? "!!" - : (status.statusCode ?? `${status.staged ? "S" : " "}${status.unstaged ? "U" : " "}`), - }; - }); - }, [reviewFiles, statusFiles]); - const lastClickRef = useRef<{ path: string; time: number; press: number } | null>(null); - /** Only consecutive clicks on the same file may mutate the index. */ - const selectFile = (path: string) => { + 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 && - previous?.path === path && + paneEntries.find((entry) => entry.id === entryId)?.kind === "file" && + previous?.id === entryId && press === previous.press + 1 && now - previous.time < 350 ) { lastClickRef.current = null; - workingTree.toggleStaged(path); + workingTree.toggleEntry(entryId); + return; + } + lastClickRef.current = { id: entryId, time: now, press }; + if (workingTree) { + workingTree.selectEntry(entryId); return; } - lastClickRef.current = { path, time: now, press }; - if (workingTree) workingTree.selectFile(path); - else actions.selectFile(path); + 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?.selectedPath ?? reviewSelectedFileId; + 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); @@ -230,12 +248,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: { ...actions, selectFile }, estimatedViewportRows: terminal.height, files, + onSelectEntry: selectEntry, scrollTop: scrollViewport.top, selectedFileId, textWidth, diff --git a/packages/hunk/src/extensions/runExtension.ts b/packages/hunk/src/extensions/runExtension.ts index 04fc307af..28ccd72bc 100644 --- a/packages/hunk/src/extensions/runExtension.ts +++ b/packages/hunk/src/extensions/runExtension.ts @@ -505,6 +505,7 @@ export function toInternalVcsAdapter( | "unstageHunk" | "discardFile" | "stashFile" + | "stashFiles" | "resolveWorkingTreeLine" >) | undefined; @@ -557,6 +558,16 @@ export function toInternalVcsAdapter( } }; } + 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 ( diff --git a/packages/hunk/src/ui/App.tsx b/packages/hunk/src/ui/App.tsx index 8cbe75ef4..87b492c71 100644 --- a/packages/hunk/src/ui/App.tsx +++ b/packages/hunk/src/ui/App.tsx @@ -1047,11 +1047,18 @@ export function App({ setHunkActionFocused(false); }, []); + const filesPaneFocused = + filesPaneVisible && focusArea === "files" && filePanelFocused && !hunkActionFocused; + const filesPaneWidth = + paneLayout.panes.find((planned) => planned.pane.key === HUNK_FILES_PANE_KEY)?.bounds.width ?? 0; const workingTree = useWorkingTreeActions({ bootstrap, selectedFile, selectedHunkIndex, filter: review.filter, + reviewFiles: filteredFiles, + filesPaneFocused, + filesPaneWidth, createLease: createReviewCapabilityLease, selectReviewFile: jumpToFile, focusFiles, @@ -1136,8 +1143,6 @@ export function App({ 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 filesPaneFocused = - filesPaneVisible && focusArea === "files" && filePanelFocused && !hunkActionFocused; const reviewPaneFocused = filesPaneVisible && focusArea === "files" && !filesPaneFocused; /** Step the review selection and record which pane now owns keyboard movement. */ @@ -1189,6 +1194,12 @@ export function App({ scrollDiff, stepDiffLine: (delta) => { if (filesPaneFocused) { + if (workingTree.pane) { + setFilePanelFocused(true); + setHunkActionFocused(false); + workingTree.moveEntry(delta); + return; + } moveReviewSelection("file", delta); return; } @@ -1457,6 +1468,8 @@ export function App({ theme={activeTheme} width={terminal.width} hunkFocused={hunkStagingActive} + selectedIsFolder={workingTree.selectedIsFolder} + selectedWillStage={workingTree.selectedWillStage} canToggle={hunkStagingActive || workingTree.canToggleSelected} switchView={workingTree.switchView} toggleSelected={ diff --git a/packages/hunk/src/ui/AppHost.working-tree.test.tsx b/packages/hunk/src/ui/AppHost.working-tree.test.tsx index 6e9111b8a..7799a5f16 100644 --- a/packages/hunk/src/ui/AppHost.working-tree.test.tsx +++ b/packages/hunk/src/ui/AppHost.working-tree.test.tsx @@ -1,7 +1,7 @@ import { afterEach, describe, expect, setDefaultTimeout, spyOn, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { act } from "react"; -import { readFileSync, rmSync, writeFileSync, renameSync } from "node:fs"; +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"; @@ -33,12 +33,14 @@ async function createReview({ 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); @@ -81,7 +83,7 @@ async function createReview({ extensions: { enabled: false, paths: [], repoPaths: [], extensionConfigs: {} }, }); setup = await testRender(, { - width: 150, + width, height: 30, }); await waitForReview(() => setup!.captureCharFrame().includes("Unstaged (")); @@ -318,4 +320,69 @@ describe("working-tree stream actions", () => { await waitForReview(() => setup!.captureCharFrame().includes("Unstaged beta.txt.")); expect(runTestGit(root, "diff", "--cached")).toBe(""); }); + + 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/components/chrome/WorkingTreeBar.tsx b/packages/hunk/src/ui/components/chrome/WorkingTreeBar.tsx index 76022f262..cbdf75826 100644 --- a/packages/hunk/src/ui/components/chrome/WorkingTreeBar.tsx +++ b/packages/hunk/src/ui/components/chrome/WorkingTreeBar.tsx @@ -10,6 +10,8 @@ export function WorkingTreeBar({ width, canToggle, hunkFocused = false, + selectedIsFolder = false, + selectedWillStage = false, switchView, toggleSelected, }: { @@ -18,14 +20,20 @@ export function WorkingTreeBar({ 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 : selected?.unstaged; + const stage = hunkFocused + ? !pane.staged + : selectedIsFolder + ? selectedWillStage + : selected?.unstaged; const action = pane.busy ? "Working…" - : `${stage ? "Stage" : "Unstage"} ${hunkFocused ? "hunk" : "file"}`; + : `${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}) `, diff --git a/packages/hunk/src/ui/components/chrome/WorkingTreeDialog.tsx b/packages/hunk/src/ui/components/chrome/WorkingTreeDialog.tsx index bb0f80121..dfc1ca7e9 100644 --- a/packages/hunk/src/ui/components/chrome/WorkingTreeDialog.tsx +++ b/packages/hunk/src/ui/components/chrome/WorkingTreeDialog.tsx @@ -34,23 +34,25 @@ export function WorkingTreeDialog({ const width = frame.width; const bodyWidth = Math.max(1, width - 4); const stash = prompt.kind === "stash"; - const unstaged = prompt.file.staged && prompt.file.unstaged; + 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(JSON.stringify(prompt.file.path), bodyWidth).map( - (chunk) => chunk.text, - ); + 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 ( {fitText( - stash ? "Only this file. Message (optional):" : "Cannot undo discarded changes.", + stash + ? folder + ? "Only these files. Message (optional):" + : "Only this file. Message (optional):" + : "Cannot undo discarded changes.", bodyWidth, )} @@ -111,8 +117,12 @@ export function WorkingTreeDialog({ {fitText( unstaged - ? "Unstaged only keeps this file's staged changes." - : "Unstaged-only discard is unavailable for this file.", + ? 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, )} diff --git a/packages/hunk/src/ui/components/panes/FileListItem.tsx b/packages/hunk/src/ui/components/panes/FileListItem.tsx index a90cfe5fe..94264d865 100644 --- a/packages/hunk/src/ui/components/panes/FileListItem.tsx +++ b/packages/hunk/src/ui/components/panes/FileListItem.tsx @@ -50,24 +50,36 @@ function getFileStateIcon( export function FileGroupHeader({ entry, paddingLeft = 1, + selected = false, textWidth, theme, + onSelect, }: { entry: FileGroupEntry; paddingLeft?: number; + selected?: boolean; textWidth: number; theme: ExtensionSidebarTheme; + onSelect?: (entryId: string) => void; }) { + const rowBackground = selected ? theme.accentMuted : theme.panel; return ( { + if (event.button !== MouseButton.LEFT || !onSelect) return; + onSelect(entry.id); }} > - {fitText(entry.label, Math.max(1, textWidth))} + + {fitText(entry.label, Math.max(1, textWidth))} + ); } @@ -83,18 +95,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"}` @@ -110,30 +127,32 @@ 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 && ( { }, ], selectedPath: "added.txt", + selectedEntryId: "added.txt", staged: false, busy: false, selectFile: () => {}, + selectEntry: () => {}, toggleStaged: () => {}, + toggleEntry: () => {}, }} />, { width, height: 8 }, @@ -655,7 +658,9 @@ describe("UI components", () => { createTestDiffFile("beta", "beta.ts", "b\n", "bb\n"), ]); const selectFile = mock(() => {}); + const selectEntry = mock(() => {}); const toggleStaged = mock(() => {}); + const toggleEntry = mock(() => {}); const setup = await testRender( { version: "test", })), selectedPath: "alpha.ts", + selectedEntryId: "alpha.ts", staged: false, busy: false, selectFile, + selectEntry, toggleStaged, + toggleEntry, }} />, { width: 40, height: 8 }, @@ -692,10 +700,10 @@ describe("UI components", () => { for (const row of [alpha, beta, alpha]) { await act(async () => setup.mockMouse.click(5, row)); } - expect(selectFile.mock.calls).toHaveLength(3); - expect(toggleStaged).not.toHaveBeenCalled(); + expect(selectEntry.mock.calls).toHaveLength(3); + expect(toggleEntry).not.toHaveBeenCalled(); await act(async () => setup.mockMouse.click(5, alpha)); - expect(toggleStaged).toHaveBeenCalledWith("alpha.ts"); + expect(toggleEntry).toHaveBeenCalledWith("alpha.ts"); } finally { time.mockRestore(); await act(async () => setup.renderer.destroy()); diff --git a/packages/hunk/src/ui/hooks/useWorkingTreeActions.ts b/packages/hunk/src/ui/hooks/useWorkingTreeActions.ts index fcc39f083..a451bda8c 100644 --- a/packages/hunk/src/ui/hooks/useWorkingTreeActions.ts +++ b/packages/hunk/src/ui/hooks/useWorkingTreeActions.ts @@ -17,11 +17,27 @@ import type { } from "../../extension-api/types"; import type { ExtensionCapabilityLease } from "../lib/extensionCapabilityLease"; import type { WorkspaceWriteRunner } from "./useExtensionWorkspaceControls"; +import { + cursorFromSidebarIndex, + filesVisuallyUnderSidebarEntry, + sidebarEntryIdAtIndex, + sidebarIndexFromCursor, + stepSidebarIndex, + type FilePaneCursor, +} from "../lib/filePaneSelection"; +import { + buildFilePaneEntries, + fileSidebarContentWidth, + workingTreeSidebarSources, + type SidebarFileSource, +} from "../lib/files"; -/** Capture the exact file and generation the user is being asked to change. */ +/** Capture the exact files and generation the user is being asked to change. */ export interface WorkingTreePrompt { kind: "discard" | "stash"; - file: ExtensionWorkingTreeFile; + files: readonly ExtensionWorkingTreeFile[]; + label: string; + folder: boolean; lease: ExtensionCapabilityLease; } @@ -42,6 +58,9 @@ export function useWorkingTreeActions({ selectedFile, selectedHunkIndex, filter, + reviewFiles, + filesPaneFocused, + filesPaneWidth, createLease, selectReviewFile, focusFiles, @@ -54,6 +73,9 @@ export function useWorkingTreeActions({ selectedFile: DiffFile | undefined; selectedHunkIndex: number; filter: string; + reviewFiles: readonly SidebarFileSource[]; + filesPaneFocused: boolean; + filesPaneWidth: number; createLease: () => ExtensionCapabilityLease; selectReviewFile: (fileId: string, options: { alignFileHeaderTop: true }) => void; focusFiles: () => void; @@ -78,16 +100,39 @@ export function useWorkingTreeActions({ const busyRef = useRef(false); const switchingRef = useRef(false); const mutatingRef = useRef(false); - const pendingPathRef = useRef<{ path: string; changeset: AppBootstrap["changeset"] } | null>( - null, + 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 [chosenPath, setChosenPath] = useState(null); - const selectedPath = - [pendingPathRef.current?.path, selectedFile?.path, chosenPath].find((path) => - files.some((file) => file.path === path), - ) ?? - files[0]?.path ?? - null; + 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); + const selectedVisualFiles = filesVisuallyUnderSidebarEntry(entries, selectedIndex); + const selectedStatusFiles = selectedVisualFiles + .map((entry) => files.find((file) => file.path === entry.id)) + .filter((file): file is ExtensionWorkingTreeFile => Boolean(file)); + const selectedPath = selectedStatusFiles[0]?.path ?? null; + const selectedIsFolder = Boolean( + entries[selectedIndex] && entries[selectedIndex]!.kind !== "file", + ); + const selectedLabel = + entries[selectedIndex] && entries[selectedIndex]!.kind !== "file" + ? entries[selectedIndex]!.label + : (selectedPath ?? ""); const lease = useMemo(() => createLease(), [bootstrap, createLease]); const [promptState, setPromptState] = useState(null); const promptRef = useRef(null); @@ -110,14 +155,20 @@ export function useWorkingTreeActions({ }, []); useEffect(() => { - const pending = pendingPathRef.current; + const pending = pendingCursorRef.current; if (!pending || pending.changeset === bootstrap.changeset) return; // Consume a follow request once its authoritative inventory arrives, including clean files. - pendingPathRef.current = null; - const file = bootstrap.changeset.files.find((candidate) => candidate.path === pending.path); - setChosenPath(file?.path ?? null); + 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, selectReviewFile]); + }, [bootstrap, entries, selectReviewFile]); /** Switch the full stream without letting fast repeated keys enqueue stale switches. */ const switchView = useCallback( @@ -129,7 +180,7 @@ export function useWorkingTreeActions({ try { await setStagedView(next); } catch (error) { - pendingPathRef.current = null; + pendingCursorRef.current = null; showNotice( `Could not switch review: ${error instanceof Error ? error.message : String(error)}`, ); @@ -142,14 +193,14 @@ export function useWorkingTreeActions({ [enabled, lease, staged, setStagedView, showNotice], ); - const selectFile = useCallback( - (path: string) => { + 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(); - pendingPathRef.current = null; - setChosenPath(path); + 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) @@ -158,36 +209,66 @@ export function useWorkingTreeActions({ selectReviewFile(file.id, { alignFileHeaderTop: true }); return; } - pendingPathRef.current = { path, changeset: bootstrap.changeset }; + pendingCursorRef.current = { cursor, changeset: bootstrap.changeset }; void switchView(!staged); }, [lease, files, focusFiles, bootstrap.changeset, selectReviewFile, switchView, staged], ); - const canToggle = useCallback( - (path: string | null) => { - const file = files.find((candidate) => candidate.path === path); - return Boolean( + 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 firstPath = nested[0]?.id; + if (firstPath) revealStatusPath(firstPath, cursor); + else { + focusFiles(); + setChosenCursor(cursor); + } + }, + [entries, focusFiles, revealStatusPath], + ); + + 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) }; }, - [files, operation], + [canToggleFile], ); /** Keep file and hunk writes on the same busy, error, refresh, and graceful-exit path. */ const startMutation = useCallback( - ( - file: ExtensionWorkingTreeFile, - action: { - mutate: () => Promise; - progress: string; - complete: string; - followStaged?: boolean; - }, - ) => { + (action: { + mutate: () => Promise; + progress: string; + complete: string; + followStaged?: boolean; + followCursor?: FilePaneCursor | null; + }) => { busyRef.current = true; mutatingRef.current = true; setBusy(true); @@ -197,8 +278,11 @@ export function useWorkingTreeActions({ try { await action.mutate(); succeeded = true; - if (action.followStaged !== undefined) - pendingPathRef.current = { path: file.path, changeset: bootstrap.changeset }; + 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( @@ -233,28 +317,61 @@ export function useWorkingTreeActions({ [bootstrap.changeset, runMutation, refreshAfterMutation, showNotice], ); - const toggleStaged = useCallback( - (path: string) => { - // A double-click may finish while its first click switches tabs; its explicit path is attested. - if ( - !lease.isLive() || - mutatingRef.current || - !canToggle(path) || - bootstrap.input.kind !== "vcs" - ) - return; - const file = files.find((candidate) => candidate.path === path)!; - const mutate = file.unstaged ? operation?.stageFile : operation?.unstageFile; + 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; - startMutation(file, { - mutate: () => mutate(input, file, { cwd: bootstrap.changeset.sourceLabel }), - progress: `${file.unstaged ? "Staging" : "Unstaging"} ${file.path}…`, - complete: `${file.unstaged ? "Staged" : "Unstaged"} ${file.path}.`, - followStaged: file.unstaged, + 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, canToggle, bootstrap, files, operation, startMutation], + [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( @@ -286,7 +403,7 @@ export function useWorkingTreeActions({ if (!mutate) return false; const input = bootstrap.input; const hunk = summarizeHunk(reviewed.metadata.hunks[hunkIndex]!, hunkIndex); - startMutation(file, { + 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}.`, @@ -296,11 +413,19 @@ export function useWorkingTreeActions({ [canToggleHunk, bootstrap, files, operation, staged, startMutation], ); - const selectedStatus = files.find((file) => file.path === selectedPath); - const actionableFile = - enabled && selectedStatus && !selectedStatus.conflicted && !selectedStatus.unavailableReason; - const canDiscardSelected = Boolean(actionableFile && operation?.discardFile); - const canStashSelected = Boolean(actionableFile && operation?.stashFile); + const actionableFiles = selectedStatusFiles.filter( + (file) => !file.conflicted && !file.unavailableReason, + ); + const canDiscardSelected = Boolean( + enabled && actionableFiles.length > 0 && operation?.discardFile, + ); + const canStashSelected = 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"]) => { @@ -308,11 +433,17 @@ export function useWorkingTreeActions({ !lease.isLive() || busyRef.current || getPrompt() || - !selectedStatus || + actionableFiles.length === 0 || !(kind === "discard" ? canDiscardSelected : canStashSelected) ) return; - const pending = { kind, file: selectedStatus, lease }; + const pending = { + kind, + files: actionableFiles, + label: selectedLabel || actionableFiles[0]!.path, + folder: selectedIsFolder, + lease, + }; setMessage(""); promptRef.current = pending; setPromptState(pending); @@ -322,25 +453,45 @@ export function useWorkingTreeActions({ const acceptPrompt = (scope: ExtensionVcsDiscardScope = "all") => { const pending = getPrompt(); if (!pending || busyRef.current || bootstrap.input.kind !== "vcs") return; - const { file, kind } = pending; - if (kind === "discard" && scope === "unstaged" && !(file.staged && file.unstaged)) 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 stash = operation?.stashFile; - if (kind === "discard" ? !discard : !stash) return; + 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(file, { - mutate: () => - kind === "discard" - ? discard!(input, file, scope, context) - : stash!(input, file, stashMessage, context), - progress: `${kind === "discard" ? "Discarding" : "Stashing"} ${file.path}…`, + 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 ${file.path}.` - : `Stashed ${file.path}.`, + kind === "discard" ? `Discarded ${scope} changes in ${subject}.` : `Stashed ${subject}.`, }); }; @@ -350,14 +501,37 @@ export function useWorkingTreeActions({ ? Object.freeze({ files, selectedPath, + selectedEntryId, staged, busy, selectFile, + selectEntry, toggleStaged, + toggleEntry, }) : undefined, - [enabled, files, selectedPath, staged, busy, selectFile, toggleStaged], + [ + enabled, + files, + selectedPath, + selectedEntryId, + staged, + busy, + selectFile, + selectEntry, + toggleStaged, + toggleEntry, + ], + ); + + const selectedToggle = stagingTargets( + filesPaneFocused + ? selectedStatusFiles + : selectedPath + ? files.filter((file) => file.path === selectedPath) + : [], ); + const canToggleSelectedTargets = selectedToggle.files.length > 0; return { prompt: promptState?.lease.isLive() ? promptState : null, @@ -374,7 +548,9 @@ export function useWorkingTreeActions({ busy, staged, selectedPath, - canToggleSelected: enabled && canToggle(selectedPath), + selectedIsFolder: Boolean(filesPaneFocused && selectedIsFolder), + selectedWillStage: selectedToggle.stage, + canToggleSelected: enabled && canToggleSelectedTargets, canToggleSelectedHunk: enabled && canToggleHunk(selectedFile?.id, selectedHunkIndex), canToggleHunk, toggleHunk, @@ -382,7 +558,12 @@ export function useWorkingTreeActions({ if (selectedFile) toggleHunk(selectedFile.id, selectedHunkIndex); }, toggleSelected: () => { - if (!busyRef.current && selectedPath) toggleStaged(selectedPath); + if (busyRef.current) return; + if (filesPaneFocused && selectedEntryId) { + toggleEntry(selectedEntryId); + return; + } + if (selectedPath) toggleStaged(selectedPath); }, switchView: (next: boolean) => { void switchView(next); @@ -392,5 +573,10 @@ export function useWorkingTreeActions({ 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 nextIndex = stepSidebarIndex(entries, selectedIndex, delta); + const next = entries[nextIndex]; + if (next && next.id !== selectedEntryId) selectEntry(next.id); + }, }; } diff --git a/packages/hunk/src/ui/lib/appCommands.test.ts b/packages/hunk/src/ui/lib/appCommands.test.ts index 9e03b40be..56610110a 100644 --- a/packages/hunk/src/ui/lib/appCommands.test.ts +++ b/packages/hunk/src/ui/lib/appCommands.test.ts @@ -175,7 +175,7 @@ describe("built-in command chords", () => { 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 or hunk")).toBe("Space"); + 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" }); 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..1a0d16cca --- /dev/null +++ b/packages/hunk/src/ui/lib/filePaneSelection.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from "bun:test"; +import { createTestDiffFile } from "../../../../../test/helpers/diff-helpers"; +import { + cursorFromSidebarIndex, + filesVisuallyUnderSidebarEntry, + 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("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..4df4a86ce --- /dev/null +++ b/packages/hunk/src/ui/lib/filePaneSelection.ts @@ -0,0 +1,103 @@ +/** + * 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 }; + +/** 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..2a325014e 100644 --- a/packages/hunk/src/ui/lib/files.test.ts +++ b/packages/hunk/src/ui/lib/files.test.ts @@ -312,7 +312,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 266e1d076..380e426bb 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"; @@ -45,6 +45,8 @@ export interface FileGroupEntry { kind: "group"; id: string; label: string; + /** Directory this compact group represents, without a trailing slash. */ + path: string; } export interface FileDirectoryEntry { @@ -52,6 +54,8 @@ export interface FileDirectoryEntry { id: string; path: string; label: string; + /** Directory this expanded row represents, without a trailing slash. */ + path: string; depth: number; descendantFileCount: number; } @@ -61,11 +65,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); @@ -142,6 +161,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 ?? { 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; @@ -175,6 +218,7 @@ export function buildFlatSidebarEntries(files: readonly SidebarFileSource[]): Si kind: "group", id: `group:${group}:${index}`, label: group === "." ? "./" : `${group}/`, + path: group, }); } @@ -246,6 +290,7 @@ export function buildTreeSidebarEntries(files: readonly SidebarFileSource[]): Si id: `directory:${fileIndex}:${depth}:${directoryPath}`, path: directoryPath, label: sidebarDirectoryLabel(segment), + path: directoryPath, depth, descendantFileCount: 0, }; diff --git a/packages/hunk/src/ui/lib/helpContent.ts b/packages/hunk/src/ui/lib/helpContent.ts index c9d366a64..765469215 100644 --- a/packages/hunk/src/ui/lib/helpContent.ts +++ b/packages/hunk/src/ui/lib/helpContent.ts @@ -102,11 +102,11 @@ const HELP_SECTIONS: readonly HelpSectionSpec[] = [ }, { commandIds: ["hunk.review.discardSelectedFile"], - description: "discard selected file changes (confirmed)", + description: "discard selected file or folder changes (confirmed)", }, { commandIds: ["hunk.review.stashSelectedFile"], - description: "stash selected file with a message", + description: "stash selected file or folder with a message", }, { commandIds: ["hunk.review.editSelectedFile"], @@ -119,7 +119,7 @@ const HELP_SECTIONS: readonly HelpSectionSpec[] = [ entries: [ { commandIds: ["hunk.review.toggleFileStaged", "hunk.review.toggleHunkStaged"], - description: "stage / unstage focused file or hunk", + description: "stage / unstage focused file, folder, or hunk", }, { commandIds: ["hunk.review.toggleStagedView"], 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/test/pty/working-tree-integration.test.ts b/test/pty/working-tree-integration.test.ts index 4a04fe200..85df6dea9 100644 --- a/test/pty/working-tree-integration.test.ts +++ b/test/pty/working-tree-integration.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; -import { readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { createTestWorkingTreeRepo, runTestGit } from "../helpers/working-tree"; import { createPtyHarness } from "./harness"; @@ -118,6 +118,47 @@ describe("PTY working-tree staging", () => { } }); + test("Space on a selected folder stages the nested files shown under it", 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.press("space"); + await harness.waitForSnapshot( + session, + (text) => /A\s+one\.ts/.test(text) && /A\s+two\.ts/.test(text), + 10_000, + ); + expect(runTestGit(root, "show", ":src/one.ts")).toBe("one\n"); + expect(runTestGit(root, "show", ":src/nested/two.ts")).toBe("two\n"); + } 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"); diff --git a/website/src/content/docs/docs/extend/custom-sidebars.md b/website/src/content/docs/docs/extend/custom-sidebars.md index d7c804881..cccfe7635 100644 --- a/website/src/content/docs/docs/extend/custom-sidebars.md +++ b/website/src/content/docs/docs/extend/custom-sidebars.md @@ -8,7 +8,8 @@ description: Render React panes around Hunk's review stream. 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. Retained controls expire on review reload. Ordinary `props.files` and navigation +staged file. API v22 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 diff --git a/website/src/content/docs/docs/extend/vcs-adapters.md b/website/src/content/docs/docs/extend/vcs-adapters.md index 4cbd3e9b0..c5f1ecdfb 100644 --- a/website/src/content/docs/docs/extend/vcs-adapters.md +++ b/website/src/content/docs/docs/extend/vcs-adapters.md @@ -64,6 +64,9 @@ preserve its staged content for unstaged-only discard, and exclude unrelated cha 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 v22 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 v20 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 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 839c19413..fe5eb6f30 100644 --- a/website/src/content/docs/docs/start/keyboard-and-mouse.md +++ b/website/src/content/docs/docs/start/keyboard-and-mouse.md @@ -21,7 +21,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. -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 `k` / `j` move between files; 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. +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 `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 +51,7 @@ 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 and focus the files pane. +- Click a sidebar file to jump to it in the review stream and focus the files pane. Click a folder to select it; Space then stages or unstages the files shown under that folder. - 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. From 6faffe69473f51b12653a449311b6d8e7e08f19d Mon Sep 17 00:00:00 2001 From: yusing Date: Sun, 6 Sep 2026 05:07:20 +0000 Subject: [PATCH 10/27] feat(ui): keep working-tree folder actions and line stats on both tabs Add optional `stats` (`additions`/`deletions`) to working-tree file inventory in API v23 and enrich git status rows from `git diff --numstat` across both staged and unstaged sides, including rename/copy-safe parsing and untracked/symlink/large-file count handling, so sidebar +/- stays visible even when a file is off the active review stream. Preserve folder selection semantics by choosing a nested path on the current tab when available so folder-stage/discard/stash actions do not unexpectedly jump sides, and update sidebar rendering helpers, selection tests, and docs/keybinding wording to reflect file-or-folder actions and stable stats visibility across tabs. --- .changeset/folder-select-keeps-stats.md | 5 + docs/extensions.md | 14 ++- docs/keybindings.md | 4 +- packages/hunk-git/src/commands.test.ts | 16 +++ packages/hunk-git/src/commands.ts | 47 +++++--- packages/hunk-git/src/index.ts | 11 +- packages/hunk-git/src/workingTree.test.ts | 66 ++++++++++- packages/hunk-git/src/workingTree.ts | 104 +++++++++++++++++- packages/hunk/src/extension-api/types.ts | 8 +- .../default/ui/sidebar/FileSidebars.tsx | 1 + packages/hunk/src/opentui/HunkFileNav.tsx | 3 +- .../hunk/src/ui/AppHost.working-tree.test.tsx | 50 +++++++++ .../src/ui/components/panes/FileListItem.tsx | 12 +- .../src/ui/components/ui-components.test.tsx | 55 +++++++++ .../src/ui/hooks/useWorkingTreeActions.ts | 10 +- .../hunk/src/ui/lib/filePaneSelection.test.ts | 14 +++ packages/hunk/src/ui/lib/filePaneSelection.ts | 13 +++ packages/hunk/src/ui/lib/files.test.ts | 38 +++++++ packages/hunk/src/ui/lib/files.ts | 2 +- .../docs/docs/extend/custom-sidebars.md | 2 +- .../content/docs/docs/extend/vcs-adapters.md | 14 ++- .../docs/docs/start/keyboard-and-mouse.md | 2 +- 22 files changed, 447 insertions(+), 44 deletions(-) create mode 100644 .changeset/folder-select-keeps-stats.md diff --git a/.changeset/folder-select-keeps-stats.md b/.changeset/folder-select-keeps-stats.md new file mode 100644 index 000000000..b355ba64a --- /dev/null +++ b/.changeset/folder-select-keeps-stats.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Keep files-pane +/- counts on every working-tree row, including files that only have changes on the other Unstaged/Staged tab. Folder selection also stays on the current side when any nested file belongs there. diff --git a/docs/extensions.md b/docs/extensions.md index bb297b8f0..ae445a031 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -532,24 +532,28 @@ staged and unstaged files, independent of `input.staged`. Each entry supplies `p `previousPath`, `staged`, `unstaged`, `untracked`, `conflicted`, an opaque `version` attestation, and optionally an `unavailableReason`. Do not return this inventory for revision comparisons. -API v21 adds optional `statusCode`, a two-character Git-style index/worktree status (`??` for +API v23 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. -API v18 also adds optional `stageHunk(input, file, hunk, ctx)` and +API v20 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 v19 adds optional `discardFile(input, file, scope, ctx)` and +API v21 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 @@ -558,11 +562,11 @@ excluding unrelated changes from every stash tree and from live cleanup. Revalid 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 v22 adds optional `stashFiles(input, files, message, ctx)` for one stash covering several +API v24 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 v20 adds optional `resolveWorkingTreeLine(input, file, line, ctx): Promise`. +API v22 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 diff --git a/docs/keybindings.md b/docs/keybindings.md index c1107725d..a986c9e19 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -90,7 +90,9 @@ renames whose former path has been recreated. Stashing requires an initial commi 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. Use `/` to focus the filter. +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. 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 2de5123f4..197cdff24 100644 --- a/packages/hunk-git/src/commands.ts +++ b/packages/hunk-git/src/commands.ts @@ -209,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. */ diff --git a/packages/hunk-git/src/index.ts b/packages/hunk-git/src/index.ts index ef446530c..9d977b075 100644 --- a/packages/hunk-git/src/index.ts +++ b/packages/hunk-git/src/index.ts @@ -28,7 +28,11 @@ import { } from "./commands"; import { openGitHistory } from "./history"; import { gitEndpointSourceSpec, readGitFileSource } from "./source"; -import { loadGitWorkingTreeFiles, mutateGitFileStaging } from "./workingTree"; +import { + attachGitWorkingTreeLineStats, + loadGitWorkingTreeFiles, + mutateGitFileStaging, +} from "./workingTree"; import { HUNK_VCS_DETECTION_BASELINE_PRIORITY, type ExtensionVcsAdapter, @@ -377,12 +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: loadGitWorkingTreeFiles(input, { cwd, gitExecutable }), + workingTreeFiles: statusFiles + ? attachGitWorkingTreeLineStats(statusFiles, input, { cwd, gitExecutable }) + : undefined, patchText: await runGitTextAsync({ input, args: buildGitDiffArgs( diff --git a/packages/hunk-git/src/workingTree.test.ts b/packages/hunk-git/src/workingTree.test.ts index 8013c1e5a..7b751bd4c 100644 --- a/packages/hunk-git/src/workingTree.test.ts +++ b/packages/hunk-git/src/workingTree.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; -import { readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +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 "."; @@ -153,8 +154,71 @@ describe("working-tree file staging", () => { 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 index 21ed5b517..be9372458 100644 --- a/packages/hunk-git/src/workingTree.ts +++ b/packages/hunk-git/src/workingTree.ts @@ -3,14 +3,23 @@ * 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 } from "node:fs"; +import { lstatSync, readFileSync, readlinkSync } from "node:fs"; import { isAbsolute, join } from "node:path"; import { HunkExtensionUserError, type ExtensionVcsDiffInput, type ExtensionWorkingTreeFile, } from "hunkdiff/extension"; -import { appendGitPathspecs, resolveGitRepoRoot, runGitMutation, runGitText } from "./commands"; +import { inspectLargeUntrackedFile } from "@hunk/vcs/large-file"; +import { + appendGitPathspecs, + buildGitDiffNumstatArgs, + parseGitNumstat, + resolveGitRepoRoot, + runGitMutation, + runGitText, + type GitNumstatFile, +} from "./commands"; interface GitWorkingTreeContext { cwd: string; @@ -110,6 +119,97 @@ export function loadGitWorkingTreeFiles( 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, diff --git a/packages/hunk/src/extension-api/types.ts b/packages/hunk/src/extension-api/types.ts index 736a4e93c..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 = 24; +export const HUNK_EXTENSION_API_VERSION = 25; export type HunkExtensionApiVersion = typeof HUNK_EXTENSION_API_VERSION; export type ExtensionNotifyType = "info" | "warning" | "error"; @@ -1087,6 +1087,12 @@ export interface ExtensionWorkingTreeFile { 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. */ diff --git a/packages/hunk/src/extensions/default/ui/sidebar/FileSidebars.tsx b/packages/hunk/src/extensions/default/ui/sidebar/FileSidebars.tsx index 340500161..6d660b1c4 100644 --- a/packages/hunk/src/extensions/default/ui/sidebar/FileSidebars.tsx +++ b/packages/hunk/src/extensions/default/ui/sidebar/FileSidebars.tsx @@ -101,6 +101,7 @@ export function VirtualizedFileSidebarRows({ entry={entry} paddingLeft={paddingLeft} selected={entry.id === selectedFileId} + statsWidth={statsWidth} textWidth={textWidth} theme={theme} onSelect={onSelectEntry} 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/ui/AppHost.working-tree.test.tsx b/packages/hunk/src/ui/AppHost.working-tree.test.tsx index 7799a5f16..04668020d 100644 --- a/packages/hunk/src/ui/AppHost.working-tree.test.tsx +++ b/packages/hunk/src/ui/AppHost.working-tree.test.tsx @@ -303,6 +303,20 @@ describe("working-tree stream actions", () => { 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"); @@ -321,6 +335,42 @@ describe("working-tree stream actions", () => { 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) => { diff --git a/packages/hunk/src/ui/components/panes/FileListItem.tsx b/packages/hunk/src/ui/components/panes/FileListItem.tsx index 94264d865..fff5a7372 100644 --- a/packages/hunk/src/ui/components/panes/FileListItem.tsx +++ b/packages/hunk/src/ui/components/panes/FileListItem.tsx @@ -46,11 +46,12 @@ 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, @@ -58,11 +59,14 @@ export function FileGroupHeader({ 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 ( - - {fitText(entry.label, Math.max(1, textWidth))} - + {fitText(entry.label, labelWidth, "…")} ); } @@ -250,6 +252,8 @@ export const FileListItem = memo(function FileListItem({ { 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={{ + 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) => { diff --git a/packages/hunk/src/ui/hooks/useWorkingTreeActions.ts b/packages/hunk/src/ui/hooks/useWorkingTreeActions.ts index a451bda8c..3ea1f09f8 100644 --- a/packages/hunk/src/ui/hooks/useWorkingTreeActions.ts +++ b/packages/hunk/src/ui/hooks/useWorkingTreeActions.ts @@ -20,6 +20,7 @@ import type { WorkspaceWriteRunner } from "./useExtensionWorkspaceControls"; import { cursorFromSidebarIndex, filesVisuallyUnderSidebarEntry, + nestedRevealPath, sidebarEntryIdAtIndex, sidebarIndexFromCursor, stepSidebarIndex, @@ -229,14 +230,17 @@ export function useWorkingTreeActions({ const cursor = cursorFromSidebarIndex(entries, index); if (!cursor) return; const nested = filesVisuallyUnderSidebarEntry(entries, index); - const firstPath = nested[0]?.id; - if (firstPath) revealStatusPath(firstPath, cursor); + 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(); setChosenCursor(cursor); } }, - [entries, focusFiles, revealStatusPath], + [entries, files, focusFiles, revealStatusPath, staged], ); const canToggleFile = useCallback( diff --git a/packages/hunk/src/ui/lib/filePaneSelection.test.ts b/packages/hunk/src/ui/lib/filePaneSelection.test.ts index 1a0d16cca..443057e69 100644 --- a/packages/hunk/src/ui/lib/filePaneSelection.test.ts +++ b/packages/hunk/src/ui/lib/filePaneSelection.test.ts @@ -3,6 +3,7 @@ import { createTestDiffFile } from "../../../../../test/helpers/diff-helpers"; import { cursorFromSidebarIndex, filesVisuallyUnderSidebarEntry, + nestedRevealPath, sidebarIndexFromCursor, stepSidebarIndex, } from "./filePaneSelection"; @@ -93,6 +94,19 @@ describe("file pane visual selection", () => { ); }); + 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( diff --git a/packages/hunk/src/ui/lib/filePaneSelection.ts b/packages/hunk/src/ui/lib/filePaneSelection.ts index 4df4a86ce..38bd4d63a 100644 --- a/packages/hunk/src/ui/lib/filePaneSelection.ts +++ b/packages/hunk/src/ui/lib/filePaneSelection.ts @@ -10,6 +10,19 @@ 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[], diff --git a/packages/hunk/src/ui/lib/files.test.ts b/packages/hunk/src/ui/lib/files.test.ts index 2a325014e..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"); diff --git a/packages/hunk/src/ui/lib/files.ts b/packages/hunk/src/ui/lib/files.ts index 380e426bb..db4382b8e 100644 --- a/packages/hunk/src/ui/lib/files.ts +++ b/packages/hunk/src/ui/lib/files.ts @@ -174,7 +174,7 @@ export function workingTreeSidebarSources( id: status.path, path: status.path, previousPath: status.previousPath, - stats: reviewed?.stats ?? { additions: 0, deletions: 0 }, + stats: reviewed?.stats ?? status.stats ?? { additions: 0, deletions: 0 }, isUntracked: status.untracked, stageStatus: status.untracked ? "??" diff --git a/website/src/content/docs/docs/extend/custom-sidebars.md b/website/src/content/docs/docs/extend/custom-sidebars.md index cccfe7635..b06959ea2 100644 --- a/website/src/content/docs/docs/extend/custom-sidebars.md +++ b/website/src/content/docs/docs/extend/custom-sidebars.md @@ -8,7 +8,7 @@ description: Render React panes around Hunk's review stream. 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 v22 adds `selectedEntryId`, `selectEntry(id)`, and `toggleEntry(id)` so a pane can +staged file. API v24 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. diff --git a/website/src/content/docs/docs/extend/vcs-adapters.md b/website/src/content/docs/docs/extend/vcs-adapters.md index c5f1ecdfb..6e684a3fa 100644 --- a/website/src/content/docs/docs/extend/vcs-adapters.md +++ b/website/src/content/docs/docs/extend/vcs-adapters.md @@ -53,29 +53,33 @@ and throw `HunkExtensionUserError` for failures. The host tracks started writes and refreshes status and diff before allowing another action. These are terminal-host actions, not implicit remote capabilities. -API v18 adds `stageHunk(input, file, hunk, ctx)` and `unstageHunk(input, file, hunk, ctx)`. +API v20 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 v19 adds `discardFile(input, file, scope, ctx)` and `stashFile(input, file, message, ctx)`. +API v21 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 v22 adds optional `stashFiles(input, files, message, ctx)` so a selected folder can become one +API v24 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 v20 adds optional `resolveWorkingTreeLine(input, file, line, ctx): Promise` for +API v22 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 v21 adds optional inventory `statusCode`: two Git-style status columns (index, worktree), +API v23 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 fe5eb6f30..818a7a766 100644 --- a/website/src/content/docs/docs/start/keyboard-and-mouse.md +++ b/website/src/content/docs/docs/start/keyboard-and-mouse.md @@ -51,7 +51,7 @@ 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 and focus the files pane. Click a folder to select it; Space then stages or unstages the files shown under that folder. +- Click a sidebar file to jump to it in the review stream and focus the files pane. Click a folder to select 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. From f2808c15ec4ce2f5ba3329b14ca049eba40a2f00 Mon Sep 17 00:00:00 2001 From: yusing Date: Sun, 6 Sep 2026 06:39:01 +0000 Subject: [PATCH 11/27] feat(ui): add Enter/escape pane focus commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `hunk.review.focusDiffPane` and `hunk.review.focusFilesPane` command entries and App bindings so Enter focuses the selected file’s review and Escape returns focus to the files pane, with enablement tied to active pane state and selection kind. Normalize Escape key matching for `escape`, `esc`, and raw `\u001b`, expose both actions in file menu/help/keyboard docs, and extend command, menu, and interaction tests (including mode handoff coverage with overrideable bindings) for the new focus behavior. --- .changeset/enter-esc-pane-focus.md | 5 + docs/keybindings.md | 2 + packages/hunk/src/core/run/commandCatalog.ts | 17 +++ packages/hunk/src/extension-api/keys.test.ts | 3 + packages/hunk/src/extension-api/keys.ts | 6 + packages/hunk/src/ui/App.tsx | 10 ++ .../src/ui/AppHost.file-view-modes.test.tsx | 20 ++- .../hunk/src/ui/AppHost.interactions.test.tsx | 119 ++++++++++++++++++ .../src/ui/components/ui-components.test.tsx | 9 +- packages/hunk/src/ui/lib/appCommands.test.ts | 23 ++++ packages/hunk/src/ui/lib/appCommands.ts | 16 +++ packages/hunk/src/ui/lib/appMenus.test.ts | 10 ++ packages/hunk/src/ui/lib/appMenus.ts | 2 + packages/hunk/src/ui/lib/helpContent.test.ts | 1 + packages/hunk/src/ui/lib/helpContent.ts | 4 + .../docs/docs/start/keyboard-and-mouse.md | 4 +- 16 files changed, 242 insertions(+), 9 deletions(-) create mode 100644 .changeset/enter-esc-pane-focus.md diff --git a/.changeset/enter-esc-pane-focus.md b/.changeset/enter-esc-pane-focus.md new file mode 100644 index 000000000..361324411 --- /dev/null +++ b/.changeset/enter-esc-pane-focus.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +With the files pane focused on a file, Enter focuses that file's review. Esc returns to the files pane on the current file. diff --git a/docs/keybindings.md b/docs/keybindings.md index a986c9e19..6514b574e 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -110,6 +110,8 @@ remain remappable, and an explicit user binding takes precedence over contextual | `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` | diff --git a/packages/hunk/src/core/run/commandCatalog.ts b/packages/hunk/src/core/run/commandCatalog.ts index a0677251c..3a868a8c5 100644 --- a/packages/hunk/src/core/run/commandCatalog.ts +++ b/packages/hunk/src/core/run/commandCatalog.ts @@ -202,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", 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/ui/App.tsx b/packages/hunk/src/ui/App.tsx index 87b492c71..0d2046967 100644 --- a/packages/hunk/src/ui/App.tsx +++ b/packages/hunk/src/ui/App.tsx @@ -1173,12 +1173,22 @@ export function App({ toggleStagedView: () => workingTree.switchView(!workingTree.staged), canAlignCurrentLine: cursorLine !== "off" && review.lineCursor !== null, canApplyFilePresentationToAllMatching: selectedFileViewBulkTarget !== null, + canFocusDiffPane: + filesPaneFocused && selectedFile !== undefined && !workingTree.selectedIsFolder, + canFocusFilesPane: reviewPaneFocused, 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); }, 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 15bc2bf22..59537c100 100644 --- a/packages/hunk/src/ui/AppHost.interactions.test.tsx +++ b/packages/hunk/src/ui/AppHost.interactions.test.tsx @@ -590,6 +590,24 @@ function leftPaneBorderColor(setup: Awaited>, row: 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, @@ -3887,6 +3905,107 @@ describe("App interactions", () => { } }); + 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); diff --git a/packages/hunk/src/ui/components/ui-components.test.tsx b/packages/hunk/src/ui/components/ui-components.test.tsx index 057ea5541..11cade583 100644 --- a/packages/hunk/src/ui/components/ui-components.test.tsx +++ b/packages/hunk/src/ui/components/ui-components.test.tsx @@ -4089,13 +4089,13 @@ describe("UI components", () => { const frame = await captureFrame( {}} />, 76, - 39, + 41, ); const expectedRows = [ @@ -4103,6 +4103,7 @@ describe("UI components", () => { "[Esc]", "Navigation", "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", @@ -4210,13 +4211,13 @@ describe("UI components", () => { const frame = await captureFrame( {}} />, 76, - 39, + 41, ); expect(frame).toContain("Ctrl+X"); diff --git a/packages/hunk/src/ui/lib/appCommands.test.ts b/packages/hunk/src/ui/lib/appCommands.test.ts index 56610110a..4885c29dd 100644 --- a/packages/hunk/src/ui/lib/appCommands.test.ts +++ b/packages/hunk/src/ui/lib/appCommands.test.ts @@ -49,10 +49,14 @@ function createTestCommands( 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"), @@ -341,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(), diff --git a/packages/hunk/src/ui/lib/appCommands.ts b/packages/hunk/src/ui/lib/appCommands.ts index 08fc8abfc..791993373 100644 --- a/packages/hunk/src/ui/lib/appCommands.ts +++ b/packages/hunk/src/ui/lib/appCommands.ts @@ -125,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. */ @@ -212,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), @@ -352,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, diff --git a/packages/hunk/src/ui/lib/appMenus.test.ts b/packages/hunk/src/ui/lib/appMenus.test.ts index 2472c5099..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,6 +122,8 @@ describe("buildAppMenus", () => { expect(items(menus.file).map((item) => item.label)).toEqual([ "Toggle files/filter focus", "Focus filter", + "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,6 +239,8 @@ describe("buildAppMenus", () => { expect(items(menus.file).map((item) => item.label)).toEqual([ "Toggle files/filter focus", "Focus filter", + "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 76b262afc..0b4664a40 100644 --- a/packages/hunk/src/ui/lib/appMenus.ts +++ b/packages/hunk/src/ui/lib/appMenus.ts @@ -145,6 +145,8 @@ export function buildAppMenus({ file: [ { commandId: "hunk.app.toggleFocusArea", label: "Toggle files/filter focus" }, { commandId: "hunk.review.focusFilter", label: "Focus filter" }, + { 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" }, diff --git a/packages/hunk/src/ui/lib/helpContent.test.ts b/packages/hunk/src/ui/lib/helpContent.test.ts index 2de9afc3a..80fd9b2ee 100644 --- a/packages/hunk/src/ui/lib/helpContent.test.ts +++ b/packages/hunk/src/ui/lib/helpContent.test.ts @@ -32,6 +32,7 @@ describe("buildHelpSections", () => { expect(keysFor(sections, "previous / next hunk")).toBe("[ / ]"); expect(keysFor(sections, "half page down / up")).toBe("d / u"); 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 765469215..c1f265efd 100644 --- a/packages/hunk/src/ui/lib/helpContent.ts +++ b/packages/hunk/src/ui/lib/helpContent.ts @@ -45,6 +45,10 @@ const HELP_SECTIONS: readonly HelpSectionSpec[] = [ commandIds: ["hunk.review.stepUp", "hunk.review.stepDown"], 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" }, { 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 818a7a766..35c08edda 100644 --- a/website/src/content/docs/docs/start/keyboard-and-mouse.md +++ b/website/src/content/docs/docs/start/keyboard-and-mouse.md @@ -10,6 +10,8 @@ Press `?` at any time for Hunk's in-app shortcut reference. Menus and primary re | Keys | Action | | ------------------------- | ----------------------------------------------------- | | `↑` / `↓`, `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. -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 `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. +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 From 4677cd15a0978a9eb6a40ced46ba6c0c1edac76a Mon Sep 17 00:00:00 2001 From: yusing Date: Mon, 7 Sep 2026 04:57:58 +0000 Subject: [PATCH 12/27] fix: reconcile rebased staging and sidebar integrations --- .changeset/rebase-integration.md | 2 ++ docs/keybindings.md | 3 ++- packages/hunk-git/src/commands.ts | 2 +- .../hunk/src/ui/AppHost.working-tree.test.tsx | 2 +- .../src/ui/components/panes/FileListItem.tsx | 4 +++- packages/hunk/src/ui/lib/files.ts | 2 -- test/pty/editor-integration.test.ts | 2 ++ test/pty/working-tree-integration.test.ts | 16 ++++++++++------ .../docs/docs/start/keyboard-and-mouse.md | 2 +- 9 files changed, 22 insertions(+), 13 deletions(-) create mode 100644 .changeset/rebase-integration.md 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/docs/keybindings.md b/docs/keybindings.md index 6514b574e..df32095fe 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -61,9 +61,10 @@ Quitting the opened review returns to the retained history selection and viewpor 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, double-click, +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, diff --git a/packages/hunk-git/src/commands.ts b/packages/hunk-git/src/commands.ts index 197cdff24..70c5388ba 100644 --- a/packages/hunk-git/src/commands.ts +++ b/packages/hunk-git/src/commands.ts @@ -51,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; } diff --git a/packages/hunk/src/ui/AppHost.working-tree.test.tsx b/packages/hunk/src/ui/AppHost.working-tree.test.tsx index 04668020d..1b6a17864 100644 --- a/packages/hunk/src/ui/AppHost.working-tree.test.tsx +++ b/packages/hunk/src/ui/AppHost.working-tree.test.tsx @@ -6,7 +6,7 @@ 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 { AppHost } from "./AppHost"; +import { TestAppHost as AppHost } from "../../../../test/helpers/app-host"; import { loadStartupExtensions } from "../extensions/startup"; setDefaultTimeout(30_000); diff --git a/packages/hunk/src/ui/components/panes/FileListItem.tsx b/packages/hunk/src/ui/components/panes/FileListItem.tsx index fff5a7372..372206b8f 100644 --- a/packages/hunk/src/ui/components/panes/FileListItem.tsx +++ b/packages/hunk/src/ui/components/panes/FileListItem.tsx @@ -154,7 +154,9 @@ export function FileDirectoryRow({ }} > {collapsed ? "› " : "⌄ "} - {padText(fitText(entry.label, labelWidth), labelWidth)} + + {padText(fitText(entry.label, labelWidth), labelWidth)} + {countText && ( { 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("")); diff --git a/test/pty/working-tree-integration.test.ts b/test/pty/working-tree-integration.test.ts index 85df6dea9..20ebac705 100644 --- a/test/pty/working-tree-integration.test.ts +++ b/test/pty/working-tree-integration.test.ts @@ -118,7 +118,7 @@ describe("PTY working-tree staging", () => { } }); - test("Space on a selected folder stages the nested files shown under it", async () => { + 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 }); @@ -146,14 +146,18 @@ describe("PTY working-tree staging", () => { 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 harness.waitForSnapshot( - session, - (text) => /A\s+one\.ts/.test(text) && /A\s+two\.ts/.test(text), - 10_000, - ); + 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(); } 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 35c08edda..488c2020c 100644 --- a/website/src/content/docs/docs/start/keyboard-and-mouse.md +++ b/website/src/content/docs/docs/start/keyboard-and-mouse.md @@ -53,7 +53,7 @@ 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 and focus the files pane. Click a folder to select 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 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. From 6acd5adb4af1857e5799fcff92c8e9086986abaf Mon Sep 17 00:00:00 2001 From: yusing Date: Mon, 7 Sep 2026 08:19:38 +0000 Subject: [PATCH 13/27] feat(review): support opening source lines in micro Add micro editor line-jump support for the `e` shortcut, including editor flags, documentation, and PTY coverage. --- .changeset/micro-editor.md | 5 + docs/keybindings.md | 2 +- packages/hunk/src/ui/lib/openInEditor.test.ts | 17 ++ packages/hunk/src/ui/lib/openInEditor.ts | 6 +- test/pty/editor-integration.test.ts | 147 +++++++++--------- 5 files changed, 101 insertions(+), 76 deletions(-) create mode 100644 .changeset/micro-editor.md 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/docs/keybindings.md b/docs/keybindings.md index df32095fe..6a1b1ca0d 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -81,7 +81,7 @@ uses the active hunk's first changed line, not its leading context. From the sta 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, and hx; unknown editor syntax is refused instead of opening at file start. +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 diff --git a/packages/hunk/src/ui/lib/openInEditor.test.ts b/packages/hunk/src/ui/lib/openInEditor.test.ts index abd9675a0..2d6a0f6e9 100644 --- a/packages/hunk/src/ui/lib/openInEditor.test.ts +++ b/packages/hunk/src/ui/lib/openInEditor.test.ts @@ -70,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({ diff --git a/packages/hunk/src/ui/lib/openInEditor.ts b/packages/hunk/src/ui/lib/openInEditor.ts index dccc1c21e..4d5d1ed72 100644 --- a/packages/hunk/src/ui/lib/openInEditor.ts +++ b/packages/hunk/src/ui/lib/openInEditor.ts @@ -96,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. */ @@ -122,7 +122,7 @@ export function buildEditorCommand({ 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] }; } @@ -181,7 +181,7 @@ export function openSelectedFileInEditor({ }); if (!command) - return "Cannot position this editor at a source line. Set $EDITOR to vi, vim, nvim, code, code-insiders, cursor, or hx."; + 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) { diff --git a/test/pty/editor-integration.test.ts b/test/pty/editor-integration.test.ts index ed2f6829f..7790e40ba 100644 --- a/test/pty/editor-integration.test.ts +++ b/test/pty/editor-integration.test.ts @@ -50,77 +50,80 @@ describe("PTY external editor positioning", () => { } }); - test("e maps the selected change or explicit context line through unstaged insertions", async () => { - 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, "nvim"); - 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`, + 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', ); - 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(); - } - }); + 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(); + } + }, + ); }); From c950f0e970f16826a2ecca9cd644ba8b51f52acc Mon Sep 17 00:00:00 2001 From: yusing Date: Mon, 7 Sep 2026 08:51:01 +0000 Subject: [PATCH 14/27] fix(ui): window diff rows before viewport measurement Keep large unwrapped diffs responsive on the first frame by applying provisional row bounds before the terminal viewport is measured. Update PTY coverage for split and stack modes, keyboard readiness, and shared sidebar frame assertions. --- .changeset/initial-row-window.md | 5 ++ .changeset/pty-frame-probes.md | 2 + .../hunk/src/ui/AppHost.interactions.test.tsx | 7 +- .../src/ui/AppHost.sidebar-resize.test.tsx | 44 ++++------- .../hunk/src/ui/components/panes/DiffPane.tsx | 9 +-- packages/hunk/src/ui/lib/viewportTiming.ts | 2 +- test/helpers/sidebar-frame.ts | 16 ++++ test/pty/chrome.test.ts | 2 + test/pty/highlighting.test.ts | 78 ++++++++++--------- test/pty/layout.test.ts | 33 ++------ test/pty/scroll.test.ts | 1 + 11 files changed, 99 insertions(+), 100 deletions(-) create mode 100644 .changeset/initial-row-window.md create mode 100644 .changeset/pty-frame-probes.md create mode 100644 test/helpers/sidebar-frame.ts 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/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/packages/hunk/src/ui/AppHost.interactions.test.tsx b/packages/hunk/src/ui/AppHost.interactions.test.tsx index 59537c100..8ecfdbbfe 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"; @@ -3718,9 +3719,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); @@ -3732,7 +3733,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); 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/components/panes/DiffPane.tsx b/packages/hunk/src/ui/components/panes/DiffPane.tsx index 13197616c..a4003e810 100644 --- a/packages/hunk/src/ui/components/panes/DiffPane.tsx +++ b/packages/hunk/src/ui/components/panes/DiffPane.tsx @@ -1983,13 +1983,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 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/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/pty/chrome.test.ts b/test/pty/chrome.test.ts index 9e9044b78..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 harness.ensureKeyboardIsLive(session); await session.press("/"); + await session.waitForText("filter: type to filter files"); await session.type("beta"); const filtered = await harness.waitForSnapshot( session, 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/scroll.test.ts b/test/pty/scroll.test.ts index 920c45c78..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); From 17c14fcf5688424335ac91227b8a4cd9d02a66a2 Mon Sep 17 00:00:00 2001 From: yusing Date: Mon, 7 Sep 2026 09:28:50 +0000 Subject: [PATCH 15/27] test(broker): create private runtime fixture regardless of umask --- .changeset/little-clouds-rush.md | 2 ++ packages/hunk/src/session/broker/brokerClient.test.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 .changeset/little-clouds-rush.md 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/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, From a1f3c02b4569416577bcc7ec8db4df42d2f7ccfb Mon Sep 17 00:00:00 2001 From: yusing Date: Mon, 7 Sep 2026 09:29:04 +0000 Subject: [PATCH 16/27] test(git): expect custom executable for working-tree inventory --- .changeset/hot-comics-battle.md | 2 ++ packages/hunk/src/core/changeset/loaders.test.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 .changeset/hot-comics-battle.md 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/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"]); From b3df1e7c356e74b4b85bbecc690343f27fd6cc5d Mon Sep 17 00:00:00 2001 From: yusing Date: Mon, 7 Sep 2026 09:29:12 +0000 Subject: [PATCH 17/27] test(cli): preserve Git ISO timezone in history expectations --- .changeset/sunny-worlds-design.md | 2 ++ test/cli/log.test.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 .changeset/sunny-worlds-design.md 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/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"); From 1f6e282b479b88f7f84328b8066e9294339cb1bb Mon Sep 17 00:00:00 2001 From: yusing Date: Mon, 7 Sep 2026 09:29:20 +0000 Subject: [PATCH 18/27] test(vcs): allow isolated compiler startup under contention --- .changeset/calm-bikes-march.md | 2 ++ scripts/quality/hunk-vcs-package.test.ts | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 .changeset/calm-bikes-march.md 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/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); }); From c79baf02e7070200c6588f217f1b2bd055cc71a4 Mon Sep 17 00:00:00 2001 From: yusing Date: Mon, 7 Sep 2026 09:29:44 +0000 Subject: [PATCH 19/27] fix(jj): include repository context in source failure diagnostics --- .changeset/quiet-jj-diagnostics.md | 5 +++++ packages/hunk-jj/src/source.test.ts | 18 +++++++++++++++++- packages/hunk-jj/src/source.ts | 7 +++++-- 3 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 .changeset/quiet-jj-diagnostics.md 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/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; } From 807f7134c5a1d2d9368fd14026f1aae3a2675ec3 Mon Sep 17 00:00:00 2001 From: yusing Date: Mon, 7 Sep 2026 09:30:42 +0000 Subject: [PATCH 20/27] test(pty): wait for a painted frame when measuring key scroll --- .changeset/smooth-comics-fail.md | 2 ++ test/pty/cursor-line.test.ts | 14 +++++++------- test/pty/harness.ts | 25 ++++++++++++++++++++----- 3 files changed, 29 insertions(+), 12 deletions(-) create mode 100644 .changeset/smooth-comics-fail.md 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/test/pty/cursor-line.test.ts b/test/pty/cursor-line.test.ts index 493e2a483..71662c713 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(); } @@ -232,7 +232,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 +335,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/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( From c40b2e123651161d971360465234fa6632e615ba Mon Sep 17 00:00:00 2001 From: yusing Date: Mon, 7 Sep 2026 09:30:49 +0000 Subject: [PATCH 21/27] test(pty): wait for multi-row selection backgrounds to paint --- .changeset/open-donuts-allow.md | 2 ++ test/pty/cursor-line.test.ts | 11 ++++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 .changeset/open-donuts-allow.md 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/test/pty/cursor-line.test.ts b/test/pty/cursor-line.test.ts index 71662c713..d74f17bc2 100644 --- a/test/pty/cursor-line.test.ts +++ b/test/pty/cursor-line.test.ts @@ -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]); From 373826aa983dd212ce642844e9dbc057769fca15 Mon Sep 17 00:00:00 2001 From: yusing Date: Mon, 7 Sep 2026 09:33:02 +0000 Subject: [PATCH 22/27] fix(extensions): accept the latest dialog answer in an input burst --- .changeset/fast-dialog-answers.md | 5 ++ .../useExtensionDialogController.test.tsx | 14 ++--- .../ui/hooks/useExtensionDialogController.ts | 52 +++++++++++++------ test/pty/extensions-integration.test.ts | 4 +- 4 files changed, 51 insertions(+), 24 deletions(-) create mode 100644 .changeset/fast-dialog-answers.md 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/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/test/pty/extensions-integration.test.ts b/test/pty/extensions-integration.test.ts index c76e3a4a6..80943d60f 100644 --- a/test/pty/extensions-integration.test.ts +++ b/test/pty/extensions-integration.test.ts @@ -912,8 +912,8 @@ describe("PTY extensions", () => { 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 { From 27e9432e753e34ef3e5d5f6adcfd761b9a2ac6b9 Mon Sep 17 00:00:00 2001 From: yusing Date: Mon, 7 Sep 2026 09:36:14 +0000 Subject: [PATCH 23/27] fix(ui): preserve newer note hover during deferred scroll reads --- .changeset/newer-note-hover.md | 5 +++ .../hunk/src/ui/components/panes/DiffPane.tsx | 19 ++++++++--- .../src/ui/components/ui-components.test.tsx | 3 ++ test/pty/add-note-hover-integration.test.ts | 33 +++++++++++++++++++ 4 files changed, 55 insertions(+), 5 deletions(-) create mode 100644 .changeset/newer-note-hover.md create mode 100644 test/pty/add-note-hover-integration.test.ts 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/packages/hunk/src/ui/components/panes/DiffPane.tsx b/packages/hunk/src/ui/components/panes/DiffPane.tsx index a4003e810..c9604382a 100644 --- a/packages/hunk/src/ui/components/panes/DiffPane.tsx +++ b/packages/hunk/src/ui/components/panes/DiffPane.tsx @@ -468,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; @@ -875,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) => { @@ -965,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, diff --git a/packages/hunk/src/ui/components/ui-components.test.tsx b/packages/hunk/src/ui/components/ui-components.test.tsx index 11cade583..2929ddca0 100644 --- a/packages/hunk/src/ui/components/ui-components.test.tsx +++ b/packages/hunk/src/ui/components/ui-components.test.tsx @@ -1621,6 +1621,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, 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); From 1efb9a6eb365e903742337253e282c5cdbb99df7 Mon Sep 17 00:00:00 2001 From: yusing Date: Mon, 7 Sep 2026 09:47:26 +0000 Subject: [PATCH 24/27] fix(ui): keep file-action focus and targets fresh within input bursts --- .changeset/fast-discard-targets.md | 5 + packages/hunk/src/ui/App.tsx | 34 +++-- .../hunk/src/ui/AppHost.working-tree.test.tsx | 91 ++++++++++++++ packages/hunk/src/ui/hooks/useLiveState.ts | 15 +++ .../src/ui/hooks/useWorkingTreeActions.ts | 119 +++++++++++++----- test/pty/working-tree-integration.test.ts | 4 +- 6 files changed, 225 insertions(+), 43 deletions(-) create mode 100644 .changeset/fast-discard-targets.md create mode 100644 packages/hunk/src/ui/hooks/useLiveState.ts 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/packages/hunk/src/ui/App.tsx b/packages/hunk/src/ui/App.tsx index 0d2046967..bdba7d897 100644 --- a/packages/hunk/src/ui/App.tsx +++ b/packages/hunk/src/ui/App.tsx @@ -1,5 +1,6 @@ import { getConfiguredVcsAdapter } from "../core/vcs"; import { WorkingTreeDialog } from "./components/chrome/WorkingTreeDialog"; +import { useLiveState } from "./hooks/useLiveState"; import { recordMousePress } from "./lib/mousePressSequence"; import type { BoxRenderable, @@ -253,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; @@ -354,8 +355,8 @@ export function App({ getSelectedFileId, getSelection: getExtensionSelection, } = extensionRuntime; - const [hunkActionFocused, setHunkActionFocused] = useState(false); - const [filePanelFocused, setFilePanelFocused] = useState(false); + const [hunkActionFocused, setHunkActionFocused, getHunkActionFocused] = useLiveState(false); + const [filePanelFocused, setFilePanelFocused, getFilePanelFocused] = useLiveState(false); const jumpToFile = useCallback( (fileId: string, options?: { alignFileHeaderTop?: boolean }) => { setHunkActionFocused(false); @@ -1049,12 +1050,18 @@ export function App({ 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, selectedHunkIndex, + getSelectedFileId, filter: review.filter, reviewFiles: filteredFiles, filesPaneFocused, @@ -1161,8 +1168,12 @@ export function App({ const appCommands = observeAppCommandDispatch( [ ...buildAppCommands({ - canDiscardSelectedFile: filesPaneFocused && workingTree.canDiscardSelected, - canStashSelectedFile: filesPaneFocused && workingTree.canStashSelected, + get canDiscardSelectedFile() { + return isFilesPaneFocused() && workingTree.canDiscardSelected; + }, + get canStashSelectedFile() { + return isFilesPaneFocused() && workingTree.canStashSelected; + }, discardSelectedFile: workingTree.discardSelected, stashSelectedFile: workingTree.stashSelected, canToggleFileStaged: !hunkStagingActive && workingTree.canToggleSelected, @@ -1173,9 +1184,14 @@ export function App({ toggleStagedView: () => workingTree.switchView(!workingTree.staged), canAlignCurrentLine: cursorLine !== "off" && review.lineCursor !== null, canApplyFilePresentationToAllMatching: selectedFileViewBulkTarget !== null, - canFocusDiffPane: - filesPaneFocused && selectedFile !== undefined && !workingTree.selectedIsFolder, - canFocusFilesPane: reviewPaneFocused, + 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, @@ -1203,7 +1219,7 @@ export function App({ scrollCodeHorizontally, scrollDiff, stepDiffLine: (delta) => { - if (filesPaneFocused) { + if (isFilesPaneFocused()) { if (workingTree.pane) { setFilePanelFocused(true); setHunkActionFocused(false); diff --git a/packages/hunk/src/ui/AppHost.working-tree.test.tsx b/packages/hunk/src/ui/AppHost.working-tree.test.tsx index 1b6a17864..2b3438ae8 100644 --- a/packages/hunk/src/ui/AppHost.working-tree.test.tsx +++ b/packages/hunk/src/ui/AppHost.working-tree.test.tsx @@ -1,5 +1,6 @@ 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"; @@ -97,6 +98,29 @@ async function press(key: string) { }); } +/** 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; @@ -107,6 +131,73 @@ afterEach(async () => { }); describe("working-tree stream actions", () => { + 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) => { 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/useWorkingTreeActions.ts b/packages/hunk/src/ui/hooks/useWorkingTreeActions.ts index 3ea1f09f8..5b0978da8 100644 --- a/packages/hunk/src/ui/hooks/useWorkingTreeActions.ts +++ b/packages/hunk/src/ui/hooks/useWorkingTreeActions.ts @@ -58,6 +58,7 @@ export function useWorkingTreeActions({ bootstrap, selectedFile, selectedHunkIndex, + getSelectedFileId, filter, reviewFiles, filesPaneFocused, @@ -73,6 +74,7 @@ export function useWorkingTreeActions({ bootstrap: AppBootstrap; selectedFile: DiffFile | undefined; selectedHunkIndex: number; + getSelectedFileId: () => string | null; filter: string; reviewFiles: readonly SidebarFileSource[]; filesPaneFocused: boolean; @@ -122,18 +124,44 @@ export function useWorkingTreeActions({ const activeCursor = pendingCursor ?? folderCursor ?? reviewFileCursor ?? chosenCursor; const selectedIndex = sidebarIndexFromCursor(entries, activeCursor); const selectedEntryId = sidebarEntryIdAtIndex(entries, selectedIndex); - const selectedVisualFiles = filesVisuallyUnderSidebarEntry(entries, selectedIndex); - const selectedStatusFiles = selectedVisualFiles - .map((entry) => files.find((file) => file.path === entry.id)) - .filter((file): file is ExtensionWorkingTreeFile => Boolean(file)); - const selectedPath = selectedStatusFiles[0]?.path ?? null; - const selectedIsFolder = Boolean( - entries[selectedIndex] && entries[selectedIndex]!.kind !== "file", - ); - const selectedLabel = - entries[selectedIndex] && entries[selectedIndex]!.kind !== "file" - ? entries[selectedIndex]!.label - : (selectedPath ?? ""); + // 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 = getSelectedFileId(); + 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); @@ -200,6 +228,7 @@ export function useWorkingTreeActions({ 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. @@ -208,12 +237,22 @@ export function useWorkingTreeActions({ : undefined; if (file) { selectReviewFile(file.id, { alignFileHeaderTop: true }); + cursorReviewFileIdRef.current = getSelectedFileId(); return; } pendingCursorRef.current = { cursor, changeset: bootstrap.changeset }; void switchView(!staged); }, - [lease, files, focusFiles, bootstrap.changeset, selectReviewFile, switchView, staged], + [ + lease, + files, + focusFiles, + bootstrap.changeset, + selectReviewFile, + getSelectedFileId, + switchView, + staged, + ], ); const selectFile = useCallback( @@ -237,6 +276,7 @@ export function useWorkingTreeActions({ if (revealPath) revealStatusPath(revealPath, cursor); else { focusFiles(); + activeCursorRef.current = cursor; setChosenCursor(cursor); } }, @@ -417,35 +457,43 @@ export function useWorkingTreeActions({ [canToggleHunk, bootstrap, files, operation, staged, startMutation], ); - const actionableFiles = selectedStatusFiles.filter( - (file) => !file.conflicted && !file.unavailableReason, - ); - const canDiscardSelected = Boolean( - enabled && actionableFiles.length > 0 && operation?.discardFile, - ); - const canStashSelected = Boolean( - enabled && - actionableFiles.length > 0 && - (actionableFiles.length === 1 - ? operation?.stashFile || operation?.stashFiles - : operation?.stashFiles), - ); + /** 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" ? canDiscardSelected : canStashSelected) + !(kind === "discard" ? selection.canDiscard : selection.canStash) ) return; const pending = { kind, files: actionableFiles, - label: selectedLabel || actionableFiles[0]!.path, - folder: selectedIsFolder, + label: selection.label || actionableFiles[0]!.path, + folder: selection.folder, lease, }; setMessage(""); @@ -544,8 +592,12 @@ export function useWorkingTreeActions({ acceptPrompt, message, setMessage, - canDiscardSelected, - canStashSelected, + get canDiscardSelected() { + return promptSelection().canDiscard; + }, + get canStashSelected() { + return promptSelection().canStash; + }, discardSelected: () => openPrompt("discard"), stashSelected: () => openPrompt("stash"), pane, @@ -553,6 +605,7 @@ export function useWorkingTreeActions({ staged, selectedPath, selectedIsFolder: Boolean(filesPaneFocused && selectedIsFolder), + isSelectedFolder: () => liveSelection().folder, selectedWillStage: selectedToggle.stage, canToggleSelected: enabled && canToggleSelectedTargets, canToggleSelectedHunk: enabled && canToggleHunk(selectedFile?.id, selectedHunkIndex), @@ -573,14 +626,16 @@ export function useWorkingTreeActions({ 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 !== selectedEntryId) selectEntry(next.id); + if (next && next.id !== sidebarEntryIdAtIndex(entries, selectedIndex)) selectEntry(next.id); }, }; } diff --git a/test/pty/working-tree-integration.test.ts b/test/pty/working-tree-integration.test.ts index 20ebac705..acaf0b19b 100644 --- a/test/pty/working-tree-integration.test.ts +++ b/test/pty/working-tree-integration.test.ts @@ -175,8 +175,8 @@ describe("PTY working-tree staging", () => { }); try { await session.waitForText("later alpha", { timeout: 15_000 }); - await focusFilesPane(session); - await session.press("d"); + 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"); From 182a484648ba72a0f2c7dfd3a47af581ad46f956 Mon Sep 17 00:00:00 2001 From: yusing Date: Mon, 7 Sep 2026 10:29:53 +0000 Subject: [PATCH 25/27] fix(ui): settle native note edits before consuming drafts --- .changeset/complete-note-input.md | 5 ++ packages/hunk/src/ui/App.tsx | 1 + .../hunk/src/ui/AppHost.interactions.test.tsx | 7 +- .../src/ui/hooks/useUserNoteComposer.test.tsx | 66 +++++++++++++++++++ .../hunk/src/ui/hooks/useUserNoteComposer.ts | 56 +++++++++++----- test/pty/extensions-integration.test.ts | 4 +- test/pty/notes.test.ts | 45 +++++++++++++ 7 files changed, 159 insertions(+), 25 deletions(-) create mode 100644 .changeset/complete-note-input.md 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/packages/hunk/src/ui/App.tsx b/packages/hunk/src/ui/App.tsx index bdba7d897..be1fe5167 100644 --- a/packages/hunk/src/ui/App.tsx +++ b/packages/hunk/src/ui/App.tsx @@ -1130,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, diff --git a/packages/hunk/src/ui/AppHost.interactions.test.tsx b/packages/hunk/src/ui/AppHost.interactions.test.tsx index 8ecfdbbfe..218465f05 100644 --- a/packages/hunk/src/ui/AppHost.interactions.test.tsx +++ b/packages/hunk/src/ui/AppHost.interactions.test.tsx @@ -3188,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); 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/test/pty/extensions-integration.test.ts b/test/pty/extensions-integration.test.ts index 80943d60f..c9fde32fa 100644 --- a/test/pty/extensions-integration.test.ts +++ b/test/pty/extensions-integration.test.ts @@ -906,8 +906,8 @@ 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"); 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({ From 501a54a8ec0dda3e158264c2e136bdbcf7f532d1 Mon Sep 17 00:00:00 2001 From: yusing Date: Mon, 7 Sep 2026 16:47:49 +0000 Subject: [PATCH 26/27] fix(ui): preserve working-tree action targets and write exclusion --- .changeset/enter-esc-pane-focus.md | 2 +- .changeset/exact-line-editor.md | 2 +- .changeset/folder-file-pane-actions.md | 2 +- .changeset/folder-select-keeps-stats.md | 2 +- .changeset/live-staging-targets.md | 5 + docs/extension-architecture.md | 6 +- docs/extensions.md | 6 +- packages/hunk/src/extensions/runExtension.ts | 4 +- packages/hunk/src/ui/App.tsx | 15 ++- packages/hunk/src/ui/AppHost.tsx | 15 ++- .../hunk/src/ui/AppHost.working-tree.test.tsx | 68 +++++++++++ .../hunk/src/ui/AppHost.workspace.test.tsx | 106 ++++++++++++++++++ .../useExtensionWorkspaceControls.test.tsx | 6 +- .../ui/hooks/useExtensionWorkspaceControls.ts | 9 +- .../src/ui/hooks/useWorkingTreeActions.ts | 45 +++++--- test/pty/working-tree-integration.test.ts | 40 +++++++ 16 files changed, 298 insertions(+), 35 deletions(-) create mode 100644 .changeset/live-staging-targets.md diff --git a/.changeset/enter-esc-pane-focus.md b/.changeset/enter-esc-pane-focus.md index 361324411..7b218708d 100644 --- a/.changeset/enter-esc-pane-focus.md +++ b/.changeset/enter-esc-pane-focus.md @@ -2,4 +2,4 @@ "hunkdiff": patch --- -With the files pane focused on a file, Enter focuses that file's review. Esc returns to the files pane on the current file. +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 index ed27005d2..d019e1dd6 100644 --- a/.changeset/exact-line-editor.md +++ b/.changeset/exact-line-editor.md @@ -2,4 +2,4 @@ "hunkdiff": minor --- -Open the external editor at the deliberately selected source line or the active hunk’s first change, mapping staged lines through later unstaged edits. Refuse text-converted source coordinates and unknown editor line syntax rather than opening at the file beginning. +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/folder-file-pane-actions.md b/.changeset/folder-file-pane-actions.md index ae2d308af..ab0243eb5 100644 --- a/.changeset/folder-file-pane-actions.md +++ b/.changeset/folder-file-pane-actions.md @@ -2,4 +2,4 @@ "hunkdiff": minor --- -Make folders in the files pane selectable. Space, discard, and stash apply to the files shown under the selected folder — nested files in tree view, or only that group's files in the compact list. +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 index b355ba64a..48faa2fc3 100644 --- a/.changeset/folder-select-keeps-stats.md +++ b/.changeset/folder-select-keeps-stats.md @@ -2,4 +2,4 @@ "hunkdiff": patch --- -Keep files-pane +/- counts on every working-tree row, including files that only have changes on the other Unstaged/Staged tab. Folder selection also stays on the current side when any nested file belongs there. +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/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/docs/extension-architecture.md b/docs/extension-architecture.md index 4cccba9ff..79d40eb73 100644 --- a/docs/extension-architecture.md +++ b/docs/extension-architecture.md @@ -338,13 +338,13 @@ 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/src/extensions/default/vcs/git/workingTree.ts` derives both +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/src/extensions/default/vcs/git/hunkStaging.ts` selects attested raw Git hunks for +`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. The bundled sidebar consumes `ExtensionPaneProps.workingTree` through the public +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 diff --git a/docs/extensions.md b/docs/extensions.md index ae445a031..8eb5908cd 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -544,7 +544,7 @@ 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. +and reloads authoritative state even after a failed mutation; extension workspace writes are refused while that Git action is active. API v20 also adds optional `stageHunk(input, file, hunk, ctx)` and `unstageHunk(input, file, hunk, ctx)`. `hunk` is the existing `ExtensionDiffHunk` summary. @@ -1932,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 @@ -1944,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/packages/hunk/src/extensions/runExtension.ts b/packages/hunk/src/extensions/runExtension.ts index 28ccd72bc..c5458a355 100644 --- a/packages/hunk/src/extensions/runExtension.ts +++ b/packages/hunk/src/extensions/runExtension.ts @@ -493,7 +493,9 @@ export function toInternalVcsAdapter( } const workingTree = isPlainObject(operations) - ? (operations as { "working-tree-diff"?: ExtensionVcsWorkingTreeOperation })["working-tree-diff"] + ? (operations as { "working-tree-diff"?: ExtensionVcsWorkingTreeOperation })[ + "working-tree-diff" + ] : undefined; const internalWorkingTree = internalOperations["working-tree-diff"] as | (VcsOperation & diff --git a/packages/hunk/src/ui/App.tsx b/packages/hunk/src/ui/App.tsx index be1fe5167..f2ccfce61 100644 --- a/packages/hunk/src/ui/App.tsx +++ b/packages/hunk/src/ui/App.tsx @@ -1060,8 +1060,8 @@ export function App({ const workingTree = useWorkingTreeActions({ bootstrap, selectedFile, - selectedHunkIndex, - getSelectedFileId, + getSelection: review.getSelection, + isFilesPaneFocused, filter: review.filter, reviewFiles: filteredFiles, filesPaneFocused, @@ -1177,8 +1177,15 @@ export function App({ }, discardSelectedFile: workingTree.discardSelected, stashSelectedFile: workingTree.stashSelected, - canToggleFileStaged: !hunkStagingActive && workingTree.canToggleSelected, - canToggleHunkStaged: hunkStagingActive, + get canToggleFileStaged() { + return ( + !(getHunkActionFocused() && workingTree.canToggleSelectedHunk) && + workingTree.canToggleSelected + ); + }, + get canToggleHunkStaged() { + return getHunkActionFocused() && workingTree.canToggleSelectedHunk; + }, toggleHunkStaged: workingTree.toggleSelectedHunk, canSwitchStagedView: Boolean(workingTree.pane), toggleFileStaged: workingTree.toggleSelected, diff --git a/packages/hunk/src/ui/AppHost.tsx b/packages/hunk/src/ui/AppHost.tsx index 9c3c4223a..a60090b48 100644 --- a/packages/hunk/src/ui/AppHost.tsx +++ b/packages/hunk/src/ui/AppHost.tsx @@ -218,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); @@ -230,18 +230,27 @@ 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 runWorkspaceWrite(write); + return await trackWorkspaceWrite(write); } finally { vcsMutationPendingRef.current = false; } }, - [runWorkspaceWrite], + [trackWorkspaceWrite], ); const performReloadSession = useCallback( diff --git a/packages/hunk/src/ui/AppHost.working-tree.test.tsx b/packages/hunk/src/ui/AppHost.working-tree.test.tsx index 2b3438ae8..79b8a29e4 100644 --- a/packages/hunk/src/ui/AppHost.working-tree.test.tsx +++ b/packages/hunk/src/ui/AppHost.working-tree.test.tsx @@ -131,6 +131,74 @@ afterEach(async () => { }); 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"], 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/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/useWorkingTreeActions.ts b/packages/hunk/src/ui/hooks/useWorkingTreeActions.ts index 5b0978da8..4f5e272f2 100644 --- a/packages/hunk/src/ui/hooks/useWorkingTreeActions.ts +++ b/packages/hunk/src/ui/hooks/useWorkingTreeActions.ts @@ -57,8 +57,8 @@ export function hasWorkingTreeInventory(bootstrap: AppBootstrap) { export function useWorkingTreeActions({ bootstrap, selectedFile, - selectedHunkIndex, - getSelectedFileId, + getSelection, + isFilesPaneFocused, filter, reviewFiles, filesPaneFocused, @@ -73,8 +73,8 @@ export function useWorkingTreeActions({ }: { bootstrap: AppBootstrap; selectedFile: DiffFile | undefined; - selectedHunkIndex: number; - getSelectedFileId: () => string | null; + getSelection: () => { fileId: string | null; hunkIndex: number | null }; + isFilesPaneFocused: () => boolean; filter: string; reviewFiles: readonly SidebarFileSource[]; filesPaneFocused: boolean; @@ -152,7 +152,7 @@ export function useWorkingTreeActions({ } = selectionAt(selectedIndex); /** Read the sidebar cursor after all preceding navigation in this input burst. */ const liveCursor = () => { - const fileId = getSelectedFileId(); + 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; @@ -237,7 +237,7 @@ export function useWorkingTreeActions({ : undefined; if (file) { selectReviewFile(file.id, { alignFileHeaderTop: true }); - cursorReviewFileIdRef.current = getSelectedFileId(); + cursorReviewFileIdRef.current = getSelection().fileId; return; } pendingCursorRef.current = { cursor, changeset: bootstrap.changeset }; @@ -249,7 +249,7 @@ export function useWorkingTreeActions({ focusFiles, bootstrap.changeset, selectReviewFile, - getSelectedFileId, + getSelection, switchView, staged, ], @@ -583,7 +583,13 @@ export function useWorkingTreeActions({ ? files.filter((file) => file.path === selectedPath) : [], ); - const canToggleSelectedTargets = selectedToggle.files.length > 0; + /** 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, @@ -607,20 +613,31 @@ export function useWorkingTreeActions({ selectedIsFolder: Boolean(filesPaneFocused && selectedIsFolder), isSelectedFolder: () => liveSelection().folder, selectedWillStage: selectedToggle.stage, - canToggleSelected: enabled && canToggleSelectedTargets, - canToggleSelectedHunk: enabled && canToggleHunk(selectedFile?.id, selectedHunkIndex), + 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: () => { - if (selectedFile) toggleHunk(selectedFile.id, selectedHunkIndex); + const { fileId, hunkIndex } = getSelection(); + if (fileId && hunkIndex !== null) toggleHunk(fileId, hunkIndex); }, toggleSelected: () => { if (busyRef.current) return; - if (filesPaneFocused && selectedEntryId) { - toggleEntry(selectedEntryId); + if (isFilesPaneFocused()) { + const entryId = sidebarEntryIdAtIndex( + entries, + sidebarIndexFromCursor(entries, liveCursor()), + ); + if (entryId) toggleEntry(entryId); return; } - if (selectedPath) toggleStaged(selectedPath); + const file = liveStagingFiles()[0]; + if (file) toggleStaged(file.path); }, switchView: (next: boolean) => { void switchView(next); diff --git a/test/pty/working-tree-integration.test.ts b/test/pty/working-tree-integration.test.ts index acaf0b19b..1516d7912 100644 --- a/test/pty/working-tree-integration.test.ts +++ b/test/pty/working-tree-integration.test.ts @@ -48,6 +48,46 @@ async function focusFilesPane(session: Session) { } 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"); From c4a0785b7f330a77d788a694f07dd585b82fbb86 Mon Sep 17 00:00:00 2001 From: yusing Date: Mon, 7 Sep 2026 17:07:03 +0000 Subject: [PATCH 27/27] fix: reconcile upstream extension contracts after rebase --- .changeset/upstream-working-tree-contract.md | 2 ++ docs/extensions.md | 10 +++++----- .../hunk/src/ui/components/ui-components.test.tsx | 12 ++++++++++-- .../src/content/docs/docs/extend/custom-sidebars.md | 2 +- website/src/content/docs/docs/extend/vcs-adapters.md | 10 +++++----- 5 files changed, 23 insertions(+), 13 deletions(-) create mode 100644 .changeset/upstream-working-tree-contract.md 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/extensions.md b/docs/extensions.md index 8eb5908cd..1a3f05648 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -532,7 +532,7 @@ staged and unstaged files, independent of `input.staged`. Each entry supplies `p `previousPath`, `staged`, `unstaged`, `untracked`, `conflicted`, an opaque `version` attestation, and optionally an `unavailableReason`. Do not return this inventory for revision comparisons. -API v23 adds optional `statusCode`, a two-character Git-style index/worktree status (`??` for +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. @@ -546,14 +546,14 @@ unstaging must leave worktree contents unchanged. Throw `HunkExtensionUserError` 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 v20 also adds optional `stageHunk(input, file, hunk, ctx)` and +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 v21 adds optional `discardFile(input, file, scope, ctx)` and +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 @@ -562,11 +562,11 @@ excluding unrelated changes from every stash tree and from live cleanup. Revalid 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 v24 adds optional `stashFiles(input, files, message, ctx)` for one stash covering several +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 v22 adds optional `resolveWorkingTreeLine(input, file, line, ctx): Promise`. +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 diff --git a/packages/hunk/src/ui/components/ui-components.test.tsx b/packages/hunk/src/ui/components/ui-components.test.tsx index 2929ddca0..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"); @@ -586,6 +586,7 @@ describe("UI components", () => { width={30} keybindings={{ matches: () => false, getKeys: () => [] }} actions={{ + copyText: () => false, selectFile: () => {}, selectHunk: () => {}, revealLine: () => {}, @@ -625,6 +626,7 @@ describe("UI components", () => { width={width} keybindings={{ matches: () => false, getKeys: () => [] }} actions={{ + copyText: () => false, selectFile: () => {}, selectHunk: () => {}, revealLine: () => {}, @@ -724,7 +726,13 @@ describe("UI components", () => { theme={resolveTheme("github-dark-default", null)} width={40} keybindings={{ matches: () => false, getKeys: () => [] }} - actions={{ selectFile, selectHunk: () => {}, revealLine: () => {}, notify: () => {} }} + actions={{ + copyText: () => false, + selectFile, + selectHunk: () => {}, + revealLine: () => {}, + notify: () => {}, + }} workingTree={{ files: files.map((file) => ({ path: file.path, diff --git a/website/src/content/docs/docs/extend/custom-sidebars.md b/website/src/content/docs/docs/extend/custom-sidebars.md index b06959ea2..a9f1551e0 100644 --- a/website/src/content/docs/docs/extend/custom-sidebars.md +++ b/website/src/content/docs/docs/extend/custom-sidebars.md @@ -8,7 +8,7 @@ description: Render React panes around Hunk's review stream. 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 v24 adds `selectedEntryId`, `selectEntry(id)`, and `toggleEntry(id)` so a pane can +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. diff --git a/website/src/content/docs/docs/extend/vcs-adapters.md b/website/src/content/docs/docs/extend/vcs-adapters.md index 6e684a3fa..16ae90332 100644 --- a/website/src/content/docs/docs/extend/vcs-adapters.md +++ b/website/src/content/docs/docs/extend/vcs-adapters.md @@ -53,26 +53,26 @@ and throw `HunkExtensionUserError` for failures. The host tracks started writes and refreshes status and diff before allowing another action. These are terminal-host actions, not implicit remote capabilities. -API v20 adds `stageHunk(input, file, hunk, ctx)` and `unstageHunk(input, file, hunk, ctx)`. +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 v21 adds `discardFile(input, file, scope, ctx)` and `stashFile(input, file, message, ctx)`. +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 v24 adds optional `stashFiles(input, files, message, ctx)` so a selected folder can become one +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 v22 adds optional `resolveWorkingTreeLine(input, file, line, ctx): Promise` for +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 v23 adds optional inventory `statusCode`: two Git-style status columns (index, worktree), +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.