From a72002d9619aa2d0723451ba5f2d236b55d084d6 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 6 Oct 2026 00:04:46 -0400 Subject: [PATCH 01/22] feat(open): add complete-file viewer and lazy directory browser --- .changeset/lazy-documents-open.md | 5 + README.md | 14 + docs/module-boundaries.md | 10 +- docs/open.md | 76 ++++ packages/hunk/README.md | 14 + packages/hunk/src/app/cli.ts | 39 ++ .../hunk/src/app/documents/bootstrap.test.ts | 79 ++++ packages/hunk/src/app/documents/bootstrap.ts | 38 ++ .../app/documents/filesystemSource.test.ts | 219 +++++++++++ .../src/app/documents/filesystemSource.ts | 368 ++++++++++++++++++ .../hunk/src/app/sessionBootstrap.test.ts | 4 +- packages/hunk/src/app/sessionBootstrap.ts | 2 +- packages/hunk/src/app/startup.ts | 14 + .../hunk/src/core/changeset/diffFile.test.ts | 2 +- packages/hunk/src/core/changeset/diffFile.ts | 2 +- .../hunk/src/core/changeset/loaders.test.ts | 2 +- packages/hunk/src/core/documents/bootstrap.ts | 11 + .../fileLanguage.test.ts | 0 .../{changeset => documents}/fileLanguage.ts | 2 +- .../fileLanguageLookup.ts | 4 +- packages/hunk/src/core/documents/source.ts | 35 ++ packages/hunk/src/core/run/cliCommandNames.ts | 1 + packages/hunk/src/core/run/commandInputs.ts | 11 + packages/hunk/src/core/run/config.ts | 22 +- .../src/core/run/documentCommandCatalog.ts | 104 +++++ packages/hunk/src/extensions/apply.test.ts | 8 +- packages/hunk/src/extensions/apply.ts | 2 +- .../extensions/publicApiRobustness.test.ts | 2 +- packages/hunk/src/main.tsx | 6 + packages/hunk/src/ui/App.tsx | 3 +- .../hunk/src/ui/AppHost.extensions.test.tsx | 2 +- .../hunk/src/ui/AppHost.file-views.test.tsx | 4 +- packages/hunk/src/ui/AppHost.tsx | 2 +- .../src/ui/components/panes/FileView.test.tsx | 2 +- packages/hunk/src/ui/diff/diffRows.ts | 4 +- .../src/ui/diff/useHighlightedSource.test.tsx | 2 +- .../hunk/src/ui/diff/useHighlightedSource.ts | 2 +- .../src/ui/documents/DocumentApp.test.tsx | 91 +++++ .../hunk/src/ui/documents/DocumentApp.tsx | 351 +++++++++++++++++ .../hunk/src/ui/documents/DocumentPane.tsx | 195 ++++++++++ .../hunk/src/ui/documents/DocumentTree.tsx | 80 ++++ .../hunk/src/ui/documents/controller.test.ts | 116 ++++++ packages/hunk/src/ui/documents/controller.ts | 265 +++++++++++++ .../hunk/src/ui/documents/geometry.test.ts | 41 ++ packages/hunk/src/ui/documents/geometry.ts | 83 ++++ .../src/ui/documents/runDocumentBrowser.tsx | 32 ++ .../hunk/src/ui/documents/useRowViewport.ts | 36 ++ packages/hunk/src/ui/fileViews/layout.ts | 6 +- .../hunk/src/ui/fileViews/syntaxPaint.test.ts | 2 +- packages/hunk/src/ui/fileViews/syntaxPaint.ts | 2 +- .../hunk/src/ui/fileViews/textDisplay.test.ts | 95 ----- .../useFileViewSyntaxHighlight.test.tsx | 2 +- .../fileViews/useFileViewSyntaxHighlight.ts | 2 +- packages/hunk/src/ui/lib/openInEditor.ts | 39 +- packages/hunk/src/ui/log/LogApp.tsx | 3 +- packages/hunk/src/ui/log/commands.ts | 24 +- .../hunk/src/ui/session/HunkSessionHost.tsx | 32 +- packages/hunk/src/ui/session/commands.ts | 35 ++ .../documentHighlightRenderer.ts | 6 +- .../documentHighlightService.test.ts | 2 +- .../documentHighlightService.ts | 6 +- packages/hunk/src/ui/text/measurement.test.ts | 89 +++++ .../textDisplay.ts => text/measurement.ts} | 61 ++- test/pty/open-integration.test.ts | 125 ++++++ .../src/content/docs/docs/reference/cli.md | 27 ++ .../src/content/docs/docs/reference/config.md | 1 + .../docs/docs/workflows/files-and-patches.md | 21 +- 67 files changed, 2773 insertions(+), 214 deletions(-) create mode 100644 .changeset/lazy-documents-open.md create mode 100644 docs/open.md create mode 100644 packages/hunk/src/app/documents/bootstrap.test.ts create mode 100644 packages/hunk/src/app/documents/bootstrap.ts create mode 100644 packages/hunk/src/app/documents/filesystemSource.test.ts create mode 100644 packages/hunk/src/app/documents/filesystemSource.ts create mode 100644 packages/hunk/src/core/documents/bootstrap.ts rename packages/hunk/src/core/{changeset => documents}/fileLanguage.test.ts (100%) rename packages/hunk/src/core/{changeset => documents}/fileLanguage.ts (96%) rename packages/hunk/src/core/{changeset => documents}/fileLanguageLookup.ts (95%) create mode 100644 packages/hunk/src/core/documents/source.ts create mode 100644 packages/hunk/src/core/run/documentCommandCatalog.ts create mode 100644 packages/hunk/src/ui/documents/DocumentApp.test.tsx create mode 100644 packages/hunk/src/ui/documents/DocumentApp.tsx create mode 100644 packages/hunk/src/ui/documents/DocumentPane.tsx create mode 100644 packages/hunk/src/ui/documents/DocumentTree.tsx create mode 100644 packages/hunk/src/ui/documents/controller.test.ts create mode 100644 packages/hunk/src/ui/documents/controller.ts create mode 100644 packages/hunk/src/ui/documents/geometry.test.ts create mode 100644 packages/hunk/src/ui/documents/geometry.ts create mode 100644 packages/hunk/src/ui/documents/runDocumentBrowser.tsx create mode 100644 packages/hunk/src/ui/documents/useRowViewport.ts delete mode 100644 packages/hunk/src/ui/fileViews/textDisplay.test.ts create mode 100644 packages/hunk/src/ui/session/commands.ts rename packages/hunk/src/ui/{diff => syntax}/documentHighlightRenderer.ts (96%) rename packages/hunk/src/ui/{diff => syntax}/documentHighlightService.test.ts (99%) rename packages/hunk/src/ui/{diff => syntax}/documentHighlightService.ts (98%) create mode 100644 packages/hunk/src/ui/text/measurement.test.ts rename packages/hunk/src/ui/{fileViews/textDisplay.ts => text/measurement.ts} (50%) create mode 100644 test/pty/open-integration.test.ts diff --git a/.changeset/lazy-documents-open.md b/.changeset/lazy-documents-open.md new file mode 100644 index 000000000..77ab62a05 --- /dev/null +++ b/.changeset/lazy-documents-open.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Add `hunk open` to view complete syntax-highlighted files and browse directories with lazy loading, live refresh, and keyboard/mouse navigation. diff --git a/README.md b/README.md index 8274246e2..795dd3b62 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,20 @@ private repositories and higher API limits. GitHub Enterprise is not currently s Hunk auto-detects Jujutsu and Sapling checkouts, so `hunk diff [revset]` and `hunk show [revset]` use native revsets inside jj or Sapling workspaces. `hunk log --vcs jj` also reads JJ history directly, including in a non-colocated workspace. To override VCS detection, set `vcs = "git"` or `vcs = "jj"` or `vcs = "sl"` in [config](#config). +### Viewing complete files and browsing directories + +```bash +hunk open # browse the current directory +hunk open README.md # view a complete, syntax-highlighted document +hunk open path/to/project # browse another directory without requiring a repository +``` + +The read-only browser loads directories and documents lazily and refreshes watched content. +Use arrows and Enter to navigate the tree, Tab to switch focus, `i` to show hidden/Git-ignored +entries, and `e` to open the displayed file in `$EDITOR`. Themes, menus, line numbers, and wrapping +use Hunk's normal controls. Symlinks are listed but not followed; binary, unreadable, missing, +and oversized files (over 1 MiB) show placeholders. See [the open workflow](docs/open.md) for details. + ### Working with raw files and patches ```bash diff --git a/docs/module-boundaries.md b/docs/module-boundaries.md index 3f5cfbf3f..c03f9e6a8 100644 --- a/docs/module-boundaries.md +++ b/docs/module-boundaries.md @@ -40,7 +40,7 @@ Other workspaces are bounded units rather than one tier in that stack: adapters. They do not import Hunk source internals. - `packages/term-video` owns terminal capture tooling and does not import Hunk source internals. -Current `core/` modules are `changeset/`, `history/`, `install/`, `patch/`, `process/`, `review/`, +Current `core/` modules are `changeset/`, `documents/`, `history/`, `install/`, `patch/`, `process/`, `review/`, `run/`, `theme/`, `vcs/`, and `watch/`. Its root contains `bootstrap.ts`, `liveComments.ts`, `reviewDescriptor.ts`, and `reviewDigest.ts` plus tests. @@ -51,6 +51,14 @@ Intentional exceptions, allowed by the rules: surface by design. - Tests are excluded: they are colocated and free to reach across boundaries. +Complete-document browsing uses `core/documents/source.ts` rather than `DiffFile` or review +state. `app/documents` owns filesystem I/O and optional Git metadata; `ui/documents` consumes +only document capabilities and finalized preferences. `HunkSessionHost` routes documents, +history, and review in one renderer lifetime. Language selectors live in `core/documents`; +complete-document syntax paint lives in `ui/syntax`, and native word-wrap measurement lives in +`ui/text`. Review/file-view consumers use those same services. The existing public extension +review contracts and broker protocol remain unchanged. + ## Module interiors Tier rules say which trees may reach each other; interior rules say which _files_ in a tree diff --git a/docs/open.md b/docs/open.md new file mode 100644 index 000000000..95bc14e4a --- /dev/null +++ b/docs/open.md @@ -0,0 +1,76 @@ +# Complete-document browsing + +`hunk open [path]` views a file or browses a directory. The default path is `.`. It requires an +interactive terminal, but not a repository or a diff. + +```bash +hunk open README.md +hunk open ~/project +hunk open hypr/ --theme nord +hunk open file.ts --wrap --tab-width 4 +``` + +## Controls + +- Arrow keys or `j`/`k`: move through the focused tree or document. +- Enter/Space: open a file or expand/collapse a directory. +- Left/Right: collapse/expand the selected directory, or scroll the focused document horizontally. +- Tab: switch tree/document focus. `s`: toggle the tree. +- PageUp/PageDown, `g`/`G`, Home/End: scroll the focused surface. +- `i`: toggle hidden and Git-ignored entries together. Hidden entries are names starting with `.`. +- `e`: open the displayed regular file in `$EDITOR`, using the visible line as the editor target. +- `t`, `l`, `w`, `?`, `r`, `q`: themes, line numbers, wrapping, help, refresh, quit. +- File/View/Help menus provide the same actions with mouse support. F10 opens the menu by keyboard. + +Common app/view commands keep their existing command ids. Document actions use `hunk.documents.*`: +`activate`, `edit`, `stepDown`, `stepUp`, `pageDown`, `pageUp`, `jumpToTop`, `jumpToBottom`, +`scrollLeft`, `scrollRight`, and `toggleExcluded`. User `[keybindings]` remaps apply to menus and help. + +```toml +[open] +theme = "nord" +wrap_lines = true + +[keybindings] +"hunk.documents.stepDown" = "ctrl+n" +"hunk.documents.stepUp" = "ctrl+p" +``` + +## Filesystem policy + +Only immediate children of expanded directories are read. The document viewer mounts a bounded +row window and reads only the selected document. Refresh preserves the displayed document and +scroll position; selecting a directory does not replace the document already displayed. + +In a Git worktree, Git supplies ignore rules and lightweight status markers. Metadata queries are +optional, bounded, and asynchronous; repository fsmonitor hooks and clean/process filters are +disabled. Status queries do not recurse into submodules or fetch missing objects. Ordinary +directory browsing works without Git. Hidden and +ignored entries are initially excluded, but an explicitly named hidden file still opens normally. + +Symlinks are listed but not followed, including explicitly opened links and symlinked descendants. +Devices and other non-regular entries are not opened. Binary/non-UTF-8 files, files over 1 MiB, +and missing or unreadable entries show a placeholder. Each directory is limited to 10,000 entries +and at most 128 directories may be expanded. Source reads remain bounded when a file grows while +it is being read. Terminal control sequences are stripped before painting. + +Expanded directories and the displayed file's parent are observed for changes, covering atomic +file replacements and deletions. Watch notifications are debounced; manual and watch refreshes +serialize. A failed watcher can be worked around with `r`. Shutdown cancels pending source work, +releases observers, and waits for started reads to settle before tearing down the terminal. + +## Architecture and current boundaries + +`core/documents` defines opaque entry identities, complete text and availability results, and lazy +source capabilities. Filesystem operations and Git metadata live in `app/documents`; the browser +controller and geometry live in `ui/documents`. The existing session host routes this surface +alongside history and review, reusing terminal lifecycle, theme ownership, menu components, +keymaps, editor launching, syntax paint, and exact native text measurement. + +Browsing never constructs `DiffFile`, hunks, or `ReviewDocument`, and it does not publish a review +session to the broker. User extensions remain review-specific and are not loaded by `open`. +The source contract is currently internal; a future public document extension API should expose +explicit capabilities without weakening review navigation or workspace-write policies. + +Stdin, multiple path arguments, and `file:line` addressing are deferred. The browser never writes +files itself; edits happen only in the explicitly launched external editor. diff --git a/packages/hunk/README.md b/packages/hunk/README.md index 8274246e2..795dd3b62 100644 --- a/packages/hunk/README.md +++ b/packages/hunk/README.md @@ -127,6 +127,20 @@ private repositories and higher API limits. GitHub Enterprise is not currently s Hunk auto-detects Jujutsu and Sapling checkouts, so `hunk diff [revset]` and `hunk show [revset]` use native revsets inside jj or Sapling workspaces. `hunk log --vcs jj` also reads JJ history directly, including in a non-colocated workspace. To override VCS detection, set `vcs = "git"` or `vcs = "jj"` or `vcs = "sl"` in [config](#config). +### Viewing complete files and browsing directories + +```bash +hunk open # browse the current directory +hunk open README.md # view a complete, syntax-highlighted document +hunk open path/to/project # browse another directory without requiring a repository +``` + +The read-only browser loads directories and documents lazily and refreshes watched content. +Use arrows and Enter to navigate the tree, Tab to switch focus, `i` to show hidden/Git-ignored +entries, and `e` to open the displayed file in `$EDITOR`. Themes, menus, line numbers, and wrapping +use Hunk's normal controls. Symlinks are listed but not followed; binary, unreadable, missing, +and oversized files (over 1 MiB) show placeholders. See [the open workflow](docs/open.md) for details. + ### Working with raw files and patches ```bash diff --git a/packages/hunk/src/app/cli.ts b/packages/hunk/src/app/cli.ts index 4529db295..4121efd4b 100644 --- a/packages/hunk/src/app/cli.ts +++ b/packages/hunk/src/app/cli.ts @@ -204,6 +204,25 @@ const DIFF_OPTIONS = [ /** Non-session command tree consumed by the parser and generated reference. */ export const CLI_REFERENCE_COMMANDS = { + open: { + path: "open", + summary: "view a complete file or browse a directory", + synopsis: ["hunk open [path]"], + options: [ + { flag: "--theme ", description: "named theme override" }, + { flag: "--line-numbers", description: "show line numbers" }, + { flag: "--no-line-numbers", description: "hide line numbers" }, + { flag: "--sidebar", description: "show the directory tree" }, + { flag: "--no-sidebar", description: "hide the directory tree" }, + { flag: "--wrap", description: "wrap long document lines" }, + { flag: "--no-wrap", description: "keep long document lines on one row" }, + { flag: "-x, --tab-width ", description: "tab stop width: 1-16", parse: "tabWidth" }, + ], + details: [ + "Defaults to the current directory. Reads are lazy and the browser refreshes automatically.", + "Press i to show hidden and Git-ignored entries. Symlinks are displayed but not followed.", + ], + }, diff: { path: "diff", summary: "review diffs or compare two concrete files", @@ -639,6 +658,7 @@ function renderCliHelp() { "Desktop-inspired terminal diff viewer for agent-authored changesets.", "", "Commands:", + " hunk open [path] view a file or browse a directory", " hunk diff [target] [-- ] review working tree changes or compare against a target", " hunk diff compare two revisions", " hunk diff --staged [-- ] review staged changes", @@ -1260,6 +1280,7 @@ function requireReloadableCliInput(input: ParsedCliInput): CliInput { input.kind === "extension-manage" || input.kind === "extension-cli" || input.kind === "history" || + input.kind === "open" || input.kind === "update" ) { throw new Error( @@ -2514,6 +2535,24 @@ export async function parseCli(argv: string[]): Promise { // Host bootstrap options must stay before a review command's `--` pathspec separator. const reviewRest = [...extensionFlagTokens, ...rest]; switch (commandName) { + case "open": { + const command = createCliReferenceCommand("open").argument( + "[path]", + "file or directory", + ".", + ); + if (rest.includes("--help") || rest.includes("-h")) { + return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` }; + } + let path = "."; + let options: Record = {}; + command.action((value: string, flags: Record) => { + path = value; + options = flags; + }); + await parseStandaloneCommand(command, rest); + return { kind: "open", path, options: buildCommonOptions(options, argv) }; + } case "diff": return parseDiffCommand(reviewRest, argv); case "show": diff --git a/packages/hunk/src/app/documents/bootstrap.test.ts b/packages/hunk/src/app/documents/bootstrap.test.ts new file mode 100644 index 000000000..7e0305043 --- /dev/null +++ b/packages/hunk/src/app/documents/bootstrap.test.ts @@ -0,0 +1,79 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parseCli } from "../cli"; +import { prepareStartupPlan } from "../startup"; +import { prepareDocumentBrowser } from "./bootstrap"; +import { resolveConfiguredInput } from "../../core/run/config"; + +const roots: string[] = []; +/** Create a project with an isolated config source for open-workflow tests. */ +function createOpenBootstrapTestRoot() { + const root = mkdtempSync(join(tmpdir(), "hunk-open-bootstrap-")); + roots.push(root); + return root; +} +afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))); + +test("open is a built-in defaulting to . and rejects extra paths instead of delegating to extensions", async () => { + expect(await parseCli(["bun", "hunk", "open"])).toMatchObject({ kind: "open", path: "." }); + expect( + await parseCli(["bun", "hunk", "open", "file.ts", "--theme", "nord", "--no-line-numbers"]), + ).toMatchObject({ + kind: "open", + path: "file.ts", + options: { theme: "nord", lineNumbers: false }, + }); + expect(await parseCli(["bun", "hunk", "open", "--help"])).toMatchObject({ kind: "help" }); + await expect(parseCli(["bun", "hunk", "open", "a", "b"])).rejects.toThrow(); +}); + +test("open config uses its own section, file location and shared user keybindings without loading repo code", async () => { + const root = createOpenBootstrapTestRoot(); + const configHome = createOpenBootstrapTestRoot(); + mkdirSync(join(root, ".hunk")); + mkdirSync(join(configHome, "hunk")); + writeFileSync(join(root, "README.md"), "whole document\n"); + writeFileSync( + join(root, ".hunk", "config.toml"), + '[open]\ntheme = "nord"\nline_numbers = false\n[extensions]\npaths = ["./must-not-execute.ts"]\n', + ); + writeFileSync( + join(configHome, "hunk", "config.toml"), + '[keybindings]\n"hunk.documents.stepDown" = "ctrl+n"\n', + ); + const bootstrap = await prepareDocumentBrowser( + { kind: "open", path: join(root, "README.md"), options: {} }, + configHome, + { ...process.env, XDG_CONFIG_HOME: configHome }, + ); + expect(bootstrap.source.root.kind).toBe("file"); + expect(bootstrap.configured.input.options.theme).toBe("nord"); + expect(bootstrap.configured.input.options.lineNumbers).toBe(false); + expect(bootstrap.configured.keybindings["hunk.documents.stepDown"]).toBe("ctrl+n"); + expect(bootstrap.initialization.theme.initialTheme).toBe("nord"); + const generic = resolveConfiguredInput( + { kind: "open", path: root, options: {} }, + { cwd: root, env: { XDG_CONFIG_HOME: configHome } }, + ); + expect(generic.input.options.lineNumbers).toBe(false); +}); + +test("open startup requires terminal ownership and returns a document plan, not a changeset", async () => { + const root = createOpenBootstrapTestRoot(); + const argv = ["bun", "hunk", "open", root]; + await expect(prepareStartupPlan(argv, { stdinIsTTY: false, stdoutIsTTY: true })).rejects.toThrow( + "requires a terminal", + ); + const plan = await prepareStartupPlan(argv, { + stdinIsTTY: true, + stdoutIsTTY: true, + env: { XDG_CONFIG_HOME: root }, + }); + expect(plan.kind).toBe("documents"); + if (plan.kind === "documents") expect("changeset" in plan.bootstrap).toBe(false); + await expect( + prepareDocumentBrowser({ kind: "open", path: join(root, "missing"), options: {} }), + ).rejects.toThrow("missing or unreadable"); +}); diff --git a/packages/hunk/src/app/documents/bootstrap.ts b/packages/hunk/src/app/documents/bootstrap.ts new file mode 100644 index 000000000..d186461af --- /dev/null +++ b/packages/hunk/src/app/documents/bootstrap.ts @@ -0,0 +1,38 @@ +import { dirname, resolve } from "node:path"; +import { HunkUserError } from "../../core/run/errors"; +import { resolveConfiguredInput, persistedViewPreferencesFromOptions } from "../../core/run/config"; +import type { OpenCommandInput } from "../../core/run/commandInputs"; +import type { DocumentSource } from "../../core/documents/source"; +import { createInteractiveSessionInitialization } from "../../core/session/initialization"; +import { createFilesystemSource } from "./filesystemSource"; + +import type { DocumentBrowserBootstrap } from "../../core/documents/bootstrap"; + +/** Resolve document launch inputs without loading extensions, VCS providers or review state. */ +export async function prepareDocumentBrowser( + input: OpenCommandInput, + cwd = process.cwd(), + env = process.env, +): Promise { + let source: DocumentSource; + try { + source = await createFilesystemSource(resolve(cwd, input.path)); + } catch { + throw new HunkUserError(`Cannot open ${input.path}: path is missing or unreadable.`); + } + const configured = resolveConfiguredInput(input, { + cwd: source.root.kind === "directory" ? source.root.key : dirname(source.root.key), + env, + }); + return { + source, + configured, + initialization: createInteractiveSessionInitialization({ + theme: { + initialTheme: configured.input.options.theme, + customThemes: configured.customThemes, + }, + viewPreferences: persistedViewPreferencesFromOptions(configured.input.options), + }), + }; +} diff --git a/packages/hunk/src/app/documents/filesystemSource.test.ts b/packages/hunk/src/app/documents/filesystemSource.test.ts new file mode 100644 index 000000000..469b0dd69 --- /dev/null +++ b/packages/hunk/src/app/documents/filesystemSource.test.ts @@ -0,0 +1,219 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync } from "node:fs"; +import { chmod, mkdtemp, mkdir, rename, rm, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { devNull, tmpdir } from "node:os"; +import { + createFilesystemSource, + DOCUMENT_MAX_BYTES, + DIRECTORY_MAX_ENTRIES, +} from "./filesystemSource"; + +const roots: string[] = []; +/** Create one isolated filesystem collection. */ +async function createTestRoot() { + const root = await mkdtemp(join(tmpdir(), "hunk-documents-")); + roots.push(root); + return root; +} +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe("filesystem documents", () => { + test("lists only immediate children, directories first, and reads complete unchanged text", async () => { + const root = await createTestRoot(); + await mkdir(join(root, "nested")); + await writeFile(join(root, "nested", "child.txt"), "child"); + await writeFile(join(root, "README.md"), "# complete\nunchanged\n"); + await writeFile(join(root, ".hidden"), "hidden"); + const source = await createFilesystemSource(root); + const listing = await source.list(source.root.key); + expect(listing.kind).toBe("entries"); + if (listing.kind !== "entries") throw new Error(listing.detail); + expect(listing.entries[0]?.name).toBe("nested"); + expect(listing.entries.some((entry) => entry.name === "child.txt")).toBe(false); + expect(listing.entries.find((entry) => entry.name === ".hidden")?.hidden).toBe(true); + const document = await source.read(join(root, "README.md")); + expect(document.kind).toBe("text"); + if (document.kind === "text") expect(document.text).toBe("# complete\nunchanged\n"); + }); + test("refuses binary, invalid UTF-8, oversize, missing, special and escaping entries", async () => { + const root = await createTestRoot(); + await writeFile(join(root, "binary"), Buffer.from([0, 1])); + await writeFile(join(root, "encoding"), Buffer.from([255, 254])); + await writeFile(join(root, "huge"), Buffer.alloc(DOCUMENT_MAX_BYTES + 1, 65)); + const source = await createFilesystemSource(root); + expect(await source.read(join(root, "binary"))).toMatchObject({ + kind: "unavailable", + reason: "binary", + }); + expect(await source.read(join(root, "encoding"))).toMatchObject({ + kind: "unavailable", + reason: "binary", + }); + expect(await source.read(join(root, "huge"))).toMatchObject({ + kind: "unavailable", + reason: "too-large", + }); + expect(await source.read(join(root, "missing"))).toMatchObject({ + kind: "unavailable", + reason: "missing", + }); + expect(await source.read(root)).toMatchObject({ kind: "unavailable", reason: "special" }); + expect(await source.read(join(root, "..", "escape"))).toMatchObject({ kind: "unavailable" }); + }); + test.skipIf(process.platform === "win32")( + "does not follow file or directory symlinks, even when opened explicitly", + async () => { + const root = await createTestRoot(); + const outside = await createTestRoot(); + await writeFile(join(outside, "secret"), "outside"); + await symlink(outside, join(root, "link"), "dir"); + await symlink(join(outside, "secret"), join(root, "file-link")); + const source = await createFilesystemSource(root); + expect(await source.read(join(root, "file-link"))).toMatchObject({ + kind: "unavailable", + reason: "symlink", + }); + expect(await source.list(join(root, "link"))).toMatchObject({ kind: "unavailable" }); + expect(await source.read(join(root, "link", "secret"))).toMatchObject({ + kind: "unavailable", + }); + expect(await source.editablePath?.(join(root, "file-link"))).toBeNull(); + const direct = await createFilesystemSource(join(root, "file-link")); + expect(direct.root.kind).toBe("symlink"); + expect(await direct.read(direct.root.key)).toMatchObject({ reason: "symlink" }); + }, + ); + test("honors Git ignore rules without hiding tracked files and reports statuses", async () => { + const root = await createTestRoot(); + const runGit = (...args: string[]) => { + const result = Bun.spawnSync(["git", ...args], { cwd: root }); + expect(result.exitCode).toBe(0); + }; + runGit("init", "--quiet"); + await writeFile(join(root, ".gitignore"), "*.log\nignored/\n"); + await writeFile(join(root, "tracked.log"), "tracked"); + runGit("add", "-f", "tracked.log"); + await writeFile(join(root, "ignored.log"), "ignored"); + await mkdir(join(root, "ignored")); + const source = await createFilesystemSource(root); + const listing = await source.list(root); + if (listing.kind !== "entries") throw new Error(listing.detail); + expect(listing.entries.find((entry) => entry.name === "ignored.log")?.ignored).toBe(true); + expect(listing.entries.find((entry) => entry.name === "ignored")?.ignored).toBe(true); + expect(listing.entries.find((entry) => entry.name === "tracked.log")?.ignored).toBe(false); + expect(listing.entries.find((entry) => entry.name === "tracked.log")?.status).toBe("A"); + }); + test("metadata queries do not execute repository fsmonitor hooks or clean/process filters", async () => { + const root = await createTestRoot(); + const marker = join(root, "executed"); + const script = join(root, "hook.mjs"); + await writeFile( + script, + `import { writeFileSync } from "node:fs"; writeFileSync(${JSON.stringify(marker)}, "ran");`, + ); + const runGit = (...args: string[]) => { + const result = Bun.spawnSync( + ["git", "-c", "core.fsmonitor=false", "-c", `core.hooksPath=${devNull}`, ...args], + { cwd: root }, + ); + expect(result.exitCode).toBe(0); + return result.stdout.toString().trim(); + }; + runGit("init", "--quiet"); + await writeFile(join(root, "tracked.txt"), "before"); + await writeFile(join(root, ".gitattributes"), "tracked.txt filter=unsafe\n"); + runGit("add", ".gitattributes", "tracked.txt"); + const command = `"${process.execPath}" "${script}"`; + runGit("config", "core.fsmonitor", command); + runGit("config", "filter.unsafe.clean", command); + runGit("config", "filter.unsafe.process", command); + runGit("config", "filter.unsafe.required", "true"); + await writeFile(join(root, "tracked.txt"), "after"); + // Submodule status must not execute filters declared only in that nested repository. + const nested = join(root, "nested"); + await mkdir(nested); + runGit("-C", nested, "init", "--quiet"); + await writeFile(join(nested, "file.txt"), "before"); + await writeFile(join(nested, ".gitattributes"), "file.txt filter=nestedUnsafe\n"); + runGit("-C", nested, "add", "."); + runGit( + "-C", + nested, + "-c", + "user.name=Test", + "-c", + "user.email=test@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--quiet", + "-m", + "fixture", + ); + const revision = runGit("-C", nested, "rev-parse", "HEAD"); + runGit("update-index", "--add", "--cacheinfo", `160000,${revision},nested`); + runGit("-C", nested, "config", "filter.nestedUnsafe.clean", command); + runGit("-C", nested, "config", "filter.nestedUnsafe.required", "true"); + await writeFile(join(nested, "file.txt"), "after"); + const source = await createFilesystemSource(root); + expect((await source.list(root)).kind).toBe("entries"); + expect(existsSync(marker)).toBe(false); + }); + // Windows chmod does not revoke read access; root bypasses Unix permission modes. + test.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "shows a placeholder for unreadable files", + async () => { + const root = await createTestRoot(); + const file = join(root, "unreadable"); + await writeFile(file, "private"); + await chmod(file, 0); + try { + const source = await createFilesystemSource(root); + expect(await source.read(file)).toMatchObject({ + kind: "unavailable", + reason: "unreadable", + }); + } finally { + await chmod(file, 0o600); + } + }, + ); + test("bounds directory enumeration and cancels reads", async () => { + const root = await createTestRoot(); + await Promise.all( + Array.from({ length: DIRECTORY_MAX_ENTRIES + 1 }, (_, index) => + mkdir(join(root, `entry-${index}`)), + ), + ); + const source = await createFilesystemSource(root); + expect(await source.list(root)).toMatchObject({ + kind: "unavailable", + detail: "Directory exceeds the 10,000-entry browsing limit.", + }); + const abort = new AbortController(); + abort.abort(); + await expect(source.list(root, abort.signal)).rejects.toThrow(); + await expect(source.read(root, abort.signal)).rejects.toThrow(); + }); + test("observes atomic replacement and releases subscriptions", async () => { + const root = await createTestRoot(); + const file = join(root, "selected.txt"); + await writeFile(file, "one"); + const source = await createFilesystemSource(root); + let changes = 0; + const stop = source.observe?.([file], () => changes++); + const replacement = join(root, "replacement"); + await writeFile(replacement, "two"); + await rename(replacement, file); + await Bun.sleep(350); + expect(changes).toBeGreaterThan(0); + stop?.(); + const before = changes; + await writeFile(file, "three"); + await Bun.sleep(200); + expect(changes).toBe(before); + }); +}); diff --git a/packages/hunk/src/app/documents/filesystemSource.ts b/packages/hunk/src/app/documents/filesystemSource.ts new file mode 100644 index 000000000..892842093 --- /dev/null +++ b/packages/hunk/src/app/documents/filesystemSource.ts @@ -0,0 +1,368 @@ +import { constants, lstatSync, watch } from "node:fs"; +import { lstat, open, opendir, realpath } from "node:fs/promises"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { createHash } from "node:crypto"; +import { devNull } from "node:os"; +import { execFile } from "node:child_process"; +import type { + DirectoryReadResult, + DocumentEntry, + DocumentReadResult, + DocumentSource, +} from "../../core/documents/source"; + +export const DOCUMENT_MAX_BYTES = 1024 * 1024; +export const DIRECTORY_MAX_ENTRIES = 10_000; + +/** Prevent metadata inspection from fetching missing objects or launching transport helpers. */ +function gitQueryEnvironment() { + return { ...process.env, GIT_NO_LAZY_FETCH: "1", GIT_ALLOW_PROTOCOL: "" }; +} + +/** Run optional Git metadata queries without blocking the renderer or executing repository hooks. */ +function git(cwd: string, args: string[], signal?: AbortSignal): Promise { + return new Promise((done) => { + execFile( + "git", + [ + "--no-pager", + "--no-optional-locks", + "-c", + "core.fsmonitor=false", + "-c", + `core.hooksPath=${devNull}`, + ...args, + ], + { + cwd, + signal, + encoding: "utf8", + timeout: 1500, + maxBuffer: 2 * 1024 * 1024, + env: gitQueryEnvironment(), + }, + (error, output) => + done(error && !(error.code === 1 && args.includes("--get-regexp")) ? null : output), + ); + }); +} + +/** Classify entries without following symlinks or opening devices. */ +function entryKind(info: { + isDirectory(): boolean; + isFile(): boolean; + isSymbolicLink(): boolean; +}): DocumentEntry["kind"] { + return info.isSymbolicLink() + ? "symlink" + : info.isDirectory() + ? "directory" + : info.isFile() + ? "file" + : "special"; +} + +/** Translate filesystem failures into document availability rather than fatal browser errors. */ +function unavailable(error: unknown): DocumentReadResult & { kind: "unavailable" } { + const code = (error as NodeJS.ErrnoException).code; + return { + kind: "unavailable", + reason: code === "ENOENT" ? "missing" : "unreadable", + detail: code === "ENOENT" ? "File no longer exists." : "Cannot read this entry.", + }; +} + +/** Open a bounded filesystem collection; directory expansion never scans descendants. */ +export async function createFilesystemSource(inputPath: string): Promise { + const input = resolve(inputPath); + const initial = await lstat(input); + // Canonicalize the parent, not the final entry: explicitly opened symlinks retain their placeholder. + const path = + initial.isDirectory() && !initial.isSymbolicLink() + ? await realpath(input) + : join(await realpath(dirname(input)), basename(input)); + const base = initial.isDirectory() && !initial.isSymbolicLink() ? path : dirname(path); + const root: DocumentEntry = { + key: path, + name: basename(path) || path, + displayPath: path, + kind: entryKind(initial), + hidden: basename(path).startsWith("."), + }; + + /** Refuse root escapes and symlinked ancestors before any collection read. */ + async function safePath(key: string) { + const rel = relative(base, key); + if (isAbsolute(rel) || rel === ".." || rel.startsWith(`..${sep}`)) + throw new Error("Path escapes document collection."); + if (root.kind !== "directory" && key !== root.key) throw new Error("Unknown document."); + const segments = rel.split(sep).filter(Boolean); + let parent = base; + const baseInfo = await lstat(base); + if (!baseInfo.isDirectory() || baseInfo.isSymbolicLink()) + throw new Error("Collection root changed."); + for (const segment of segments.slice(0, -1)) { + parent = join(parent, segment); + const info = await lstat(parent); + if (!info.isDirectory() || info.isSymbolicLink()) + throw new Error("Symlink traversal is unavailable."); + } + if ((await realpath(dirname(key))) !== dirname(key)) + throw new Error("Collection ancestry changed."); + return key; + } + + /** Read one regular file through a bounded handle, including files that grow during reading. */ + async function read(key: string, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + try { + await safePath(key); + const info = await lstat(key); + const kind = entryKind(info); + if (kind !== "file") + return { + kind: "unavailable", + reason: kind === "symlink" ? "symlink" : "special", + detail: + kind === "symlink" ? "Symlinks are not followed." : "This entry is not a regular file.", + }; + if (info.size > DOCUMENT_MAX_BYTES) + return { + kind: "unavailable", + reason: "too-large", + detail: "File exceeds the 1 MiB viewing limit.", + }; + const handle = await open( + key, + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0), + ); + try { + const openedInfo = await handle.stat(); + if (openedInfo.dev !== info.dev || openedInfo.ino !== info.ino) + throw new Error("Document changed during opening."); + await safePath(key); + if (!openedInfo.isFile()) + return { + kind: "unavailable", + reason: "special", + detail: "This entry is not a regular file.", + }; + const buffer = Buffer.alloc(DOCUMENT_MAX_BYTES + 1); + let count = 0; + while (count < buffer.length) { + signal?.throwIfAborted(); + const result = await handle.read(buffer, count, buffer.length - count, count); + if (!result.bytesRead) break; + count += result.bytesRead; + } + signal?.throwIfAborted(); + if (count > DOCUMENT_MAX_BYTES) + return { + kind: "unavailable", + reason: "too-large", + detail: "File exceeds the 1 MiB viewing limit.", + }; + const bytes = buffer.subarray(0, count); + if (bytes.includes(0)) + return { + kind: "unavailable", + reason: "binary", + detail: "Binary file — no text preview.", + }; + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + return { + kind: "unavailable", + reason: "binary", + detail: "Non-UTF-8 file — no text preview.", + }; + } + return { kind: "text", text, identity: createHash("sha256").update(bytes).digest("hex") }; + } finally { + await handle.close(); + } + } catch (error) { + signal?.throwIfAborted(); + return unavailable(error); + } + } + + /** List one directory and attach optional Git visibility/status metadata for its immediate children. */ + async function list(key: string, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + try { + await safePath(key); + const info = await lstat(key); + if (!info.isDirectory() || info.isSymbolicLink()) + return { + kind: "unavailable", + detail: "This directory is unavailable; symlinks are not followed.", + }; + const entries: DocumentEntry[] = []; + for await (const child of await opendir(key)) { + signal?.throwIfAborted(); + if (entries.length === DIRECTORY_MAX_ENTRIES) + return { + kind: "unavailable", + detail: "Directory exceeds the 10,000-entry browsing limit.", + }; + entries.push({ + key: join(key, child.name), + name: child.name, + displayPath: relative(base, join(key, child.name)), + kind: entryKind(child), + hidden: child.name.startsWith("."), + }); + } + const repository = (await git(key, ["rev-parse", "--show-toplevel"], signal))?.trim(); + if (repository && entries.length) { + const paths = entries.map((entry) => entry.key); + // Git supplies its own ignore semantics, including tracked files, nested rules and global excludes. + signal?.throwIfAborted(); + const process = Bun.spawn( + [ + "git", + "--no-optional-locks", + "-c", + "core.fsmonitor=false", + "-c", + `core.hooksPath=${devNull}`, + "check-ignore", + "-z", + "--stdin", + ], + { + cwd: key, + env: gitQueryEnvironment(), + stdin: Buffer.from(paths.join("\0") + "\0"), + stdout: "pipe", + stderr: "ignore", + }, + ); + const abort = () => process.kill(); + signal?.addEventListener("abort", abort, { once: true }); + const timeout = setTimeout(abort, 1500); + let ignored: Set; + try { + ignored = new Set((await new Response(process.stdout).text()).split("\0")); + await process.exited; + } finally { + clearTimeout(timeout); + signal?.removeEventListener("abort", abort); + } + // Status can otherwise invoke repository-configured clean/process filters. Browsing never + // grants that execution authority; disable every declared filter before querying metadata. + const filterKeys = await git( + repository, + [ + "config", + "--null", + "--name-only", + "--get-regexp", + "^filter\\..*\\.(clean|process|required)$", + ], + signal, + ); + const names = filterKeys?.split("\0").filter(Boolean) ?? []; + const safeFilterConfig = + filterKeys !== null && + names.every((name) => /^filter\.[^=\r\n]*\.(clean|process|required)$/.test(name)); + const filterOverrides = names.flatMap((name) => [ + "-c", + `${name}=${name.endsWith(".required") ? "false" : ""}`, + ]); + const status = safeFilterConfig + ? await git( + repository, + [ + ...filterOverrides, + "status", + "--porcelain=v1", + "-z", + "--untracked-files=normal", + "--ignore-submodules=all", + "--", + key, + ], + signal, + ) + : null; + const markers = new Map(); + const records = status?.split("\0") ?? []; + for (let index = 0; index < records.length; index++) { + const record = records[index]!; + if (record.length < 4) continue; + const code = record.slice(0, 2); + const changed = resolve(repository, record.slice(3)); + const child = relative(key, changed).split(sep)[0]; + if (child && child !== "..") + markers.set(join(key, child), code === "??" ? "?" : code.trim().slice(0, 1)); + if (code.includes("R") || code.includes("C")) index++; + } + for (const entry of entries) { + entry.ignored = ignored.has(entry.key); + entry.status = markers.get(entry.key); + } + } + signal?.throwIfAborted(); + entries.sort( + (a, b) => + Number(b.kind === "directory") - Number(a.kind === "directory") || + a.name.localeCompare(b.name), + ); + return { kind: "entries", entries }; + } catch (error) { + signal?.throwIfAborted(); + return { kind: "unavailable", detail: unavailable(error).detail }; + } + } + + return { + root, + read, + list, + async editablePath(key) { + try { + await safePath(key); + return entryKind(await lstat(key)) === "file" ? key : null; + } catch { + return null; + } + }, + observe(keys, onChange) { + let timer: ReturnType | undefined; + const changed = () => { + clearTimeout(timer); + timer = setTimeout(onChange, 100); + }; + const watchers: ReturnType[] = []; + // Directory watches cover their immediate children. File and symlink watches use only the + // parent, preserving atomic replacements without ever observing a symlink target. + const targets = new Set(); + for (const key of keys) { + targets.add(dirname(key)); + try { + const info = lstatSync(key); + if (info.isDirectory() && !info.isSymbolicLink()) targets.add(key); + } catch { + /* Missing entries retain parent observation. */ + } + } + for (const target of targets) { + try { + const watcher = watch(target, changed); + watcher.on("error", changed); + watchers.push(watcher); + } catch { + /* Deleted entries retain their parent's observation. */ + } + } + return () => { + clearTimeout(timer); + for (const watcher of watchers) watcher.close(); + }; + }, + }; +} diff --git a/packages/hunk/src/app/sessionBootstrap.test.ts b/packages/hunk/src/app/sessionBootstrap.test.ts index 41359332b..0613bddf7 100644 --- a/packages/hunk/src/app/sessionBootstrap.test.ts +++ b/packages/hunk/src/app/sessionBootstrap.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { fileLanguageForPath } from "../core/changeset/fileLanguageLookup"; -import { replaceExtensionFileLanguages } from "../core/changeset/fileLanguage"; +import { fileLanguageForPath } from "../core/documents/fileLanguageLookup"; +import { replaceExtensionFileLanguages } from "../core/documents/fileLanguage"; import { persistedViewPreferencesFromOptions, type HunkConfigResolution } from "../core/run/config"; import type { AppBootstrap } from "../core/bootstrap"; import type { CliInput } from "../core/run/commandInputs"; diff --git a/packages/hunk/src/app/sessionBootstrap.ts b/packages/hunk/src/app/sessionBootstrap.ts index dd717c722..7c44f1244 100644 --- a/packages/hunk/src/app/sessionBootstrap.ts +++ b/packages/hunk/src/app/sessionBootstrap.ts @@ -2,7 +2,7 @@ import { fileLanguageRegistrationSnapshot, restoreFileLanguageRegistrations, type FileLanguageRegistrationSnapshot, -} from "../core/changeset/fileLanguage"; +} from "../core/documents/fileLanguage"; import { persistedViewPreferencesFromOptions, type HunkConfigResolution } from "../core/run/config"; import { isVcsReviewInput } from "../core/vcs"; import type { VcsCatalog } from "../core/vcs/types"; diff --git a/packages/hunk/src/app/startup.ts b/packages/hunk/src/app/startup.ts index 811555fe0..092d219fc 100644 --- a/packages/hunk/src/app/startup.ts +++ b/packages/hunk/src/app/startup.ts @@ -51,6 +51,7 @@ function createBundledVcsCatalogLoader() { } export type StartupPlan = + | { kind: "documents"; bootstrap: import("../core/documents/bootstrap").DocumentBrowserBootstrap } | { kind: "help"; text: string; @@ -384,6 +385,19 @@ export async function prepareStartupPlan( throw new Error("Unreachable extension CLI delegation state."); } + if (parsedCliInput.kind === "open") { + const documentInput = parsedCliInput; + return await whileStartupOwnsExtensions(async () => { + if (!stdinIsTTY || !stdoutIsTTY) + throw new HunkUserError("The `hunk open` browser requires a terminal."); + const { prepareDocumentBrowser } = await import("./documents/bootstrap"); + return await finishHeadlessPlan({ + kind: "documents", + bootstrap: await prepareDocumentBrowser(documentInput, startupCwd, env), + }); + }); + } + if (parsedCliInput.kind === "help") { return await finishHeadlessPlan({ kind: "help", diff --git a/packages/hunk/src/core/changeset/diffFile.test.ts b/packages/hunk/src/core/changeset/diffFile.test.ts index d137fd25e..8185d4428 100644 --- a/packages/hunk/src/core/changeset/diffFile.test.ts +++ b/packages/hunk/src/core/changeset/diffFile.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { parseDiffFromFile, type FileDiffMetadata } from "@pierre/diffs"; import { buildDiffFile, countDiffStats, createSkippedLargeMetadata } from "./diffFile"; -import { replaceExtensionFileLanguages } from "./fileLanguage"; +import { replaceExtensionFileLanguages } from "../documents/fileLanguage"; afterEach(() => { replaceExtensionFileLanguages([]); diff --git a/packages/hunk/src/core/changeset/diffFile.ts b/packages/hunk/src/core/changeset/diffFile.ts index dfb603df1..665e44ab7 100644 --- a/packages/hunk/src/core/changeset/diffFile.ts +++ b/packages/hunk/src/core/changeset/diffFile.ts @@ -1,7 +1,7 @@ import { setLanguageOverride, type FileDiffMetadata } from "@pierre/diffs"; import { findSidecarFileContext } from "./sidecar"; import { patchLooksBinary } from "./binary"; -import { fileLanguageForPath } from "./fileLanguageLookup"; +import { fileLanguageForPath } from "../documents/fileLanguageLookup"; import { normalizeDiffMetadataPaths, normalizeDiffPath } from "./diffPaths"; import type { FileSourceFetcher } from "./fileSource"; import type { DiffFile, DiffLineMoveKinds, SidecarContext } from "./model"; diff --git a/packages/hunk/src/core/changeset/loaders.test.ts b/packages/hunk/src/core/changeset/loaders.test.ts index b326e32b9..75dfb56e0 100644 --- a/packages/hunk/src/core/changeset/loaders.test.ts +++ b/packages/hunk/src/core/changeset/loaders.test.ts @@ -11,7 +11,7 @@ import { } from "node:fs"; import { platform, tmpdir } from "node:os"; import { join } from "node:path"; -import { replaceExtensionFileLanguages } from "./fileLanguage"; +import { replaceExtensionFileLanguages } from "../documents/fileLanguage"; import { SourceTextTooLargeError } from "./fileSource"; import { getBundledVcsCatalog } from "../../app/vcsCatalog"; import { createGitVcsAdapter } from "@hunk/git"; diff --git a/packages/hunk/src/core/documents/bootstrap.ts b/packages/hunk/src/core/documents/bootstrap.ts new file mode 100644 index 000000000..512a42b42 --- /dev/null +++ b/packages/hunk/src/core/documents/bootstrap.ts @@ -0,0 +1,11 @@ +import type { HunkConfigResolution } from "../run/config"; +import type { OpenCommandInput } from "../run/commandInputs"; +import type { InteractiveSessionInitialization } from "../session/initialization"; +import type { DocumentSource } from "./source"; + +/** Carry finalized document capabilities and preferences across startup and presentation tiers. */ +export interface DocumentBrowserBootstrap { + source: DocumentSource; + configured: HunkConfigResolution; + initialization: InteractiveSessionInitialization; +} diff --git a/packages/hunk/src/core/changeset/fileLanguage.test.ts b/packages/hunk/src/core/documents/fileLanguage.test.ts similarity index 100% rename from packages/hunk/src/core/changeset/fileLanguage.test.ts rename to packages/hunk/src/core/documents/fileLanguage.test.ts diff --git a/packages/hunk/src/core/changeset/fileLanguage.ts b/packages/hunk/src/core/documents/fileLanguage.ts similarity index 96% rename from packages/hunk/src/core/changeset/fileLanguage.ts rename to packages/hunk/src/core/documents/fileLanguage.ts index 3e473af0b..afa777af7 100644 --- a/packages/hunk/src/core/changeset/fileLanguage.ts +++ b/packages/hunk/src/core/documents/fileLanguage.ts @@ -5,7 +5,7 @@ import type { ExtensionFileLanguageMatcher } from "../../extension-api/types"; * Records file-language selectors without loading the diff engine. * * Registration happens during startup, on every invocation, while the selectors are only read - * when a changeset is built. Compiling them eagerly would pull the whole diff engine — and its + * when a document or changeset is rendered. Compiling them eagerly would pull the whole diff engine — and its * syntax grammars — into commands that never render anything, so this module holds them as plain * data and `fileLanguageLookup` compiles them at the first lookup for each registration version. * diff --git a/packages/hunk/src/core/changeset/fileLanguageLookup.ts b/packages/hunk/src/core/documents/fileLanguageLookup.ts similarity index 95% rename from packages/hunk/src/core/changeset/fileLanguageLookup.ts rename to packages/hunk/src/core/documents/fileLanguageLookup.ts index 46d04812a..3d2a53490 100644 --- a/packages/hunk/src/core/changeset/fileLanguageLookup.ts +++ b/packages/hunk/src/core/documents/fileLanguageLookup.ts @@ -4,8 +4,8 @@ import { fileLanguageRegistrationSnapshot, type FileLanguageRegistration } from /** * Resolves a path to a highlight language, compiling the current registration set on demand. * - * Importing this module loads the diff engine, so call it from changeset construction, never from - * startup. A registration version change atomically replaces compiled selectors, which keeps + * Importing this module loads syntax resources, so keep it behind document/review demand rather + * than headless startup. A registration version change atomically replaces compiled selectors, which keeps * extension reloads from retaining rules that were removed. */ diff --git a/packages/hunk/src/core/documents/source.ts b/packages/hunk/src/core/documents/source.ts new file mode 100644 index 000000000..d207c007b --- /dev/null +++ b/packages/hunk/src/core/documents/source.ts @@ -0,0 +1,35 @@ +/** Describe lazy document collections without introducing diff or review semantics. */ +export interface DocumentEntry { + /** Opaque source-owned identity; consumers must not resolve it as a filesystem path. */ + key: string; + name: string; + /** Optional display/language hint, never an authority for source reads or editor paths. */ + displayPath?: string; + kind: "directory" | "file" | "symlink" | "special"; + hidden: boolean; + ignored?: boolean; + status?: string; +} + +export type DocumentReadResult = + | { kind: "text"; text: string; identity: string; language?: string } + | { + kind: "unavailable"; + reason: "binary" | "too-large" | "symlink" | "special" | "missing" | "unreadable"; + detail: string; + }; + +export type DirectoryReadResult = + | { kind: "entries"; entries: readonly DocumentEntry[] } + | { kind: "unavailable"; detail: string }; + +/** Serve bounded lazy reads, with observation and editing supplied as explicit capabilities. */ +export interface DocumentSource { + root: DocumentEntry; + read(key: string, signal?: AbortSignal): Promise; + list(key: string, signal?: AbortSignal): Promise; + /** Observe only demanded entries; disposing releases every watcher owned by this subscription. */ + observe?(keys: readonly string[], onChange: () => void): () => void; + /** Resolve an explicitly selected regular document for an external editor, not arbitrary paths. */ + editablePath?(key: string): Promise; +} diff --git a/packages/hunk/src/core/run/cliCommandNames.ts b/packages/hunk/src/core/run/cliCommandNames.ts index a0baa3659..d70bd458c 100644 --- a/packages/hunk/src/core/run/cliCommandNames.ts +++ b/packages/hunk/src/core/run/cliCommandNames.ts @@ -1,5 +1,6 @@ /** Names every built-in top-level CLI command and alias Hunk reserves. */ export const BUILT_IN_CLI_COMMAND_NAMES = new Set([ + "open", "diff", "show", "log", diff --git a/packages/hunk/src/core/run/commandInputs.ts b/packages/hunk/src/core/run/commandInputs.ts index 9eb452821..f8ffcb525 100644 --- a/packages/hunk/src/core/run/commandInputs.ts +++ b/packages/hunk/src/core/run/commandInputs.ts @@ -441,7 +441,18 @@ export type ExtensionManageCommandInput = | ExtensionUpdateCommandInput | ExtensionRemoveCommandInput; +/** Open complete documents without creating a review changeset. */ +export interface OpenCommandInput { + kind: "open"; + path: string; + options: CommonOptions; +} + +/** Inputs whose view preferences use the shared configuration resolver. */ +export type ConfigurableInput = CliInput | OpenCommandInput; + export type ParsedCliInput = + | OpenCommandInput | CliInput | HistoryCommandInput | HelpCommandInput diff --git a/packages/hunk/src/core/run/config.ts b/packages/hunk/src/core/run/config.ts index 71d1c6895..0f3d6d11a 100644 --- a/packages/hunk/src/core/run/config.ts +++ b/packages/hunk/src/core/run/config.ts @@ -41,6 +41,7 @@ import { isLayoutModeInput, normalizeLayoutModeInput, type CliInput, + type ConfigurableInput, type CommonOptions, type CursorLine, type LayoutMode, @@ -167,8 +168,8 @@ export interface ExtensionBootstrapConfigOptions extends ConfigResolutionOptions const CONFIG_FALLBACK_VCS_ID = "git"; const EMPTY_CONFIG_VCS_CATALOG = createVcsCatalog([], CONFIG_FALLBACK_VCS_ID, []); -export interface HunkConfigResolution { - input: CliInput; +export interface HunkConfigResolution { + input: Input & { options: CommonOptions }; /** Config-defined custom themes in declaration order, user layer before repo layer. */ customThemes: NamedCustomThemeConfig[]; extensions: ExtensionsConfig; @@ -522,13 +523,14 @@ export const CONFIG_REFERENCE_OPTIONS: readonly ConfigReferenceOption[] = [ /** Command-specific TOML tables accepted by the runtime resolver. */ export const CONFIG_COMMAND_SECTIONS = { + open: "complete documents and directory browsing (`hunk open`)", vcs: "working-tree and target reviews (`hunk diff`)", show: "commit and target display reviews (`hunk show`)", "stash-show": "stash reviews (`hunk stash show`)", diff: "two-file comparisons (`hunk diff --files `)", patch: "patch-file reviews (`hunk patch`)", difftool: "Git difftool pair reviews (`hunk difftool`)", -} as const satisfies Record; +} as const satisfies Record; /** Reference metadata for the root-only custom-theme tables. */ export const CONFIG_REFERENCE_CUSTOM_THEME = { @@ -1128,7 +1130,7 @@ function mergeOptions(base: CommonOptions, overrides: CommonOptions): CommonOpti /** Apply one parsed config object, including command/pager sections, to the current invocation. */ function resolveConfigLayer( source: Record, - input: CliInput, + input: ConfigurableInput, { includeUserOnly = true }: { includeUserOnly?: boolean } = {}, ): CommonOptions { let resolved = readConfigPreferences(source, { includeUserOnly }); @@ -1312,15 +1314,23 @@ export function resolveExtensionBootstrapConfig({ }; } -/** Resolve CLI input against global and repo-local config files. */ +/** Resolve review inputs while preserving the existing review bootstrap contract. */ export function resolveConfiguredCliInput( input: CliInput, + options: ConfigResolutionOptions = {}, +): HunkConfigResolution { + return resolveConfiguredInput(input, options); +} + +/** Resolve view-capable inputs against global and repo-local config files. */ +export function resolveConfiguredInput( + input: Input, { cwd = process.cwd(), env = process.env, vcsCatalog = EMPTY_CONFIG_VCS_CATALOG, }: ConfigResolutionOptions = {}, -): HunkConfigResolution { +): HunkConfigResolution { const sources = readConfigSources(cwd, env, vcsCatalog); const repoRoot = sources.projectRoot; const repoConfigPath = sources.repoConfigPath; diff --git a/packages/hunk/src/core/run/documentCommandCatalog.ts b/packages/hunk/src/core/run/documentCommandCatalog.ts new file mode 100644 index 000000000..dac46e79b --- /dev/null +++ b/packages/hunk/src/core/run/documentCommandCatalog.ts @@ -0,0 +1,104 @@ +import { builtinAppCommand, type AppCommandId } from "./commandCatalog"; + +export interface DocumentCommandCatalogEntry { + id: string; + title: string; + defaultKeys: readonly string[]; + aliases?: readonly string[]; + publicToExtensions: false; +} + +/** Reuse app/view command identities and defaults without granting review-extension authority. */ +function sharedDocumentCommand(id: Id, title?: string) { + const shared = builtinAppCommand(id); + return { + id, + title: title ?? shared.title, + defaultKeys: shared.defaultKeys, + aliases: shared.aliases, + publicToExtensions: false as const, + }; +} + +/** Declare document actions independently of review hunks, selection and remote capabilities. */ +export const DOCUMENT_COMMAND_CATALOG = [ + sharedDocumentCommand("hunk.app.quit"), + sharedDocumentCommand("hunk.app.refresh", "Refresh documents"), + sharedDocumentCommand("hunk.app.toggleFocusArea", "Switch tree / document focus"), + sharedDocumentCommand("hunk.app.toggleHelp", "Controls help"), + sharedDocumentCommand("hunk.view.openThemeSelector"), + sharedDocumentCommand("hunk.view.toggleFilesPane", "Directory tree"), + sharedDocumentCommand("hunk.view.toggleLineNumbers", "Line numbers"), + sharedDocumentCommand("hunk.view.toggleLineWrap", "Wrap lines"), + { + id: "hunk.documents.toggleExcluded", + title: "Hidden and ignored entries", + defaultKeys: ["i"], + publicToExtensions: false, + }, + { + id: "hunk.documents.activate", + title: "Open / expand selected entry", + defaultKeys: ["enter", "space"], + publicToExtensions: false, + }, + { + id: "hunk.documents.edit", + title: "Open file in $EDITOR", + defaultKeys: ["e"], + publicToExtensions: false, + }, + { + id: "hunk.documents.stepDown", + title: "Next row", + defaultKeys: ["down", "j"], + publicToExtensions: false, + }, + { + id: "hunk.documents.stepUp", + title: "Previous row", + defaultKeys: ["up", "k"], + publicToExtensions: false, + }, + { + id: "hunk.documents.pageDown", + title: "Page down", + defaultKeys: ["pagedown", "f"], + publicToExtensions: false, + }, + { + id: "hunk.documents.pageUp", + title: "Page up", + defaultKeys: ["pageup", "b"], + publicToExtensions: false, + }, + { + id: "hunk.documents.jumpToTop", + title: "Jump to start", + defaultKeys: ["g", "home"], + publicToExtensions: false, + }, + { + id: "hunk.documents.jumpToBottom", + title: "Jump to end", + defaultKeys: ["G", "end"], + publicToExtensions: false, + }, + { + id: "hunk.documents.scrollLeft", + title: "Scroll left / collapse", + defaultKeys: ["left"], + publicToExtensions: false, + }, + { + id: "hunk.documents.scrollRight", + title: "Scroll right / expand", + defaultKeys: ["right"], + publicToExtensions: false, + }, +] as const satisfies readonly DocumentCommandCatalogEntry[]; + +/** Recognize document bindings on other surfaces without making those actions executable there. */ +export const DOCUMENT_COMMAND_NAMES: ReadonlySet = new Set( + DOCUMENT_COMMAND_CATALOG.map((entry) => entry.id), +); diff --git a/packages/hunk/src/extensions/apply.test.ts b/packages/hunk/src/extensions/apply.test.ts index 74178273a..70caa0dde 100644 --- a/packages/hunk/src/extensions/apply.test.ts +++ b/packages/hunk/src/extensions/apply.test.ts @@ -115,7 +115,7 @@ describe("extension file languages", () => { }); test("lets the last extension registration win for the same file extension", async () => { - const { fileLanguageForPath } = await import("../core/changeset/fileLanguageLookup"); + const { fileLanguageForPath } = await import("../core/documents/fileLanguageLookup"); const { result } = createTestLoadResult(); result.registry.fileLanguages.push( { @@ -135,7 +135,7 @@ describe("extension file languages", () => { }); test("applies exact-filename and glob selectors through the extension registry", async () => { - const { fileLanguageForPath } = await import("../core/changeset/fileLanguageLookup"); + const { fileLanguageForPath } = await import("../core/documents/fileLanguageLookup"); const { result } = createTestLoadResult(); result.registry.fileLanguages.push( { @@ -157,7 +157,7 @@ describe("extension file languages", () => { }); test("atomically removes selectors that disappear from a reload", async () => { - const { fileLanguageForPath } = await import("../core/changeset/fileLanguageLookup"); + const { fileLanguageForPath } = await import("../core/documents/fileLanguageLookup"); const { result } = createTestLoadResult(); result.registry.fileLanguages.push({ extensionId: "temporary", @@ -173,7 +173,7 @@ describe("extension file languages", () => { }); test("keeps reserved extensions authoritative over broader selectors", async () => { - const { fileLanguageForPath } = await import("../core/changeset/fileLanguageLookup"); + const { fileLanguageForPath } = await import("../core/documents/fileLanguageLookup"); const { result } = createTestLoadResult(); result.registry.fileLanguages.push({ extensionId: "broad", diff --git a/packages/hunk/src/extensions/apply.ts b/packages/hunk/src/extensions/apply.ts index 6776a00d5..dbde9ae53 100644 --- a/packages/hunk/src/extensions/apply.ts +++ b/packages/hunk/src/extensions/apply.ts @@ -2,7 +2,7 @@ import { BUILT_IN_FILE_LANGUAGE_EXTENSIONS, replaceExtensionFileLanguages, type FileLanguageRegistration, -} from "../core/changeset/fileLanguage"; +} from "../core/documents/fileLanguage"; import type { StartupNotice } from "../core/process/startupNotice"; import type { Changeset } from "../core/changeset/model"; import { detectVcs, extendVcsCatalog, getDefaultVcsAdapter } from "../core/vcs"; diff --git a/packages/hunk/src/extensions/publicApiRobustness.test.ts b/packages/hunk/src/extensions/publicApiRobustness.test.ts index 961e51643..caed30ce1 100644 --- a/packages/hunk/src/extensions/publicApiRobustness.test.ts +++ b/packages/hunk/src/extensions/publicApiRobustness.test.ts @@ -179,7 +179,7 @@ describe("registerFileLanguage with junk", () => { }); test("preserves exact filename and glob values through matching", async () => { - const { fileLanguageForPath } = await import("../core/changeset/fileLanguageLookup"); + const { fileLanguageForPath } = await import("../core/documents/fileLanguageLookup"); const { registry, issues } = loadFactory( (hunk: { registerFileLanguage: (matcher: unknown, language: string) => void }) => { hunk.registerFileLanguage({ kind: "filename", value: " Tool\\Hunkfile " }, "python"); diff --git a/packages/hunk/src/main.tsx b/packages/hunk/src/main.tsx index 362e4a605..440eae15f 100644 --- a/packages/hunk/src/main.tsx +++ b/packages/hunk/src/main.tsx @@ -111,6 +111,12 @@ async function main() { ); } + if (startupPlan.kind === "documents") { + const { runDocumentBrowser } = await import("./ui/documents/runDocumentBrowser"); + await runDocumentBrowser(startupPlan.bootstrap); + return; + } + if (startupPlan.kind === "history-static") { const { runStaticHistory } = await import("./ui/history/runStaticHistory"); await runStaticHistory(startupPlan.bootstrap); diff --git a/packages/hunk/src/ui/App.tsx b/packages/hunk/src/ui/App.tsx index cc5499283..ca2e062cf 100644 --- a/packages/hunk/src/ui/App.tsx +++ b/packages/hunk/src/ui/App.tsx @@ -16,6 +16,7 @@ import { } from "react"; import type { PersistedViewPreferences } from "../core/run/config"; import { HISTORY_COMMAND_NAMES } from "../core/run/historyCommandCatalog"; +import { DOCUMENT_COMMAND_NAMES } from "../core/run/documentCommandCatalog"; import type { ExtensionReviewReloadResult } from "../extension-api/types"; import { experimentalFeatureEnabled, resolveExperimentalDiffFiles } from "../core/run/experimental"; import { DEFAULT_FILE_GAP, DEFAULT_HUNK_GAP } from "../core/run/reviewGap"; @@ -718,7 +719,7 @@ export function App({ ...builtinCommandKeyDefaults(), ...extensionCommandKeyDefaults(registeredExtensionCommands), ], - inactiveCommandNames: HISTORY_COMMAND_NAMES, + inactiveCommandNames: new Set([...HISTORY_COMMAND_NAMES, ...DOCUMENT_COMMAND_NAMES]), userBindings: bootstrap.keybindings, }), [bootstrap.keybindings, registeredExtensionCommands], diff --git a/packages/hunk/src/ui/AppHost.extensions.test.tsx b/packages/hunk/src/ui/AppHost.extensions.test.tsx index d9283c0a9..45f5964d4 100644 --- a/packages/hunk/src/ui/AppHost.extensions.test.tsx +++ b/packages/hunk/src/ui/AppHost.extensions.test.tsx @@ -17,7 +17,7 @@ import { ReviewProducer } from "../app/review/producer"; import type { AppBootstrap } from "../app/types"; import { getBundledVcsCatalog } from "../app/vcsCatalog"; import { loadAppBootstrap as loadCoreAppBootstrap } from "../core/changeset/loaders"; -import { fileLanguageForPath } from "../core/changeset/fileLanguageLookup"; +import { fileLanguageForPath } from "../core/documents/fileLanguageLookup"; import type { CliInput } from "../core/run/commandInputs"; import type { HunkSessionBrokerClient } from "../session/broker/brokerClient"; diff --git a/packages/hunk/src/ui/AppHost.file-views.test.tsx b/packages/hunk/src/ui/AppHost.file-views.test.tsx index 8a472afda..e09d9bfea 100644 --- a/packages/hunk/src/ui/AppHost.file-views.test.tsx +++ b/packages/hunk/src/ui/AppHost.file-views.test.tsx @@ -15,13 +15,13 @@ import { loadStartupExtensions } from "../extensions/startup"; import { documentHighlightRunsForLine, loadDocumentHighlight, -} from "./diff/documentHighlightService"; +} from "./syntax/documentHighlightService"; import { resolveTheme } from "./themes"; import { setFileViewSyntaxHighlightLoaderForTest } from "./fileViews/useFileViewSyntaxHighlight"; import type { DocumentHighlightInput, DocumentHighlightResult, -} from "./diff/documentHighlightService"; +} from "./syntax/documentHighlightService"; import { TestAppHost as AppHost } from "../../../../test/helpers/app-host"; import { capturedTestColorToHex } from "../../../../test/helpers/test-color-helpers"; diff --git a/packages/hunk/src/ui/AppHost.tsx b/packages/hunk/src/ui/AppHost.tsx index b952988be..c69255570 100644 --- a/packages/hunk/src/ui/AppHost.tsx +++ b/packages/hunk/src/ui/AppHost.tsx @@ -5,7 +5,7 @@ import { ReviewProducer } from "../app/review/producer"; import { reviewDescriptorAfterReload, reviewDescriptorResourceCwd } from "../app/delegatedReview"; import { loadConfiguredSessionBootstrap } from "../app/sessionBootstrap"; import { getBundledVcsCatalog } from "../app/vcsCatalog"; -import { restoreFileLanguageRegistrations } from "../core/changeset/fileLanguage"; +import { restoreFileLanguageRegistrations } from "../core/documents/fileLanguage"; import { resolveConfiguredCliInput } from "../core/run/config"; import { resolveRuntimeCliInput } from "../core/process/terminal"; import type { StartupNotice } from "../core/process/startupNotice"; diff --git a/packages/hunk/src/ui/components/panes/FileView.test.tsx b/packages/hunk/src/ui/components/panes/FileView.test.tsx index 5540532ab..dab31e357 100644 --- a/packages/hunk/src/ui/components/panes/FileView.test.tsx +++ b/packages/hunk/src/ui/components/panes/FileView.test.tsx @@ -10,7 +10,7 @@ import type { import { documentHighlightRunsForLine, loadDocumentHighlight, -} from "../../diff/documentHighlightService"; +} from "../../syntax/documentHighlightService"; import { cursorLineHighlightBg } from "../../diff/rowStyle"; import { measureFileViewGeometry } from "../../fileViews/geometry"; import { validateFileViewLayout } from "../../fileViews/layout"; diff --git a/packages/hunk/src/ui/diff/diffRows.ts b/packages/hunk/src/ui/diff/diffRows.ts index eb99b2439..5e13584ee 100644 --- a/packages/hunk/src/ui/diff/diffRows.ts +++ b/packages/hunk/src/ui/diff/diffRows.ts @@ -42,12 +42,12 @@ import { prepareDocumentHighlighter, queueDocumentHighlightWork, type HighlightThemeInput, -} from "./documentHighlightRenderer"; +} from "../syntax/documentHighlightRenderer"; import { documentHighlightRunsForLine, loadDocumentHighlight, type DocumentHighlightResult, -} from "./documentHighlightService"; +} from "../syntax/documentHighlightService"; import { HIGHLIGHT_WORKER_MIN_LINES, pierreHighlightRenderOptions } from "./highlightRenderOptions"; export { HIGHLIGHT_WORKER_MIN_LINES } from "./highlightRenderOptions"; diff --git a/packages/hunk/src/ui/diff/useHighlightedSource.test.tsx b/packages/hunk/src/ui/diff/useHighlightedSource.test.tsx index 77f677cb2..f749aae77 100644 --- a/packages/hunk/src/ui/diff/useHighlightedSource.test.tsx +++ b/packages/hunk/src/ui/diff/useHighlightedSource.test.tsx @@ -9,7 +9,7 @@ import { loadHighlightedSourceLines, spansForHighlightedSourceLine } from "./dif import { createDocumentHighlightService, type DocumentHighlightResult, -} from "./documentHighlightService"; +} from "../syntax/documentHighlightService"; import { encodeCompactHighlightedDocument, registerHighlightWorker, type HastNode } from "./worker"; import { useHighlightedSource } from "./useHighlightedSource"; diff --git a/packages/hunk/src/ui/diff/useHighlightedSource.ts b/packages/hunk/src/ui/diff/useHighlightedSource.ts index 8f6152926..b1dcee323 100644 --- a/packages/hunk/src/ui/diff/useHighlightedSource.ts +++ b/packages/hunk/src/ui/diff/useHighlightedSource.ts @@ -5,7 +5,7 @@ import { loadHighlightedSourceLines, sourceHasIncompatibleLoneCarriageReturn } f import { documentHighlightCacheKey, type DocumentHighlightResult, -} from "./documentHighlightService"; +} from "../syntax/documentHighlightService"; const SOURCE_HIGHLIGHT_MAX_RETRIES = 1; const SOURCE_HIGHLIGHT_RETRY_DELAY_MS = 25; diff --git a/packages/hunk/src/ui/documents/DocumentApp.test.tsx b/packages/hunk/src/ui/documents/DocumentApp.test.tsx new file mode 100644 index 000000000..a9eb492b5 --- /dev/null +++ b/packages/hunk/src/ui/documents/DocumentApp.test.tsx @@ -0,0 +1,91 @@ +import { expect, mock, test } from "bun:test"; +import { testRender } from "@opentui/react/test-utils"; +import { act } from "react"; +import type { DocumentSource } from "../../core/documents/source"; +import { resolveConfiguredInput } from "../../core/run/config"; +import { createInteractiveSessionInitialization } from "../../core/session/initialization"; +import { persistedViewPreferencesFromOptions } from "../../core/run/config"; +import { HunkSessionHost } from "../session/HunkSessionHost"; +import { DocumentBrowserController } from "./controller"; + +/** Create a document route whose opaque identities deliberately are not filesystem paths. */ +async function createTestDocumentRoute() { + const source: DocumentSource = { + root: { key: "collection:opaque", name: "Collection", kind: "directory", hidden: false }, + async list() { + return { + kind: "entries", + entries: [{ key: "document:opaque", name: "example.ts", kind: "file", hidden: false }], + }; + }, + async read() { + return { + kind: "text", + text: + "const completeDocument = 42;\n" + + Array.from({ length: 80 }, (_, index) => `// Row ${index + 1}`).join("\n"), + identity: "stable", + language: "typescript", + }; + }, + }; + const controller = new DocumentBrowserController(source); + await controller.initialize(); + const options = { theme: "github-dark-default", lineNumbers: true }; + const initialization = createInteractiveSessionInitialization({ + theme: { initialTheme: options.theme }, + viewPreferences: persistedViewPreferencesFromOptions(options), + }); + const configured = resolveConfiguredInput({ kind: "open", path: ".", options }, { env: {} }); + configured.keybindings = { "hunk.documents.stepDown": "n" }; + return { + kind: "documents" as const, + controller, + bootstrap: { source, configured, initialization }, + }; +} + +test("the session host mounts documents, honors remaps and acknowledges external quit without a review runtime", async () => { + const route = await createTestDocumentRoute(); + const abort = new AbortController(); + const quit = mock(() => undefined); + const createReview = mock(() => { + throw new Error("Documents must not create review runtimes."); + }); + const setup = await testRender( + , + { width: 90, height: 20 }, + ); + try { + await setup.renderOnce(); + expect(setup.captureCharFrame()).toContain("example.ts"); + await act(async () => setup.mockInput.pressKey("j")); + expect(route.controller.getSnapshot().selectedKey).toBe("collection:opaque"); + await act(async () => { + setup.mockInput.pressKey("n"); + await Bun.sleep(20); + }); + await setup.renderOnce(); + expect(setup.captureCharFrame()).toContain("const completeDocument = 42;"); + expect(setup.captureCharFrame()).not.toContain("document:opaque"); + await act(async () => setup.mockInput.pressTab()); + await act(async () => setup.mockInput.pressArrow("down")); + await setup.renderOnce(); + expect(setup.captureCharFrame()).toContain("const completeDocument = 42;"); + await act(async () => setup.mockInput.pressKey("n")); + await setup.renderOnce(); + expect(setup.captureCharFrame()).not.toContain("const completeDocument = 42;"); + await act(async () => abort.abort()); + expect(quit).toHaveBeenCalledTimes(1); + expect(createReview).not.toHaveBeenCalled(); + } finally { + await act(async () => setup.renderer.destroy()); + await route.controller.close(); + } +}); diff --git a/packages/hunk/src/ui/documents/DocumentApp.tsx b/packages/hunk/src/ui/documents/DocumentApp.tsx new file mode 100644 index 000000000..312342247 --- /dev/null +++ b/packages/hunk/src/ui/documents/DocumentApp.tsx @@ -0,0 +1,351 @@ +import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"; +import { useMemo, useRef, useState, useSyncExternalStore } from "react"; +import type { ScrollBoxRenderable } from "@opentui/core"; +import type { CommonOptions } from "../../core/run/commandInputs"; +import type { UserKeyBinding } from "../../core/run/config"; +import { sanitizeTerminalLine } from "../../lib/terminalText"; +import { MenuBar } from "../components/chrome/MenuBar"; +import { MenuDropdown } from "../components/chrome/MenuDropdown"; +import { HelpDialog } from "../components/chrome/HelpDialog"; +import { ThemeSelectorDialog } from "../components/chrome/ThemeSelectorDialog"; +import type { AppMenus, MenuEntry } from "../components/chrome/menu"; +import { useMenuController } from "../hooks/useMenuController"; +import { useThemeSelectorController } from "../hooks/useThemeSelectorController"; +import { useTimedNotice } from "../hooks/useTimedNotice"; +import { dispatchAppCommand, executeAppCommand } from "../lib/appCommands"; +import { APP_COMMAND_NAMES } from "../../core/run/commandCatalog"; +import { HISTORY_COMMAND_NAMES } from "../../core/run/historyCommandCatalog"; +import { DOCUMENT_COMMAND_CATALOG } from "../../core/run/documentCommandCatalog"; +import { resolveCommandKeys } from "../lib/keymap"; +import { openFileInEditor } from "../lib/openInEditor"; +import { fitText } from "../lib/text"; +import type { ThemeController } from "../theme/controller"; +import { buildSurfaceCommands } from "../session/commands"; +import { DocumentPane } from "./DocumentPane"; +import { DocumentTree } from "./DocumentTree"; +import type { DocumentBrowserController } from "./controller"; + +/** Coordinate a document surface using shared session chrome, keymaps and theme ownership. */ +export function DocumentApp({ + controller, + themeController, + options, + keybindings, + onQuit, +}: { + controller: DocumentBrowserController; + themeController: ThemeController; + options: CommonOptions; + keybindings: Record; + onQuit: () => void; +}) { + const terminal = useTerminalDimensions(); + const renderer = useRenderer(); + const snapshot = useSyncExternalStore(controller.subscribe, controller.getSnapshot); + const [focus, setFocus] = useState<"tree" | "document">( + controller.source.root.kind === "directory" ? "tree" : "document", + ); + const [sidebar, setSidebar] = useState( + options.sidebar !== false && controller.source.root.kind === "directory", + ); + const [numbers, setNumbers] = useState(options.lineNumbers !== false); + const [wrap, setWrap] = useState(options.wrapLines ?? false); + const [help, setHelp] = useState(false); + const notice = useTimedNotice(4000); + const setNotice = (text: string | null) => { + if (text === null) notice.clear(); + else notice.show(text); + }; + const scrollRef = useRef(null); + const visibleLineRef = useRef(() => 1); + const themeSelector = useThemeSelectorController({ + themeController, + transparentBackground: options.transparentBackground ?? false, + onTransientNotice: notice.show, + }); + const theme = themeSelector.activeTheme; + const treeWidth = sidebar ? Math.min(34, Math.max(12, Math.floor(terminal.width / 3))) : 0; + const height = Math.max(1, terminal.height - 3); + const treeFocused = sidebar && focus === "tree"; + const keys = useMemo( + () => + resolveCommandKeys({ + defaults: DOCUMENT_COMMAND_CATALOG, + userBindings: keybindings, + inactiveCommandNames: new Set([...APP_COMMAND_NAMES, ...HISTORY_COMMAND_NAMES]), + }), + [keybindings], + ); + + /** Delegate selected documents to an external editor without borrowing review file types. */ + const edit = async () => { + const key = controller.getSnapshot().documentKey; + const path = key ? await controller.source.editablePath?.(key) : null; + if (renderer.isDestroyed || controller.isClosed) return; + if (!path) { + setNotice("No editable regular file selected."); + return; + } + setNotice(openFileInEditor({ filePath: path, line: visibleLineRef.current(), renderer })); + await controller.refresh(); + }; + + /** Apply navigation to the focused surface, preserving the document's independent selection. */ + const step = (delta: number) => { + if (treeFocused) controller.move(delta); + else scrollRef.current?.scrollBy(delta); + }; + const commands = buildSurfaceCommands(DOCUMENT_COMMAND_CATALOG, { + resolvedKeys: keys.keys, + run(entry) { + const current = controller.getSnapshot(); + const selected = current.rows.find((row) => row.entry.key === current.selectedKey)?.entry; + switch (entry.id) { + case "hunk.app.quit": + onQuit(); + break; + case "hunk.app.refresh": + void controller.refresh(); + break; + case "hunk.app.toggleFocusArea": + setFocus((focus) => (focus === "tree" ? "document" : "tree")); + break; + case "hunk.app.toggleHelp": + setHelp((help) => !help); + break; + case "hunk.view.openThemeSelector": + themeSelector.openThemeSelector(); + break; + case "hunk.view.toggleFilesPane": + setSidebar((value) => !value); + break; + case "hunk.view.toggleLineNumbers": + setNumbers((value) => !value); + break; + case "hunk.view.toggleLineWrap": + setWrap((value) => !value); + break; + case "hunk.documents.toggleExcluded": + controller.toggleExcluded(); + break; + case "hunk.documents.activate": + if (selected) void controller.activate(selected.key); + break; + case "hunk.documents.edit": + void edit(); + break; + case "hunk.documents.stepDown": + step(1); + break; + case "hunk.documents.stepUp": + step(-1); + break; + case "hunk.documents.pageDown": + step(height - 1); + break; + case "hunk.documents.pageUp": + step(1 - height); + break; + case "hunk.documents.jumpToTop": + if (treeFocused) controller.move(-current.rows.length); + else scrollRef.current?.scrollTo(0); + break; + case "hunk.documents.jumpToBottom": + if (treeFocused) controller.move(current.rows.length); + else scrollRef.current?.scrollTo(scrollRef.current.scrollHeight); + break; + case "hunk.documents.scrollLeft": + if (treeFocused && selected?.kind === "directory" && current.expanded.has(selected.key)) + void controller.activate(selected.key); + else if (!treeFocused) scrollRef.current?.scrollBy({ x: -4, y: 0 }); + break; + case "hunk.documents.scrollRight": + if (treeFocused && selected?.kind === "directory" && !current.expanded.has(selected.key)) + void controller.activate(selected.key); + else if (!treeFocused) scrollRef.current?.scrollBy({ x: 4, y: 0 }); + break; + } + }, + }); + const menuItem = (id: string, checked?: boolean): MenuEntry => { + const command = commands.find((command) => command.id === id)!; + return { + kind: "item", + label: command.title, + commandId: id, + hint: command.keyLabels.join(" / "), + checked, + action: () => { + executeAppCommand(commands, id); + }, + }; + }; + const menus: AppMenus = { + file: [ + menuItem("hunk.app.refresh"), + menuItem("hunk.documents.edit"), + menuItem("hunk.app.quit"), + ], + view: [ + menuItem("hunk.view.openThemeSelector"), + menuItem("hunk.view.toggleFilesPane", sidebar), + menuItem("hunk.view.toggleLineNumbers", numbers), + menuItem("hunk.view.toggleLineWrap", wrap), + menuItem("hunk.documents.toggleExcluded", snapshot.showExcluded), + ], + help: [menuItem("hunk.app.toggleHelp")], + }; + const menu = useMenuController(menus); + useKeyboard((key) => { + if (help) { + if (key.name === "escape" || key.sequence === "?") setHelp(false); + key.preventDefault(); + return; + } + if (themeSelector.themeSelectorOpen) { + if (key.name === "escape") themeSelector.closeThemeSelector(); + else if (key.name === "up") themeSelector.moveThemeSelector(-1); + else if (key.name === "down") themeSelector.moveThemeSelector(1); + else if (key.name === "return") themeSelector.acceptThemeSelector(); + key.preventDefault(); + return; + } + if (key.name === "f10") { + if (menu.activeMenuId) menu.closeMenu(); + else menu.openMenu("file"); + key.preventDefault(); + return; + } + if (menu.getActiveMenuId()) { + if (key.name === "escape") menu.closeMenu(); + else if (key.name === "left") menu.switchMenu(-1); + else if (key.name === "right") menu.switchMenu(1); + else if (key.name === "up") menu.moveMenuItem(-1); + else if (key.name === "down") menu.moveMenuItem(1); + else if (key.name === "return") menu.activateCurrentMenuItem(); + key.preventDefault(); + return; + } + dispatchAppCommand(commands, key); + }); + const overlay = help || themeSelector.themeSelectorOpen || menu.activeMenuId !== null; + return ( + + { + if (menu.activeMenuId) menu.openMenu(id); + }} + onToggleMenu={menu.toggleMenu} + /> + + {sidebar ? ( + setFocus("tree")} + > + { + setFocus("tree"); + void controller.activate(key); + }} + /> + + ) : null} + {sidebar ? : null} + setFocus("document")} + > + + {fitText( + ` ${sanitizeTerminalLine(snapshot.documentEntry?.displayPath ?? snapshot.documentEntry?.name ?? "Complete document")}`, + Math.max(1, terminal.width - treeWidth - 1), + )} + + + + + + {fitText( + ` ${sanitizeTerminalLine(notice.text ?? snapshot.notice ?? keys.issues[0]?.message ?? `${treeFocused ? "Tree" : "Document"} · read-only · ${snapshot.showExcluded ? "hidden/ignored shown" : "hidden/ignored hidden"}`)}`, + terminal.width, + )} + + {menu.activeMenuId && menu.activeMenuSpec ? ( + { + entry.action(); + menu.closeMenu(); + }} + /> + ) : null} + {themeSelector.themeSelectorOpen ? ( + + ) : null} + {help ? ( + command.keyLabels.length) + .map((command) => ({ + keys: command.keyLabels.join(" / "), + description: command.title, + })), + }, + ]} + terminalHeight={terminal.height} + terminalWidth={terminal.width} + theme={theme} + onClose={() => setHelp(false)} + /> + ) : null} + + ); +} diff --git a/packages/hunk/src/ui/documents/DocumentPane.tsx b/packages/hunk/src/ui/documents/DocumentPane.tsx new file mode 100644 index 000000000..71245bb1a --- /dev/null +++ b/packages/hunk/src/ui/documents/DocumentPane.tsx @@ -0,0 +1,195 @@ +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import type { ScrollBoxRenderable } from "@opentui/core"; +import type { MutableRefObject } from "react"; +import type { DocumentReadResult } from "../../core/documents/source"; +import { fileLanguageForPath } from "../../core/documents/fileLanguageLookup"; +import { + documentHighlightRunsForLine, + loadDocumentHighlight, + type DocumentHighlightResult, +} from "../syntax/documentHighlightService"; +import type { AppTheme } from "../themes"; +import { useRowViewport } from "./useRowViewport"; +import { + completeDocumentLines, + completeDocumentGeometry, + completeDocumentWindow, + completeDocumentLineAt, +} from "./geometry"; + +/** Paint complete documents with the host syntax service; syntax arrival never changes row geometry. */ +export function DocumentPane({ + document, + documentKey, + pathHint, + height, + width, + wrap, + tabWidth, + theme, + lineNumbers, + focused, + scrollRef, + visibleLineRef, +}: { + document: DocumentReadResult | null; + documentKey: string | null; + pathHint: string; + height: number; + width: number; + wrap: boolean; + tabWidth: number; + theme: AppTheme; + lineNumbers: boolean; + focused: boolean; + scrollRef: MutableRefObject; + visibleLineRef: MutableRefObject<() => number>; +}) { + const viewport = useRowViewport(height); + const text = document?.kind === "text" ? document.text : ""; + const lines = useMemo(() => completeDocumentLines(text, tabWidth), [text, tabWidth]); + const safeText = useMemo(() => lines.join("\n"), [lines]); + const [paint, setPaint] = useState<{ + text: string; + theme: AppTheme; + key: string | null; + result: DocumentHighlightResult; + } | null>(null); + const layout = useMemo(() => { + try { + return { geometry: completeDocumentGeometry(lines, width, lineNumbers, wrap), wrap }; + } catch { + return { geometry: completeDocumentGeometry(lines, width, lineNumbers, false), wrap: false }; + } + }, [lines, width, lineNumbers, wrap]); + const { geometry } = layout; + visibleLineRef.current = () => + Math.max( + 1, + Math.min( + lines.length, + completeDocumentLineAt(geometry, viewport.ref.current?.scrollTop ?? 0) + 1, + ), + ); + const gutter = geometry.gutter; + const window = completeDocumentWindow(geometry, viewport.top, viewport.height); + const previousGeometry = useRef({ documentKey, geometry }); + useLayoutEffect(() => { + const previous = previousGeometry.current; + const scroll = viewport.ref.current; + if (scroll && previous.documentKey === documentKey && previous.geometry !== geometry) { + const top = Math.floor(scroll.scrollTop); + const line = completeDocumentLineAt(previous.geometry, top); + scroll.scrollTo({ + x: layout.wrap ? 0 : scroll.scrollLeft, + y: geometry.rows[Math.max(0, line)]?.start ?? 0, + }); + } + previousGeometry.current = { documentKey, geometry }; + }, [documentKey, geometry, layout.wrap, viewport.ref]); + const result = + paint?.text === safeText && paint.theme === theme && paint.key === documentKey + ? paint.result + : null; + useEffect(() => { + scrollRef.current = viewport.ref.current; + return () => { + scrollRef.current = null; + }; + }, [scrollRef, viewport.ref]); + useEffect(() => { + viewport.ref.current?.scrollTo(0); + }, [documentKey, viewport.ref]); + useEffect(() => { + if (!documentKey || document?.kind !== "text") return; + const controller = new AbortController(); + void loadDocumentHighlight({ + text: safeText, + path: pathHint, + language: + document.language ?? + fileLanguageForPath( + process.platform === "win32" ? pathHint.replaceAll("\\", "/") : pathHint, + ) ?? + "text", + theme, + offloadLargeDiff: false, + signal: controller.signal, + }) + .then((result) => { + if (!controller.signal.aborted) + setPaint({ text: safeText, key: documentKey, theme, result }); + }) + .catch(() => undefined); + return () => controller.abort(); + }, [document, documentKey, pathHint, safeText, theme]); + + /** Project syntax runs and fill token gaps with ordinary foreground paint. */ + const paintLine = (line: string, index: number) => { + let offset = 0; + const spans: { text: string; fg?: string }[] = []; + for (const run of documentHighlightRunsForLine(result, index)) { + if (run.start > offset) spans.push({ text: line.slice(offset, run.start) }); + spans.push({ text: line.slice(run.start, run.end), fg: run.fg }); + offset = run.end; + } + if (offset < line.length) spans.push({ text: line.slice(offset) }); + return spans.map((span, index) => ( + + {span.text} + + )); + }; + const placeholder = + document?.kind === "unavailable" + ? document.detail + : !documentKey + ? "Select a file to view its complete document." + : !document + ? "Loading…" + : !lines.length + ? "Empty file." + : null; + return ( + key.preventDefault()} + > + {placeholder ? ( + {` ${placeholder}`} + ) : ( + + {window.start > 0 ? ( + + ) : null} + {lines.slice(window.start, window.end).map((line, offset) => { + const index = window.start + offset; + return ( + + {lineNumbers ? ( + {`${String(index + 1).padStart(gutter - 2)} `} + ) : null} + {paintLine(line, index)} + + ); + })} + {window.end < lines.length ? ( + + ) : null} + + )} + + ); +} diff --git a/packages/hunk/src/ui/documents/DocumentTree.tsx b/packages/hunk/src/ui/documents/DocumentTree.tsx new file mode 100644 index 000000000..7fb3a6b55 --- /dev/null +++ b/packages/hunk/src/ui/documents/DocumentTree.tsx @@ -0,0 +1,80 @@ +import { useEffect } from "react"; +import type { AppTheme } from "../themes"; +import { fitText } from "../lib/text"; +import { sanitizeTerminalLine } from "../../lib/terminalText"; +import type { DocumentBrowserSnapshot } from "./controller"; +import { mountedRowWindow, useRowViewport } from "./useRowViewport"; + +/** Paint a windowed filesystem tree with one shared activation path for every entry. */ +export function DocumentTree({ + snapshot, + theme, + width, + height, + focused, + onActivate, +}: { + snapshot: DocumentBrowserSnapshot; + theme: AppTheme; + width: number; + height: number; + focused: boolean; + onActivate: (key: string) => void; +}) { + const viewport = useRowViewport(height); + const selected = snapshot.rows.findIndex((row) => row.entry.key === snapshot.selectedKey); + useEffect(() => { + const scroll = viewport.ref.current; + if (!scroll || selected < 0) return; + if (selected < scroll.scrollTop) scroll.scrollTo(selected); + else if (selected >= scroll.scrollTop + viewport.height) + scroll.scrollTo(selected - viewport.height + 1); + }, [selected, viewport.height, viewport.ref]); + const window = mountedRowWindow(snapshot.rows.length, viewport.top, viewport.height); + return ( + key.preventDefault()} + > + {window.start ? : null} + {snapshot.rows.slice(window.start, window.end).map(({ entry, depth }) => { + const selected = entry.key === snapshot.selectedKey; + const marker = + entry.kind === "directory" + ? snapshot.expanded.has(entry.key) + ? "▼" + : "▶" + : entry.kind === "symlink" + ? "↗" + : " "; + return ( + onActivate(entry.key)} + > + + {fitText( + `${" ".repeat(Math.min(depth, 20))}${marker} ${sanitizeTerminalLine(entry.name)}${entry.status ? ` ${entry.status}` : ""}`, + width, + )} + + + ); + })} + {window.end < snapshot.rows.length ? ( + + ) : null} + + ); +} diff --git a/packages/hunk/src/ui/documents/controller.test.ts b/packages/hunk/src/ui/documents/controller.test.ts new file mode 100644 index 000000000..c957d02f4 --- /dev/null +++ b/packages/hunk/src/ui/documents/controller.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "bun:test"; +import type { + DocumentEntry, + DocumentReadResult, + DocumentSource, +} from "../../core/documents/source"; +import { DocumentBrowserController } from "./controller"; + +/** Supply a deterministic document collection without filesystem or review dependencies. */ +function createTestSource() { + const entry = ( + key: string, + kind: DocumentEntry["kind"] = "file", + hidden = false, + ): DocumentEntry => ({ key, name: key, kind, hidden }); + const root = entry("root", "directory"); + const listings: string[] = []; + const reads: string[] = []; + let changes: (() => void) | undefined; + let subscriptions = 0; + const source: DocumentSource = { + root, + async list(key) { + listings.push(key); + return { + kind: "entries", + entries: + key === "root" + ? [ + entry("folder", "directory"), + entry("a"), + entry("b"), + entry("hidden", "file", true), + { ...entry("ignored"), ignored: true }, + ] + : [entry("child")], + }; + }, + async read(key) { + reads.push(key); + return { kind: "text", text: key, identity: key }; + }, + observe(_keys, onChange) { + changes = onChange; + subscriptions++; + return () => { + subscriptions--; + }; + }, + }; + return { + source, + listings, + reads, + changes: () => changes?.(), + subscriptions: () => subscriptions, + }; +} + +describe("document browser controller", () => { + test("lazy expansion, visibility toggles and document selection do not introduce changesets", async () => { + const fixture = createTestSource(); + const browser = new DocumentBrowserController(fixture.source); + await browser.initialize(); + expect(fixture.listings).toEqual(["root"]); + expect(fixture.reads).toEqual([]); + expect(browser.getSnapshot().rows.map((row) => row.entry.key)).toEqual([ + "root", + "folder", + "a", + "b", + ]); + await browser.activate("folder"); + expect(fixture.listings).toEqual(["root", "folder"]); + expect(browser.getSnapshot().rows.some((row) => row.entry.key === "child")).toBe(true); + await browser.select("a"); + await browser.select("folder"); + expect(browser.getSnapshot().documentKey).toBe("a"); + browser.toggleExcluded(); + expect(browser.getSnapshot().rows.some((row) => row.entry.key === "ignored")).toBe(true); + browser.close(); + expect(fixture.subscriptions()).toBe(0); + }); + test("stale reads and reads settling after shutdown cannot replace the active document", async () => { + const fixture = createTestSource(); + const pending = new Map void>(); + fixture.source.read = (key) => new Promise((done) => pending.set(key, done)); + const browser = new DocumentBrowserController(fixture.source); + await browser.initialize(); + const a = browser.select("a"); + const b = browser.select("b"); + pending.get("b")!({ kind: "text", text: "new", identity: "new" }); + await b; + pending.get("a")!({ kind: "text", text: "old", identity: "old" }); + await a; + expect(browser.getSnapshot().document).toMatchObject({ text: "new" }); + const before = browser.getSnapshot(); + browser.close(); + await browser.refresh(); + expect(browser.getSnapshot()).toBe(before); + }); + test("watch/manual refreshes serialize, retain selection, and reread only demanded content", async () => { + const fixture = createTestSource(); + const browser = new DocumentBrowserController(fixture.source); + await browser.initialize(); + await browser.select("a"); + await browser.refresh(); + expect(browser.getSnapshot().selectedKey).toBe("a"); + expect(fixture.reads).toEqual(["a", "a"]); + expect(fixture.listings).toEqual(["root", "root"]); + fixture.changes(); + await browser.refresh(); + expect(fixture.subscriptions()).toBe(1); + browser.close(); + }); +}); diff --git a/packages/hunk/src/ui/documents/controller.ts b/packages/hunk/src/ui/documents/controller.ts new file mode 100644 index 000000000..fa254cf53 --- /dev/null +++ b/packages/hunk/src/ui/documents/controller.ts @@ -0,0 +1,265 @@ +import type { + DirectoryReadResult, + DocumentEntry, + DocumentReadResult, + DocumentSource, +} from "../../core/documents/source"; + +export interface DocumentTreeRow { + entry: DocumentEntry; + depth: number; +} +export interface DocumentBrowserSnapshot { + rows: readonly DocumentTreeRow[]; + selectedKey: string | null; + documentKey: string | null; + documentEntry: DocumentEntry | null; + document: DocumentReadResult | null; + expanded: ReadonlySet; + showExcluded: boolean; + loading: boolean; + notice: string | null; +} + +/** Flatten only expanded, already loaded directories; unloaded descendants never enter the tree. */ +export function documentTreeRows( + root: DocumentEntry, + directories: ReadonlyMap, + expanded: ReadonlySet, + showExcluded: boolean, +): DocumentTreeRow[] { + const rows: DocumentTreeRow[] = []; + const visit = (entry: DocumentEntry, depth: number) => { + rows.push({ entry, depth }); + if (!expanded.has(entry.key)) return; + const listing = directories.get(entry.key); + if (listing?.kind !== "entries") return; + for (const child of listing.entries) { + if (!showExcluded && (child.hidden || child.ignored)) continue; + visit(child, depth + 1); + } + }; + visit(root, 0); + return rows; +} + +/** Own lazy expansion, document selection and serialized refreshes independently of React or reviews. */ +export class DocumentBrowserController { + private directories = new Map(); + private listeners = new Set<() => void>(); + private controller = new AbortController(); + private readGeneration = 0; + private refreshPromise: Promise | null = null; + private refreshPending = false; + private closed = false; + private stopObservation?: () => void; + private listingRequests = new Map>(); + private pendingReads = new Set>(); + private readController?: AbortController; + private closePromise?: Promise; + private snapshot: DocumentBrowserSnapshot; + + constructor(readonly source: DocumentSource) { + this.snapshot = { + rows: [{ entry: source.root, depth: 0 }], + selectedKey: source.root.key, + documentKey: null, + documentEntry: null, + document: null, + expanded: new Set(), + showExcluded: false, + loading: false, + notice: null, + }; + } + /** Report whether this surface has revoked all source demand. */ + get isClosed() { + return this.closed; + } + + getSnapshot = () => this.snapshot; + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + /** Load only the root directory or explicitly opened document. */ + async initialize() { + if (this.source.root.kind === "directory") await this.activate(this.source.root.key); + else await this.select(this.source.root.key); + this.observe(); + } + + /** Replace a snapshot atomically; subscribers never observe partially updated tree geometry. */ + private publish(change: Partial) { + if (this.closed) return; + const next = { ...this.snapshot, ...change }; + next.rows = documentTreeRows( + this.source.root, + this.directories, + next.expanded, + next.showExcluded, + ); + if (!next.rows.some((row) => row.entry.key === next.selectedKey)) + next.selectedKey = next.rows[0]?.entry.key ?? null; + this.snapshot = next; + for (const listener of this.listeners) listener(); + } + + /** Load one directory once, sharing concurrent requests for the same key. */ + private loadDirectory(key: string): Promise { + const existing = this.listingRequests.get(key); + if (existing) return existing; + const request = (async () => { + const result = await this.source.list(key, this.controller.signal); + if (this.closed || !this.snapshot.expanded.has(key)) return; + this.directories.set(key, result); + this.publish({ notice: result.kind === "unavailable" ? result.detail : null }); + })() + .catch((error) => { + if (!this.closed) this.publish({ notice: String(error) }); + }) + .finally(() => { + if (this.listingRequests.get(key) === request) this.listingRequests.delete(key); + }); + this.listingRequests.set(key, request); + return request; + } + + /** Cancel obsolete document demand and retain in-flight reads until shutdown settles them. */ + private readDocument(key: string): Promise { + this.readController?.abort(); + this.readController = new AbortController(); + const signal = AbortSignal.any([this.controller.signal, this.readController.signal]); + const request = this.source.read(key, signal).finally(() => this.pendingReads.delete(request)); + this.pendingReads.add(request); + return request; + } + + /** Select a tree row; stale file reads cannot overwrite a later selection. */ + async select(key: string) { + const entry = this.snapshot.rows.find((row) => row.entry.key === key)?.entry; + if (!entry || this.closed) return; + this.publish({ selectedKey: key }); + if (entry.kind === "directory") return; + const generation = ++this.readGeneration; + this.publish({ documentKey: key, documentEntry: entry, document: null, loading: true }); + this.observe(); + try { + const document = await this.readDocument(key); + if (generation === this.readGeneration) this.publish({ document, loading: false }); + } catch (error) { + if (!this.closed && generation === this.readGeneration) + this.publish({ + loading: false, + document: { + kind: "unavailable", + reason: "unreadable", + detail: "Cannot read this document.", + }, + notice: String(error), + }); + } + } + + /** Toggle one directory or open a document, through the same path for mouse and keyboard. */ + async activate(key: string) { + const entry = this.snapshot.rows.find((row) => row.entry.key === key)?.entry; + if (!entry || this.closed) return; + if (entry.kind !== "directory") return this.select(key); + const expanded = new Set(this.snapshot.expanded); + if (expanded.has(key)) { + expanded.delete(key); + this.directories.delete(key); + const index = this.snapshot.rows.findIndex((row) => row.entry.key === key); + const depth = this.snapshot.rows[index]!.depth; + for (const row of this.snapshot.rows.slice(index + 1)) { + if (row.depth <= depth) break; + expanded.delete(row.entry.key); + this.directories.delete(row.entry.key); + } + } else { + if (expanded.size >= 128) { + this.publish({ notice: "Close a directory before expanding more than 128 directories." }); + return; + } + expanded.add(key); + } + this.publish({ selectedKey: key, expanded }); + this.observe(); + if (expanded.has(key) && !this.directories.has(key)) await this.loadDirectory(key); + this.observe(); + } + + /** Move within the visible tree without deriving navigation from filesystem indexes. */ + move(delta: number) { + const rows = this.snapshot.rows; + const current = rows.findIndex((row) => row.entry.key === this.snapshot.selectedKey); + const row = rows[Math.max(0, Math.min(rows.length - 1, current + delta))]; + if (row) void this.select(row.entry.key); + } + + /** Reveal hidden and ignored entries without changing filesystem reads or following symlinks. */ + toggleExcluded() { + this.publish({ showExcluded: !this.snapshot.showExcluded }); + } + + /** Rebuild demanded listings and the selected document, coalescing watch and manual triggers. */ + refresh(): Promise { + if (this.closed) return Promise.resolve(); + this.refreshPending = true; + if (this.refreshPromise) return this.refreshPromise; + this.refreshPromise = (async () => { + while (this.refreshPending && !this.closed) { + this.refreshPending = false; + for (const key of this.snapshot.expanded) { + // A notification during initial expansion must schedule a fresh listing after that read. + await this.listingRequests.get(key); + if (!this.closed && this.snapshot.expanded.has(key)) await this.loadDirectory(key); + } + const key = this.snapshot.documentKey; + if (key) { + const generation = ++this.readGeneration; + const document = await this.readDocument(key); + if (generation === this.readGeneration) this.publish({ document, loading: false }); + } + this.observe(); + } + })() + .catch((error) => { + if (!this.closed && (error as Error).name !== "AbortError") + this.publish({ notice: String(error) }); + }) + .finally(() => { + this.refreshPromise = null; + }); + return this.refreshPromise; + } + + /** Replace observation demand whenever expansion or document selection changes. */ + private observe() { + this.stopObservation?.(); + if (this.closed) return; + const keys = [...this.snapshot.expanded]; + if (this.snapshot.documentKey) keys.push(this.snapshot.documentKey); + this.stopObservation = this.source.observe?.(keys, () => { + void this.refresh(); + }); + } + + /** Cancel pending reads and release every observer before terminal teardown. */ + close(): Promise { + if (this.closePromise) return this.closePromise; + this.closed = true; + this.controller.abort(); + this.stopObservation?.(); + this.listeners.clear(); + this.directories.clear(); + this.closePromise = Promise.allSettled([ + ...this.listingRequests.values(), + ...this.pendingReads, + ...(this.refreshPromise ? [this.refreshPromise] : []), + ]).then(() => undefined); + return this.closePromise; + } +} diff --git a/packages/hunk/src/ui/documents/geometry.test.ts b/packages/hunk/src/ui/documents/geometry.test.ts new file mode 100644 index 000000000..a33d646a6 --- /dev/null +++ b/packages/hunk/src/ui/documents/geometry.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from "bun:test"; +import { + completeDocumentGeometry, + completeDocumentLines, + completeDocumentWindow, +} from "./geometry"; + +test("normalizes newlines, preserves empty logical lines and strips terminal controls", () => { + expect(completeDocumentLines("")).toEqual([]); + expect(completeDocumentLines("\n")).toEqual([""]); + expect(completeDocumentLines("one\r\n\r\ntwo\r")).toEqual(["one", "", "two"]); + expect(completeDocumentLines("safe\x1b[2Jtext\n")).toEqual(["safetext"]); + expect(completeDocumentLines("界\tx\n", 4)).toEqual(["界 x"]); + expect(completeDocumentLines("a\tb\tc\n", 4)).toEqual(["a b c"]); +}); + +test("measures complete-document wraps with the same native engine as file-view rows", () => { + const geometry = completeDocumentGeometry(["hello world", "last"], 7, false, true); + expect(geometry.rows).toEqual([ + { start: 0, height: 2 }, + { start: 2, height: 1 }, + ]); + expect(geometry.totalHeight).toBe(3); + expect(geometry.width).toBe(7); + const unwrapped = completeDocumentGeometry(["hello world", "last"], 7, false, false); + expect(unwrapped.rows.map((row) => row.height)).toEqual([1, 1]); + expect(unwrapped.width).toBe(11); +}); + +test("windows by physical rows and mounts an intersecting wrapped logical line", () => { + const geometry = completeDocumentGeometry( + Array.from({ length: 100 }, () => "hello world"), + 7, + false, + true, + ); + const window = completeDocumentWindow(geometry, 50, 10); + expect(window).toEqual({ start: 22, end: 33 }); + expect(window.end - window.start).toBeLessThan(20); + expect(completeDocumentWindow(geometry, 10_000, 10)).toEqual({ start: 100, end: 100 }); +}); diff --git a/packages/hunk/src/ui/documents/geometry.ts b/packages/hunk/src/ui/documents/geometry.ts new file mode 100644 index 000000000..90a93039f --- /dev/null +++ b/packages/hunk/src/ui/documents/geometry.ts @@ -0,0 +1,83 @@ +import { sanitizeTerminalLine } from "../../lib/terminalText"; +import { measureTextWidth } from "../lib/text"; +import { TerminalTextMeasurer } from "../text/measurement"; + +/** Split terminal-safe complete documents and expand tabs at code-column tab stops. */ +export function completeDocumentLines(text: string, tabWidth = 4): string[] { + const normalized = text.replace(/\r\n?/g, "\n"); + if (!normalized) return []; + return (normalized.endsWith("\n") ? normalized.slice(0, -1) : normalized) + .split("\n") + .map((raw) => { + const line = sanitizeTerminalLine(raw); + if (!line.includes("\t")) return line; + let column = 0; + return line + .split("\t") + .map((segment, index) => { + const spaces = index ? tabWidth - (column % tabWidth) : 0; + column += spaces + measureTextWidth(segment); + return " ".repeat(spaces) + segment; + }) + .join(""); + }); +} + +export interface CompleteDocumentGeometry { + gutter: number; + width: number; + rows: readonly { start: number; height: number }[]; + totalHeight: number; +} + +/** Measure each logical line once using the same native wrap engine used by file-view rows. */ +export function completeDocumentGeometry( + lines: readonly string[], + width: number, + lineNumbers: boolean, + wrap: boolean, +): CompleteDocumentGeometry { + const gutter = lineNumbers ? String(Math.max(1, lines.length)).length + 2 : 0; + const measurer = new TerminalTextMeasurer(); + let totalHeight = 0; + let contentWidth = Math.max(1, width); + try { + const rows = lines.map((line, index) => { + const text = (lineNumbers ? `${String(index + 1).padStart(gutter - 2)} ` : "") + line; + const textWidth = measureTextWidth(text); + contentWidth = Math.max(contentWidth, textWidth); + const height = wrap && textWidth > width ? measurer.measure(text, width) : 1; + const row = { start: totalHeight, height }; + totalHeight += height; + return row; + }); + return { gutter, width: wrap ? Math.max(1, width) : contentWidth, rows, totalHeight }; + } finally { + measurer.destroy(); + } +} + +/** Find the logical line containing one physical row without scanning the full document. */ +export function completeDocumentLineAt(geometry: CompleteDocumentGeometry, offset: number) { + let start = 0; + let end = geometry.rows.length; + while (start < end) { + const middle = (start + end) >>> 1; + const row = geometry.rows[middle]!; + if (row.start + row.height <= offset) start = middle + 1; + else end = middle; + } + return start; +} + +/** Locate the logical-line window intersecting visible physical rows, including a small halo. */ +export function completeDocumentWindow( + geometry: CompleteDocumentGeometry, + top: number, + height: number, +) { + return { + start: completeDocumentLineAt(geometry, Math.max(0, top - 5)), + end: Math.min(geometry.rows.length, completeDocumentLineAt(geometry, top + height + 5) + 1), + }; +} diff --git a/packages/hunk/src/ui/documents/runDocumentBrowser.tsx b/packages/hunk/src/ui/documents/runDocumentBrowser.tsx new file mode 100644 index 000000000..22a284aec --- /dev/null +++ b/packages/hunk/src/ui/documents/runDocumentBrowser.tsx @@ -0,0 +1,32 @@ +import type { DocumentBrowserBootstrap } from "../../core/documents/bootstrap"; +import { DocumentBrowserController } from "./controller"; +import { HunkSessionHost } from "../session/HunkSessionHost"; +import { runHunkSession } from "../session/runHunkSession"; + +/** Mount documents in the existing session host; the runner retains all terminal ownership. */ +export async function runDocumentBrowser(bootstrap: DocumentBrowserBootstrap) { + const controller = new DocumentBrowserController(bootstrap.source); + try { + await controller.initialize(); + await runHunkSession({ + stdin: process.stdin, + stdout: process.stdout, + useMouse: true, + signals: + process.platform === "win32" + ? ["SIGINT", "SIGTERM", "SIGBREAK"] + : ["SIGINT", "SIGTERM", "SIGHUP"], + beforeTeardown: () => controller.close(), + render: ({ externalQuitSignal, finish }) => ( + + ), + }); + } finally { + await controller.close(); + } +} diff --git a/packages/hunk/src/ui/documents/useRowViewport.ts b/packages/hunk/src/ui/documents/useRowViewport.ts new file mode 100644 index 000000000..994a56805 --- /dev/null +++ b/packages/hunk/src/ui/documents/useRowViewport.ts @@ -0,0 +1,36 @@ +import type { ScrollBoxRenderable } from "@opentui/core"; +import { useLayoutEffect, useRef, useState } from "react"; + +/** Observe scroll geometry so complete-document and tree panes mount only a bounded row window. */ +export function useRowViewport(estimatedHeight: number) { + const ref = useRef(null); + const [viewport, setViewport] = useState({ top: 0, height: estimatedHeight }); + useLayoutEffect(() => { + const scroll = ref.current; + if (!scroll) return; + const update = () => + setViewport((previous) => { + const next = { + top: Math.floor(scroll.scrollTop), + height: Math.max(1, scroll.viewport.height || estimatedHeight), + }; + return previous.top === next.top && previous.height === next.height ? previous : next; + }); + update(); + scroll.verticalScrollBar.on("change", update); + scroll.viewport.on("layout-changed", update); + scroll.viewport.on("resized", update); + return () => { + scroll.verticalScrollBar.off("change", update); + scroll.viewport.off("layout-changed", update); + scroll.viewport.off("resized", update); + }; + }, [estimatedHeight]); + return { ref, ...viewport }; +} + +/** Include a small halo around the viewport, clamping after document replacement or resize. */ +export function mountedRowWindow(count: number, top: number, height: number) { + const start = Math.max(0, Math.min(count, top - 5)); + return { start, end: Math.min(count, Math.max(start, top + height + 5)) }; +} diff --git a/packages/hunk/src/ui/fileViews/layout.ts b/packages/hunk/src/ui/fileViews/layout.ts index e3fcf012b..0479a03ea 100644 --- a/packages/hunk/src/ui/fileViews/layout.ts +++ b/packages/hunk/src/ui/fileViews/layout.ts @@ -7,7 +7,7 @@ import type { ExtensionFileViewSyntaxReference, } from "../../extension-api/types"; import { sanitizeTerminalLine } from "../../lib/terminalText"; -import { FileViewTextMeasurer } from "./textDisplay"; +import { TerminalTextMeasurer } from "../text/measurement"; /** Resource limits keep one extension layout from exhausting the review stream. */ export const FILE_VIEW_MAX_ROWS = 10_000; @@ -177,7 +177,7 @@ function validateFileViewLayoutWithMeasurer( value: unknown, hunkCount: number, width: number, - textMeasurer: FileViewTextMeasurer, + textMeasurer: TerminalTextMeasurer, ): { valid: true; value: ValidatedFileViewLayout } | { valid: false; issue: string } { if (!value || typeof value !== "object" || Array.isArray(value)) { return { valid: false, issue: "layout is not an object" }; @@ -526,7 +526,7 @@ export function validateFileViewLayout( hunkCount: number, width: number, ): { valid: true; value: ValidatedFileViewLayout } | { valid: false; issue: string } { - const textMeasurer = new FileViewTextMeasurer(); + const textMeasurer = new TerminalTextMeasurer(); try { return validateFileViewLayoutWithMeasurer(value, hunkCount, width, textMeasurer); } finally { diff --git a/packages/hunk/src/ui/fileViews/syntaxPaint.test.ts b/packages/hunk/src/ui/fileViews/syntaxPaint.test.ts index 5975763b4..d70c57b7c 100644 --- a/packages/hunk/src/ui/fileViews/syntaxPaint.test.ts +++ b/packages/hunk/src/ui/fileViews/syntaxPaint.test.ts @@ -5,7 +5,7 @@ import { createDocumentHighlightService, type DocumentHighlightResult, type DocumentHighlightRun, -} from "../diff/documentHighlightService"; +} from "../syntax/documentHighlightService"; import { preserveCrossSpanGraphemes } from "../diff/styledSpanLayout"; import type { CompactHighlightedDocument } from "../diff/worker"; import { diff --git a/packages/hunk/src/ui/fileViews/syntaxPaint.ts b/packages/hunk/src/ui/fileViews/syntaxPaint.ts index bc12114e5..fcea88005 100644 --- a/packages/hunk/src/ui/fileViews/syntaxPaint.ts +++ b/packages/hunk/src/ui/fileViews/syntaxPaint.ts @@ -3,7 +3,7 @@ import { documentHighlightRunsForLine, type DocumentHighlightResult, type DocumentHighlightRun, -} from "../diff/documentHighlightService"; +} from "../syntax/documentHighlightService"; /** One paint-only syntax run that retains text from an accepted symbolic span. */ export interface FileViewSyntaxPaintRun { diff --git a/packages/hunk/src/ui/fileViews/textDisplay.test.ts b/packages/hunk/src/ui/fileViews/textDisplay.test.ts deleted file mode 100644 index aacb671f1..000000000 --- a/packages/hunk/src/ui/fileViews/textDisplay.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - FILE_VIEW_TAB_WIDTH, - FileViewTextMeasurer, - measureFileViewDisplayTextHeight, -} from "./textDisplay"; - -describe("file-view terminal text display", () => { - test("matches native word wrapping for words and wide unbroken text", () => { - expect(measureFileViewDisplayTextHeight("hello world", 7)).toBe(2); - expect(measureFileViewDisplayTextHeight("hello world", 5)).toBe(3); - expect(measureFileViewDisplayTextHeight("ab界界cd", 4)).toBe(3); - expect(measureFileViewDisplayTextHeight("界", 1)).toBe(2); - }); - - test("measures retained tabs at their native fixed two-cell width", () => { - expect(FILE_VIEW_TAB_WIDTH).toBe(2); - expect(measureFileViewDisplayTextHeight("a\tb", 1)).toBe(4); - expect(measureFileViewDisplayTextHeight("a\tb", 2)).toBe(3); - expect(measureFileViewDisplayTextHeight("a\tb", 3)).toBe(2); - expect(measureFileViewDisplayTextHeight("a\tb", 4)).toBe(1); - expect(measureFileViewDisplayTextHeight("\t\t", 1)).toBe(4); - expect(measureFileViewDisplayTextHeight("\t\t", 2)).toBe(2); - expect(measureFileViewDisplayTextHeight("\t\t", 3)).toBe(2); - expect(measureFileViewDisplayTextHeight("\t\t", 4)).toBe(1); - }); - - test("throws a bounded error and poisons the measurer after native setup fails", () => { - let factoryCalls = 0; - const measurer = new FileViewTextMeasurer(() => { - factoryCalls += 1; - throw new Error("native detail that must not escape"); - }); - - expect(() => measurer.measure("content", 10)).toThrow("file-view text measurement unavailable"); - expect(() => measurer.measure("content", 10)).toThrow("file-view text measurement unavailable"); - expect(factoryCalls).toBe(1); - }); - - test("destroys native resources and returns no height after any measurement failure", () => { - for (const failure of [ - "set-text", - "wrap-mode", - "wrap-width", - "measure", - "malformed-measure", - ] as const) { - let bufferDestroyed = 0; - let viewDestroyed = 0; - const measurer = new FileViewTextMeasurer(() => ({ - buffer: { - setText() { - if (failure === "set-text") throw new Error("set text failed"); - }, - destroy() { - bufferDestroyed += 1; - }, - }, - view: { - setWrapMode() { - if (failure === "wrap-mode") throw new Error("wrap mode failed"); - }, - setWrapWidth() { - if (failure === "wrap-width") throw new Error("wrap width failed"); - }, - measureForDimensions() { - if (failure === "measure") return null; - return { lineCount: failure === "malformed-measure" ? Number.NaN : 1 }; - }, - destroy() { - viewDestroyed += 1; - }, - }, - })); - - expect(() => measurer.measure("content", 10)).toThrow( - "file-view text measurement unavailable", - ); - expect(bufferDestroyed).toBe(1); - expect(viewDestroyed).toBe(1); - expect(() => measurer.measure("other", 10)).toThrow("file-view text measurement unavailable"); - expect(bufferDestroyed).toBe(1); - expect(viewDestroyed).toBe(1); - } - }); - - test("keeps empty rows at height one without touching poisoned native state", () => { - const measurer = new FileViewTextMeasurer(() => { - throw new Error("unavailable"); - }); - - expect(() => measurer.measure("content", 10)).toThrow("file-view text measurement unavailable"); - expect(measurer.measure("", 10)).toBe(1); - }); -}); diff --git a/packages/hunk/src/ui/fileViews/useFileViewSyntaxHighlight.test.tsx b/packages/hunk/src/ui/fileViews/useFileViewSyntaxHighlight.test.tsx index cf65543db..11ab33f7c 100644 --- a/packages/hunk/src/ui/fileViews/useFileViewSyntaxHighlight.test.tsx +++ b/packages/hunk/src/ui/fileViews/useFileViewSyntaxHighlight.test.tsx @@ -12,7 +12,7 @@ import { createDocumentHighlightService, type DocumentHighlightInput, type DocumentHighlightResult, -} from "../diff/documentHighlightService"; +} from "../syntax/documentHighlightService"; import { encodeCompactHighlightedDocument } from "../diff/worker"; import { createVisibleAgentNote } from "../lib/agentAnnotations"; import { resolveTheme, type AppTheme } from "../themes"; diff --git a/packages/hunk/src/ui/fileViews/useFileViewSyntaxHighlight.ts b/packages/hunk/src/ui/fileViews/useFileViewSyntaxHighlight.ts index b3a3503c3..327c0c054 100644 --- a/packages/hunk/src/ui/fileViews/useFileViewSyntaxHighlight.ts +++ b/packages/hunk/src/ui/fileViews/useFileViewSyntaxHighlight.ts @@ -7,7 +7,7 @@ import { loadDocumentHighlight, type DocumentHighlightInput, type DocumentHighlightResult, -} from "../diff/documentHighlightService"; +} from "../syntax/documentHighlightService"; import { FILE_VIEW_MAX_CODE_DOCUMENTS } from "./layout"; import type { PlannedFileViewRow } from "./renderPlan"; import type { ResolvedFileViewLayout } from "./useFileViews"; diff --git a/packages/hunk/src/ui/lib/openInEditor.ts b/packages/hunk/src/ui/lib/openInEditor.ts index 956a135b5..ac4989003 100644 --- a/packages/hunk/src/ui/lib/openInEditor.ts +++ b/packages/hunk/src/ui/lib/openInEditor.ts @@ -161,22 +161,31 @@ export function openSelectedFileInEditor({ return "No file selected."; } - const editor = process.env.EDITOR?.trim(); - if (!editor) { - return "$EDITOR is not set."; - } - - const absolutePath = resolveEditableFilePath(file.path, basePath); - if (!existsSync(absolutePath)) { - return `Cannot edit ${file.path}: file does not exist on disk.`; - } - - const line = Math.max(1, selectedLine(file, selectedHunk, lineCursor)); - const command = buildEditorCommand({ - editor, - filePath: absolutePath, - line, + return openFileInEditor({ + filePath: resolveEditableFilePath(file.path, basePath), + displayPath: file.path, + line: Math.max(1, selectedLine(file, selectedHunk, lineCursor)), + renderer, }); +} + +/** Open an explicitly authorized document path without requiring a review or hunk model. */ +export function openFileInEditor({ + filePath, + displayPath = filePath, + line, + renderer, +}: { + filePath: string; + displayPath?: string; + line: number; + renderer: Pick; +}) { + if (renderer.isDestroyed) return "The terminal session has closed."; + const editor = process.env.EDITOR?.trim(); + if (!editor) return "$EDITOR is not set."; + if (!existsSync(filePath)) return `Cannot edit ${displayPath}: file does not exist on disk.`; + const command = buildEditorCommand({ editor, filePath, line }); const shouldSuspend = shouldSuspendForEditor(editor); if (shouldSuspend) { diff --git a/packages/hunk/src/ui/log/LogApp.tsx b/packages/hunk/src/ui/log/LogApp.tsx index e3b83b172..111bb3277 100644 --- a/packages/hunk/src/ui/log/LogApp.tsx +++ b/packages/hunk/src/ui/log/LogApp.tsx @@ -3,6 +3,7 @@ import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react" import { basename } from "node:path"; import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { APP_COMMAND_NAMES } from "../../core/run/commandCatalog"; +import { DOCUMENT_COMMAND_NAMES } from "../../core/run/documentCommandCatalog"; import type { PersistedViewPreferences } from "../../core/run/config"; import type { ExtensionVcsHistoryCommit, @@ -263,7 +264,7 @@ export function LogApp({ await openSelected(undefined, true); }; const inactiveHistoryCommandNames = useMemo(() => { - const names = new Set(APP_COMMAND_NAMES); + const names = new Set([...APP_COMMAND_NAMES, ...DOCUMENT_COMMAND_NAMES]); for (const registered of buildSessionCommands(runtime.extensionSession.current.registry)) { names.add(`${registered.extensionId}.${registered.command.id}`); } diff --git a/packages/hunk/src/ui/log/commands.ts b/packages/hunk/src/ui/log/commands.ts index b73c323bb..0b75526f1 100644 --- a/packages/hunk/src/ui/log/commands.ts +++ b/packages/hunk/src/ui/log/commands.ts @@ -5,9 +5,9 @@ import { type HistoryCommandHelpSection, type HistoryCommandId, } from "../../core/run/historyCommandCatalog"; -import { matchesAnyKeyChord } from "../../lib/commandKeys"; +import { buildSurfaceCommands } from "../session/commands"; import type { HelpSection } from "../lib/helpContent"; -import { formatKeyChord, type CommandKeyDefaults } from "../lib/keymap"; +import type { CommandKeyDefaults } from "../lib/keymap"; import type { AppCommand, ResolvedCommandKeys } from "../lib/appCommands"; import type { LogSnapshot } from "./controller"; @@ -78,22 +78,10 @@ export function buildHistoryCommands({ handlers, resolvedKeys, }: BuildHistoryCommandsOptions): AppCommand[] { - return HISTORY_COMMAND_CATALOG.map((entry) => { - const keys = resolvedKeys?.get(entry.id) ?? entry.defaultKeys; - return { - id: entry.id, - aliases: entry.aliases, - title: entry.title, - keys, - keyLabels: keys.map(formatKeyChord), - defaultKeys: entry.defaultKeys, - isEnabled: () => isHistoryCommandEnabled(entry.id as HistoryCommandId, getSnapshot()), - publicToExtensions: entry.publicToExtensions, - verticalDirection: entry.verticalDirection, - closesMenu: entry.closesMenu, - match: matchesAnyKeyChord(keys), - run: (key) => handlers[entry.id as HistoryCommandId](key, entry), - }; + return buildSurfaceCommands(HISTORY_COMMAND_CATALOG, { + resolvedKeys, + isEnabled: (entry) => isHistoryCommandEnabled(entry.id as HistoryCommandId, getSnapshot()), + run: (entry, key) => handlers[entry.id as HistoryCommandId](key, entry), }); } diff --git a/packages/hunk/src/ui/session/HunkSessionHost.tsx b/packages/hunk/src/ui/session/HunkSessionHost.tsx index d2a25cc70..868c56657 100644 --- a/packages/hunk/src/ui/session/HunkSessionHost.tsx +++ b/packages/hunk/src/ui/session/HunkSessionHost.tsx @@ -18,6 +18,9 @@ import { parseExtensionReviewDescriptor } from "../../core/reviewDescriptor"; import type { ExtensionSession } from "../../extensions/session"; import type { ExtensionLoadResult } from "../../extensions/types"; import { AppHost } from "../AppHost"; +import type { DocumentBrowserBootstrap } from "../../core/documents/bootstrap"; +import { DocumentApp } from "../documents/DocumentApp"; +import type { DocumentBrowserController } from "../documents/controller"; import type { InteractiveHistoryRuntime } from "../history/types"; import type { ViewPreferenceQuitScheduler } from "../hooks/useViewPreferenceQuitController"; import { interactiveLogUsesColor } from "../log/colorPolicy"; @@ -42,7 +45,16 @@ export interface StandaloneReviewSurfaceRoute { extensionSession: ExtensionSession; } -export type HunkSurfaceRoute = HistorySurfaceRoute | StandaloneReviewSurfaceRoute; +export interface DocumentSurfaceRoute { + kind: "documents"; + bootstrap: DocumentBrowserBootstrap; + controller: DocumentBrowserController; +} + +export type HunkSurfaceRoute = + | HistorySurfaceRoute + | StandaloneReviewSurfaceRoute + | DocumentSurfaceRoute; interface ActiveReviewSurfaceRoute extends StandaloneReviewSurfaceRoute { extensionOwnership: "owned" | "borrowed"; @@ -51,7 +63,7 @@ interface ActiveReviewSurfaceRoute extends StandaloneReviewSurfaceRoute { returnRoute?: HistorySurfaceRoute; } -type ActiveSurfaceRoute = HistorySurfaceRoute | ActiveReviewSurfaceRoute; +type ActiveSurfaceRoute = HistorySurfaceRoute | ActiveReviewSurfaceRoute | DocumentSurfaceRoute; export interface HunkSessionHostDeps { prepareReview?: typeof prepareEmbeddedHistoryReview; @@ -164,7 +176,7 @@ export function HunkSessionHost({ [renderer, themeController], ); const [route, setRoute] = useState(() => - initialRoute.kind === "history" + initialRoute.kind !== "review" ? initialRoute : { ...initialRoute, @@ -214,7 +226,7 @@ export function HunkSessionHost({ preparationControllerRef.current?.abort( new Error("Hunk surface preparation was cancelled during shutdown."), ); - if (routeRef.current.kind === "history" && !preparingRef.current) completeQuit(); + if (routeRef.current.kind !== "review" && !preparingRef.current) completeQuit(); }, [completeQuit], ); @@ -397,6 +409,18 @@ export function HunkSessionHost({ [stopReviewRuntime], ); + if (route.kind === "documents") { + return ( + requestQuit()} + /> + ); + } + if (route.kind === "review") { return ( ( + definitions: readonly Definition[], + options: { + resolvedKeys?: ResolvedCommandKeys; + isEnabled?: (entry: Definition) => boolean; + run: (entry: Definition, key: KeyEvent, count: number) => void; + }, +): AppCommand[] { + return definitions.map((entry) => { + const keys = options.resolvedKeys?.get(entry.id) ?? entry.defaultKeys; + return { + ...entry, + keys, + keyLabels: keys.map(formatKeyChord), + publicToExtensions: entry.publicToExtensions ?? false, + isEnabled: () => options.isEnabled?.(entry) ?? true, + match: matchesAnyKeyChord(keys), + run: (key, count) => options.run(entry, key, count), + }; + }); +} diff --git a/packages/hunk/src/ui/diff/documentHighlightRenderer.ts b/packages/hunk/src/ui/syntax/documentHighlightRenderer.ts similarity index 96% rename from packages/hunk/src/ui/diff/documentHighlightRenderer.ts rename to packages/hunk/src/ui/syntax/documentHighlightRenderer.ts index e8eaf4f58..7094d0493 100644 --- a/packages/hunk/src/ui/diff/documentHighlightRenderer.ts +++ b/packages/hunk/src/ui/syntax/documentHighlightRenderer.ts @@ -5,12 +5,12 @@ import { type FileContents, } from "@pierre/diffs"; import type { AppTheme } from "../themes"; -import { pierreHighlightRenderOptions } from "./highlightRenderOptions"; +import { pierreHighlightRenderOptions } from "../diff/highlightRenderOptions"; import { ensureSyntaxHighlightThemeRegistered, syntaxHighlightThemeName, -} from "./syntaxHighlightTheme"; -import type { HastNode } from "./worker"; +} from "../diff/syntaxHighlightTheme"; +import type { HastNode } from "../diff/worker"; export type HighlightThemeInput = AppTheme | AppTheme["appearance"]; type HighlightOptions = ReturnType; diff --git a/packages/hunk/src/ui/diff/documentHighlightService.test.ts b/packages/hunk/src/ui/syntax/documentHighlightService.test.ts similarity index 99% rename from packages/hunk/src/ui/diff/documentHighlightService.test.ts rename to packages/hunk/src/ui/syntax/documentHighlightService.test.ts index 48af91b73..292a5c4bd 100644 --- a/packages/hunk/src/ui/diff/documentHighlightService.test.ts +++ b/packages/hunk/src/ui/syntax/documentHighlightService.test.ts @@ -16,7 +16,7 @@ import { HighlightWorkerClientError, type CompactHighlightedDocument, type DocumentWorkerEligibility, -} from "./worker"; +} from "../diff/worker"; const theme = THEMES.find((candidate) => candidate.id === "github-dark-default")!; const base: Omit = { diff --git a/packages/hunk/src/ui/diff/documentHighlightService.ts b/packages/hunk/src/ui/syntax/documentHighlightService.ts similarity index 98% rename from packages/hunk/src/ui/diff/documentHighlightService.ts rename to packages/hunk/src/ui/syntax/documentHighlightService.ts index c0f7c9659..7a52780a3 100644 --- a/packages/hunk/src/ui/diff/documentHighlightService.ts +++ b/packages/hunk/src/ui/syntax/documentHighlightService.ts @@ -4,8 +4,8 @@ import { DocumentHighlighterConfigurationError, renderHighlightedDocumentLines, } from "./documentHighlightRenderer"; -import { DOCUMENT_HIGHLIGHT_RENDER_OPTIONS_REVISION } from "./highlightRenderOptions"; -import { syntaxHighlightThemeName } from "./syntaxHighlightTheme"; +import { DOCUMENT_HIGHLIGHT_RENDER_OPTIONS_REVISION } from "../diff/highlightRenderOptions"; +import { syntaxHighlightThemeName } from "../diff/syntaxHighlightTheme"; import { compactHighlightedDocumentByteLength, compactHighlightedDocumentRunsForLine, @@ -15,7 +15,7 @@ import { HighlightWorkerClientError, type CompactHighlightedDocument, type DocumentWorkerEligibility, -} from "./worker"; +} from "../diff/worker"; const DEFAULT_CACHE_BYTES = 32 * 1024 * 1024; const DEFAULT_CACHE_ENTRIES = 128; diff --git a/packages/hunk/src/ui/text/measurement.test.ts b/packages/hunk/src/ui/text/measurement.test.ts new file mode 100644 index 000000000..1a27e9e70 --- /dev/null +++ b/packages/hunk/src/ui/text/measurement.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "bun:test"; +import { + TERMINAL_TEXT_TAB_WIDTH, + TerminalTextMeasurer, + measureTerminalTextHeight, +} from "./measurement"; + +describe("terminal text measurement", () => { + test("matches native word wrapping for words and wide unbroken text", () => { + expect(measureTerminalTextHeight("hello world", 7)).toBe(2); + expect(measureTerminalTextHeight("hello world", 5)).toBe(3); + expect(measureTerminalTextHeight("ab界界cd", 4)).toBe(3); + expect(measureTerminalTextHeight("界", 1)).toBe(2); + }); + test("measures retained tabs at their native fixed two-cell width", () => { + expect(TERMINAL_TEXT_TAB_WIDTH).toBe(2); + expect(measureTerminalTextHeight("a\tb", 1)).toBe(4); + expect(measureTerminalTextHeight("a\tb", 2)).toBe(3); + expect(measureTerminalTextHeight("a\tb", 3)).toBe(2); + expect(measureTerminalTextHeight("a\tb", 4)).toBe(1); + expect(measureTerminalTextHeight("\t\t", 1)).toBe(4); + expect(measureTerminalTextHeight("\t\t", 2)).toBe(2); + expect(measureTerminalTextHeight("\t\t", 3)).toBe(2); + expect(measureTerminalTextHeight("\t\t", 4)).toBe(1); + }); + test("contains setup failures and poisons the measurer", () => { + let calls = 0; + const measurer = new TerminalTextMeasurer(() => { + calls++; + throw new Error("native details"); + }); + expect(() => measurer.measure("content", 10)).toThrow("terminal text measurement unavailable"); + expect(() => measurer.measure("content", 10)).toThrow("terminal text measurement unavailable"); + expect(calls).toBe(1); + }); + test("destroys native resources exactly once after measurement failures", () => { + for (const failure of [ + "set-text", + "wrap-mode", + "wrap-width", + "measure", + "malformed-measure", + ] as const) { + let bufferDestroyed = 0; + let viewDestroyed = 0; + const measurer = new TerminalTextMeasurer(() => ({ + buffer: { + setText() { + if (failure === "set-text") throw new Error("failed"); + }, + destroy() { + bufferDestroyed++; + }, + }, + view: { + setWrapMode() { + if (failure === "wrap-mode") throw new Error("failed"); + }, + setWrapWidth() { + if (failure === "wrap-width") throw new Error("failed"); + }, + measureForDimensions() { + return failure === "measure" + ? null + : { lineCount: failure === "malformed-measure" ? Number.NaN : 1 }; + }, + destroy() { + viewDestroyed++; + }, + }, + })); + expect(() => measurer.measure("content", 10)).toThrow( + "terminal text measurement unavailable", + ); + expect(bufferDestroyed).toBe(1); + expect(viewDestroyed).toBe(1); + expect(() => measurer.measure("other", 10)).toThrow("terminal text measurement unavailable"); + expect(bufferDestroyed).toBe(1); + expect(viewDestroyed).toBe(1); + } + }); + test("keeps empty rows at height one without touching poisoned native state", () => { + const measurer = new TerminalTextMeasurer(() => { + throw new Error("unavailable"); + }); + expect(() => measurer.measure("content", 10)).toThrow("terminal text measurement unavailable"); + expect(measurer.measure("", 10)).toBe(1); + }); +}); diff --git a/packages/hunk/src/ui/fileViews/textDisplay.ts b/packages/hunk/src/ui/text/measurement.ts similarity index 50% rename from packages/hunk/src/ui/fileViews/textDisplay.ts rename to packages/hunk/src/ui/text/measurement.ts index d0c6a93f8..c8a0d7e46 100644 --- a/packages/hunk/src/ui/fileViews/textDisplay.ts +++ b/packages/hunk/src/ui/text/measurement.ts @@ -1,31 +1,27 @@ import { TextBuffer, TextBufferView } from "@opentui/core"; /** OpenTUI renders each retained tab at a fixed two-cell width. */ -export const FILE_VIEW_TAB_WIDTH = 2; +export const TERMINAL_TEXT_TAB_WIDTH = 2; +const TEXT_MEASUREMENT_ERROR = "terminal text measurement unavailable"; -const FILE_VIEW_TEXT_MEASUREMENT_ERROR = "file-view text measurement unavailable"; - -interface NativeFileViewTextBuffer { +interface NativeTextBuffer { setText(text: string): void; destroy(): void; } - -interface NativeFileViewTextBufferView { +interface NativeTextBufferView { setWrapMode(mode: "word"): void; setWrapWidth(width: number): void; measureForDimensions(width: number, height: number): { lineCount: number } | null; destroy(): void; } - -interface NativeFileViewTextMeasurement { - readonly buffer: NativeFileViewTextBuffer; - readonly view: NativeFileViewTextBufferView; +interface NativeTextMeasurement { + readonly buffer: NativeTextBuffer; + readonly view: NativeTextBufferView; } +type NativeTextMeasurementFactory = () => NativeTextMeasurement; -type NativeFileViewTextMeasurementFactory = () => NativeFileViewTextMeasurement; - -/** Create the native buffer pair that shares FileView's OpenTUI wrapping behavior. */ -function createNativeFileViewTextMeasurement(): NativeFileViewTextMeasurement { +/** Create the native buffer pair shared by every word-wrapped terminal text surface. */ +function createNativeTextMeasurement(): NativeTextMeasurement { const buffer = TextBuffer.create("unicode"); try { return { buffer, view: TextBufferView.create(buffer) }; @@ -33,27 +29,25 @@ function createNativeFileViewTextMeasurement(): NativeFileViewTextMeasurement { try { buffer.destroy(); } catch { - // Preserve the bounded measurement failure rather than surfacing teardown details. + /* Preserve the setup failure. */ } throw error; } } -/** Reuse OpenTUI's native word-wrap engine while validating every row in one layout. */ -export class FileViewTextMeasurer { - #buffer: NativeFileViewTextBuffer | undefined; - #view: NativeFileViewTextBufferView | undefined; +/** Reuse OpenTUI's native wrap engine so measurement and painting retain identical geometry. */ +export class TerminalTextMeasurer { + #buffer: NativeTextBuffer | undefined; + #view: NativeTextBufferView | undefined; #nativeAvailable = true; - constructor( - private readonly createNativeMeasurement: NativeFileViewTextMeasurementFactory = createNativeFileViewTextMeasurement, + private readonly createNativeMeasurement: NativeTextMeasurementFactory = createNativeTextMeasurement, ) {} - /** Measure retained text exactly as FileView passes it to OpenTUI's word-wrapped renderable. */ + /** Measure one terminal-safe logical line; native failure never invents an approximate height. */ measure(text: string, width: number) { if (text.length === 0) return 1; - if (!this.#nativeAvailable) throw new Error(FILE_VIEW_TEXT_MEASUREMENT_ERROR); - + if (!this.#nativeAvailable) throw new Error(TEXT_MEASUREMENT_ERROR); const usableWidth = Math.max(1, Math.floor(width)); try { if (!this.#buffer || !this.#view) { @@ -70,18 +64,17 @@ export class FileViewTextMeasurer { !Number.isSafeInteger(measured.lineCount) || measured.lineCount < 0 || measured.lineCount > 1_000_001 - ) { - throw new Error(FILE_VIEW_TEXT_MEASUREMENT_ERROR); - } + ) + throw new Error(TEXT_MEASUREMENT_ERROR); return Math.max(1, measured.lineCount); } catch { this.#nativeAvailable = false; this.destroy(); - throw new Error(FILE_VIEW_TEXT_MEASUREMENT_ERROR); + throw new Error(TEXT_MEASUREMENT_ERROR); } } - /** Release native measurement resources after one layout validation. */ + /** Release both native resources without masking a measurement or renderer failure. */ destroy() { const view = this.#view; const buffer = this.#buffer; @@ -90,19 +83,19 @@ export class FileViewTextMeasurer { try { view?.destroy(); } catch { - // Teardown must not mask validation or renderer failures. + /* Teardown preserves the original failure. */ } try { buffer?.destroy(); } catch { - // Teardown must not mask validation or renderer failures. + /* Teardown preserves the original failure. */ } } } -/** Measure one standalone row with the same native word-wrap engine as FileView paint. */ -export function measureFileViewDisplayTextHeight(text: string, width: number) { - const measurer = new FileViewTextMeasurer(); +/** Measure a standalone row using the same native word-wrap behavior as the mounted renderer. */ +export function measureTerminalTextHeight(text: string, width: number) { + const measurer = new TerminalTextMeasurer(); try { return measurer.measure(text, width); } finally { diff --git a/test/pty/open-integration.test.ts b/test/pty/open-integration.test.ts new file mode 100644 index 000000000..d2da111ec --- /dev/null +++ b/test/pty/open-integration.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createPtyHarness } from "./harness"; + +const harness = createPtyHarness(); +const roots: string[] = []; +setDefaultTimeout(30_000); +afterEach(() => { + harness.cleanup(); + roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })); +}); + +/** Create a non-repository fixture so browsing proves it does not depend on a diff source. */ +function createOpenTestFixture() { + const root = mkdtempSync(join(tmpdir(), "hunk-open-pty-")); + roots.push(root); + mkdirSync(join(root, "nested")); + writeFileSync(join(root, "nested", "child.ts"), "const childDocument = 42;\n"); + writeFileSync(join(root, "README.md"), "# Whole document\nUnchanged content\n"); + writeFileSync(join(root, ".hidden"), "Hidden document\n"); + writeFileSync(join(root, "binary"), Buffer.from([0, 1, 2])); + writeFileSync( + join(root, "large.ts"), + Array.from( + { length: 300 }, + (_, index) => `const completeLine${index + 1} = ${index + 1};`, + ).join("\n") + "\n", + ); + return root; +} + +describe("hunk open", () => { + test("wraps complete documents with exact geometry and preserves line navigation across toggles", async () => { + const root = createOpenTestFixture(); + const path = join(root, "wrapped.txt"); + writeFileSync(path, `BEGIN ${"word ".repeat(36)} WRAPPED_TAIL\nFOLLOWING_DOCUMENT_LINE\n`); + const session = await harness.launchHunk({ + args: ["open", path, "--wrap"], + cols: 42, + rows: 20, + }); + try { + await session.waitForText("WRAPPED_TAIL"); + await harness.ensureKeyboardIsLive(session); + expect(await session.text()).toContain("FOLLOWING_DOCUMENT_LINE"); + await session.press("w"); + await harness.waitForSnapshot(session, (text) => !text.includes("WRAPPED_TAIL")); + expect(await session.text()).toContain("FOLLOWING_DOCUMENT_LINE"); + await session.press("w"); + await session.waitForText("WRAPPED_TAIL"); + session.resize({ cols: 60, rows: 16 }); + await session.waitForText("FOLLOWING_DOCUMENT_LINE"); + await session.press("q"); + } finally { + session.close(); + } + }); + test("opens a complete file, scrolls, resizes and reuses normal theme/help/menu chrome", async () => { + const root = createOpenTestFixture(); + const session = await harness.launchHunk({ + args: ["open", join(root, "large.ts"), "--theme", "github-dark-default"], + cols: 100, + rows: 20, + }); + try { + await session.waitForText("completeLine1 = 1"); + await harness.ensureKeyboardIsLive(session); + let keywords = ""; + for (let attempt = 0; attempt < 100; attempt++) { + keywords = await session.text({ immediate: true, only: { foreground: "#ff7b72" } }); + if (keywords.includes("const")) break; + await session.waitIdle({ timeout: 50 }); + } + expect(keywords).toContain("const"); + const frame = await session.text(); + expect(frame).not.toContain("@@"); + expect(frame).not.toContain("+300"); + await session.press(["shift", "g"]); + await session.waitForText("completeLine300 = 300"); + session.resize({ cols: 65, rows: 16 }); + await session.waitForText("completeLine300 = 300"); + await session.press("g"); + await session.waitForText("completeLine1 = 1"); + await session.press("t"); + await session.waitForText("github-dark-default"); + await session.press("escape"); + await session.click(/View/, { first: true }); + await session.waitForText("Line numbers"); + await session.press("escape"); + await session.press("q"); + } finally { + session.close(); + } + }); + test("expands directories by keyboard and mouse, toggles hidden files, watches and shows binary placeholders", async () => { + const root = createOpenTestFixture(); + const session = await harness.launchHunk({ args: ["open", root], cols: 120, rows: 24 }); + try { + await session.waitForText("README.md"); + await harness.ensureKeyboardIsLive(session); + expect(await session.text()).not.toContain(".hidden"); + await session.press("down"); + await session.press("right"); + await session.waitForText("child.ts"); + await session.press("down"); + await session.waitForText("childDocument = 42"); + await session.click(/README\.md/, { first: true }); + await session.waitForText("# Whole document"); + await session.press("i"); + await session.waitForText(".hidden"); + await session.click(/binary/, { first: true }); + await session.waitForText("Binary file"); + await session.click(/README\.md/, { first: true }); + writeFileSync(join(root, "README.md"), "# Refreshed document\n"); + await session.waitForText("# Refreshed document"); + rmSync(join(root, "README.md")); + await session.waitForText("File no longer exists"); + await session.press("q"); + } finally { + session.close(); + } + }); +}); diff --git a/website/src/content/docs/docs/reference/cli.md b/website/src/content/docs/docs/reference/cli.md index 07ff30ffc..a28c808c7 100644 --- a/website/src/content/docs/docs/reference/cli.md +++ b/website/src/content/docs/docs/reference/cli.md @@ -48,6 +48,33 @@ This reference is generated from the command metadata used by Hunk itself. Run ` `--experimental` may also be placed before the review command, as in `hunk --experimental diff`. +## `hunk open` + +view a complete file or browse a directory + +### Usage + +```bash +hunk open [path] +``` + +Defaults to the current directory. Reads are lazy and the browser refreshes automatically. + +Press i to show hidden and Git-ignored entries. Symlinks are displayed but not followed. + +### Command-specific options + +| Option | Description | +| --------------------------- | ----------------------------------- | +| `--theme ` | named theme override | +| `--line-numbers` | show line numbers | +| `--no-line-numbers` | hide line numbers | +| `--sidebar` | show the directory tree | +| `--no-sidebar` | hide the directory tree | +| `--wrap` | wrap long document lines | +| `--no-wrap` | keep long document lines on one row | +| `-x, --tab-width ` | tab stop width: 1-16 | + ## `hunk diff` review diffs or compare two concrete files diff --git a/website/src/content/docs/docs/reference/config.md b/website/src/content/docs/docs/reference/config.md index 734fd3da1..6b4e98a70 100644 --- a/website/src/content/docs/docs/reference/config.md +++ b/website/src/content/docs/docs/reference/config.md @@ -234,6 +234,7 @@ Enable moved-line coloring when the renderer supports it. | Table | Applies to | | -------------- | --------------------------------------------------------- | +| `[open]` | complete documents and directory browsing (`hunk open`) | | `[vcs]` | working-tree and target reviews (`hunk diff`) | | `[show]` | commit and target display reviews (`hunk show`) | | `[stash-show]` | stash reviews (`hunk stash show`) | diff --git a/website/src/content/docs/docs/workflows/files-and-patches.md b/website/src/content/docs/docs/workflows/files-and-patches.md index 0c8415352..9f41543b9 100644 --- a/website/src/content/docs/docs/workflows/files-and-patches.md +++ b/website/src/content/docs/docs/workflows/files-and-patches.md @@ -1,10 +1,29 @@ --- title: Files and patches -description: Compare two concrete files or open unified diff text from a file or stdin. +description: View complete files, browse directories, compare concrete files, or review patches. --- Use file comparison when you already have before and after content, and patch mode when another tool emits unified diff text. +## View a complete file or directory + +```bash +hunk open # browse the current directory +hunk open README.md # syntax-highlighted complete document +hunk open path/to/project +``` + +No repository or changeset is required. The read-only browser lazy-loads expanded directories and +selected documents, and watches them for changes. Arrow keys and Enter navigate the tree; Tab +switches tree/document focus. Mouse clicks select files and expand folders. Press `i` to show +hidden and Git-ignored entries, `e` to open the displayed file in `$EDITOR`, `t` for themes, +`w` for wrapping, and `?` for controls. Commands can be remapped in user configuration. + +Symlinks are listed but never followed. Binary/non-UTF-8, unreadable, missing, or files over +1 MiB show a placeholder. Extremely large directories are bounded to 10,000 entries. Only one +path is accepted; stdin, multiple paths, and `file:line` are not supported yet. `open` does not +load user extensions or register a review session with the agent broker. + ## Compare files ```bash From 251ad8027d3aa64af7056462cfbe2a61fb49cc04 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 6 Oct 2026 10:12:17 -0400 Subject: [PATCH 02/22] fix(open): bind document operations to checked file identities --- .changeset/checked-documents-stay.md | 5 + docs/open.md | 22 +- .../app/documents/filesystemSource.test.ts | 140 ++++++++++- .../src/app/documents/filesystemSource.ts | 232 ++++++++++++++++-- packages/hunk/src/core/documents/source.ts | 4 +- .../src/ui/documents/DocumentApp.test.tsx | 10 + .../hunk/src/ui/documents/DocumentApp.tsx | 10 +- packages/hunk/src/ui/lib/openInEditor.test.ts | 16 ++ packages/hunk/src/ui/lib/openInEditor.ts | 6 +- test/pty/open-integration.test.ts | 31 ++- 10 files changed, 435 insertions(+), 41 deletions(-) create mode 100644 .changeset/checked-documents-stay.md diff --git a/.changeset/checked-documents-stay.md b/.changeset/checked-documents-stay.md new file mode 100644 index 000000000..dc514f00a --- /dev/null +++ b/.changeset/checked-documents-stay.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Harden document browsing and editor saves against path replacement, reject incomplete Git ignore results, and fix Windows path checks. diff --git a/docs/open.md b/docs/open.md index 95bc14e4a..a1d5607a3 100644 --- a/docs/open.md +++ b/docs/open.md @@ -18,7 +18,8 @@ hunk open file.ts --wrap --tab-width 4 - Tab: switch tree/document focus. `s`: toggle the tree. - PageUp/PageDown, `g`/`G`, Home/End: scroll the focused surface. - `i`: toggle hidden and Git-ignored entries together. Hidden entries are names starting with `.`. -- `e`: open the displayed regular file in `$EDITOR`, using the visible line as the editor target. +- `e`: edit a private copy of the displayed regular file in `$EDITOR`, using the visible line as + the editor target; save back when the editor exits. - `t`, `l`, `w`, `?`, `r`, `q`: themes, line numbers, wrapping, help, refresh, quit. - File/View/Help menus provide the same actions with mouse support. F10 opens the menu by keyboard. @@ -45,7 +46,8 @@ scroll position; selecting a directory does not replace the document already dis In a Git worktree, Git supplies ignore rules and lightweight status markers. Metadata queries are optional, bounded, and asynchronous; repository fsmonitor hooks and clean/process filters are disabled. Status queries do not recurse into submodules or fetch missing objects. Ordinary -directory browsing works without Git. Hidden and +directory browsing works without Git. An interrupted or failed ignore query makes that directory +unavailable rather than treating partial ignore output as complete. Hidden and ignored entries are initially excluded, but an explicitly named hidden file still opens normally. Symlinks are listed but not followed, including explicitly opened links and symlinked descendants. @@ -54,6 +56,17 @@ and missing or unreadable entries show a placeholder. Each directory is limited and at most 128 directories may be expanded. Source reads remain bounded when a file grows while it is being read. Terminal control sequences are stripped before painting. +On Linux, directory enumeration uses the checked directory's retained handle. Other platforms +recheck ancestry and directory identity before publishing entries, but portable Node APIs do not +provide handle-relative directory enumeration. Browsing is not a sandbox against a hostile process +that can repeatedly replace and restore ancestors; use it on trusted local directories. + +Editors receive a private temporary copy, not a checked collection path. Hunk retains the original +file handle and saves through it only if the file identity and content still match. Concurrent +replacement or modification cancels writeback and retains the edited copy at the path shown in the +notice. Editor errors also retain that copy. Known GUI editors receive `--wait`; custom editor +wrappers must wait until editing finishes. `$EDITOR` itself is trusted code, not sandboxed. + Expanded directories and the displayed file's parent are observed for changes, covering atomic file replacements and deletions. Watch notifications are debounced; manual and watch refreshes serialize. A failed watcher can be worked around with `r`. Shutdown cancels pending source work, @@ -72,5 +85,6 @@ session to the broker. User extensions remain review-specific and are not loaded The source contract is currently internal; a future public document extension API should expose explicit capabilities without weakening review navigation or workspace-write policies. -Stdin, multiple path arguments, and `file:line` addressing are deferred. The browser never writes -files itself; edits happen only in the explicitly launched external editor. +Stdin, multiple path arguments, and `file:line` addressing are deferred. The browser writes only +when saving a private copy from an explicitly launched external editor; it does not reopen the +collection path for writeback. diff --git a/packages/hunk/src/app/documents/filesystemSource.test.ts b/packages/hunk/src/app/documents/filesystemSource.test.ts index 469b0dd69..2c765ab18 100644 --- a/packages/hunk/src/app/documents/filesystemSource.test.ts +++ b/packages/hunk/src/app/documents/filesystemSource.test.ts @@ -1,6 +1,16 @@ import { afterEach, describe, expect, test } from "bun:test"; import { existsSync } from "node:fs"; -import { chmod, mkdtemp, mkdir, rename, rm, symlink, writeFile } from "node:fs/promises"; +import { + chmod, + mkdtemp, + mkdir, + opendir, + readFile, + rename, + rm, + symlink, + writeFile, +} from "node:fs/promises"; import { join } from "node:path"; import { devNull, tmpdir } from "node:os"; import { @@ -80,7 +90,14 @@ describe("filesystem documents", () => { expect(await source.read(join(root, "link", "secret"))).toMatchObject({ kind: "unavailable", }); - expect(await source.editablePath?.(join(root, "file-link"))).toBeNull(); + let launched = false; + expect( + await source.edit?.(join(root, "file-link"), async () => { + launched = true; + return null; + }), + ).toBe("No editable regular text file selected."); + expect(launched).toBe(false); const direct = await createFilesystemSource(join(root, "file-link")); expect(direct.root.kind).toBe("symlink"); expect(await direct.read(direct.root.key)).toMatchObject({ reason: "symlink" }); @@ -197,6 +214,125 @@ describe("filesystem documents", () => { abort.abort(); await expect(source.list(root, abort.signal)).rejects.toThrow(); await expect(source.read(root, abort.signal)).rejects.toThrow(); + }, 30_000); + test("edits a private copy, preserves BOM bytes, and saves through the original handle", async () => { + const root = await createTestRoot(); + const file = join(root, "selected.txt"); + const original = Buffer.from("\ufeffbefore\n"); + await writeFile(file, original); + const source = await createFilesystemSource(root); + let copyPath = ""; + expect( + await source.edit?.(file, async (copy) => { + copyPath = copy; + expect(copy).not.toBe(file); + expect(await readFile(copy)).toEqual(original); + return null; + }), + ).toBeNull(); + expect(await readFile(file)).toEqual(original); + expect(existsSync(copyPath)).toBe(false); + expect( + await source.edit?.(file, async (copy) => { + await writeFile(copy, "after\n"); + return null; + }), + ).toBeNull(); + expect(await readFile(file, "utf8")).toBe("after\n"); + }); + test("refuses editor writeback after atomic replacement and retains the edited copy", async () => { + const root = await createTestRoot(); + const file = join(root, "selected.txt"); + await writeFile(file, "before"); + const source = await createFilesystemSource(root); + let copyPath = ""; + const result = await source.edit?.(file, async (copy) => { + copyPath = copy; + await writeFile(copy, "edited"); + const replacement = join(root, "replacement"); + await writeFile(replacement, "replacement"); + await rename(replacement, file); + return null; + }); + // Register retained recovery directories for test cleanup as well. + roots.push(join(copyPath, "..")); + expect(result).toContain("Document changed while editing"); + expect(result).toContain("Editor copy retained"); + expect(await readFile(file, "utf8")).toBe("replacement"); + expect(await readFile(copyPath, "utf8")).toBe("edited"); + }); + test.skipIf(process.platform === "win32")( + "never passes a collection path to the editor after a symlink swap", + async () => { + const root = await createTestRoot(); + const outside = await createTestRoot(); + const file = join(root, "selected.txt"); + const secret = join(outside, "secret.txt"); + await writeFile(file, "before"); + await writeFile(secret, "outside"); + const source = await createFilesystemSource(root); + let copyPath = ""; + const result = await source.edit?.(file, async (copy) => { + copyPath = copy; + await rename(file, join(root, "original.txt")); + await symlink(secret, file); + await writeFile(copy, "edited"); + return null; + }); + roots.push(join(copyPath, "..")); + expect(result).toContain("Document changed while editing"); + expect(await readFile(secret, "utf8")).toBe("outside"); + expect(await readFile(join(root, "original.txt"), "utf8")).toBe("before"); + }, + ); + test.skipIf(process.platform !== "linux")( + "binds enumeration to the checked directory during a swap and restore", + async () => { + const root = await createTestRoot(); + const outside = await createTestRoot(); + const directory = join(root, "nested"); + const retained = join(root, "retained"); + await mkdir(directory); + await writeFile(join(directory, "inside.txt"), "inside"); + await writeFile(join(outside, "secret.txt"), "outside"); + const source = await createFilesystemSource(root, { + async openDirectory(path) { + await rename(directory, retained); + await symlink(outside, directory, "dir"); + const opened = await opendir(path); + await rm(directory); + await rename(retained, directory); + return opened; + }, + }); + const listing = await source.list(directory); + expect(listing.kind).toBe("entries"); + if (listing.kind === "entries") + expect(listing.entries.map((entry) => entry.name)).toEqual(["inside.txt"]); + }, + ); + test("rejects incomplete ignore output when the query times out or fails", async () => { + const root = await createTestRoot(); + expect(Bun.spawnSync(["git", "init", "--quiet"], { cwd: root }).exitCode).toBe(0); + await writeFile(join(root, "ignored.log"), "ignored"); + await writeFile(join(root, "another.log"), "ignored"); + for (const script of [ + 'process.stdout.write("partial\\0"); setTimeout(() => {}, 10000);', + 'process.stdout.write("partial\\0"); process.exit(2);', + ]) { + const source = await createFilesystemSource(root, { + spawn: ((...args: Parameters) => { + const [command, options] = args; + if (Array.isArray(command) && command.includes("check-ignore")) + return Bun.spawn([process.execPath, "-e", script], options); + throw new Error("Unexpected subprocess."); + }) as typeof Bun.spawn, + }); + expect(await source.list(root)).toMatchObject({ + kind: "unavailable", + detail: "Git ignore query did not complete; refresh to retry.", + }); + } }); test("observes atomic replacement and releases subscriptions", async () => { const root = await createTestRoot(); diff --git a/packages/hunk/src/app/documents/filesystemSource.ts b/packages/hunk/src/app/documents/filesystemSource.ts index 892842093..7a5a3ba3a 100644 --- a/packages/hunk/src/app/documents/filesystemSource.ts +++ b/packages/hunk/src/app/documents/filesystemSource.ts @@ -1,8 +1,8 @@ import { constants, lstatSync, watch } from "node:fs"; -import { lstat, open, opendir, realpath } from "node:fs/promises"; +import { lstat, mkdtemp, open, opendir, realpath, rm, writeFile } from "node:fs/promises"; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { createHash } from "node:crypto"; -import { devNull } from "node:os"; +import { devNull, tmpdir } from "node:os"; import { execFile } from "node:child_process"; import type { DirectoryReadResult, @@ -73,7 +73,16 @@ function unavailable(error: unknown): DocumentReadResult & { kind: "unavailable" } /** Open a bounded filesystem collection; directory expansion never scans descendants. */ -export async function createFilesystemSource(inputPath: string): Promise { +export async function createFilesystemSource( + inputPath: string, + { + openDirectory = opendir, + spawn = Bun.spawn, + }: { + openDirectory?: typeof opendir; + spawn?: typeof Bun.spawn; + } = {}, +): Promise { const input = resolve(inputPath); const initial = await lstat(input); // Canonicalize the parent, not the final entry: explicitly opened symlinks retain their placeholder. @@ -82,6 +91,7 @@ export async function createFilesystemSource(inputPath: string): Promise { signal?.throwIfAborted(); @@ -132,7 +182,7 @@ export async function createFilesystemSource(inputPath: string): Promise entry.key); // Git supplies its own ignore semantics, including tracked files, nested rules and global excludes. signal?.throwIfAborted(); - const process = Bun.spawn( + const process = spawn( [ "git", "--no-optional-locks", @@ -241,13 +316,28 @@ export async function createFilesystemSource(inputPath: string): Promise process.kill(); + let interrupted = false; + const abort = () => { + interrupted = true; + process.kill(); + }; signal?.addEventListener("abort", abort, { once: true }); const timeout = setTimeout(abort, 1500); let ignored: Set; try { - ignored = new Set((await new Response(process.stdout).text()).split("\0")); - await process.exited; + const output = await new Response(process.stdout).text(); + const exitCode = await process.exited; + if (interrupted || (exitCode !== 0 && exitCode !== 1)) + return { + kind: "unavailable", + detail: "Git ignore query did not complete; refresh to retry.", + }; + ignored = new Set( + output + .split("\0") + .filter(Boolean) + .map((path) => resolve(path)), + ); } finally { clearTimeout(timeout); signal?.removeEventListener("abort", abort); @@ -323,12 +413,100 @@ export async function createFilesystemSource(inputPath: string): Promise> | undefined; + let keepCopy = false; try { await safePath(key); - return entryKind(await lstat(key)) === "file" ? key : null; - } catch { + const info = await lstat(key); + if (!info.isFile() || info.isSymbolicLink() || info.size > DOCUMENT_MAX_BYTES) + return "No editable regular text file selected."; + handle = await openCheckedPath( + key, + constants.O_RDWR | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0), + ); + const opened = await handle.stat(); + if (!opened.isFile() || opened.dev !== info.dev || opened.ino !== info.ino) + throw new Error("Document changed during opening."); + await safePath(key); + const original = await read(key); + if (original.kind !== "text") return original.detail; + // Editors may replace their target atomically or defer opening it. Give them a private + // copy, never the checked collection path; write back through the retained file handle. + temporary = await mkdtemp(join(tmpdir(), "hunk-edit-")); + const copy = join(temporary, basename(key)); + const snapshot = Buffer.alloc(DOCUMENT_MAX_BYTES + 1); + let snapshotSize = 0; + while (snapshotSize < snapshot.length) { + const result = await handle.read( + snapshot, + snapshotSize, + snapshot.length - snapshotSize, + snapshotSize, + ); + if (!result.bytesRead) break; + snapshotSize += result.bytesRead; + } + const originalBytes = snapshot.subarray(0, snapshotSize); + if (createHash("sha256").update(originalBytes).digest("hex") !== original.identity) + throw new Error("Document changed while preparing editor copy."); + await writeFile(copy, originalBytes, { mode: 0o600 }); + keepCopy = true; + const failure = await launch(copy); + if (failure) throw new Error(failure); + const edited = await open( + copy, + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0), + ); + let bytes: Buffer; + try { + if (!(await edited.stat()).isFile()) + throw new Error("Editor output is not a regular file."); + bytes = Buffer.alloc(DOCUMENT_MAX_BYTES + 1); + let count = 0; + while (count < bytes.length) { + const result = await edited.read(bytes, count, bytes.length - count, count); + if (!result.bytesRead) break; + count += result.bytesRead; + } + bytes = bytes.subarray(0, count); + if (count > DOCUMENT_MAX_BYTES || bytes.includes(0)) + throw new Error("Editor output exceeds the text viewing policy."); + new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } finally { + await edited.close(); + } + const current = await read(key); + const currentInfo = await lstat(key); + if ( + current.kind !== "text" || + current.identity !== original.identity || + currentInfo.isSymbolicLink() || + currentInfo.dev !== opened.dev || + currentInfo.ino !== opened.ino + ) + throw new Error("Document changed while editing; original was not overwritten."); + if (createHash("sha256").update(bytes).digest("hex") === original.identity) { + keepCopy = false; + return null; + } + // Never reopen the collection path for writing after the editor returns. + let count = 0; + while (count < bytes.length) { + const result = await handle.write(bytes, count, bytes.length - count, count); + if (!result.bytesWritten) throw new Error("Cannot save editor output."); + count += result.bytesWritten; + } + await handle.truncate(bytes.length); + keepCopy = false; return null; + } catch (error) { + const detail = error instanceof Error ? error.message : "Cannot edit this entry."; + return keepCopy ? `${detail} Editor copy retained in ${temporary}.` : detail; + } finally { + await handle?.close(); + if (temporary && !keepCopy) await rm(temporary, { recursive: true, force: true }); } }, observe(keys, onChange) { diff --git a/packages/hunk/src/core/documents/source.ts b/packages/hunk/src/core/documents/source.ts index d207c007b..82d2527a2 100644 --- a/packages/hunk/src/core/documents/source.ts +++ b/packages/hunk/src/core/documents/source.ts @@ -30,6 +30,6 @@ export interface DocumentSource { list(key: string, signal?: AbortSignal): Promise; /** Observe only demanded entries; disposing releases every watcher owned by this subscription. */ observe?(keys: readonly string[], onChange: () => void): () => void; - /** Resolve an explicitly selected regular document for an external editor, not arbitrary paths. */ - editablePath?(key: string): Promise; + /** Edit a private copy and save through the source-owned handle, never a checked path string. */ + edit?(key: string, launch: (copyPath: string) => Promise): Promise; } diff --git a/packages/hunk/src/ui/documents/DocumentApp.test.tsx b/packages/hunk/src/ui/documents/DocumentApp.test.tsx index a9eb492b5..cde1bc07f 100644 --- a/packages/hunk/src/ui/documents/DocumentApp.test.tsx +++ b/packages/hunk/src/ui/documents/DocumentApp.test.tsx @@ -74,6 +74,16 @@ test("the session host mounts documents, honors remaps and acknowledges external await setup.renderOnce(); expect(setup.captureCharFrame()).toContain("const completeDocument = 42;"); expect(setup.captureCharFrame()).not.toContain("document:opaque"); + const edit = mock(async (_key: string) => "Source-owned editor capability invoked."); + route.controller.source.edit = edit; + await act(async () => { + setup.mockInput.pressKey("e"); + await Bun.sleep(20); + }); + await setup.renderOnce(); + expect(edit).toHaveBeenCalledTimes(1); + expect(edit.mock.calls[0]?.[0]).toBe("document:opaque"); + expect(setup.captureCharFrame()).toContain("Source-owned editor capability"); await act(async () => setup.mockInput.pressTab()); await act(async () => setup.mockInput.pressArrow("down")); await setup.renderOnce(); diff --git a/packages/hunk/src/ui/documents/DocumentApp.tsx b/packages/hunk/src/ui/documents/DocumentApp.tsx index 312342247..5b65c9329 100644 --- a/packages/hunk/src/ui/documents/DocumentApp.tsx +++ b/packages/hunk/src/ui/documents/DocumentApp.tsx @@ -80,13 +80,15 @@ export function DocumentApp({ /** Delegate selected documents to an external editor without borrowing review file types. */ const edit = async () => { const key = controller.getSnapshot().documentKey; - const path = key ? await controller.source.editablePath?.(key) : null; - if (renderer.isDestroyed || controller.isClosed) return; - if (!path) { + if (!key || !controller.source.edit || renderer.isDestroyed || controller.isClosed) { setNotice("No editable regular file selected."); return; } - setNotice(openFileInEditor({ filePath: path, line: visibleLineRef.current(), renderer })); + setNotice( + await controller.source.edit(key, async (filePath) => + openFileInEditor({ filePath, line: visibleLineRef.current(), renderer, wait: true }), + ), + ); await controller.refresh(); }; diff --git a/packages/hunk/src/ui/lib/openInEditor.test.ts b/packages/hunk/src/ui/lib/openInEditor.test.ts index 9baee9469..27db7fcb3 100644 --- a/packages/hunk/src/ui/lib/openInEditor.test.ts +++ b/packages/hunk/src/ui/lib/openInEditor.test.ts @@ -5,6 +5,7 @@ import { join, resolve } from "node:path"; import { createTestDiffFile } from "../../../../../test/helpers/diff-helpers"; import { buildEditorCommand, + openFileInEditor, openSelectedFileInEditor, resolveEditableFilePath, shouldSuspendForEditor, @@ -478,6 +479,21 @@ describe("open in editor helpers", () => { expect(renderer.resume).not.toHaveBeenCalled(); }); + test("waits for GUI editors before releasing a document copy", () => { + const filePath = join(createTempDir(), "example.ts"); + writeFileSync(filePath, "before"); + process.env.EDITOR = "code"; + const spawnCalls: string[][] = []; + mockSpawnSync((cmds) => { + spawnCalls.push(cmds); + return { exitCode: 0 }; + }); + expect( + openFileInEditor({ filePath, line: 3, renderer: createRenderer(), wait: true }), + ).toBeNull(); + expect(spawnCalls).toEqual([["code", "--wait", "--goto", `${filePath}:3`]]); + }); + test("resumes after spawn failures and reports launch errors", () => { 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 ac4989003..12ce1cfaa 100644 --- a/packages/hunk/src/ui/lib/openInEditor.ts +++ b/packages/hunk/src/ui/lib/openInEditor.ts @@ -169,23 +169,27 @@ export function openSelectedFileInEditor({ }); } -/** Open an explicitly authorized document path without requiring a review or hunk model. */ +/** Launch the user-selected editor without requiring a review or hunk model. */ export function openFileInEditor({ filePath, displayPath = filePath, line, renderer, + wait = false, }: { filePath: string; displayPath?: string; line: number; renderer: Pick; + wait?: boolean; }) { if (renderer.isDestroyed) return "The terminal session has closed."; const editor = process.env.EDITOR?.trim(); if (!editor) return "$EDITOR is not set."; if (!existsSync(filePath)) return `Cannot edit ${displayPath}: file does not exist on disk.`; const command = buildEditorCommand({ editor, filePath, line }); + if (wait && [...CODE_STYLE_EDITORS, "zed", "zeditor"].includes(editorProgram(editor))) + command.args.unshift("--wait"); const shouldSuspend = shouldSuspendForEditor(editor); if (shouldSuspend) { diff --git a/test/pty/open-integration.test.ts b/test/pty/open-integration.test.ts index d2da111ec..41160fbc5 100644 --- a/test/pty/open-integration.test.ts +++ b/test/pty/open-integration.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { createPtyHarness } from "./harness"; @@ -32,6 +32,35 @@ function createOpenTestFixture() { } describe("hunk open", () => { + test("edits through a private copy and refreshes the saved complete document", async () => { + const root = createOpenTestFixture(); + const path = join(root, "README.md"); + const editor = join(root, "editor.mjs"); + const target = join(root, "editor-target.txt"); + writeFileSync( + editor, + `import { writeFileSync } from "node:fs"; + writeFileSync(${JSON.stringify(target)}, process.argv[2]); + writeFileSync(process.argv[2], "Saved document from editor\\n");`, + ); + const session = await harness.launchHunk({ + args: ["open", path], + cols: 100, + rows: 20, + env: { EDITOR: `"${process.execPath}" "${editor}"` }, + }); + try { + await session.waitForText("Whole document"); + await harness.ensureKeyboardIsLive(session); + await session.press("e"); + await session.waitForText("Saved document from editor"); + expect(readFileSync(target, "utf8")).not.toBe(path); + expect(readFileSync(path, "utf8")).toBe("Saved document from editor\n"); + await session.press("q"); + } finally { + session.close(); + } + }); test("wraps complete documents with exact geometry and preserves line navigation across toggles", async () => { const root = createOpenTestFixture(); const path = join(root, "wrapped.txt"); From b6656bcf28cbcfed5a1d0e06296a6a21a50a69aa Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 6 Oct 2026 10:13:42 -0400 Subject: [PATCH 03/22] test(open): canonicalize temporary collection paths on Windows --- packages/hunk/src/app/documents/filesystemSource.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/hunk/src/app/documents/filesystemSource.test.ts b/packages/hunk/src/app/documents/filesystemSource.test.ts index 2c765ab18..db804c75b 100644 --- a/packages/hunk/src/app/documents/filesystemSource.test.ts +++ b/packages/hunk/src/app/documents/filesystemSource.test.ts @@ -6,6 +6,7 @@ import { mkdir, opendir, readFile, + realpath, rename, rm, symlink, @@ -22,7 +23,8 @@ import { const roots: string[] = []; /** Create one isolated filesystem collection. */ async function createTestRoot() { - const root = await mkdtemp(join(tmpdir(), "hunk-documents-")); + // Windows temp paths may use 8.3 aliases; source keys use canonical paths on every platform. + const root = await realpath(await mkdtemp(join(tmpdir(), "hunk-documents-"))); roots.push(root); return root; } From 49b08258dcee2ed340b37a10106c9ef5ccc5984b Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 6 Oct 2026 10:14:37 -0400 Subject: [PATCH 04/22] fix(open): preserve cancellation when an ignore query is interrupted --- .../src/app/documents/filesystemSource.test.ts | 18 ++++++++++++++++++ .../hunk/src/app/documents/filesystemSource.ts | 1 + 2 files changed, 19 insertions(+) diff --git a/packages/hunk/src/app/documents/filesystemSource.test.ts b/packages/hunk/src/app/documents/filesystemSource.test.ts index db804c75b..e7b9787eb 100644 --- a/packages/hunk/src/app/documents/filesystemSource.test.ts +++ b/packages/hunk/src/app/documents/filesystemSource.test.ts @@ -336,6 +336,24 @@ describe("filesystem documents", () => { }); } }); + test("cancels an in-flight ignore query instead of returning a partial directory", async () => { + const root = await createTestRoot(); + expect(Bun.spawnSync(["git", "init", "--quiet"], { cwd: root }).exitCode).toBe(0); + await writeFile(join(root, "ignored.log"), "ignored"); + const abort = new AbortController(); + let launched = false; + const source = await createFilesystemSource(root, { + spawn: ((...args: Parameters) => { + const [, options] = args; + launched = true; + const child = Bun.spawn([process.execPath, "-e", "setTimeout(() => {}, 10000);"], options); + setTimeout(() => abort.abort(), 50); + return child; + }) as typeof Bun.spawn, + }); + await expect(source.list(root, abort.signal)).rejects.toThrow(); + expect(launched).toBe(true); + }); test("observes atomic replacement and releases subscriptions", async () => { const root = await createTestRoot(); const file = join(root, "selected.txt"); diff --git a/packages/hunk/src/app/documents/filesystemSource.ts b/packages/hunk/src/app/documents/filesystemSource.ts index 7a5a3ba3a..00c81a80f 100644 --- a/packages/hunk/src/app/documents/filesystemSource.ts +++ b/packages/hunk/src/app/documents/filesystemSource.ts @@ -327,6 +327,7 @@ export async function createFilesystemSource( try { const output = await new Response(process.stdout).text(); const exitCode = await process.exited; + signal?.throwIfAborted(); if (interrupted || (exitCode !== 0 && exitCode !== 1)) return { kind: "unavailable", From dad40e3ee9baa36417268ab3ba4bdc76c45a98cc Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 6 Oct 2026 10:22:29 -0400 Subject: [PATCH 05/22] test(open): cover Windows handle pinning during editor conflicts --- .../app/documents/filesystemSource.test.ts | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/hunk/src/app/documents/filesystemSource.test.ts b/packages/hunk/src/app/documents/filesystemSource.test.ts index e7b9787eb..e74028cd1 100644 --- a/packages/hunk/src/app/documents/filesystemSource.test.ts +++ b/packages/hunk/src/app/documents/filesystemSource.test.ts @@ -242,25 +242,47 @@ describe("filesystem documents", () => { ).toBeNull(); expect(await readFile(file, "utf8")).toBe("after\n"); }); - test("refuses editor writeback after atomic replacement and retains the edited copy", async () => { + test("refuses editor writeback after attempted atomic replacement and retains the edited copy", async () => { const root = await createTestRoot(); const file = join(root, "selected.txt"); await writeFile(file, "before"); const source = await createFilesystemSource(root); let copyPath = ""; + let replaced = false; const result = await source.edit?.(file, async (copy) => { copyPath = copy; await writeFile(copy, "edited"); const replacement = join(root, "replacement"); await writeFile(replacement, "replacement"); + // Windows Bun currently pins writable handles against rename (EPERM). A rejected + // editor operation must still preserve its private copy and leave the original intact. await rename(replacement, file); + replaced = true; return null; }); // Register retained recovery directories for test cleanup as well. roots.push(join(copyPath, "..")); - expect(result).toContain("Document changed while editing"); + if (!replaced) expect(process.platform).toBe("win32"); + expect(result).toContain(replaced ? "Document changed while editing" : "EPERM"); expect(result).toContain("Editor copy retained"); - expect(await readFile(file, "utf8")).toBe("replacement"); + expect(await readFile(file, "utf8")).toBe(replaced ? "replacement" : "before"); + expect(await readFile(copyPath, "utf8")).toBe("edited"); + }); + test("rejects concurrent in-place changes without overwriting either the original or editor copy", async () => { + const root = await createTestRoot(); + const file = join(root, "selected.txt"); + await writeFile(file, "before"); + const source = await createFilesystemSource(root); + let copyPath = ""; + const result = await source.edit?.(file, async (copy) => { + copyPath = copy; + await writeFile(copy, "edited"); + await writeFile(file, "concurrent"); + return null; + }); + roots.push(join(copyPath, "..")); + expect(result).toContain("Document changed while editing"); + expect(await readFile(file, "utf8")).toBe("concurrent"); expect(await readFile(copyPath, "utf8")).toBe("edited"); }); test.skipIf(process.platform === "win32")( From 4e2490999279606b426cf44f35a21c59f339bb55 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 6 Oct 2026 10:47:34 -0400 Subject: [PATCH 06/22] refactor(open): split document workflows and bound function complexity --- .changeset/focused-document-workflows.md | 2 + .oxlintrc.json | 14 +- .../src/app/documents/documentBytes.test.ts | 77 ++++ .../hunk/src/app/documents/documentBytes.ts | 58 +++ .../hunk/src/app/documents/editDocument.ts | 104 +++++ .../src/app/documents/filesystemSource.ts | 391 +++--------------- .../src/app/documents/gitMetadata.test.ts | 26 ++ .../hunk/src/app/documents/gitMetadata.ts | 166 ++++++++ .../src/ui/documents/DocumentApp.test.tsx | 16 + .../hunk/src/ui/documents/DocumentApp.tsx | 264 +++--------- .../hunk/src/ui/documents/DocumentContent.tsx | 111 +++++ .../hunk/src/ui/documents/commands.test.ts | 127 ++++++ packages/hunk/src/ui/documents/commands.ts | 118 ++++++ .../src/ui/documents/useDocumentKeyboard.ts | 64 +++ 14 files changed, 1012 insertions(+), 526 deletions(-) create mode 100644 .changeset/focused-document-workflows.md create mode 100644 packages/hunk/src/app/documents/documentBytes.test.ts create mode 100644 packages/hunk/src/app/documents/documentBytes.ts create mode 100644 packages/hunk/src/app/documents/editDocument.ts create mode 100644 packages/hunk/src/app/documents/gitMetadata.test.ts create mode 100644 packages/hunk/src/app/documents/gitMetadata.ts create mode 100644 packages/hunk/src/ui/documents/DocumentContent.tsx create mode 100644 packages/hunk/src/ui/documents/commands.test.ts create mode 100644 packages/hunk/src/ui/documents/commands.ts create mode 100644 packages/hunk/src/ui/documents/useDocumentKeyboard.ts diff --git a/.changeset/focused-document-workflows.md b/.changeset/focused-document-workflows.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/focused-document-workflows.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.oxlintrc.json b/.oxlintrc.json index 5645b172f..3e91fe7ff 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,5 +1,17 @@ { "rules": { "no-control-regex": "off" - } + }, + "overrides": [ + { + "files": [ + "packages/hunk/src/app/documents/*.ts", + "packages/hunk/src/ui/documents/*.ts", + "packages/hunk/src/ui/documents/*.tsx" + ], + "rules": { + "complexity": ["error", { "max": 20 }] + } + } + ] } diff --git a/packages/hunk/src/app/documents/documentBytes.test.ts b/packages/hunk/src/app/documents/documentBytes.test.ts new file mode 100644 index 000000000..27b825efc --- /dev/null +++ b/packages/hunk/src/app/documents/documentBytes.test.ts @@ -0,0 +1,77 @@ +import { expect, mock, test } from "bun:test"; +import { mkdtemp, open, rm, writeFile, type FileHandle } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + DOCUMENT_MAX_BYTES, + decodeDocumentBytes, + documentBytesIdentity, + readBoundedDocumentBytes, + writeDocumentBytes, +} from "./documentBytes"; + +test("bounded byte reads retain one overflow byte and honor cancellation", async () => { + const root = await mkdtemp(join(tmpdir(), "hunk-bytes-test-")); + const path = join(root, "file.txt"); + try { + await writeFile(path, Buffer.alloc(DOCUMENT_MAX_BYTES + 128, 65)); + const handle = await open(path, "r"); + try { + const bytes = await readBoundedDocumentBytes(handle); + expect(bytes.length).toBe(DOCUMENT_MAX_BYTES + 1); + expect(decodeDocumentBytes(bytes)).toMatchObject({ + kind: "unavailable", + reason: "too-large", + }); + const abort = new AbortController(); + abort.abort(); + await expect(readBoundedDocumentBytes(handle, abort.signal)).rejects.toThrow(); + } finally { + await handle.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("text decoding preserves the raw-byte identity while refusing binary and invalid UTF-8", () => { + const bytes = Buffer.from("\ufeffcomplete\r\n"); + expect(decodeDocumentBytes(bytes)).toEqual({ + kind: "text", + text: "complete\r\n", + identity: documentBytesIdentity(bytes), + }); + expect(documentBytesIdentity(bytes)).not.toBe(documentBytesIdentity(Buffer.from("complete\r\n"))); + expect(decodeDocumentBytes(Buffer.from([0]))).toMatchObject({ + kind: "unavailable", + reason: "binary", + }); + expect(decodeDocumentBytes(Buffer.from([255]))).toMatchObject({ + kind: "unavailable", + reason: "binary", + }); +}); + +/** Create a deliberately partial writer to exercise retained-handle save ordering. */ +function createTestWriteHandle(stall = false) { + const writes: Buffer[] = []; + const truncate = mock(async (_size: number) => undefined); + const write = mock(async (buffer: Buffer, offset: number, length: number) => { + const bytesWritten = stall ? 0 : Math.min(length, 2); + writes.push(Buffer.from(buffer.subarray(offset, offset + bytesWritten))); + return { bytesWritten, buffer }; + }); + return { handle: { write, truncate } as unknown as FileHandle, writes, truncate }; +} + +test("retained-handle saves finish partial writes before truncation and reject a stalled writer", async () => { + const writer = createTestWriteHandle(); + await writeDocumentBytes(writer.handle, Buffer.from("saved")); + expect(Buffer.concat(writer.writes).toString()).toBe("saved"); + expect(writer.truncate).toHaveBeenCalledWith(5); + const stalled = createTestWriteHandle(true); + await expect(writeDocumentBytes(stalled.handle, Buffer.from("saved"))).rejects.toThrow( + "Cannot save editor output.", + ); + expect(stalled.truncate).not.toHaveBeenCalled(); +}); diff --git a/packages/hunk/src/app/documents/documentBytes.ts b/packages/hunk/src/app/documents/documentBytes.ts new file mode 100644 index 000000000..33cb65e95 --- /dev/null +++ b/packages/hunk/src/app/documents/documentBytes.ts @@ -0,0 +1,58 @@ +import { createHash } from "node:crypto"; +import type { FileHandle } from "node:fs/promises"; +import type { DocumentReadResult } from "../../core/documents/source"; + +export const DOCUMENT_MAX_BYTES = 1024 * 1024; + +/** Read at most the viewing limit plus one byte, including files that grow while being read. */ +export async function readBoundedDocumentBytes(handle: FileHandle, signal?: AbortSignal) { + const buffer = Buffer.alloc(DOCUMENT_MAX_BYTES + 1); + let count = 0; + while (count < buffer.length) { + signal?.throwIfAborted(); + const result = await handle.read(buffer, count, buffer.length - count, count); + if (!result.bytesRead) break; + count += result.bytesRead; + } + signal?.throwIfAborted(); + return buffer.subarray(0, count); +} + +/** Hash original bytes without normalizing newlines or removing a UTF-8 BOM. */ +export function documentBytesIdentity(bytes: Buffer) { + return createHash("sha256").update(bytes).digest("hex"); +} + +/** Decode bounded UTF-8 document bytes or explain why the viewer cannot display them. */ +export function decodeDocumentBytes(bytes: Buffer): DocumentReadResult { + if (bytes.length > DOCUMENT_MAX_BYTES) + return { + kind: "unavailable", + reason: "too-large", + detail: "File exceeds the 1 MiB viewing limit.", + }; + if (bytes.includes(0)) + return { + kind: "unavailable", + reason: "binary", + detail: "Binary file — no text preview.", + }; + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + return { kind: "unavailable", reason: "binary", detail: "Non-UTF-8 file — no text preview." }; + } + return { kind: "text", text, identity: documentBytesIdentity(bytes) }; +} + +/** Save through a retained handle and truncate only after every edited byte has been written. */ +export async function writeDocumentBytes(handle: FileHandle, bytes: Buffer) { + let count = 0; + while (count < bytes.length) { + const result = await handle.write(bytes, count, bytes.length - count, count); + if (!result.bytesWritten) throw new Error("Cannot save editor output."); + count += result.bytesWritten; + } + await handle.truncate(bytes.length); +} diff --git a/packages/hunk/src/app/documents/editDocument.ts b/packages/hunk/src/app/documents/editDocument.ts new file mode 100644 index 000000000..768ed0a8d --- /dev/null +++ b/packages/hunk/src/app/documents/editDocument.ts @@ -0,0 +1,104 @@ +import { constants, type Stats } from "node:fs"; +import { lstat, mkdtemp, open, rm, writeFile, type FileHandle } from "node:fs/promises"; +import { basename, join } from "node:path"; +import { tmpdir } from "node:os"; +import type { DocumentSource } from "../../core/documents/source"; +import { + DOCUMENT_MAX_BYTES, + documentBytesIdentity, + readBoundedDocumentBytes, + writeDocumentBytes, +} from "./documentBytes"; + +interface DocumentEditIO { + safePath: (key: string) => Promise; + openCheckedPath: (key: string, flags: number) => Promise; + read: DocumentSource["read"]; +} + +/** Read only bounded regular UTF-8 output from an editor-owned copy. */ +async function readEditorOutput(copy: string) { + const edited = await open( + copy, + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0), + ); + try { + if (!(await edited.stat()).isFile()) throw new Error("Editor output is not a regular file."); + const bytes = await readBoundedDocumentBytes(edited); + if (bytes.length > DOCUMENT_MAX_BYTES || bytes.includes(0)) + throw new Error("Editor output exceeds the text viewing policy."); + new TextDecoder("utf-8", { fatal: true }).decode(bytes); + return bytes; + } finally { + await edited.close(); + } +} + +/** Refuse writeback when either the selected path's identity or its original bytes changed. */ +async function assertOriginalUnchanged( + key: string, + opened: Stats, + identity: string, + read: DocumentSource["read"], +) { + const current = await read(key); + const currentInfo = await lstat(key); + if ( + current.kind !== "text" || + current.identity !== identity || + currentInfo.isSymbolicLink() || + currentInfo.dev !== opened.dev || + currentInfo.ino !== opened.ino + ) + throw new Error("Document changed while editing; original was not overwritten."); +} + +/** Launch an editor on a private copy, then save through the retained checked original handle. + * Never pass or reopen the collection path for editing. Keep recovery copies after launch errors, + * invalid editor output, conflicts or write failures; close every handle on all exits. + */ +export async function editDocumentCopy( + key: string, + launch: (copy: string) => Promise, + io: DocumentEditIO, +) { + let temporary: string | undefined; + let handle: FileHandle | undefined; + let keepCopy = false; + try { + await io.safePath(key); + const info = await lstat(key); + if (!info.isFile() || info.isSymbolicLink() || info.size > DOCUMENT_MAX_BYTES) + return "No editable regular text file selected."; + handle = await io.openCheckedPath( + key, + constants.O_RDWR | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0), + ); + const opened = await handle.stat(); + if (!opened.isFile() || opened.dev !== info.dev || opened.ino !== info.ino) + throw new Error("Document changed during opening."); + await io.safePath(key); + const original = await io.read(key); + if (original.kind !== "text") return original.detail; + temporary = await mkdtemp(join(tmpdir(), "hunk-edit-")); + const copy = join(temporary, basename(key)); + const originalBytes = await readBoundedDocumentBytes(handle); + if (documentBytesIdentity(originalBytes) !== original.identity) + throw new Error("Document changed while preparing editor copy."); + await writeFile(copy, originalBytes, { mode: 0o600 }); + keepCopy = true; + const failure = await launch(copy); + if (failure) throw new Error(failure); + const bytes = await readEditorOutput(copy); + await assertOriginalUnchanged(key, opened, original.identity, io.read); + if (documentBytesIdentity(bytes) !== original.identity) await writeDocumentBytes(handle, bytes); + keepCopy = false; + return null; + } catch (error) { + const detail = error instanceof Error ? error.message : "Cannot edit this entry."; + return keepCopy ? `${detail} Editor copy retained in ${temporary}.` : detail; + } finally { + await handle?.close(); + if (temporary && !keepCopy) await rm(temporary, { recursive: true, force: true }); + } +} diff --git a/packages/hunk/src/app/documents/filesystemSource.ts b/packages/hunk/src/app/documents/filesystemSource.ts index 00c81a80f..a3f5e8a99 100644 --- a/packages/hunk/src/app/documents/filesystemSource.ts +++ b/packages/hunk/src/app/documents/filesystemSource.ts @@ -1,52 +1,19 @@ import { constants, lstatSync, watch } from "node:fs"; -import { lstat, mkdtemp, open, opendir, realpath, rm, writeFile } from "node:fs/promises"; +import { lstat, open, opendir, realpath } from "node:fs/promises"; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; -import { createHash } from "node:crypto"; -import { devNull, tmpdir } from "node:os"; -import { execFile } from "node:child_process"; import type { DirectoryReadResult, DocumentEntry, DocumentReadResult, DocumentSource, } from "../../core/documents/source"; +import { DOCUMENT_MAX_BYTES, decodeDocumentBytes, readBoundedDocumentBytes } from "./documentBytes"; +import { editDocumentCopy } from "./editDocument"; +import { applyDirectoryGitMetadata } from "./gitMetadata"; -export const DOCUMENT_MAX_BYTES = 1024 * 1024; +export { DOCUMENT_MAX_BYTES } from "./documentBytes"; export const DIRECTORY_MAX_ENTRIES = 10_000; -/** Prevent metadata inspection from fetching missing objects or launching transport helpers. */ -function gitQueryEnvironment() { - return { ...process.env, GIT_NO_LAZY_FETCH: "1", GIT_ALLOW_PROTOCOL: "" }; -} - -/** Run optional Git metadata queries without blocking the renderer or executing repository hooks. */ -function git(cwd: string, args: string[], signal?: AbortSignal): Promise { - return new Promise((done) => { - execFile( - "git", - [ - "--no-pager", - "--no-optional-locks", - "-c", - "core.fsmonitor=false", - "-c", - `core.hooksPath=${devNull}`, - ...args, - ], - { - cwd, - signal, - encoding: "utf8", - timeout: 1500, - maxBuffer: 2 * 1024 * 1024, - env: gitQueryEnvironment(), - }, - (error, output) => - done(error && !(error.code === 1 && args.includes("--get-regexp")) ? null : output), - ); - }); -} - /** Classify entries without following symlinks or opening devices. */ function entryKind(info: { isDirectory(): boolean; @@ -72,7 +39,10 @@ function unavailable(error: unknown): DocumentReadResult & { kind: "unavailable" }; } -/** Open a bounded filesystem collection; directory expansion never scans descendants. */ +/** Open a bounded filesystem collection for selection, expansion, editing and watch refreshes. + * Own path confinement and retained-handle opens; byte policies, editor copies and optional Git + * metadata live in focused neighbors. Directory expansion never scans descendants. + */ export async function createFilesystemSource( inputPath: string, { @@ -197,39 +167,7 @@ export async function createFilesystemSource( reason: "special", detail: "This entry is not a regular file.", }; - const buffer = Buffer.alloc(DOCUMENT_MAX_BYTES + 1); - let count = 0; - while (count < buffer.length) { - signal?.throwIfAborted(); - const result = await handle.read(buffer, count, buffer.length - count, count); - if (!result.bytesRead) break; - count += result.bytesRead; - } - signal?.throwIfAborted(); - if (count > DOCUMENT_MAX_BYTES) - return { - kind: "unavailable", - reason: "too-large", - detail: "File exceeds the 1 MiB viewing limit.", - }; - const bytes = buffer.subarray(0, count); - if (bytes.includes(0)) - return { - kind: "unavailable", - reason: "binary", - detail: "Binary file — no text preview.", - }; - let text: string; - try { - text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); - } catch { - return { - kind: "unavailable", - reason: "binary", - detail: "Non-UTF-8 file — no text preview.", - }; - } - return { kind: "text", text, identity: createHash("sha256").update(bytes).digest("hex") }; + return decodeDocumentBytes(await readBoundedDocumentBytes(handle, signal)); } finally { await handle.close(); } @@ -239,164 +177,65 @@ export async function createFilesystemSource( } } - /** List one directory and attach optional Git visibility/status metadata for its immediate children. */ - async function list(key: string, signal?: AbortSignal): Promise { - signal?.throwIfAborted(); + /** Enumerate a checked directory without releasing its Linux handle before validation finishes. */ + async function enumerate(key: string, signal?: AbortSignal): Promise { + await safePath(key); + const info = await lstat(key); + if (!info.isDirectory() || info.isSymbolicLink()) + return { + kind: "unavailable", + detail: "This directory is unavailable; symlinks are not followed.", + }; + const entries: DocumentEntry[] = []; + // Portable Node APIs cannot bind opendir to a directory descriptor on other platforms. + const handle = + process.platform === "linux" + ? await openCheckedPath( + key, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, + ) + : null; try { - await safePath(key); - const info = await lstat(key); - if (!info.isDirectory() || info.isSymbolicLink()) - return { - kind: "unavailable", - detail: "This directory is unavailable; symlinks are not followed.", - }; - const entries: DocumentEntry[] = []; - // Linux exposes a handle-bound directory path. Other platforms validate the path again - // before publishing; portable Node APIs cannot bind opendir to a directory descriptor. - const handle = - process.platform === "linux" - ? await openCheckedPath( - key, - constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, - ) - : null; - try { - if (handle) { - const opened = await handle.stat(); - if (opened.dev !== info.dev || opened.ino !== info.ino) - throw new Error("Directory changed during opening."); - } - await safePath(key); - for await (const child of await openDirectory( - handle ? `/proc/self/fd/${handle.fd}` : key, - )) { - signal?.throwIfAborted(); - if (entries.length === DIRECTORY_MAX_ENTRIES) - return { - kind: "unavailable", - detail: "Directory exceeds the 10,000-entry browsing limit.", - }; - entries.push({ - key: join(key, child.name), - name: child.name, - displayPath: relative(base, join(key, child.name)), - kind: entryKind(child), - hidden: child.name.startsWith("."), - }); - } - await safePath(key); - const current = await lstat(key); - if (current.isSymbolicLink() || current.dev !== info.dev || current.ino !== info.ino) - throw new Error("Directory changed during enumeration."); - } finally { - await handle?.close(); + if (handle) { + const opened = await handle.stat(); + if (opened.dev !== info.dev || opened.ino !== info.ino) + throw new Error("Directory changed during opening."); } - const repository = (await git(key, ["rev-parse", "--show-toplevel"], signal))?.trim(); - if (repository && entries.length) { - const paths = entries.map((entry) => entry.key); - // Git supplies its own ignore semantics, including tracked files, nested rules and global excludes. + await safePath(key); + for await (const child of await openDirectory(handle ? `/proc/self/fd/${handle.fd}` : key)) { signal?.throwIfAborted(); - const process = spawn( - [ - "git", - "--no-optional-locks", - "-c", - "core.fsmonitor=false", - "-c", - `core.hooksPath=${devNull}`, - "check-ignore", - "-z", - "--stdin", - ], - { - cwd: key, - env: gitQueryEnvironment(), - stdin: Buffer.from(paths.join("\0") + "\0"), - stdout: "pipe", - stderr: "ignore", - }, - ); - let interrupted = false; - const abort = () => { - interrupted = true; - process.kill(); - }; - signal?.addEventListener("abort", abort, { once: true }); - const timeout = setTimeout(abort, 1500); - let ignored: Set; - try { - const output = await new Response(process.stdout).text(); - const exitCode = await process.exited; - signal?.throwIfAborted(); - if (interrupted || (exitCode !== 0 && exitCode !== 1)) - return { - kind: "unavailable", - detail: "Git ignore query did not complete; refresh to retry.", - }; - ignored = new Set( - output - .split("\0") - .filter(Boolean) - .map((path) => resolve(path)), - ); - } finally { - clearTimeout(timeout); - signal?.removeEventListener("abort", abort); - } - // Status can otherwise invoke repository-configured clean/process filters. Browsing never - // grants that execution authority; disable every declared filter before querying metadata. - const filterKeys = await git( - repository, - [ - "config", - "--null", - "--name-only", - "--get-regexp", - "^filter\\..*\\.(clean|process|required)$", - ], - signal, - ); - const names = filterKeys?.split("\0").filter(Boolean) ?? []; - const safeFilterConfig = - filterKeys !== null && - names.every((name) => /^filter\.[^=\r\n]*\.(clean|process|required)$/.test(name)); - const filterOverrides = names.flatMap((name) => [ - "-c", - `${name}=${name.endsWith(".required") ? "false" : ""}`, - ]); - const status = safeFilterConfig - ? await git( - repository, - [ - ...filterOverrides, - "status", - "--porcelain=v1", - "-z", - "--untracked-files=normal", - "--ignore-submodules=all", - "--", - key, - ], - signal, - ) - : null; - const markers = new Map(); - const records = status?.split("\0") ?? []; - for (let index = 0; index < records.length; index++) { - const record = records[index]!; - if (record.length < 4) continue; - const code = record.slice(0, 2); - const changed = resolve(repository, record.slice(3)); - const child = relative(key, changed).split(sep)[0]; - if (child && child !== "..") - markers.set(join(key, child), code === "??" ? "?" : code.trim().slice(0, 1)); - if (code.includes("R") || code.includes("C")) index++; - } - for (const entry of entries) { - entry.ignored = ignored.has(entry.key); - entry.status = markers.get(entry.key); - } + if (entries.length === DIRECTORY_MAX_ENTRIES) + return { + kind: "unavailable", + detail: "Directory exceeds the 10,000-entry browsing limit.", + }; + entries.push({ + key: join(key, child.name), + name: child.name, + displayPath: relative(base, join(key, child.name)), + kind: entryKind(child), + hidden: child.name.startsWith("."), + }); } + await safePath(key); + const current = await lstat(key); + if (current.isSymbolicLink() || current.dev !== info.dev || current.ino !== info.ino) + throw new Error("Directory changed during enumeration."); + return { kind: "entries", entries }; + } finally { + await handle?.close(); + } + } + + /** List one bounded directory and attach optional metadata before publishing sorted children. */ + async function list(key: string, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + try { + const result = await enumerate(key, signal); + if (result.kind !== "entries") return result; + const entries = [...result.entries]; + const failure = await applyDirectoryGitMetadata(key, entries, spawn, signal); + if (failure) return failure; signal?.throwIfAborted(); entries.sort( (a, b) => @@ -414,102 +253,7 @@ export async function createFilesystemSource( root, read, list, - async edit(key, launch) { - let temporary: string | undefined; - let handle: Awaited> | undefined; - let keepCopy = false; - try { - await safePath(key); - const info = await lstat(key); - if (!info.isFile() || info.isSymbolicLink() || info.size > DOCUMENT_MAX_BYTES) - return "No editable regular text file selected."; - handle = await openCheckedPath( - key, - constants.O_RDWR | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0), - ); - const opened = await handle.stat(); - if (!opened.isFile() || opened.dev !== info.dev || opened.ino !== info.ino) - throw new Error("Document changed during opening."); - await safePath(key); - const original = await read(key); - if (original.kind !== "text") return original.detail; - // Editors may replace their target atomically or defer opening it. Give them a private - // copy, never the checked collection path; write back through the retained file handle. - temporary = await mkdtemp(join(tmpdir(), "hunk-edit-")); - const copy = join(temporary, basename(key)); - const snapshot = Buffer.alloc(DOCUMENT_MAX_BYTES + 1); - let snapshotSize = 0; - while (snapshotSize < snapshot.length) { - const result = await handle.read( - snapshot, - snapshotSize, - snapshot.length - snapshotSize, - snapshotSize, - ); - if (!result.bytesRead) break; - snapshotSize += result.bytesRead; - } - const originalBytes = snapshot.subarray(0, snapshotSize); - if (createHash("sha256").update(originalBytes).digest("hex") !== original.identity) - throw new Error("Document changed while preparing editor copy."); - await writeFile(copy, originalBytes, { mode: 0o600 }); - keepCopy = true; - const failure = await launch(copy); - if (failure) throw new Error(failure); - const edited = await open( - copy, - constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0), - ); - let bytes: Buffer; - try { - if (!(await edited.stat()).isFile()) - throw new Error("Editor output is not a regular file."); - bytes = Buffer.alloc(DOCUMENT_MAX_BYTES + 1); - let count = 0; - while (count < bytes.length) { - const result = await edited.read(bytes, count, bytes.length - count, count); - if (!result.bytesRead) break; - count += result.bytesRead; - } - bytes = bytes.subarray(0, count); - if (count > DOCUMENT_MAX_BYTES || bytes.includes(0)) - throw new Error("Editor output exceeds the text viewing policy."); - new TextDecoder("utf-8", { fatal: true }).decode(bytes); - } finally { - await edited.close(); - } - const current = await read(key); - const currentInfo = await lstat(key); - if ( - current.kind !== "text" || - current.identity !== original.identity || - currentInfo.isSymbolicLink() || - currentInfo.dev !== opened.dev || - currentInfo.ino !== opened.ino - ) - throw new Error("Document changed while editing; original was not overwritten."); - if (createHash("sha256").update(bytes).digest("hex") === original.identity) { - keepCopy = false; - return null; - } - // Never reopen the collection path for writing after the editor returns. - let count = 0; - while (count < bytes.length) { - const result = await handle.write(bytes, count, bytes.length - count, count); - if (!result.bytesWritten) throw new Error("Cannot save editor output."); - count += result.bytesWritten; - } - await handle.truncate(bytes.length); - keepCopy = false; - return null; - } catch (error) { - const detail = error instanceof Error ? error.message : "Cannot edit this entry."; - return keepCopy ? `${detail} Editor copy retained in ${temporary}.` : detail; - } finally { - await handle?.close(); - if (temporary && !keepCopy) await rm(temporary, { recursive: true, force: true }); - } - }, + edit: (key, launch) => editDocumentCopy(key, launch, { safePath, openCheckedPath, read }), observe(keys, onChange) { let timer: ReturnType | undefined; const changed = () => { @@ -517,8 +261,7 @@ export async function createFilesystemSource( timer = setTimeout(onChange, 100); }; const watchers: ReturnType[] = []; - // Directory watches cover their immediate children. File and symlink watches use only the - // parent, preserving atomic replacements without ever observing a symlink target. + // Parent watches preserve atomic replacement observation without following file symlinks. const targets = new Set(); for (const key of keys) { targets.add(dirname(key)); diff --git a/packages/hunk/src/app/documents/gitMetadata.test.ts b/packages/hunk/src/app/documents/gitMetadata.test.ts new file mode 100644 index 000000000..0a0178293 --- /dev/null +++ b/packages/hunk/src/app/documents/gitMetadata.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { directoryStatusMarkers } from "./gitMetadata"; + +describe("directory status markers", () => { + test("groups nested changes into immediate children and skips rename source records", () => { + const repository = join(tmpdir(), "hunk-status-test"); + const key = join(repository, "selected"); + const markers = directoryStatusMarkers( + repository, + key, + "R selected/new name.ts\0selected/old name.ts\0 M selected/nested/file.ts\0?? selected/untracked.ts\0 M elsewhere/file.ts\0\0", + ); + expect([...markers]).toEqual([ + [join(key, "new name.ts"), "R"], + [join(key, "nested"), "M"], + [join(key, "untracked.ts"), "?"], + ]); + expect(markers.has(join(key, "old name.ts"))).toBe(false); + }); + test("omits status when metadata is unavailable", () => { + const repository = join(tmpdir(), "hunk-status-test"); + expect(directoryStatusMarkers(repository, repository, null).size).toBe(0); + }); +}); diff --git a/packages/hunk/src/app/documents/gitMetadata.ts b/packages/hunk/src/app/documents/gitMetadata.ts new file mode 100644 index 000000000..c3a344c06 --- /dev/null +++ b/packages/hunk/src/app/documents/gitMetadata.ts @@ -0,0 +1,166 @@ +import { execFile } from "node:child_process"; +import { devNull } from "node:os"; +import { join, relative, resolve, sep } from "node:path"; +import type { DocumentEntry } from "../../core/documents/source"; + +/** Prevent metadata inspection from fetching objects or launching transport helpers. */ +function gitQueryEnvironment() { + return { ...process.env, GIT_NO_LAZY_FETCH: "1", GIT_ALLOW_PROTOCOL: "" }; +} + +/** Run optional metadata queries with bounded output and repository hooks disabled. */ +function git(cwd: string, args: string[], signal?: AbortSignal): Promise { + return new Promise((done) => { + execFile( + "git", + [ + "--no-pager", + "--no-optional-locks", + "-c", + "core.fsmonitor=false", + "-c", + `core.hooksPath=${devNull}`, + ...args, + ], + { + cwd, + signal, + encoding: "utf8", + timeout: 1500, + maxBuffer: 2 * 1024 * 1024, + env: gitQueryEnvironment(), + }, + (error, output) => + done(error && !(error.code === 1 && args.includes("--get-regexp")) ? null : output), + ); + }); +} + +/** Accept Git ignore results only after normal completion; interruption never grants visibility. */ +async function readIgnoredPaths( + key: string, + entries: readonly DocumentEntry[], + spawn: typeof Bun.spawn, + signal?: AbortSignal, +) { + signal?.throwIfAborted(); + const process = spawn( + [ + "git", + "--no-optional-locks", + "-c", + "core.fsmonitor=false", + "-c", + `core.hooksPath=${devNull}`, + "check-ignore", + "-z", + "--stdin", + ], + { + cwd: key, + env: gitQueryEnvironment(), + stdin: Buffer.from(entries.map((entry) => entry.key).join("\0") + "\0"), + stdout: "pipe", + stderr: "ignore", + }, + ); + let interrupted = false; + const abort = () => { + interrupted = true; + process.kill(); + }; + signal?.addEventListener("abort", abort, { once: true }); + const timeout = setTimeout(abort, 1500); + try { + const output = await new Response(process.stdout).text(); + const exitCode = await process.exited; + signal?.throwIfAborted(); + if (interrupted || (exitCode !== 0 && exitCode !== 1)) return null; + return new Set( + output + .split("\0") + .filter(Boolean) + .map((path) => resolve(path)), + ); + } finally { + clearTimeout(timeout); + signal?.removeEventListener("abort", abort); + } +} + +/** Disable declared filters before querying status; malformed or failed config queries omit status. */ +async function readStatus(repository: string, key: string, signal?: AbortSignal) { + const filterKeys = await git( + repository, + ["config", "--null", "--name-only", "--get-regexp", "^filter\\..*\\.(clean|process|required)$"], + signal, + ); + const names = filterKeys?.split("\0").filter(Boolean) ?? []; + if ( + filterKeys === null || + !names.every((name) => /^filter\.[^=\r\n]*\.(clean|process|required)$/.test(name)) + ) + return null; + const overrides = names.flatMap((name) => [ + "-c", + `${name}=${name.endsWith(".required") ? "false" : ""}`, + ]); + return git( + repository, + [ + ...overrides, + "status", + "--porcelain=v1", + "-z", + "--untracked-files=normal", + "--ignore-submodules=all", + "--", + key, + ], + signal, + ); +} + +/** Map NUL-delimited Git status records to immediate children, skipping rename-source records. */ +export function directoryStatusMarkers(repository: string, key: string, status: string | null) { + const markers = new Map(); + const records = status?.split("\0") ?? []; + for (let index = 0; index < records.length; index++) { + const record = records[index]!; + if (record.length < 4) continue; + const code = record.slice(0, 2); + const changed = resolve(repository, record.slice(3)); + const child = relative(key, changed).split(sep)[0]; + if (child && child !== "..") + markers.set(join(key, child), code === "??" ? "?" : code.trim().slice(0, 1)); + if (code.includes("R") || code.includes("C")) index++; + } + return markers; +} + +/** Attach optional status and complete ignore metadata to an already bounded directory listing. */ +export async function applyDirectoryGitMetadata( + key: string, + entries: DocumentEntry[], + spawn: typeof Bun.spawn, + signal?: AbortSignal, +) { + const repository = (await git(key, ["rev-parse", "--show-toplevel"], signal))?.trim(); + if (!repository || !entries.length) return null; + const ignored = await readIgnoredPaths(key, entries, spawn, signal); + if (ignored === null) + return { + kind: "unavailable" as const, + detail: "Git ignore query did not complete; refresh to retry.", + }; + const markers = directoryStatusMarkers( + repository, + key, + await readStatus(repository, key, signal), + ); + for (const entry of entries) { + entry.ignored = ignored.has(entry.key); + entry.status = markers.get(entry.key); + } + return null; +} diff --git a/packages/hunk/src/ui/documents/DocumentApp.test.tsx b/packages/hunk/src/ui/documents/DocumentApp.test.tsx index cde1bc07f..a2a864145 100644 --- a/packages/hunk/src/ui/documents/DocumentApp.test.tsx +++ b/packages/hunk/src/ui/documents/DocumentApp.test.tsx @@ -84,6 +84,22 @@ test("the session host mounts documents, honors remaps and acknowledges external expect(edit).toHaveBeenCalledTimes(1); expect(edit.mock.calls[0]?.[0]).toBe("document:opaque"); expect(setup.captureCharFrame()).toContain("Source-owned editor capability"); + await act(async () => setup.mockInput.pressKey("?")); + await setup.renderOnce(); + expect(setup.captureCharFrame()).toContain("Documents"); + await act(async () => { + setup.mockInput.pressKey("e"); + setup.mockInput.pressKey("n"); + await Bun.sleep(20); + }); + expect(edit).toHaveBeenCalledTimes(1); + expect(route.controller.getSnapshot().selectedKey).toBe("document:opaque"); + await act(async () => { + setup.mockInput.pressEscape(); + await Bun.sleep(50); + }); + await setup.renderOnce(); + expect(setup.captureCharFrame()).not.toContain("Controls help"); await act(async () => setup.mockInput.pressTab()); await act(async () => setup.mockInput.pressArrow("down")); await setup.renderOnce(); diff --git a/packages/hunk/src/ui/documents/DocumentApp.tsx b/packages/hunk/src/ui/documents/DocumentApp.tsx index 5b65c9329..085022355 100644 --- a/packages/hunk/src/ui/documents/DocumentApp.tsx +++ b/packages/hunk/src/ui/documents/DocumentApp.tsx @@ -1,4 +1,4 @@ -import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"; +import { useRenderer, useTerminalDimensions } from "@opentui/react"; import { useMemo, useRef, useState, useSyncExternalStore } from "react"; import type { ScrollBoxRenderable } from "@opentui/core"; import type { CommonOptions } from "../../core/run/commandInputs"; @@ -8,24 +8,23 @@ import { MenuBar } from "../components/chrome/MenuBar"; import { MenuDropdown } from "../components/chrome/MenuDropdown"; import { HelpDialog } from "../components/chrome/HelpDialog"; import { ThemeSelectorDialog } from "../components/chrome/ThemeSelectorDialog"; -import type { AppMenus, MenuEntry } from "../components/chrome/menu"; import { useMenuController } from "../hooks/useMenuController"; import { useThemeSelectorController } from "../hooks/useThemeSelectorController"; import { useTimedNotice } from "../hooks/useTimedNotice"; -import { dispatchAppCommand, executeAppCommand } from "../lib/appCommands"; import { APP_COMMAND_NAMES } from "../../core/run/commandCatalog"; import { HISTORY_COMMAND_NAMES } from "../../core/run/historyCommandCatalog"; import { DOCUMENT_COMMAND_CATALOG } from "../../core/run/documentCommandCatalog"; import { resolveCommandKeys } from "../lib/keymap"; import { openFileInEditor } from "../lib/openInEditor"; -import { fitText } from "../lib/text"; import type { ThemeController } from "../theme/controller"; -import { buildSurfaceCommands } from "../session/commands"; -import { DocumentPane } from "./DocumentPane"; -import { DocumentTree } from "./DocumentTree"; +import { DocumentContent, DocumentStatusBar } from "./DocumentContent"; +import { buildDocumentCommands, buildDocumentMenus } from "./commands"; +import { useDocumentKeyboard } from "./useDocumentKeyboard"; import type { DocumentBrowserController } from "./controller"; -/** Coordinate a document surface using shared session chrome, keymaps and theme ownership. */ +/** Coordinate document navigation, editor launches and shared chrome without owning source I/O. + * Keep tree selection independent from the displayed document and preserve the mounted scroll refs. + */ export function DocumentApp({ controller, themeController, @@ -91,145 +90,37 @@ export function DocumentApp({ ); await controller.refresh(); }; - - /** Apply navigation to the focused surface, preserving the document's independent selection. */ - const step = (delta: number) => { - if (treeFocused) controller.move(delta); - else scrollRef.current?.scrollBy(delta); - }; - const commands = buildSurfaceCommands(DOCUMENT_COMMAND_CATALOG, { + const commands = buildDocumentCommands({ + controller, + treeFocused, + height, + scrollRef, + edit: () => { + void edit(); + }, resolvedKeys: keys.keys, - run(entry) { - const current = controller.getSnapshot(); - const selected = current.rows.find((row) => row.entry.key === current.selectedKey)?.entry; - switch (entry.id) { - case "hunk.app.quit": - onQuit(); - break; - case "hunk.app.refresh": - void controller.refresh(); - break; - case "hunk.app.toggleFocusArea": - setFocus((focus) => (focus === "tree" ? "document" : "tree")); - break; - case "hunk.app.toggleHelp": - setHelp((help) => !help); - break; - case "hunk.view.openThemeSelector": - themeSelector.openThemeSelector(); - break; - case "hunk.view.toggleFilesPane": - setSidebar((value) => !value); - break; - case "hunk.view.toggleLineNumbers": - setNumbers((value) => !value); - break; - case "hunk.view.toggleLineWrap": - setWrap((value) => !value); - break; - case "hunk.documents.toggleExcluded": - controller.toggleExcluded(); - break; - case "hunk.documents.activate": - if (selected) void controller.activate(selected.key); - break; - case "hunk.documents.edit": - void edit(); - break; - case "hunk.documents.stepDown": - step(1); - break; - case "hunk.documents.stepUp": - step(-1); - break; - case "hunk.documents.pageDown": - step(height - 1); - break; - case "hunk.documents.pageUp": - step(1 - height); - break; - case "hunk.documents.jumpToTop": - if (treeFocused) controller.move(-current.rows.length); - else scrollRef.current?.scrollTo(0); - break; - case "hunk.documents.jumpToBottom": - if (treeFocused) controller.move(current.rows.length); - else scrollRef.current?.scrollTo(scrollRef.current.scrollHeight); - break; - case "hunk.documents.scrollLeft": - if (treeFocused && selected?.kind === "directory" && current.expanded.has(selected.key)) - void controller.activate(selected.key); - else if (!treeFocused) scrollRef.current?.scrollBy({ x: -4, y: 0 }); - break; - case "hunk.documents.scrollRight": - if (treeFocused && selected?.kind === "directory" && !current.expanded.has(selected.key)) - void controller.activate(selected.key); - else if (!treeFocused) scrollRef.current?.scrollBy({ x: 4, y: 0 }); - break; - } + actions: { + "hunk.app.quit": onQuit, + "hunk.app.refresh": () => { + void controller.refresh(); + }, + "hunk.app.toggleFocusArea": () => + setFocus((focus) => (focus === "tree" ? "document" : "tree")), + "hunk.app.toggleHelp": () => setHelp((help) => !help), + "hunk.view.openThemeSelector": themeSelector.openThemeSelector, + "hunk.view.toggleFilesPane": () => setSidebar((value) => !value), + "hunk.view.toggleLineNumbers": () => setNumbers((value) => !value), + "hunk.view.toggleLineWrap": () => setWrap((value) => !value), }, }); - const menuItem = (id: string, checked?: boolean): MenuEntry => { - const command = commands.find((command) => command.id === id)!; - return { - kind: "item", - label: command.title, - commandId: id, - hint: command.keyLabels.join(" / "), - checked, - action: () => { - executeAppCommand(commands, id); - }, - }; - }; - const menus: AppMenus = { - file: [ - menuItem("hunk.app.refresh"), - menuItem("hunk.documents.edit"), - menuItem("hunk.app.quit"), - ], - view: [ - menuItem("hunk.view.openThemeSelector"), - menuItem("hunk.view.toggleFilesPane", sidebar), - menuItem("hunk.view.toggleLineNumbers", numbers), - menuItem("hunk.view.toggleLineWrap", wrap), - menuItem("hunk.documents.toggleExcluded", snapshot.showExcluded), - ], - help: [menuItem("hunk.app.toggleHelp")], - }; - const menu = useMenuController(menus); - useKeyboard((key) => { - if (help) { - if (key.name === "escape" || key.sequence === "?") setHelp(false); - key.preventDefault(); - return; - } - if (themeSelector.themeSelectorOpen) { - if (key.name === "escape") themeSelector.closeThemeSelector(); - else if (key.name === "up") themeSelector.moveThemeSelector(-1); - else if (key.name === "down") themeSelector.moveThemeSelector(1); - else if (key.name === "return") themeSelector.acceptThemeSelector(); - key.preventDefault(); - return; - } - if (key.name === "f10") { - if (menu.activeMenuId) menu.closeMenu(); - else menu.openMenu("file"); - key.preventDefault(); - return; - } - if (menu.getActiveMenuId()) { - if (key.name === "escape") menu.closeMenu(); - else if (key.name === "left") menu.switchMenu(-1); - else if (key.name === "right") menu.switchMenu(1); - else if (key.name === "up") menu.moveMenuItem(-1); - else if (key.name === "down") menu.moveMenuItem(1); - else if (key.name === "return") menu.activateCurrentMenuItem(); - key.preventDefault(); - return; - } - dispatchAppCommand(commands, key); + const menus = buildDocumentMenus(commands, { + sidebar, + numbers, + wrap, + showExcluded: snapshot.showExcluded, }); + const menu = useMenuController(menus); + useDocumentKeyboard({ help, closeHelp: () => setHelp(false), themeSelector, menu, commands }); const overlay = help || themeSelector.themeSelectorOpen || menu.activeMenuId !== null; return ( @@ -244,64 +135,35 @@ export function DocumentApp({ }} onToggleMenu={menu.toggleMenu} /> - - {sidebar ? ( - setFocus("tree")} - > - { - setFocus("tree"); - void controller.activate(key); - }} - /> - - ) : null} - {sidebar ? : null} - setFocus("document")} - > - - {fitText( - ` ${sanitizeTerminalLine(snapshot.documentEntry?.displayPath ?? snapshot.documentEntry?.name ?? "Complete document")}`, - Math.max(1, terminal.width - treeWidth - 1), - )} - - - - - - {fitText( - ` ${sanitizeTerminalLine(notice.text ?? snapshot.notice ?? keys.issues[0]?.message ?? `${treeFocused ? "Tree" : "Document"} · read-only · ${snapshot.showExcluded ? "hidden/ignored shown" : "hidden/ignored hidden"}`)}`, - terminal.width, - )} - + { + void controller.activate(key); + }} + /> + {menu.activeMenuId && menu.activeMenuSpec ? ( , + "document" | "documentKey" | "pathHint" | "width" | "focused" + >; + onFocus: (focus: "tree" | "document") => void; + onActivate: (key: string) => void; +}) { + const { theme, height } = pane; + return ( + + {sidebar ? ( + onFocus("tree")} + > + { + onFocus("tree"); + onActivate(key); + }} + /> + + ) : null} + {sidebar ? : null} + onFocus("document")} + > + + {fitText( + ` ${sanitizeTerminalLine(documentHint(snapshot, "Complete document"))}`, + Math.max(1, terminalWidth - treeWidth - 1), + )} + + + + + ); +} + +/** Show transient notices before source notices, keymap issues and the focused-surface summary. */ +export function DocumentStatusBar({ + snapshot, + notice, + keymapIssue, + treeFocused, + width, + theme, +}: { + snapshot: DocumentBrowserSnapshot; + notice: string | null; + keymapIssue: string | undefined; + treeFocused: boolean; + width: number; + theme: AppTheme; +}) { + const fallback = `${treeFocused ? "Tree" : "Document"} · read-only · ${snapshot.showExcluded ? "hidden/ignored shown" : "hidden/ignored hidden"}`; + const text = notice ?? snapshot.notice ?? keymapIssue ?? fallback; + return ( + + {fitText(` ${sanitizeTerminalLine(text)}`, width)} + + ); +} diff --git a/packages/hunk/src/ui/documents/commands.test.ts b/packages/hunk/src/ui/documents/commands.test.ts new file mode 100644 index 000000000..b8b0e8be4 --- /dev/null +++ b/packages/hunk/src/ui/documents/commands.test.ts @@ -0,0 +1,127 @@ +import { expect, mock, test } from "bun:test"; +import type { DocumentSource } from "../../core/documents/source"; +import { resolveCommandKeys } from "../lib/keymap"; +import { executeAppCommand } from "../lib/appCommands"; +import { DOCUMENT_COMMAND_CATALOG } from "../../core/run/documentCommandCatalog"; +import { DocumentBrowserController } from "./controller"; +import { buildDocumentCommands, buildDocumentMenus } from "./commands"; + +/** Create document commands backed by a real browser controller with opaque source keys. */ +async function createTestDocumentCommands(treeFocused: boolean) { + const source: DocumentSource = { + root: { key: "root", name: "root", kind: "directory", hidden: false }, + async list(key) { + if (key !== "root") return { kind: "entries", entries: [] }; + return { + kind: "entries", + entries: [ + { key: "nested", name: "nested", kind: "directory", hidden: false }, + { key: "file", name: "file", kind: "file", hidden: false }, + ], + }; + }, + async read() { + return { kind: "text", text: "complete", identity: "stable" }; + }, + }; + const controller = new DocumentBrowserController(source); + await controller.initialize(); + const scrollBy = mock((_delta: unknown) => undefined); + const scrollTo = mock((_position: unknown) => undefined); + const edit = mock(() => undefined); + const keys = resolveCommandKeys({ + defaults: DOCUMENT_COMMAND_CATALOG, + userBindings: { "hunk.documents.stepDown": "n" }, + }); + const commands = buildDocumentCommands({ + controller, + treeFocused, + height: 12, + scrollRef: { current: { scrollBy, scrollTo, scrollHeight: 100 } }, + edit, + resolvedKeys: keys.keys, + actions: { + "hunk.app.quit": () => {}, + "hunk.app.refresh": () => {}, + "hunk.app.toggleFocusArea": () => {}, + "hunk.app.toggleHelp": () => {}, + "hunk.view.openThemeSelector": () => {}, + "hunk.view.toggleFilesPane": () => {}, + "hunk.view.toggleLineNumbers": () => {}, + "hunk.view.toggleLineWrap": () => {}, + }, + }); + return { controller, commands, scrollBy, scrollTo, edit }; +} + +test("document commands scroll by physical rows and keep tree selection independent", async () => { + const setup = await createTestDocumentCommands(false); + try { + for (const id of ["stepDown", "stepUp", "pageDown", "pageUp", "scrollLeft", "scrollRight"]) + executeAppCommand(setup.commands, `hunk.documents.${id}`); + expect(setup.scrollBy.mock.calls.map(([delta]) => delta)).toEqual([ + 1, + -1, + 11, + -11, + { x: -4, y: 0 }, + { x: 4, y: 0 }, + ]); + executeAppCommand(setup.commands, "hunk.documents.jumpToTop"); + executeAppCommand(setup.commands, "hunk.documents.jumpToBottom"); + expect(setup.scrollTo.mock.calls.map(([position]) => position)).toEqual([0, 100]); + expect(setup.controller.getSnapshot().selectedKey).toBe("root"); + } finally { + await setup.controller.close(); + } +}); + +test("tree commands use live selection and only toggle directories needing expansion changes", async () => { + const setup = await createTestDocumentCommands(true); + try { + await setup.controller.select("nested"); + executeAppCommand(setup.commands, "hunk.documents.scrollRight"); + expect(setup.controller.getSnapshot().expanded.has("nested")).toBe(true); + executeAppCommand(setup.commands, "hunk.documents.scrollRight"); + expect(setup.controller.getSnapshot().expanded.has("nested")).toBe(true); + executeAppCommand(setup.commands, "hunk.documents.scrollLeft"); + expect(setup.controller.getSnapshot().expanded.has("nested")).toBe(false); + await setup.controller.select("file"); + executeAppCommand(setup.commands, "hunk.documents.scrollRight"); + expect(setup.controller.getSnapshot().expanded.has("file")).toBe(false); + executeAppCommand(setup.commands, "hunk.documents.jumpToTop"); + expect(setup.controller.getSnapshot().selectedKey).toBe("root"); + executeAppCommand(setup.commands, "hunk.documents.jumpToBottom"); + expect(setup.controller.getSnapshot().selectedKey).toBe("file"); + expect(setup.scrollBy).not.toHaveBeenCalled(); + } finally { + await setup.controller.close(); + } +}); + +test("document menus share effective key labels and command handlers", async () => { + const setup = await createTestDocumentCommands(false); + try { + const menus = buildDocumentMenus(setup.commands, { + sidebar: true, + numbers: false, + wrap: true, + showExcluded: true, + }); + const edit = menus.file.find( + (entry) => entry.kind === "item" && entry.commandId === "hunk.documents.edit", + ); + if (edit?.kind !== "item") throw new Error("Missing editor menu action."); + edit.action(); + expect(setup.edit).toHaveBeenCalledTimes(1); + const numbers = menus.view.find( + (entry) => entry.kind === "item" && entry.commandId === "hunk.view.toggleLineNumbers", + ); + expect(numbers).toMatchObject({ checked: false }); + expect( + setup.commands.find((command) => command.id === "hunk.documents.stepDown")?.keyLabels, + ).toEqual(["n"]); + } finally { + await setup.controller.close(); + } +}); diff --git a/packages/hunk/src/ui/documents/commands.ts b/packages/hunk/src/ui/documents/commands.ts new file mode 100644 index 000000000..fd75b5739 --- /dev/null +++ b/packages/hunk/src/ui/documents/commands.ts @@ -0,0 +1,118 @@ +import type { ScrollBoxRenderable } from "@opentui/core"; +import { DOCUMENT_COMMAND_CATALOG } from "../../core/run/documentCommandCatalog"; +import type { AppMenus, MenuEntry } from "../components/chrome/menu"; +import type { AppCommand, ResolvedCommandKeys } from "../lib/appCommands"; +import { executeAppCommand } from "../lib/appCommands"; +import { buildSurfaceCommands } from "../session/commands"; +import type { DocumentBrowserController } from "./controller"; + +type DocumentCommandId = (typeof DOCUMENT_COMMAND_CATALOG)[number]["id"]; +type DocumentChromeCommandId = Exclude; +type DocumentScrollRef = { + current: Pick | null; +}; + +/** Bind document navigation to the current focus and snapshot without branching on command ids. */ +export function buildDocumentCommands({ + controller, + treeFocused, + height, + scrollRef, + edit, + actions, + resolvedKeys, +}: { + controller: DocumentBrowserController; + treeFocused: boolean; + height: number; + scrollRef: DocumentScrollRef; + edit: () => void; + actions: Record void>; + resolvedKeys: ResolvedCommandKeys; +}): AppCommand[] { + /** Read the live selected entry rather than retaining a selection from the render. */ + const selectedEntry = () => { + const current = controller.getSnapshot(); + return current.rows.find((row) => row.entry.key === current.selectedKey)?.entry; + }; + /** Move through the focused tree or document by physical rows. */ + const step = (delta: number) => { + if (treeFocused) controller.move(delta); + else scrollRef.current?.scrollBy(delta); + }; + /** Reveal the beginning or end of the focused surface. */ + const jump = (end: boolean) => { + if (treeFocused) controller.move(controller.getSnapshot().rows.length * (end ? 1 : -1)); + else scrollRef.current?.scrollTo(end ? scrollRef.current.scrollHeight : 0); + }; + /** Expand/collapse a selected directory or scroll the document horizontally. */ + const horizontal = (expand: boolean) => { + const selected = selectedEntry(); + if (!treeFocused) { + scrollRef.current?.scrollBy({ x: expand ? 4 : -4, y: 0 }); + return; + } + if ( + selected?.kind === "directory" && + controller.getSnapshot().expanded.has(selected.key) !== expand + ) + void controller.activate(selected.key); + }; + const handlers: Record void> = { + ...actions, + "hunk.documents.toggleExcluded": () => controller.toggleExcluded(), + "hunk.documents.activate": () => { + const selected = selectedEntry(); + if (selected) void controller.activate(selected.key); + }, + "hunk.documents.edit": edit, + "hunk.documents.stepDown": () => step(1), + "hunk.documents.stepUp": () => step(-1), + "hunk.documents.pageDown": () => step(height - 1), + "hunk.documents.pageUp": () => step(1 - height), + "hunk.documents.jumpToTop": () => jump(false), + "hunk.documents.jumpToBottom": () => jump(true), + "hunk.documents.scrollLeft": () => horizontal(false), + "hunk.documents.scrollRight": () => horizontal(true), + }; + return buildSurfaceCommands(DOCUMENT_COMMAND_CATALOG, { + resolvedKeys, + run: (entry) => handlers[entry.id](), + }); +} + +/** Build menu actions from the same effective commands used by the keyboard. */ +export function buildDocumentMenus( + commands: readonly AppCommand[], + state: { + sidebar: boolean; + numbers: boolean; + wrap: boolean; + showExcluded: boolean; + }, +): Required> { + const item = (id: DocumentCommandId, checked?: boolean): MenuEntry => { + const command = commands.find((command) => command.id === id)!; + return { + kind: "item", + label: command.title, + commandId: id, + hint: command.keyLabels.join(" / "), + checked, + action: () => { + executeAppCommand(commands, id); + }, + }; + }; + return { + file: [item("hunk.app.refresh"), item("hunk.documents.edit"), item("hunk.app.quit")], + view: [ + item("hunk.view.openThemeSelector"), + item("hunk.view.toggleFilesPane", state.sidebar), + item("hunk.view.toggleLineNumbers", state.numbers), + item("hunk.view.toggleLineWrap", state.wrap), + item("hunk.documents.toggleExcluded", state.showExcluded), + ], + help: [item("hunk.app.toggleHelp")], + }; +} diff --git a/packages/hunk/src/ui/documents/useDocumentKeyboard.ts b/packages/hunk/src/ui/documents/useDocumentKeyboard.ts new file mode 100644 index 000000000..372339c56 --- /dev/null +++ b/packages/hunk/src/ui/documents/useDocumentKeyboard.ts @@ -0,0 +1,64 @@ +import { useKeyboard } from "@opentui/react"; +import type { KeyEvent } from "@opentui/core"; +import type { useMenuController } from "../hooks/useMenuController"; +import type { useThemeSelectorController } from "../hooks/useThemeSelectorController"; +import { dispatchAppCommand, type AppCommand } from "../lib/appCommands"; + +type MenuController = ReturnType; +type ThemeSelectorController = ReturnType; + +/** Route theme-selector keys without granting document commands while the selector is open. */ +function handleThemeKey(key: KeyEvent, selector: ThemeSelectorController) { + const actions: Record void> = { + escape: selector.closeThemeSelector, + up: () => selector.moveThemeSelector(-1), + down: () => selector.moveThemeSelector(1), + return: selector.acceptThemeSelector, + }; + actions[key.name]?.(); +} + +/** Route open-menu navigation independently of document focus. */ +function handleMenuKey(key: KeyEvent, menu: MenuController) { + const actions: Record void> = { + escape: menu.closeMenu, + left: () => menu.switchMenu(-1), + right: () => menu.switchMenu(1), + up: () => menu.moveMenuItem(-1), + down: () => menu.moveMenuItem(1), + return: menu.activateCurrentMenuItem, + }; + actions[key.name]?.(); +} + +/** Give help, themes and menus keyboard priority before dispatching document commands. */ +export function useDocumentKeyboard({ + help, + closeHelp, + themeSelector, + menu, + commands, +}: { + help: boolean; + closeHelp: () => void; + themeSelector: ThemeSelectorController; + menu: MenuController; + commands: readonly AppCommand[]; +}) { + useKeyboard((key) => { + if (help) { + if (key.name === "escape" || key.sequence === "?") closeHelp(); + } else if (themeSelector.themeSelectorOpen) { + handleThemeKey(key, themeSelector); + } else if (key.name === "f10") { + if (menu.activeMenuId) menu.closeMenu(); + else menu.openMenu("file"); + } else if (menu.getActiveMenuId()) { + handleMenuKey(key, menu); + } else { + dispatchAppCommand(commands, key); + return; + } + key.preventDefault(); + }); +} From c50c8d95fddb7e7aab8683ecd4a620bdf725ccbc Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 6 Oct 2026 11:06:10 -0400 Subject: [PATCH 07/22] style(open): separate document functions and workflow phases --- .changeset/readable-document-spacing.md | 2 ++ AGENTS.md | 5 +++++ .../hunk/src/app/documents/documentBytes.ts | 7 +++++++ .../hunk/src/app/documents/editDocument.ts | 9 +++++++++ .../hunk/src/app/documents/filesystemSource.ts | 18 ++++++++++++++++++ packages/hunk/src/app/documents/gitMetadata.ts | 13 +++++++++++++ packages/hunk/src/ui/documents/DocumentApp.tsx | 12 ++++++++++++ packages/hunk/src/ui/documents/commands.ts | 8 ++++++++ packages/hunk/src/ui/documents/controller.ts | 16 ++++++++++++++++ .../src/ui/documents/useDocumentKeyboard.ts | 2 ++ 10 files changed, 92 insertions(+) create mode 100644 .changeset/readable-document-spacing.md diff --git a/.changeset/readable-document-spacing.md b/.changeset/readable-document-spacing.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/readable-document-spacing.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/AGENTS.md b/AGENTS.md index 8849a55de..f64cf867c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -152,6 +152,11 @@ ReviewIntent + caller facts -> planReviewIntent -> ReviewAction[] -> reducer -> conformance under `test/session-broker-node/`. Run the dedicated command documented in `test/README.md` when changing those areas. +## source formatting + +- Separate functions, class methods, and nested helpers with a blank line; keep JSDoc attached to its declaration. +- Use blank lines between logical phases inside functions. Keep related statements grouped rather than packing an entire workflow into one dense block. + ## code comments - Add short JSDoc-style comments to functions and helpers. diff --git a/packages/hunk/src/app/documents/documentBytes.ts b/packages/hunk/src/app/documents/documentBytes.ts index 33cb65e95..d1a55cdef 100644 --- a/packages/hunk/src/app/documents/documentBytes.ts +++ b/packages/hunk/src/app/documents/documentBytes.ts @@ -8,12 +8,14 @@ export const DOCUMENT_MAX_BYTES = 1024 * 1024; export async function readBoundedDocumentBytes(handle: FileHandle, signal?: AbortSignal) { const buffer = Buffer.alloc(DOCUMENT_MAX_BYTES + 1); let count = 0; + while (count < buffer.length) { signal?.throwIfAborted(); const result = await handle.read(buffer, count, buffer.length - count, count); if (!result.bytesRead) break; count += result.bytesRead; } + signal?.throwIfAborted(); return buffer.subarray(0, count); } @@ -31,28 +33,33 @@ export function decodeDocumentBytes(bytes: Buffer): DocumentReadResult { reason: "too-large", detail: "File exceeds the 1 MiB viewing limit.", }; + if (bytes.includes(0)) return { kind: "unavailable", reason: "binary", detail: "Binary file — no text preview.", }; + let text: string; try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { return { kind: "unavailable", reason: "binary", detail: "Non-UTF-8 file — no text preview." }; } + return { kind: "text", text, identity: documentBytesIdentity(bytes) }; } /** Save through a retained handle and truncate only after every edited byte has been written. */ export async function writeDocumentBytes(handle: FileHandle, bytes: Buffer) { let count = 0; + while (count < bytes.length) { const result = await handle.write(bytes, count, bytes.length - count, count); if (!result.bytesWritten) throw new Error("Cannot save editor output."); count += result.bytesWritten; } + await handle.truncate(bytes.length); } diff --git a/packages/hunk/src/app/documents/editDocument.ts b/packages/hunk/src/app/documents/editDocument.ts index 768ed0a8d..45d2760a5 100644 --- a/packages/hunk/src/app/documents/editDocument.ts +++ b/packages/hunk/src/app/documents/editDocument.ts @@ -22,8 +22,10 @@ async function readEditorOutput(copy: string) { copy, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0), ); + try { if (!(await edited.stat()).isFile()) throw new Error("Editor output is not a regular file."); + const bytes = await readBoundedDocumentBytes(edited); if (bytes.length > DOCUMENT_MAX_BYTES || bytes.includes(0)) throw new Error("Editor output exceeds the text viewing policy."); @@ -65,11 +67,13 @@ export async function editDocumentCopy( let temporary: string | undefined; let handle: FileHandle | undefined; let keepCopy = false; + try { await io.safePath(key); const info = await lstat(key); if (!info.isFile() || info.isSymbolicLink() || info.size > DOCUMENT_MAX_BYTES) return "No editable regular text file selected."; + handle = await io.openCheckedPath( key, constants.O_RDWR | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0), @@ -77,21 +81,26 @@ export async function editDocumentCopy( const opened = await handle.stat(); if (!opened.isFile() || opened.dev !== info.dev || opened.ino !== info.ino) throw new Error("Document changed during opening."); + await io.safePath(key); const original = await io.read(key); if (original.kind !== "text") return original.detail; + temporary = await mkdtemp(join(tmpdir(), "hunk-edit-")); const copy = join(temporary, basename(key)); const originalBytes = await readBoundedDocumentBytes(handle); if (documentBytesIdentity(originalBytes) !== original.identity) throw new Error("Document changed while preparing editor copy."); await writeFile(copy, originalBytes, { mode: 0o600 }); + keepCopy = true; const failure = await launch(copy); if (failure) throw new Error(failure); + const bytes = await readEditorOutput(copy); await assertOriginalUnchanged(key, opened, original.identity, io.read); if (documentBytesIdentity(bytes) !== original.identity) await writeDocumentBytes(handle, bytes); + keepCopy = false; return null; } catch (error) { diff --git a/packages/hunk/src/app/documents/filesystemSource.ts b/packages/hunk/src/app/documents/filesystemSource.ts index a3f5e8a99..60f954d4b 100644 --- a/packages/hunk/src/app/documents/filesystemSource.ts +++ b/packages/hunk/src/app/documents/filesystemSource.ts @@ -55,6 +55,7 @@ export async function createFilesystemSource( ): Promise { const input = resolve(inputPath); const initial = await lstat(input); + // Canonicalize the parent, not the final entry: explicitly opened symlinks retain their placeholder. const path = initial.isDirectory() && !initial.isSymbolicLink() @@ -76,6 +77,7 @@ export async function createFilesystemSource( if (isAbsolute(rel) || rel === ".." || rel.startsWith(`..${sep}`)) throw new Error("Path escapes document collection."); if (root.kind !== "directory" && key !== root.key) throw new Error("Unknown document."); + const segments = rel.split(sep).filter(Boolean); let parent = base; const baseInfo = await lstat(base); @@ -86,12 +88,14 @@ export async function createFilesystemSource( baseInfo.ino !== baseIdentity.ino ) throw new Error("Collection root changed."); + for (const segment of segments.slice(0, -1)) { parent = join(parent, segment); const info = await lstat(parent); if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("Symlink traversal is unavailable."); } + if (relative(dirname(key), await realpath(dirname(key))) !== "") throw new Error("Collection ancestry changed."); return key; @@ -101,15 +105,18 @@ export async function createFilesystemSource( async function openCheckedPath(key: string, flags: number) { await safePath(key); if (process.platform !== "linux") return open(key, flags); + let parent = await open( base, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, ); let transferred = false; + try { const opened = await parent.stat(); if (opened.dev !== baseIdentity.dev || opened.ino !== baseIdentity.ino) throw new Error("Collection root changed during opening."); + const segments = relative(base, key).split(sep).filter(Boolean); for (const segment of segments.slice(0, -1)) { const child = await open( @@ -119,10 +126,12 @@ export async function createFilesystemSource( await parent.close(); parent = child; } + if (!segments.length) { transferred = true; return parent; } + return await open( `/proc/self/fd/${parent.fd}/${segments.at(-1)}`, flags | constants.O_NOFOLLOW, @@ -152,6 +161,7 @@ export async function createFilesystemSource( reason: "too-large", detail: "File exceeds the 1 MiB viewing limit.", }; + const handle = await openCheckedPath( key, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0), @@ -186,6 +196,7 @@ export async function createFilesystemSource( kind: "unavailable", detail: "This directory is unavailable; symlinks are not followed.", }; + const entries: DocumentEntry[] = []; // Portable Node APIs cannot bind opendir to a directory descriptor on other platforms. const handle = @@ -217,6 +228,7 @@ export async function createFilesystemSource( hidden: child.name.startsWith("."), }); } + await safePath(key); const current = await lstat(key); if (current.isSymbolicLink() || current.dev !== info.dev || current.ino !== info.ino) @@ -233,9 +245,11 @@ export async function createFilesystemSource( try { const result = await enumerate(key, signal); if (result.kind !== "entries") return result; + const entries = [...result.entries]; const failure = await applyDirectoryGitMetadata(key, entries, spawn, signal); if (failure) return failure; + signal?.throwIfAborted(); entries.sort( (a, b) => @@ -254,12 +268,14 @@ export async function createFilesystemSource( read, list, edit: (key, launch) => editDocumentCopy(key, launch, { safePath, openCheckedPath, read }), + observe(keys, onChange) { let timer: ReturnType | undefined; const changed = () => { clearTimeout(timer); timer = setTimeout(onChange, 100); }; + const watchers: ReturnType[] = []; // Parent watches preserve atomic replacement observation without following file symlinks. const targets = new Set(); @@ -272,6 +288,7 @@ export async function createFilesystemSource( /* Missing entries retain parent observation. */ } } + for (const target of targets) { try { const watcher = watch(target, changed); @@ -281,6 +298,7 @@ export async function createFilesystemSource( /* Deleted entries retain their parent's observation. */ } } + return () => { clearTimeout(timer); for (const watcher of watchers) watcher.close(); diff --git a/packages/hunk/src/app/documents/gitMetadata.ts b/packages/hunk/src/app/documents/gitMetadata.ts index c3a344c06..28a52539f 100644 --- a/packages/hunk/src/app/documents/gitMetadata.ts +++ b/packages/hunk/src/app/documents/gitMetadata.ts @@ -44,6 +44,7 @@ async function readIgnoredPaths( signal?: AbortSignal, ) { signal?.throwIfAborted(); + const process = spawn( [ "git", @@ -64,18 +65,23 @@ async function readIgnoredPaths( stderr: "ignore", }, ); + let interrupted = false; + const abort = () => { interrupted = true; process.kill(); }; + signal?.addEventListener("abort", abort, { once: true }); const timeout = setTimeout(abort, 1500); + try { const output = await new Response(process.stdout).text(); const exitCode = await process.exited; signal?.throwIfAborted(); if (interrupted || (exitCode !== 0 && exitCode !== 1)) return null; + return new Set( output .split("\0") @@ -101,6 +107,7 @@ async function readStatus(repository: string, key: string, signal?: AbortSignal) !names.every((name) => /^filter\.[^=\r\n]*\.(clean|process|required)$/.test(name)) ) return null; + const overrides = names.flatMap((name) => [ "-c", `${name}=${name.endsWith(".required") ? "false" : ""}`, @@ -125,9 +132,11 @@ async function readStatus(repository: string, key: string, signal?: AbortSignal) export function directoryStatusMarkers(repository: string, key: string, status: string | null) { const markers = new Map(); const records = status?.split("\0") ?? []; + for (let index = 0; index < records.length; index++) { const record = records[index]!; if (record.length < 4) continue; + const code = record.slice(0, 2); const changed = resolve(repository, record.slice(3)); const child = relative(key, changed).split(sep)[0]; @@ -135,6 +144,7 @@ export function directoryStatusMarkers(repository: string, key: string, status: markers.set(join(key, child), code === "??" ? "?" : code.trim().slice(0, 1)); if (code.includes("R") || code.includes("C")) index++; } + return markers; } @@ -147,12 +157,14 @@ export async function applyDirectoryGitMetadata( ) { const repository = (await git(key, ["rev-parse", "--show-toplevel"], signal))?.trim(); if (!repository || !entries.length) return null; + const ignored = await readIgnoredPaths(key, entries, spawn, signal); if (ignored === null) return { kind: "unavailable" as const, detail: "Git ignore query did not complete; refresh to retry.", }; + const markers = directoryStatusMarkers( repository, key, @@ -162,5 +174,6 @@ export async function applyDirectoryGitMetadata( entry.ignored = ignored.has(entry.key); entry.status = markers.get(entry.key); } + return null; } diff --git a/packages/hunk/src/ui/documents/DocumentApp.tsx b/packages/hunk/src/ui/documents/DocumentApp.tsx index 085022355..33e2fefd1 100644 --- a/packages/hunk/src/ui/documents/DocumentApp.tsx +++ b/packages/hunk/src/ui/documents/DocumentApp.tsx @@ -41,6 +41,7 @@ export function DocumentApp({ const terminal = useTerminalDimensions(); const renderer = useRenderer(); const snapshot = useSyncExternalStore(controller.subscribe, controller.getSnapshot); + const [focus, setFocus] = useState<"tree" | "document">( controller.source.root.kind === "directory" ? "tree" : "document", ); @@ -50,22 +51,28 @@ export function DocumentApp({ const [numbers, setNumbers] = useState(options.lineNumbers !== false); const [wrap, setWrap] = useState(options.wrapLines ?? false); const [help, setHelp] = useState(false); + const notice = useTimedNotice(4000); + const setNotice = (text: string | null) => { if (text === null) notice.clear(); else notice.show(text); }; + const scrollRef = useRef(null); const visibleLineRef = useRef(() => 1); + const themeSelector = useThemeSelectorController({ themeController, transparentBackground: options.transparentBackground ?? false, onTransientNotice: notice.show, }); const theme = themeSelector.activeTheme; + const treeWidth = sidebar ? Math.min(34, Math.max(12, Math.floor(terminal.width / 3))) : 0; const height = Math.max(1, terminal.height - 3); const treeFocused = sidebar && focus === "tree"; + const keys = useMemo( () => resolveCommandKeys({ @@ -83,6 +90,7 @@ export function DocumentApp({ setNotice("No editable regular file selected."); return; } + setNotice( await controller.source.edit(key, async (filePath) => openFileInEditor({ filePath, line: visibleLineRef.current(), renderer, wait: true }), @@ -90,6 +98,7 @@ export function DocumentApp({ ); await controller.refresh(); }; + const commands = buildDocumentCommands({ controller, treeFocused, @@ -113,6 +122,7 @@ export function DocumentApp({ "hunk.view.toggleLineWrap": () => setWrap((value) => !value), }, }); + const menus = buildDocumentMenus(commands, { sidebar, numbers, @@ -120,8 +130,10 @@ export function DocumentApp({ showExcluded: snapshot.showExcluded, }); const menu = useMenuController(menus); + useDocumentKeyboard({ help, closeHelp: () => setHelp(false), themeSelector, menu, commands }); const overlay = help || themeSelector.themeSelectorOpen || menu.activeMenuId !== null; + return ( row.entry.key === current.selectedKey)?.entry; }; + /** Move through the focused tree or document by physical rows. */ const step = (delta: number) => { if (treeFocused) controller.move(delta); else scrollRef.current?.scrollBy(delta); }; + /** Reveal the beginning or end of the focused surface. */ const jump = (end: boolean) => { if (treeFocused) controller.move(controller.getSnapshot().rows.length * (end ? 1 : -1)); else scrollRef.current?.scrollTo(end ? scrollRef.current.scrollHeight : 0); }; + /** Expand/collapse a selected directory or scroll the document horizontally. */ const horizontal = (expand: boolean) => { const selected = selectedEntry(); + if (!treeFocused) { scrollRef.current?.scrollBy({ x: expand ? 4 : -4, y: 0 }); return; } + if ( selected?.kind === "directory" && controller.getSnapshot().expanded.has(selected.key) !== expand ) void controller.activate(selected.key); }; + const handlers: Record void> = { ...actions, "hunk.documents.toggleExcluded": () => controller.toggleExcluded(), @@ -75,6 +81,7 @@ export function buildDocumentCommands({ "hunk.documents.scrollLeft": () => horizontal(false), "hunk.documents.scrollRight": () => horizontal(true), }; + return buildSurfaceCommands(DOCUMENT_COMMAND_CATALOG, { resolvedKeys, run: (entry) => handlers[entry.id](), @@ -104,6 +111,7 @@ export function buildDocumentMenus( }, }; }; + return { file: [item("hunk.app.refresh"), item("hunk.documents.edit"), item("hunk.app.quit")], view: [ diff --git a/packages/hunk/src/ui/documents/controller.ts b/packages/hunk/src/ui/documents/controller.ts index fa254cf53..b219f3c6d 100644 --- a/packages/hunk/src/ui/documents/controller.ts +++ b/packages/hunk/src/ui/documents/controller.ts @@ -9,6 +9,7 @@ export interface DocumentTreeRow { entry: DocumentEntry; depth: number; } + export interface DocumentBrowserSnapshot { rows: readonly DocumentTreeRow[]; selectedKey: string | null; @@ -34,11 +35,13 @@ export function documentTreeRows( if (!expanded.has(entry.key)) return; const listing = directories.get(entry.key); if (listing?.kind !== "entries") return; + for (const child of listing.entries) { if (!showExcluded && (child.hidden || child.ignored)) continue; visit(child, depth + 1); } }; + visit(root, 0); return rows; } @@ -72,12 +75,14 @@ export class DocumentBrowserController { notice: null, }; } + /** Report whether this surface has revoked all source demand. */ get isClosed() { return this.closed; } getSnapshot = () => this.snapshot; + subscribe = (listener: () => void) => { this.listeners.add(listener); return () => this.listeners.delete(listener); @@ -93,6 +98,7 @@ export class DocumentBrowserController { /** Replace a snapshot atomically; subscribers never observe partially updated tree geometry. */ private publish(change: Partial) { if (this.closed) return; + const next = { ...this.snapshot, ...change }; next.rows = documentTreeRows( this.source.root, @@ -110,6 +116,7 @@ export class DocumentBrowserController { private loadDirectory(key: string): Promise { const existing = this.listingRequests.get(key); if (existing) return existing; + const request = (async () => { const result = await this.source.list(key, this.controller.signal); if (this.closed || !this.snapshot.expanded.has(key)) return; @@ -140,11 +147,14 @@ export class DocumentBrowserController { async select(key: string) { const entry = this.snapshot.rows.find((row) => row.entry.key === key)?.entry; if (!entry || this.closed) return; + this.publish({ selectedKey: key }); if (entry.kind === "directory") return; + const generation = ++this.readGeneration; this.publish({ documentKey: key, documentEntry: entry, document: null, loading: true }); this.observe(); + try { const document = await this.readDocument(key); if (generation === this.readGeneration) this.publish({ document, loading: false }); @@ -167,6 +177,7 @@ export class DocumentBrowserController { const entry = this.snapshot.rows.find((row) => row.entry.key === key)?.entry; if (!entry || this.closed) return; if (entry.kind !== "directory") return this.select(key); + const expanded = new Set(this.snapshot.expanded); if (expanded.has(key)) { expanded.delete(key); @@ -185,6 +196,7 @@ export class DocumentBrowserController { } expanded.add(key); } + this.publish({ selectedKey: key, expanded }); this.observe(); if (expanded.has(key) && !this.directories.has(key)) await this.loadDirectory(key); @@ -209,6 +221,7 @@ export class DocumentBrowserController { if (this.closed) return Promise.resolve(); this.refreshPending = true; if (this.refreshPromise) return this.refreshPromise; + this.refreshPromise = (async () => { while (this.refreshPending && !this.closed) { this.refreshPending = false; @@ -217,6 +230,7 @@ export class DocumentBrowserController { await this.listingRequests.get(key); if (!this.closed && this.snapshot.expanded.has(key)) await this.loadDirectory(key); } + const key = this.snapshot.documentKey; if (key) { const generation = ++this.readGeneration; @@ -240,6 +254,7 @@ export class DocumentBrowserController { private observe() { this.stopObservation?.(); if (this.closed) return; + const keys = [...this.snapshot.expanded]; if (this.snapshot.documentKey) keys.push(this.snapshot.documentKey); this.stopObservation = this.source.observe?.(keys, () => { @@ -250,6 +265,7 @@ export class DocumentBrowserController { /** Cancel pending reads and release every observer before terminal teardown. */ close(): Promise { if (this.closePromise) return this.closePromise; + this.closed = true; this.controller.abort(); this.stopObservation?.(); diff --git a/packages/hunk/src/ui/documents/useDocumentKeyboard.ts b/packages/hunk/src/ui/documents/useDocumentKeyboard.ts index 372339c56..ee2e726b1 100644 --- a/packages/hunk/src/ui/documents/useDocumentKeyboard.ts +++ b/packages/hunk/src/ui/documents/useDocumentKeyboard.ts @@ -15,6 +15,7 @@ function handleThemeKey(key: KeyEvent, selector: ThemeSelectorController) { down: () => selector.moveThemeSelector(1), return: selector.acceptThemeSelector, }; + actions[key.name]?.(); } @@ -28,6 +29,7 @@ function handleMenuKey(key: KeyEvent, menu: MenuController) { down: () => menu.moveMenuItem(1), return: menu.activateCurrentMenuItem, }; + actions[key.name]?.(); } From 82190d1c34400dbd1affe06dfaf5601aa796e62c Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 6 Oct 2026 11:10:36 -0400 Subject: [PATCH 08/22] style(open): separate document test declarations --- .changeset/spaced-document-tests.md | 2 ++ AGENTS.md | 2 +- .../src/app/documents/filesystemSource.test.ts | 16 ++++++++++++++++ .../hunk/src/app/documents/gitMetadata.test.ts | 1 + .../hunk/src/ui/documents/controller.test.ts | 2 ++ test/pty/open-integration.test.ts | 3 +++ 6 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 .changeset/spaced-document-tests.md diff --git a/.changeset/spaced-document-tests.md b/.changeset/spaced-document-tests.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/spaced-document-tests.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/AGENTS.md b/AGENTS.md index f64cf867c..41138fe1a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -154,7 +154,7 @@ ReviewIntent + caller facts -> planReviewIntent -> ReviewAction[] -> reducer -> ## source formatting -- Separate functions, class methods, and nested helpers with a blank line; keep JSDoc attached to its declaration. +- Separate functions, class methods, nested helpers, and test declarations with a blank line; keep JSDoc and test-specific comments attached to their declaration. - Use blank lines between logical phases inside functions. Keep related statements grouped rather than packing an entire workflow into one dense block. ## code comments diff --git a/packages/hunk/src/app/documents/filesystemSource.test.ts b/packages/hunk/src/app/documents/filesystemSource.test.ts index e74028cd1..9a39f55f7 100644 --- a/packages/hunk/src/app/documents/filesystemSource.test.ts +++ b/packages/hunk/src/app/documents/filesystemSource.test.ts @@ -21,6 +21,7 @@ import { } from "./filesystemSource"; const roots: string[] = []; + /** Create one isolated filesystem collection. */ async function createTestRoot() { // Windows temp paths may use 8.3 aliases; source keys use canonical paths on every platform. @@ -28,6 +29,7 @@ async function createTestRoot() { roots.push(root); return root; } + afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); @@ -50,6 +52,7 @@ describe("filesystem documents", () => { expect(document.kind).toBe("text"); if (document.kind === "text") expect(document.text).toBe("# complete\nunchanged\n"); }); + test("refuses binary, invalid UTF-8, oversize, missing, special and escaping entries", async () => { const root = await createTestRoot(); await writeFile(join(root, "binary"), Buffer.from([0, 1])); @@ -75,6 +78,7 @@ describe("filesystem documents", () => { expect(await source.read(root)).toMatchObject({ kind: "unavailable", reason: "special" }); expect(await source.read(join(root, "..", "escape"))).toMatchObject({ kind: "unavailable" }); }); + test.skipIf(process.platform === "win32")( "does not follow file or directory symlinks, even when opened explicitly", async () => { @@ -105,6 +109,7 @@ describe("filesystem documents", () => { expect(await direct.read(direct.root.key)).toMatchObject({ reason: "symlink" }); }, ); + test("honors Git ignore rules without hiding tracked files and reports statuses", async () => { const root = await createTestRoot(); const runGit = (...args: string[]) => { @@ -125,6 +130,7 @@ describe("filesystem documents", () => { expect(listing.entries.find((entry) => entry.name === "tracked.log")?.ignored).toBe(false); expect(listing.entries.find((entry) => entry.name === "tracked.log")?.status).toBe("A"); }); + test("metadata queries do not execute repository fsmonitor hooks or clean/process filters", async () => { const root = await createTestRoot(); const marker = join(root, "executed"); @@ -181,6 +187,7 @@ describe("filesystem documents", () => { expect((await source.list(root)).kind).toBe("entries"); expect(existsSync(marker)).toBe(false); }); + // Windows chmod does not revoke read access; root bypasses Unix permission modes. test.skipIf(process.platform === "win32" || process.getuid?.() === 0)( "shows a placeholder for unreadable files", @@ -200,6 +207,7 @@ describe("filesystem documents", () => { } }, ); + test("bounds directory enumeration and cancels reads", async () => { const root = await createTestRoot(); await Promise.all( @@ -217,6 +225,7 @@ describe("filesystem documents", () => { await expect(source.list(root, abort.signal)).rejects.toThrow(); await expect(source.read(root, abort.signal)).rejects.toThrow(); }, 30_000); + test("edits a private copy, preserves BOM bytes, and saves through the original handle", async () => { const root = await createTestRoot(); const file = join(root, "selected.txt"); @@ -242,6 +251,7 @@ describe("filesystem documents", () => { ).toBeNull(); expect(await readFile(file, "utf8")).toBe("after\n"); }); + test("refuses editor writeback after attempted atomic replacement and retains the edited copy", async () => { const root = await createTestRoot(); const file = join(root, "selected.txt"); @@ -268,6 +278,7 @@ describe("filesystem documents", () => { expect(await readFile(file, "utf8")).toBe(replaced ? "replacement" : "before"); expect(await readFile(copyPath, "utf8")).toBe("edited"); }); + test("rejects concurrent in-place changes without overwriting either the original or editor copy", async () => { const root = await createTestRoot(); const file = join(root, "selected.txt"); @@ -285,6 +296,7 @@ describe("filesystem documents", () => { expect(await readFile(file, "utf8")).toBe("concurrent"); expect(await readFile(copyPath, "utf8")).toBe("edited"); }); + test.skipIf(process.platform === "win32")( "never passes a collection path to the editor after a symlink swap", async () => { @@ -309,6 +321,7 @@ describe("filesystem documents", () => { expect(await readFile(join(root, "original.txt"), "utf8")).toBe("before"); }, ); + test.skipIf(process.platform !== "linux")( "binds enumeration to the checked directory during a swap and restore", async () => { @@ -335,6 +348,7 @@ describe("filesystem documents", () => { expect(listing.entries.map((entry) => entry.name)).toEqual(["inside.txt"]); }, ); + test("rejects incomplete ignore output when the query times out or fails", async () => { const root = await createTestRoot(); expect(Bun.spawnSync(["git", "init", "--quiet"], { cwd: root }).exitCode).toBe(0); @@ -358,6 +372,7 @@ describe("filesystem documents", () => { }); } }); + test("cancels an in-flight ignore query instead of returning a partial directory", async () => { const root = await createTestRoot(); expect(Bun.spawnSync(["git", "init", "--quiet"], { cwd: root }).exitCode).toBe(0); @@ -376,6 +391,7 @@ describe("filesystem documents", () => { await expect(source.list(root, abort.signal)).rejects.toThrow(); expect(launched).toBe(true); }); + test("observes atomic replacement and releases subscriptions", async () => { const root = await createTestRoot(); const file = join(root, "selected.txt"); diff --git a/packages/hunk/src/app/documents/gitMetadata.test.ts b/packages/hunk/src/app/documents/gitMetadata.test.ts index 0a0178293..d9f694b34 100644 --- a/packages/hunk/src/app/documents/gitMetadata.test.ts +++ b/packages/hunk/src/app/documents/gitMetadata.test.ts @@ -19,6 +19,7 @@ describe("directory status markers", () => { ]); expect(markers.has(join(key, "old name.ts"))).toBe(false); }); + test("omits status when metadata is unavailable", () => { const repository = join(tmpdir(), "hunk-status-test"); expect(directoryStatusMarkers(repository, repository, null).size).toBe(0); diff --git a/packages/hunk/src/ui/documents/controller.test.ts b/packages/hunk/src/ui/documents/controller.test.ts index c957d02f4..a5bcea82c 100644 --- a/packages/hunk/src/ui/documents/controller.test.ts +++ b/packages/hunk/src/ui/documents/controller.test.ts @@ -81,6 +81,7 @@ describe("document browser controller", () => { browser.close(); expect(fixture.subscriptions()).toBe(0); }); + test("stale reads and reads settling after shutdown cannot replace the active document", async () => { const fixture = createTestSource(); const pending = new Map void>(); @@ -99,6 +100,7 @@ describe("document browser controller", () => { await browser.refresh(); expect(browser.getSnapshot()).toBe(before); }); + test("watch/manual refreshes serialize, retain selection, and reread only demanded content", async () => { const fixture = createTestSource(); const browser = new DocumentBrowserController(fixture.source); diff --git a/test/pty/open-integration.test.ts b/test/pty/open-integration.test.ts index 41160fbc5..be10ba231 100644 --- a/test/pty/open-integration.test.ts +++ b/test/pty/open-integration.test.ts @@ -61,6 +61,7 @@ describe("hunk open", () => { session.close(); } }); + test("wraps complete documents with exact geometry and preserves line navigation across toggles", async () => { const root = createOpenTestFixture(); const path = join(root, "wrapped.txt"); @@ -86,6 +87,7 @@ describe("hunk open", () => { session.close(); } }); + test("opens a complete file, scrolls, resizes and reuses normal theme/help/menu chrome", async () => { const root = createOpenTestFixture(); const session = await harness.launchHunk({ @@ -123,6 +125,7 @@ describe("hunk open", () => { session.close(); } }); + test("expands directories by keyboard and mouse, toggles hidden files, watches and shows binary placeholders", async () => { const root = createOpenTestFixture(); const session = await harness.launchHunk({ args: ["open", root], cols: 120, rows: 24 }); From 0b400f306e3b6f9e149b7547bd2265ea6353ef0c Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 6 Oct 2026 14:44:15 -0400 Subject: [PATCH 09/22] fix(open): address initial document review regressions --- .changeset/settled-document-reviews.md | 5 + docs/open.md | 5 +- .../app/documents/filesystemSource.test.ts | 38 ++++++ .../src/app/documents/filesystemSource.ts | 13 +- packages/hunk/src/core/documents/source.ts | 2 +- .../src/ui/documents/DocumentApp.test.tsx | 62 ++++++++- .../hunk/src/ui/documents/DocumentApp.tsx | 8 +- .../src/ui/documents/DocumentPane.test.tsx | 54 ++++++++ .../hunk/src/ui/documents/DocumentPane.tsx | 11 +- .../hunk/src/ui/documents/DocumentTree.tsx | 4 +- .../hunk/src/ui/documents/controller.test.ts | 126 +++++++++++++++++- packages/hunk/src/ui/documents/controller.ts | 76 +++++++++-- .../src/ui/documents/runDocumentBrowser.tsx | 3 + .../src/ui/documents/useDocumentKeyboard.ts | 11 +- .../hunk/src/ui/documents/useRowViewport.ts | 30 +++-- .../hunk/src/ui/session/HunkSessionHost.tsx | 7 +- test/pty/open-integration.test.ts | 95 ++++++++++++- 17 files changed, 504 insertions(+), 46 deletions(-) create mode 100644 .changeset/settled-document-reviews.md create mode 100644 packages/hunk/src/ui/documents/DocumentPane.test.tsx diff --git a/.changeset/settled-document-reviews.md b/.changeset/settled-document-reviews.md new file mode 100644 index 000000000..076cde149 --- /dev/null +++ b/.changeset/settled-document-reviews.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Prevent overlapping document edits and lost recovery notices on quit, preserve wrapped text beside scrollbars, release vanished directory expansions, and restore keyboard scrolling in controls help. diff --git a/docs/open.md b/docs/open.md index a1d5607a3..92d744ddc 100644 --- a/docs/open.md +++ b/docs/open.md @@ -65,7 +65,10 @@ Editors receive a private temporary copy, not a checked collection path. Hunk re file handle and saves through it only if the file identity and content still match. Concurrent replacement or modification cancels writeback and retains the edited copy at the path shown in the notice. Editor errors also retain that copy. Known GUI editors receive `--wait`; custom editor -wrappers must wait until editing finishes. `$EDITOR` itself is trusted code, not sandboxed. +wrappers must wait until editing finishes. Only one edit may run at a time; repeated editor actions +are rejected while it is active. Quitting waits for the editor transaction to settle, and late +failures/recovery paths are printed after the terminal is restored. `$EDITOR` itself is trusted +code, not sandboxed. Expanded directories and the displayed file's parent are observed for changes, covering atomic file replacements and deletions. Watch notifications are debounced; manual and watch refreshes diff --git a/packages/hunk/src/app/documents/filesystemSource.test.ts b/packages/hunk/src/app/documents/filesystemSource.test.ts index 9a39f55f7..612e22e27 100644 --- a/packages/hunk/src/app/documents/filesystemSource.test.ts +++ b/packages/hunk/src/app/documents/filesystemSource.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import { existsSync } from "node:fs"; +import { createTestDeferred } from "../../../../../test/helpers/diff-helpers"; import { chmod, mkdtemp, @@ -252,6 +253,43 @@ describe("filesystem documents", () => { expect(await readFile(file, "utf8")).toBe("after\n"); }); + test("rejects overlapping editors before launch and permits another edit after settlement", async () => { + const root = await createTestRoot(); + const file = join(root, "selected.txt"); + await writeFile(file, "original"); + const source = await createFilesystemSource(root); + const started = createTestDeferred(); + const release = createTestDeferred(); + let launches = 0; + + const first = source.edit!(file, async (copy) => { + launches++; + started.resolve(); + await release.promise; + await writeFile(copy, "A".repeat(500_000)); + return null; + }); + + try { + expect( + await source.edit!(file, async (copy) => { + launches++; + await writeFile(copy, "B".repeat(900_000)); + return null; + }), + ).toBe("An editor is already open."); + await started.promise; + expect(launches).toBe(1); + release.resolve(); + expect(await first).toBeNull(); + expect(await readFile(file, "utf8")).toBe("A".repeat(500_000)); + expect(await source.edit!(file, async () => null)).toBeNull(); + } finally { + release.resolve(); + await first; + } + }); + test("refuses editor writeback after attempted atomic replacement and retains the edited copy", async () => { const root = await createTestRoot(); const file = join(root, "selected.txt"); diff --git a/packages/hunk/src/app/documents/filesystemSource.ts b/packages/hunk/src/app/documents/filesystemSource.ts index 60f954d4b..92596f24d 100644 --- a/packages/hunk/src/app/documents/filesystemSource.ts +++ b/packages/hunk/src/app/documents/filesystemSource.ts @@ -263,11 +263,22 @@ export async function createFilesystemSource( } } + let editing = false; + return { root, read, list, - edit: (key, launch) => editDocumentCopy(key, launch, { safePath, openCheckedPath, read }), + async edit(key, launch) { + if (editing) return "An editor is already open."; + editing = true; + + try { + return await editDocumentCopy(key, launch, { safePath, openCheckedPath, read }); + } finally { + editing = false; + } + }, observe(keys, onChange) { let timer: ReturnType | undefined; diff --git a/packages/hunk/src/core/documents/source.ts b/packages/hunk/src/core/documents/source.ts index 82d2527a2..81e46aa88 100644 --- a/packages/hunk/src/core/documents/source.ts +++ b/packages/hunk/src/core/documents/source.ts @@ -30,6 +30,6 @@ export interface DocumentSource { list(key: string, signal?: AbortSignal): Promise; /** Observe only demanded entries; disposing releases every watcher owned by this subscription. */ observe?(keys: readonly string[], onChange: () => void): () => void; - /** Edit a private copy and save through the source-owned handle, never a checked path string. */ + /** Edit one private copy at a time and save through the source-owned handle, never a checked path string. */ edit?(key: string, launch: (copyPath: string) => Promise): Promise; } diff --git a/packages/hunk/src/ui/documents/DocumentApp.test.tsx b/packages/hunk/src/ui/documents/DocumentApp.test.tsx index a2a864145..d387ac8d5 100644 --- a/packages/hunk/src/ui/documents/DocumentApp.test.tsx +++ b/packages/hunk/src/ui/documents/DocumentApp.test.tsx @@ -1,6 +1,7 @@ import { expect, mock, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { act } from "react"; +import { createTestDeferred } from "../../../../../test/helpers/diff-helpers"; import type { DocumentSource } from "../../core/documents/source"; import { resolveConfiguredInput } from "../../core/run/config"; import { createInteractiveSessionInitialization } from "../../core/session/initialization"; @@ -63,7 +64,7 @@ test("the session host mounts documents, honors remaps and acknowledges external { width: 90, height: 20 }, ); try { - await setup.renderOnce(); + await act(async () => setup.renderOnce()); expect(setup.captureCharFrame()).toContain("example.ts"); await act(async () => setup.mockInput.pressKey("j")); expect(route.controller.getSnapshot().selectedKey).toBe("collection:opaque"); @@ -71,7 +72,7 @@ test("the session host mounts documents, honors remaps and acknowledges external setup.mockInput.pressKey("n"); await Bun.sleep(20); }); - await setup.renderOnce(); + await act(async () => setup.renderOnce()); expect(setup.captureCharFrame()).toContain("const completeDocument = 42;"); expect(setup.captureCharFrame()).not.toContain("document:opaque"); const edit = mock(async (_key: string) => "Source-owned editor capability invoked."); @@ -80,13 +81,19 @@ test("the session host mounts documents, honors remaps and acknowledges external setup.mockInput.pressKey("e"); await Bun.sleep(20); }); - await setup.renderOnce(); + await act(async () => setup.renderOnce()); expect(edit).toHaveBeenCalledTimes(1); expect(edit.mock.calls[0]?.[0]).toBe("document:opaque"); expect(setup.captureCharFrame()).toContain("Source-owned editor capability"); await act(async () => setup.mockInput.pressKey("?")); - await setup.renderOnce(); + await act(async () => setup.renderOnce()); expect(setup.captureCharFrame()).toContain("Documents"); + expect(setup.captureCharFrame()).not.toContain("Scroll right / expand"); + await act(async () => { + for (let index = 0; index < 12; index++) setup.mockInput.pressArrow("down"); + }); + await act(async () => setup.renderOnce()); + expect(setup.captureCharFrame()).toContain("Scroll right / expand"); await act(async () => { setup.mockInput.pressKey("e"); setup.mockInput.pressKey("n"); @@ -98,14 +105,14 @@ test("the session host mounts documents, honors remaps and acknowledges external setup.mockInput.pressEscape(); await Bun.sleep(50); }); - await setup.renderOnce(); + await act(async () => setup.renderOnce()); expect(setup.captureCharFrame()).not.toContain("Controls help"); await act(async () => setup.mockInput.pressTab()); await act(async () => setup.mockInput.pressArrow("down")); - await setup.renderOnce(); + await act(async () => setup.renderOnce()); expect(setup.captureCharFrame()).toContain("const completeDocument = 42;"); await act(async () => setup.mockInput.pressKey("n")); - await setup.renderOnce(); + await act(async () => setup.renderOnce()); expect(setup.captureCharFrame()).not.toContain("const completeDocument = 42;"); await act(async () => abort.abort()); expect(quit).toHaveBeenCalledTimes(1); @@ -115,3 +122,44 @@ test("the session host mounts documents, honors remaps and acknowledges external await route.controller.close(); } }); + +test("repeated editor actions launch once and quit waits for the recovery result", async () => { + const route = await createTestDocumentRoute(); + await route.controller.select("document:opaque"); + const pending = createTestDeferred(); + const edit = mock(() => pending.promise); + route.controller.source.edit = edit; + const quit = mock(() => undefined); + const setup = await testRender( + , + { width: 90, height: 20 }, + ); + + try { + await act(async () => setup.renderOnce()); + await act(async () => { + setup.mockInput.pressKey("e"); + setup.mockInput.pressKey("e"); + await Bun.sleep(20); + }); + expect(edit).toHaveBeenCalledTimes(1); + await act(async () => setup.mockInput.pressKey("q")); + expect(quit).not.toHaveBeenCalled(); + expect(route.controller.isClosed).toBe(true); + await act(async () => { + pending.resolve("Conflict. Editor copy retained in recovery-directory."); + await Bun.sleep(20); + }); + expect(quit).toHaveBeenCalledTimes(1); + expect(route.controller.shutdownEditNotice).toContain("recovery-directory"); + } finally { + pending.resolve(null); + await route.controller.close(); + await act(async () => setup.renderer.destroy()); + } +}); diff --git a/packages/hunk/src/ui/documents/DocumentApp.tsx b/packages/hunk/src/ui/documents/DocumentApp.tsx index 33e2fefd1..2dfe0550b 100644 --- a/packages/hunk/src/ui/documents/DocumentApp.tsx +++ b/packages/hunk/src/ui/documents/DocumentApp.tsx @@ -85,14 +85,10 @@ export function DocumentApp({ /** Delegate selected documents to an external editor without borrowing review file types. */ const edit = async () => { - const key = controller.getSnapshot().documentKey; - if (!key || !controller.source.edit || renderer.isDestroyed || controller.isClosed) { - setNotice("No editable regular file selected."); - return; - } + if (renderer.isDestroyed || controller.isClosed) return; setNotice( - await controller.source.edit(key, async (filePath) => + await controller.editDisplayedDocument(async (filePath) => openFileInEditor({ filePath, line: visibleLineRef.current(), renderer, wait: true }), ), ); diff --git a/packages/hunk/src/ui/documents/DocumentPane.test.tsx b/packages/hunk/src/ui/documents/DocumentPane.test.tsx new file mode 100644 index 000000000..fe2a2f14d --- /dev/null +++ b/packages/hunk/src/ui/documents/DocumentPane.test.tsx @@ -0,0 +1,54 @@ +import { expect, test } from "bun:test"; +import { testRender } from "@opentui/react/test-utils"; +import { act } from "react"; +import type { ScrollBoxRenderable } from "@opentui/core"; +import { THEMES } from "../themes"; +import { DocumentPane } from "./DocumentPane"; + +/** Flush native layout and viewport notifications without depending on syntax arrival. */ +async function flushTestDocumentLayout(setup: Awaited>) { + await act(async () => { + await setup.renderOnce(); + await Bun.sleep(30); + }); + await setup.renderOnce(); +} + +test("wrapped documents use the scrollbar-adjusted viewport and retain right-edge characters after resize", async () => { + const scrollRef = { current: null as ScrollBoxRenderable | null }; + const visibleLineRef = { current: () => 1 }; + const setup = await testRender( + "1234567890123456789X").join("\n"), + identity: "stable", + }} + documentKey="file" + pathHint="file.txt" + height={8} + width={20} + wrap={true} + tabWidth={4} + theme={THEMES[0]!} + lineNumbers={false} + focused={true} + scrollRef={scrollRef} + visibleLineRef={visibleLineRef} + />, + { width: 20, height: 8 }, + ); + + try { + await flushTestDocumentLayout(setup); + expect(scrollRef.current?.viewport.width).toBe(19); + expect(setup.captureCharFrame()).toContain("X"); + expect(scrollRef.current?.scrollLeft).toBe(0); + await act(async () => setup.resize(19, 8)); + await flushTestDocumentLayout(setup); + expect(scrollRef.current?.viewport.width).toBe(18); + expect(setup.captureCharFrame()).toContain("9X"); + } finally { + await act(async () => setup.renderer.destroy()); + } +}); diff --git a/packages/hunk/src/ui/documents/DocumentPane.tsx b/packages/hunk/src/ui/documents/DocumentPane.tsx index 71245bb1a..deafdee78 100644 --- a/packages/hunk/src/ui/documents/DocumentPane.tsx +++ b/packages/hunk/src/ui/documents/DocumentPane.tsx @@ -45,7 +45,7 @@ export function DocumentPane({ scrollRef: MutableRefObject; visibleLineRef: MutableRefObject<() => number>; }) { - const viewport = useRowViewport(height); + const viewport = useRowViewport(height, width); const text = document?.kind === "text" ? document.text : ""; const lines = useMemo(() => completeDocumentLines(text, tabWidth), [text, tabWidth]); const safeText = useMemo(() => lines.join("\n"), [lines]); @@ -57,11 +57,14 @@ export function DocumentPane({ } | null>(null); const layout = useMemo(() => { try { - return { geometry: completeDocumentGeometry(lines, width, lineNumbers, wrap), wrap }; + return { geometry: completeDocumentGeometry(lines, viewport.width, lineNumbers, wrap), wrap }; } catch { - return { geometry: completeDocumentGeometry(lines, width, lineNumbers, false), wrap: false }; + return { + geometry: completeDocumentGeometry(lines, viewport.width, lineNumbers, false), + wrap: false, + }; } - }, [lines, width, lineNumbers, wrap]); + }, [lines, viewport.width, lineNumbers, wrap]); const { geometry } = layout; visibleLineRef.current = () => Math.max( diff --git a/packages/hunk/src/ui/documents/DocumentTree.tsx b/packages/hunk/src/ui/documents/DocumentTree.tsx index 7fb3a6b55..a85222440 100644 --- a/packages/hunk/src/ui/documents/DocumentTree.tsx +++ b/packages/hunk/src/ui/documents/DocumentTree.tsx @@ -21,7 +21,7 @@ export function DocumentTree({ focused: boolean; onActivate: (key: string) => void; }) { - const viewport = useRowViewport(height); + const viewport = useRowViewport(height, width); const selected = snapshot.rows.findIndex((row) => row.entry.key === snapshot.selectedKey); useEffect(() => { const scroll = viewport.ref.current; @@ -66,7 +66,7 @@ export function DocumentTree({ > {fitText( `${" ".repeat(Math.min(depth, 20))}${marker} ${sanitizeTerminalLine(entry.name)}${entry.status ? ` ${entry.status}` : ""}`, - width, + viewport.width, )} diff --git a/packages/hunk/src/ui/documents/controller.test.ts b/packages/hunk/src/ui/documents/controller.test.ts index a5bcea82c..cc5eef5b9 100644 --- a/packages/hunk/src/ui/documents/controller.test.ts +++ b/packages/hunk/src/ui/documents/controller.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, mock, test } from "bun:test"; +import { createTestDeferred } from "../../../../../test/helpers/diff-helpers"; import type { DocumentEntry, DocumentReadResult, @@ -101,6 +102,129 @@ describe("document browser controller", () => { expect(browser.getSnapshot()).toBe(before); }); + test("removed expanded directories release slots and observation demand", async () => { + const fixture = createTestSource(); + const directory = (key: string): DocumentEntry => ({ + key, + name: key, + kind: "directory", + hidden: false, + }); + let replaced = false; + let observed: readonly string[] = []; + fixture.source.list = async (key) => ({ + kind: "entries", + entries: + key !== "root" + ? [] + : replaced + ? [directory("new-folder")] + : Array.from({ length: 127 }, (_, index) => directory(`folder-${index}`)), + }); + fixture.source.observe = (keys) => { + observed = [...keys]; + return () => {}; + }; + const browser = new DocumentBrowserController(fixture.source); + + try { + await browser.initialize(); + for (let index = 0; index < 127; index++) await browser.activate(`folder-${index}`); + expect(browser.getSnapshot().expanded.size).toBe(128); + replaced = true; + await browser.refresh(); + expect([...browser.getSnapshot().expanded]).toEqual(["root"]); + expect(observed).toEqual(["root"]); + await browser.activate("root"); + await browser.activate("root"); + await browser.activate("new-folder"); + expect(browser.getSnapshot().expanded.has("new-folder")).toBe(true); + expect(browser.getSnapshot().notice).toBeNull(); + } finally { + await browser.close(); + } + }); + + test("collapsing a parent clears expanded descendants hidden by the visibility filter", async () => { + const fixture = createTestSource(); + fixture.source.list = async (key) => ({ + kind: "entries", + entries: + key === "root" + ? [{ key: "hidden", name: ".hidden", kind: "directory", hidden: true }] + : key === "hidden" + ? [{ key: "child", name: "child", kind: "directory", hidden: false }] + : [], + }); + const browser = new DocumentBrowserController(fixture.source); + + try { + await browser.initialize(); + browser.toggleExcluded(); + await browser.activate("hidden"); + await browser.activate("child"); + browser.toggleExcluded(); + expect(browser.getSnapshot().rows.map((row) => row.entry.key)).toEqual(["root"]); + await browser.activate("root"); + expect(browser.getSnapshot().expanded.size).toBe(0); + } finally { + await browser.close(); + } + }); + + test("shutdown rejects another editor and waits for the active edit's recovery result", async () => { + const fixture = createTestSource(); + const result = createTestDeferred(); + const edit = mock(() => result.promise); + fixture.source.edit = edit; + const browser = new DocumentBrowserController(fixture.source); + await browser.initialize(); + await browser.select("a"); + const pending = browser.editDisplayedDocument(async () => null); + + try { + expect(await browser.editDisplayedDocument(async () => null)).toBe( + "An editor is already open.", + ); + expect(edit).toHaveBeenCalledTimes(1); + let closed = false; + const shutdown = browser.close().then(() => { + closed = true; + }); + await Promise.resolve(); + expect(closed).toBe(false); + expect(await browser.editDisplayedDocument(async () => null)).toBe( + "The document session has closed.", + ); + result.resolve("Conflict. Editor copy retained in recovery-directory."); + await pending; + await shutdown; + expect(closed).toBe(true); + expect(browser.shutdownEditNotice).toBe( + "Conflict. Editor copy retained in recovery-directory.", + ); + } finally { + result.resolve(null); + await browser.close(); + } + }); + + test("an already settled recovery notice survives quit before the next UI frame", async () => { + const fixture = createTestSource(); + fixture.source.edit = async () => "Editor copy retained in recovery-directory."; + const browser = new DocumentBrowserController(fixture.source); + + try { + await browser.initialize(); + await browser.select("a"); + await browser.editDisplayedDocument(async () => null); + await browser.close(); + expect(browser.shutdownEditNotice).toBe("Editor copy retained in recovery-directory."); + } finally { + await browser.close(); + } + }); + test("watch/manual refreshes serialize, retain selection, and reread only demanded content", async () => { const fixture = createTestSource(); const browser = new DocumentBrowserController(fixture.source); diff --git a/packages/hunk/src/ui/documents/controller.ts b/packages/hunk/src/ui/documents/controller.ts index b219f3c6d..3b9d79cd5 100644 --- a/packages/hunk/src/ui/documents/controller.ts +++ b/packages/hunk/src/ui/documents/controller.ts @@ -60,6 +60,8 @@ export class DocumentBrowserController { private pendingReads = new Set>(); private readController?: AbortController; private closePromise?: Promise; + private editPromise: Promise | null = null; + private editNotice: string | null = null; private snapshot: DocumentBrowserSnapshot; constructor(readonly source: DocumentSource) { @@ -81,6 +83,11 @@ export class DocumentBrowserController { return this.closed; } + /** Retain editor failures so recovery paths survive teardown even when no final frame paints. */ + get shutdownEditNotice() { + return this.editNotice; + } + getSnapshot = () => this.snapshot; subscribe = (listener: () => void) => { @@ -120,8 +127,10 @@ export class DocumentBrowserController { const request = (async () => { const result = await this.source.list(key, this.controller.signal); if (this.closed || !this.snapshot.expanded.has(key)) return; + const expanded = new Set(this.snapshot.expanded); + this.pruneRemovedDirectories(key, result, expanded); this.directories.set(key, result); - this.publish({ notice: result.kind === "unavailable" ? result.detail : null }); + this.publish({ expanded, notice: result.kind === "unavailable" ? result.detail : null }); })() .catch((error) => { if (!this.closed) this.publish({ notice: String(error) }); @@ -133,6 +142,58 @@ export class DocumentBrowserController { return request; } + /** Release a cached directory subtree independently of visibility filters or mounted rows. */ + private forgetDirectory(key: string, expanded: Set) { + const listing = this.directories.get(key); + expanded.delete(key); + this.directories.delete(key); + + if (listing?.kind !== "entries") return; + for (const child of listing.entries) { + if (child.kind === "directory") this.forgetDirectory(child.key, expanded); + } + } + + /** Drop vanished, changed-kind and unavailable descendants before replacing their parent listing. */ + private pruneRemovedDirectories(key: string, result: DirectoryReadResult, expanded: Set) { + const previous = this.directories.get(key); + if (previous?.kind !== "entries") return; + + const retained = new Set( + result.kind === "entries" + ? result.entries.filter((entry) => entry.kind === "directory").map((entry) => entry.key) + : [], + ); + for (const child of previous.entries) { + if (child.kind === "directory" && !retained.has(child.key)) + this.forgetDirectory(child.key, expanded); + } + } + + /** Run one displayed-document edit and retain its settlement through shutdown. */ + editDisplayedDocument(launch: (copy: string) => Promise): Promise { + if (this.closed) return Promise.resolve("The document session has closed."); + if (this.editPromise) return Promise.resolve("An editor is already open."); + + const key = this.snapshot.documentKey; + const edit = this.source.edit; + if (!key || !edit) return Promise.resolve("No editable regular file selected."); + + const request = Promise.resolve() + .then(() => edit.call(this.source, key, launch)) + .catch((error) => (error instanceof Error ? error.message : String(error))) + .then((notice) => { + this.editNotice = notice; + this.publish({ notice }); + return notice; + }) + .finally(() => { + if (this.editPromise === request) this.editPromise = null; + }); + this.editPromise = request; + return request; + } + /** Cancel obsolete document demand and retain in-flight reads until shutdown settles them. */ private readDocument(key: string): Promise { this.readController?.abort(); @@ -180,15 +241,7 @@ export class DocumentBrowserController { const expanded = new Set(this.snapshot.expanded); if (expanded.has(key)) { - expanded.delete(key); - this.directories.delete(key); - const index = this.snapshot.rows.findIndex((row) => row.entry.key === key); - const depth = this.snapshot.rows[index]!.depth; - for (const row of this.snapshot.rows.slice(index + 1)) { - if (row.depth <= depth) break; - expanded.delete(row.entry.key); - this.directories.delete(row.entry.key); - } + this.forgetDirectory(key, expanded); } else { if (expanded.size >= 128) { this.publish({ notice: "Close a directory before expanding more than 128 directories." }); @@ -262,7 +315,7 @@ export class DocumentBrowserController { }); } - /** Cancel pending reads and release every observer before terminal teardown. */ + /** Cancel pending reads, settle the editor transaction and release observers before teardown. */ close(): Promise { if (this.closePromise) return this.closePromise; @@ -275,6 +328,7 @@ export class DocumentBrowserController { ...this.listingRequests.values(), ...this.pendingReads, ...(this.refreshPromise ? [this.refreshPromise] : []), + ...(this.editPromise ? [this.editPromise] : []), ]).then(() => undefined); return this.closePromise; } diff --git a/packages/hunk/src/ui/documents/runDocumentBrowser.tsx b/packages/hunk/src/ui/documents/runDocumentBrowser.tsx index 22a284aec..fdb7c30ac 100644 --- a/packages/hunk/src/ui/documents/runDocumentBrowser.tsx +++ b/packages/hunk/src/ui/documents/runDocumentBrowser.tsx @@ -2,6 +2,7 @@ import type { DocumentBrowserBootstrap } from "../../core/documents/bootstrap"; import { DocumentBrowserController } from "./controller"; import { HunkSessionHost } from "../session/HunkSessionHost"; import { runHunkSession } from "../session/runHunkSession"; +import { sanitizeTerminalLine } from "../../lib/terminalText"; /** Mount documents in the existing session host; the runner retains all terminal ownership. */ export async function runDocumentBrowser(bootstrap: DocumentBrowserBootstrap) { @@ -28,5 +29,7 @@ export async function runDocumentBrowser(bootstrap: DocumentBrowserBootstrap) { }); } finally { await controller.close(); + if (controller.shutdownEditNotice) + process.stderr.write(`${sanitizeTerminalLine(controller.shutdownEditNotice)}\n`); } } diff --git a/packages/hunk/src/ui/documents/useDocumentKeyboard.ts b/packages/hunk/src/ui/documents/useDocumentKeyboard.ts index ee2e726b1..feb605d92 100644 --- a/packages/hunk/src/ui/documents/useDocumentKeyboard.ts +++ b/packages/hunk/src/ui/documents/useDocumentKeyboard.ts @@ -49,8 +49,15 @@ export function useDocumentKeyboard({ }) { useKeyboard((key) => { if (help) { - if (key.name === "escape" || key.sequence === "?") closeHelp(); - } else if (themeSelector.themeSelectorOpen) { + if (key.name === "escape" || key.sequence === "?") { + closeHelp(); + key.preventDefault(); + } + // The focused help scrollbox owns navigation; document commands remain blocked. + return; + } + + if (themeSelector.themeSelectorOpen) { handleThemeKey(key, themeSelector); } else if (key.name === "f10") { if (menu.activeMenuId) menu.closeMenu(); diff --git a/packages/hunk/src/ui/documents/useRowViewport.ts b/packages/hunk/src/ui/documents/useRowViewport.ts index 994a56805..feb72b0a8 100644 --- a/packages/hunk/src/ui/documents/useRowViewport.ts +++ b/packages/hunk/src/ui/documents/useRowViewport.ts @@ -2,9 +2,13 @@ import type { ScrollBoxRenderable } from "@opentui/core"; import { useLayoutEffect, useRef, useState } from "react"; /** Observe scroll geometry so complete-document and tree panes mount only a bounded row window. */ -export function useRowViewport(estimatedHeight: number) { +export function useRowViewport(estimatedHeight: number, estimatedWidth: number) { const ref = useRef(null); - const [viewport, setViewport] = useState({ top: 0, height: estimatedHeight }); + const [viewport, setViewport] = useState({ + top: 0, + height: estimatedHeight, + width: estimatedWidth, + }); useLayoutEffect(() => { const scroll = ref.current; if (!scroll) return; @@ -13,19 +17,29 @@ export function useRowViewport(estimatedHeight: number) { const next = { top: Math.floor(scroll.scrollTop), height: Math.max(1, scroll.viewport.height || estimatedHeight), + width: Math.max(1, scroll.viewport.width || estimatedWidth), }; - return previous.top === next.top && previous.height === next.height ? previous : next; + return previous.top === next.top && + previous.height === next.height && + previous.width === next.width + ? previous + : next; }); + // Yoga size changes use this callback, not the explicit resize/layout events. + const previousSizeChange = scroll.viewport.onSizeChange; + const onSizeChange = () => { + previousSizeChange?.call(scroll.viewport); + update(); + }; + scroll.viewport.onSizeChange = onSizeChange; update(); scroll.verticalScrollBar.on("change", update); - scroll.viewport.on("layout-changed", update); - scroll.viewport.on("resized", update); return () => { scroll.verticalScrollBar.off("change", update); - scroll.viewport.off("layout-changed", update); - scroll.viewport.off("resized", update); + if (scroll.viewport.onSizeChange === onSizeChange) + scroll.viewport.onSizeChange = previousSizeChange; }; - }, [estimatedHeight]); + }, [estimatedHeight, estimatedWidth]); return { ref, ...viewport }; } diff --git a/packages/hunk/src/ui/session/HunkSessionHost.tsx b/packages/hunk/src/ui/session/HunkSessionHost.tsx index 868c56657..374218fd6 100644 --- a/packages/hunk/src/ui/session/HunkSessionHost.tsx +++ b/packages/hunk/src/ui/session/HunkSessionHost.tsx @@ -226,7 +226,12 @@ export function HunkSessionHost({ preparationControllerRef.current?.abort( new Error("Hunk surface preparation was cancelled during shutdown."), ); - if (routeRef.current.kind !== "review" && !preparingRef.current) completeQuit(); + const current = routeRef.current; + if (current.kind === "documents") { + void current.controller.close().then(completeQuit); + } else if (current.kind !== "review" && !preparingRef.current) { + completeQuit(); + } }, [completeQuit], ); diff --git a/test/pty/open-integration.test.ts b/test/pty/open-integration.test.ts index be10ba231..863d9eb2e 100644 --- a/test/pty/open-integration.test.ts +++ b/test/pty/open-integration.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; import { createPtyHarness } from "./harness"; @@ -62,6 +62,99 @@ describe("hunk open", () => { } }); + test("a burst of edit/edit/quit launches once and reports a retained recovery copy after teardown", async () => { + const root = createOpenTestFixture(); + const path = join(root, "README.md"); + const editor = join(root, "conflicting-editor.mjs"); + const targets = join(root, "editor-targets.txt"); + writeFileSync( + editor, + `import { appendFileSync, writeFileSync } from "node:fs"; + appendFileSync(${JSON.stringify(targets)}, process.argv[2] + "\\n"); + writeFileSync(${JSON.stringify(path)}, "Concurrent external update\\n"); + await new Promise(resolve => setTimeout(resolve, 100)); + writeFileSync(process.argv[2], "Editor recovery content\\n");`, + ); + const session = await harness.launchHunk({ + args: ["open", path], + cols: 120, + rows: 20, + env: { EDITOR: `"${process.execPath}" "${editor}"` }, + }); + let recoveryDirectory: string | undefined; + + try { + await session.waitForText("Whole document"); + await harness.ensureKeyboardIsLive(session); + session.writeRaw("eeq"); + await harness.waitForSnapshot(session, () => { + const raw = session.getRawOutput(); + return ( + raw.lastIndexOf("Editor copy retained in") > raw.lastIndexOf("\x1b[?1049l") && + raw.includes("Editor copy retained in") + ); + }); + const copies = readFileSync(targets, "utf8").trim().split("\n"); + expect(copies).toHaveLength(1); + recoveryDirectory = dirname(copies[0]!); + expect(readFileSync(copies[0]!, "utf8")).toBe("Editor recovery content\n"); + expect(readFileSync(path, "utf8")).toBe("Concurrent external update\n"); + } finally { + session.close(); + if (recoveryDirectory) rmSync(recoveryDirectory, { recursive: true, force: true }); + } + }); + + test("retains wrapped right-edge characters beside the scrollbar after resizing", async () => { + const root = createOpenTestFixture(); + const path = join(root, "edge.txt"); + writeFileSync( + path, + Array.from({ length: 40 }, () => "12345678901234567890123456789X").join("\n"), + ); + const session = await harness.launchHunk({ + args: ["open", path, "--wrap", "--no-line-numbers"], + cols: 30, + rows: 16, + }); + + try { + await session.waitForText("X"); + await harness.ensureKeyboardIsLive(session); + session.resize({ cols: 29, rows: 16 }); + await session.waitForText("9X"); + await session.press("q"); + } finally { + session.close(); + } + }); + + test("keyboard navigation reaches the lower help controls without activating document commands", async () => { + const root = createOpenTestFixture(); + const session = await harness.launchHunk({ + args: ["open", join(root, "README.md")], + cols: 100, + rows: 20, + }); + + try { + await session.waitForText("Whole document"); + await harness.ensureKeyboardIsLive(session); + await session.press("?"); + await session.waitForText("Controls help"); + expect(await session.text()).not.toContain("Scroll right / expand"); + await session.press("end"); + await session.waitForText("Scroll right / expand"); + await session.press("q"); + expect(await session.text()).toContain("Controls help"); + await session.press("escape"); + await session.waitForText("Whole document"); + await session.press("q"); + } finally { + session.close(); + } + }); + test("wraps complete documents with exact geometry and preserves line navigation across toggles", async () => { const root = createOpenTestFixture(); const path = join(root, "wrapped.txt"); From 3d73ef1ef608232ac8dab989802c314cddc3bc2b Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 6 Oct 2026 14:58:48 -0400 Subject: [PATCH 10/22] fix(open): coordinate saves across document source instances --- .changeset/coordinated-document-saves.md | 5 + docs/open.md | 17 +- .../src/app/documents/documentSaveAccount.ts | 70 +++++++ .../app/documents/documentSaveClaim.test.ts | 194 ++++++++++++++++++ .../src/app/documents/documentSaveClaim.ts | 96 +++++++++ .../hunk/src/app/documents/editDocument.ts | 14 +- .../app/documents/filesystemSource.test.ts | 56 ++++- .../src/app/documents/filesystemSource.ts | 9 +- 8 files changed, 454 insertions(+), 7 deletions(-) create mode 100644 .changeset/coordinated-document-saves.md create mode 100644 packages/hunk/src/app/documents/documentSaveAccount.ts create mode 100644 packages/hunk/src/app/documents/documentSaveClaim.test.ts create mode 100644 packages/hunk/src/app/documents/documentSaveClaim.ts diff --git a/.changeset/coordinated-document-saves.md b/.changeset/coordinated-document-saves.md new file mode 100644 index 000000000..8d46d89a8 --- /dev/null +++ b/.changeset/coordinated-document-saves.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Coordinate document saves across Hunk windows in the same OS account to prevent overlapping writeback and preserve losing editors' recovery copies. diff --git a/docs/open.md b/docs/open.md index 92d744ddc..6163f90d2 100644 --- a/docs/open.md +++ b/docs/open.md @@ -62,14 +62,25 @@ provide handle-relative directory enumeration. Browsing is not a sandbox against that can repeatedly replace and restore ancestors; use it on trusted local directories. Editors receive a private temporary copy, not a checked collection path. Hunk retains the original -file handle and saves through it only if the file identity and content still match. Concurrent +file handle and checks that the file identity and content still match before writeback. Detected replacement or modification cancels writeback and retains the edited copy at the path shown in the notice. Editor errors also retain that copy. Known GUI editors receive `--wait`; custom editor -wrappers must wait until editing finishes. Only one edit may run at a time; repeated editor actions -are rejected while it is active. Quitting waits for the editor transaction to settle, and late +wrappers must wait until editing finishes. Each document browser permits one active editor action; +repeated editor actions in that browser are rejected while it is active. Quitting waits for the editor transaction to settle, and late failures/recovery paths are printed after the terminal is restored. `$EDITOR` itself is trusted code, not sandboxed. +Updated Hunk sessions in the same OS account and process namespace coordinate validation and writeback with +private, inode-keyed save claims under the OS account home's `.hunk/document-save-claims/` directory. +This location is independent of per-session `HOME`, XDG and temporary-directory overrides. Account +lookup uses OS records, including built-in PowerShell without profiles on Windows; lookup or private +directory failures refuse writeback and retain the edited copy. Competing +saves are rejected with their editor copies retained; claims left by definitely dead processes are +removed. An unrelated process reusing an owner's PID can conservatively delay saving until it exits. +Save claims do not coordinate different hosts/accounts or container process namespaces, older Hunk +versions, or non-Hunk writers. +Those writers can still race the final conflict check, and in-place writeback is not crash-atomic. + Expanded directories and the displayed file's parent are observed for changes, covering atomic file replacements and deletions. Watch notifications are debounced; manual and watch refreshes serialize. A failed watcher can be worked around with `r`. Shutdown cancels pending source work, diff --git a/packages/hunk/src/app/documents/documentSaveAccount.ts b/packages/hunk/src/app/documents/documentSaveAccount.ts new file mode 100644 index 000000000..836fb3cfc --- /dev/null +++ b/packages/hunk/src/app/documents/documentSaveAccount.ts @@ -0,0 +1,70 @@ +import { execFile } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { isAbsolute, join } from "node:path"; +import { promisify } from "node:util"; + +const execute = promisify(execFile); + +/** Read bounded OS account metadata without loading shell profiles or repository configuration. */ +async function accountMetadata(command: string, args: string[]) { + const { stdout } = await execute(command, args, { + encoding: "utf8", + maxBuffer: 8192, + timeout: 5000, + windowsHide: true, + }); + return stdout.trim(); +} + +/** Resolve the account home from OS records, never session HOME/USERPROFILE overrides. + * Bun's userInfo/homedir projections honor environment overrides and cannot identify a namespace + * shared by differently configured windows. Missing account metadata fails closed before saving. + */ +export async function documentSaveAccountHome() { + let home: string | undefined; + if (process.platform === "win32") { + const powershell = join( + process.env.SystemRoot ?? "C:\\Windows", + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ); + home = await accountMetadata(powershell, [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); [Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile)", + ]); + } else { + const uid = process.getuid?.(); + if (uid === undefined) + throw new Error("Cannot identify the OS account for document save coordination."); + if (process.platform === "darwin") { + const record = await accountMetadata("/usr/bin/dscacheutil", [ + "-q", + "user", + "-a", + "uid", + String(uid), + ]); + home = /^dir:\s*(.+)$/m.exec(record)?.[1]?.trim(); + } else { + const local = await readFile("/etc/passwd", "utf8").catch(() => ""); + home = local + .split("\n") + .map((line) => line.split(":")) + .find((fields) => fields[2] === String(uid))?.[5]; + if (!home) { + const record = await accountMetadata("/usr/bin/getent", ["passwd", String(uid)]); + const fields = record.split(":"); + if (fields[2] === String(uid)) home = fields[5]; + } + } + } + + if (!home || !isAbsolute(home)) + throw new Error("Cannot locate the OS account home for document save coordination."); + return home; +} diff --git a/packages/hunk/src/app/documents/documentSaveClaim.test.ts b/packages/hunk/src/app/documents/documentSaveClaim.test.ts new file mode 100644 index 000000000..efaff7be8 --- /dev/null +++ b/packages/hunk/src/app/documents/documentSaveClaim.test.ts @@ -0,0 +1,194 @@ +import { afterEach, expect, test } from "bun:test"; +import { chmod, lstat, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createTestDeferred } from "../../../../../test/helpers/diff-helpers"; +import { withDocumentSaveClaim } from "./documentSaveClaim"; + +const roots: string[] = []; +const identity = { dev: 1, ino: 42 }; + +/** Create an account-private namespace independent of production coordination files. */ +async function createTestClaimDirectory() { + const directory = await mkdtemp(join(tmpdir(), "hunk-save-claims-test-")); + roots.push(directory); + return directory; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +test("a live reservation rejects the same inode while allowing unrelated documents", async () => { + const directory = await createTestClaimDirectory(); + const started = createTestDeferred(); + const release = createTestDeferred(); + const first = withDocumentSaveClaim( + identity, + async () => { + started.resolve(); + await release.promise; + }, + { directory }, + ); + + try { + await started.promise; + await expect( + withDocumentSaveClaim( + identity, + async () => { + throw new Error("Must not save"); + }, + { directory }, + ), + ).rejects.toThrow("Another Hunk session is saving"); + expect(await readdir(directory)).toHaveLength(1); + expect( + await withDocumentSaveClaim({ dev: 1, ino: 43 }, async () => "saved", { directory }), + ).toBe("saved"); + } finally { + release.resolve(); + await first; + } + expect(await readdir(directory)).toEqual([]); +}); + +test("failed save work releases only its own reservation", async () => { + const directory = await createTestClaimDirectory(); + await expect( + withDocumentSaveClaim( + identity, + async () => { + throw new Error("write failed"); + }, + { directory }, + ), + ).rejects.toThrow("write failed"); + expect(await readdir(directory)).toEqual([]); + expect(await withDocumentSaveClaim(identity, async () => "retry", { directory })).toBe("retry"); +}); + +test("definitely dead process reservations are removed without touching a replacement generation", async () => { + const directory = await createTestClaimDirectory(); + const child = Bun.spawn([process.execPath, "-e", "process.exit(0)"], { + stdout: "ignore", + stderr: "ignore", + }); + await child.exited; + let stale = ""; + await withDocumentSaveClaim( + identity, + async () => { + const name = (await readdir(directory))[0]!; + stale = name.replace(`-${process.pid}-`, `-${child.pid}-`); + await writeFile(join(directory, stale), "", { flag: "wx", mode: 0o600 }); + }, + { directory }, + ); + expect(await readdir(directory)).toEqual([stale]); + await withDocumentSaveClaim( + identity, + async () => { + expect(await readdir(directory)).toHaveLength(1); + }, + { directory }, + ); + expect(await readdir(directory)).toEqual([]); +}); + +test("separate processes cannot enter the same inode's critical section", async () => { + const directory = await createTestClaimDirectory(); + const started = createTestDeferred(); + const release = createTestDeferred(); + const first = withDocumentSaveClaim( + identity, + async () => { + started.resolve(); + await release.promise; + }, + { directory }, + ); + const module = new URL("./documentSaveClaim.ts", import.meta.url).href; + const output = join(directory, "child-result.txt"); + + try { + await started.promise; + const child = Bun.spawn( + [ + process.execPath, + "-e", + `import { withDocumentSaveClaim } from ${JSON.stringify(module)}; + import { writeFile } from "node:fs/promises"; + try { await withDocumentSaveClaim(${JSON.stringify(identity)}, async () => "unexpected", { directory: ${JSON.stringify(directory)} }); process.exit(2); } + catch(error) { await writeFile(${JSON.stringify(output)}, error.message); }`, + ], + { stdout: "pipe", stderr: "pipe" }, + ); + expect(await child.exited).toBe(0); + expect(await readFile(output, "utf8")).toContain("Another Hunk session is saving"); + } finally { + release.resolve(); + await first; + } +}); + +test("default reservations remain shared across session home and temporary-directory overrides", async () => { + const fixture = await createTestClaimDirectory(); + const file = join(fixture, "original.txt"); + await writeFile(file, "original"); + const info = await lstat(file); + const identity = { dev: info.dev, ino: info.ino }; + const started = createTestDeferred(); + const release = createTestDeferred(); + const first = withDocumentSaveClaim(identity, async () => { + started.resolve(); + await release.promise; + }); + const module = new URL("./documentSaveClaim.ts", import.meta.url).href; + const output = join(fixture, "result.txt"); + + try { + await started.promise; + const child = Bun.spawn( + [ + process.execPath, + "-e", + `import { withDocumentSaveClaim } from ${JSON.stringify(module)}; + import { writeFile } from "node:fs/promises"; + try { await withDocumentSaveClaim(${JSON.stringify(identity)}, async () => "unexpected"); process.exit(2); } + catch(error) { await writeFile(${JSON.stringify(output)}, error.message); }`, + ], + { + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + HOME: fixture, + USERPROFILE: fixture, + USER: "hunk-save-test", + TMPDIR: fixture, + TMP: fixture, + TEMP: fixture, + XDG_RUNTIME_DIR: fixture, + }, + }, + ); + expect(await child.exited).toBe(0); + expect(await readFile(output, "utf8")).toContain("Another Hunk session is saving"); + } finally { + release.resolve(); + await first; + } +}); + +test.skipIf(process.platform === "win32")( + "coordination refuses a publicly accessible namespace", + async () => { + const directory = await createTestClaimDirectory(); + await chmod(directory, 0o755); + await expect(withDocumentSaveClaim(identity, async () => null, { directory })).rejects.toThrow( + "account-private", + ); + }, +); diff --git a/packages/hunk/src/app/documents/documentSaveClaim.ts b/packages/hunk/src/app/documents/documentSaveClaim.ts new file mode 100644 index 000000000..b93613cd9 --- /dev/null +++ b/packages/hunk/src/app/documents/documentSaveClaim.ts @@ -0,0 +1,96 @@ +import { createHash, randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import { lstat, mkdir, open, readdir, readlink, rm } from "node:fs/promises"; +import { hostname } from "node:os"; +import { join } from "node:path"; +import { documentSaveAccountHome } from "./documentSaveAccount"; + +interface DocumentSaveIdentity { + dev: number; + ino: number; +} + +interface DocumentSaveClaimOptions { + directory?: string; +} + +/** Locate account/process-namespace coordination independently of session environment overrides. */ +async function documentSaveClaimDirectory() { + const home = await documentSaveAccountHome(); + + // A PID is meaningful only in its namespace; shared container homes must not reap host claims. + const namespace = process.platform === "linux" ? await readlink("/proc/self/ns/pid") : "host"; + const host = createHash("sha256").update(`${hostname()}\0${namespace}`).digest("hex"); + return join(home, ".hunk", "document-save-claims", host); +} + +/** Treat unknown process-access failures as live owners; only ESRCH permits stale-claim cleanup. */ +function claimOwnerIsAlive(pid: number) { + if (pid === process.pid) return true; + + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code !== "ESRCH"; + } +} + +/** Require an account-private directory before publishing or trusting save reservations. */ +async function prepareClaimDirectory(directory: string) { + await mkdir(directory, { recursive: true, mode: 0o700 }); + const info = await lstat(directory); + if (!info.isDirectory() || info.isSymbolicLink()) + throw new Error("Cannot secure the document save coordination directory."); + + if ( + typeof process.getuid === "function" && + (info.uid !== process.getuid() || (info.mode & 0o777) !== 0o700) + ) + throw new Error("Document save coordination requires an account-private directory."); +} + +/** Reserve one inode while saving it for Hunk writers in the same account and process namespace. + * Each writer publishes a unique claim before scanning for peers. Two contenders can both fail, + * but cannot both miss each other's still-live claim and enter writeback. Unique names also let + * readers remove definitely dead owners without accidentally deleting a replacement reservation. + * Keep inode keys and claim names compatible across releases. This coordinates cooperating Hunk + * writers in this namespace; it cannot lock out other applications or accounts. + */ +export async function withDocumentSaveClaim( + identity: DocumentSaveIdentity, + save: () => Promise, + { directory: configuredDirectory }: DocumentSaveClaimOptions = {}, +): Promise { + const directory = configuredDirectory ?? (await documentSaveClaimDirectory()); + await prepareClaimDirectory(directory); + const key = createHash("sha256").update(`${identity.dev}:${identity.ino}`).digest("hex"); + const prefix = `${key}-`; + const name = `${prefix}${process.pid}-${randomUUID()}.claim`; + const path = join(directory, name); + const handle = await open( + path, + constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | (constants.O_NOFOLLOW ?? 0), + 0o600, + ); + + try { + for (const peer of await readdir(directory, { withFileTypes: true })) { + if (peer.name === name || !peer.name.startsWith(prefix)) continue; + const owner = /^([1-9]\d*)-[0-9a-f-]{36}\.claim$/.exec(peer.name.slice(prefix.length)); + const pid = owner ? Number(owner[1]) : NaN; + if (!peer.isFile() || !Number.isSafeInteger(pid)) + throw new Error("Cannot verify an existing document save reservation."); + + if (claimOwnerIsAlive(pid)) + throw new Error( + "Another Hunk session is saving this document; original was not overwritten.", + ); + await rm(join(directory, peer.name), { force: true }); + } + return await save(); + } finally { + await handle.close(); + await rm(path, { force: true }); + } +} diff --git a/packages/hunk/src/app/documents/editDocument.ts b/packages/hunk/src/app/documents/editDocument.ts index 45d2760a5..b96886b36 100644 --- a/packages/hunk/src/app/documents/editDocument.ts +++ b/packages/hunk/src/app/documents/editDocument.ts @@ -10,10 +10,13 @@ import { writeDocumentBytes, } from "./documentBytes"; +import { withDocumentSaveClaim } from "./documentSaveClaim"; + interface DocumentEditIO { safePath: (key: string) => Promise; openCheckedPath: (key: string, flags: number) => Promise; read: DocumentSource["read"]; + saveClaimDirectory?: string; } /** Read only bounded regular UTF-8 output from an editor-owned copy. */ @@ -98,8 +101,15 @@ export async function editDocumentCopy( if (failure) throw new Error(failure); const bytes = await readEditorOutput(copy); - await assertOriginalUnchanged(key, opened, original.identity, io.read); - if (documentBytesIdentity(bytes) !== original.identity) await writeDocumentBytes(handle, bytes); + await withDocumentSaveClaim( + opened, + async () => { + await assertOriginalUnchanged(key, opened, original.identity, io.read); + if (documentBytesIdentity(bytes) !== original.identity) + await writeDocumentBytes(handle!, bytes); + }, + { directory: io.saveClaimDirectory }, + ); keepCopy = false; return null; diff --git a/packages/hunk/src/app/documents/filesystemSource.test.ts b/packages/hunk/src/app/documents/filesystemSource.test.ts index 612e22e27..d82b2101e 100644 --- a/packages/hunk/src/app/documents/filesystemSource.test.ts +++ b/packages/hunk/src/app/documents/filesystemSource.test.ts @@ -3,17 +3,19 @@ import { existsSync } from "node:fs"; import { createTestDeferred } from "../../../../../test/helpers/diff-helpers"; import { chmod, + link, mkdtemp, mkdir, opendir, readFile, + readdir, realpath, rename, rm, symlink, writeFile, } from "node:fs/promises"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { devNull, tmpdir } from "node:os"; import { createFilesystemSource, @@ -290,6 +292,58 @@ describe("filesystem documents", () => { } }); + test("independent sources and hard-link aliases cannot overlap original-file writeback", async () => { + for (const hardLink of [false, true]) { + const root = await createTestRoot(); + const file = join(root, "selected.txt"); + const other = hardLink ? join(root, "alias.txt") : file; + await writeFile(file, "O".repeat(1_000_000)); + if (hardLink) await link(file, other); + const saveClaimDirectory = join(root, "claims"); + const first = await createFilesystemSource(file, { saveClaimDirectory }); + const second = await createFilesystemSource(other, { saveClaimDirectory }); + const ready = createTestDeferred(); + const copies: string[] = []; + let launches = 0; + const launch = (index: number, text: string) => async (copy: string) => { + copies[index] = copy; + await writeFile(copy, text); + if (++launches === 2) ready.resolve(); + await ready.promise; + return null; + }; + + try { + const results = await Promise.all([ + first.edit!(first.root.key, launch(0, "A".repeat(500_000))), + second.edit!(second.root.key, launch(1, "B".repeat(900_000))), + ]); + expect(results.filter((result) => result === null).length).toBeLessThanOrEqual(1); + const expected = + results[0] === null + ? "A".repeat(500_000) + : results[1] === null + ? "B".repeat(900_000) + : "O".repeat(1_000_000); + expect(await readFile(file, "utf8")).toBe(expected); + expect(await readFile(other, "utf8")).toBe(expected); + for (let index = 0; index < results.length; index++) { + if (results[index] !== null) { + expect(results[index]).toContain("Editor copy retained in"); + expect(await readFile(copies[index]!, "utf8")).toBe( + index === 0 ? "A".repeat(500_000) : "B".repeat(900_000), + ); + } + } + expect(await readdir(saveClaimDirectory)).toEqual([]); + } finally { + await Promise.all( + copies.map((copy) => rm(dirname(copy), { recursive: true, force: true })), + ); + } + } + }); + test("refuses editor writeback after attempted atomic replacement and retains the edited copy", async () => { const root = await createTestRoot(); const file = join(root, "selected.txt"); diff --git a/packages/hunk/src/app/documents/filesystemSource.ts b/packages/hunk/src/app/documents/filesystemSource.ts index 92596f24d..ce10583a4 100644 --- a/packages/hunk/src/app/documents/filesystemSource.ts +++ b/packages/hunk/src/app/documents/filesystemSource.ts @@ -48,9 +48,11 @@ export async function createFilesystemSource( { openDirectory = opendir, spawn = Bun.spawn, + saveClaimDirectory, }: { openDirectory?: typeof opendir; spawn?: typeof Bun.spawn; + saveClaimDirectory?: string; } = {}, ): Promise { const input = resolve(inputPath); @@ -274,7 +276,12 @@ export async function createFilesystemSource( editing = true; try { - return await editDocumentCopy(key, launch, { safePath, openCheckedPath, read }); + return await editDocumentCopy(key, launch, { + safePath, + openCheckedPath, + read, + saveClaimDirectory, + }); } finally { editing = false; } From 85e8c227accd34355be4568f8c77afd4a6e3f927 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 6 Oct 2026 14:46:58 -0400 Subject: [PATCH 11/22] fix(documents): retain all editor failure notices through shutdown --- .../retained-document-editor-notices.md | 5 ++ .../src/ui/documents/DocumentApp.test.tsx | 4 +- .../hunk/src/ui/documents/controller.test.ts | 55 ++++++++++++++++++- packages/hunk/src/ui/documents/controller.ts | 12 ++-- .../src/ui/documents/runDocumentBrowser.tsx | 4 +- 5 files changed, 69 insertions(+), 11 deletions(-) create mode 100644 .changeset/retained-document-editor-notices.md diff --git a/.changeset/retained-document-editor-notices.md b/.changeset/retained-document-editor-notices.md new file mode 100644 index 000000000..bf780222a --- /dev/null +++ b/.changeset/retained-document-editor-notices.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Preserve every unique document editor failure notice and print its full recovery location after terminal teardown, even after later successful edits. diff --git a/packages/hunk/src/ui/documents/DocumentApp.test.tsx b/packages/hunk/src/ui/documents/DocumentApp.test.tsx index d387ac8d5..762adafa4 100644 --- a/packages/hunk/src/ui/documents/DocumentApp.test.tsx +++ b/packages/hunk/src/ui/documents/DocumentApp.test.tsx @@ -156,7 +156,9 @@ test("repeated editor actions launch once and quit waits for the recovery result await Bun.sleep(20); }); expect(quit).toHaveBeenCalledTimes(1); - expect(route.controller.shutdownEditNotice).toContain("recovery-directory"); + expect(route.controller.shutdownEditNotices).toEqual([ + "Conflict. Editor copy retained in recovery-directory.", + ]); } finally { pending.resolve(null); await route.controller.close(); diff --git a/packages/hunk/src/ui/documents/controller.test.ts b/packages/hunk/src/ui/documents/controller.test.ts index cc5eef5b9..19fd8eb8a 100644 --- a/packages/hunk/src/ui/documents/controller.test.ts +++ b/packages/hunk/src/ui/documents/controller.test.ts @@ -200,9 +200,9 @@ describe("document browser controller", () => { await pending; await shutdown; expect(closed).toBe(true); - expect(browser.shutdownEditNotice).toBe( + expect(browser.shutdownEditNotices).toEqual([ "Conflict. Editor copy retained in recovery-directory.", - ); + ]); } finally { result.resolve(null); await browser.close(); @@ -219,7 +219,56 @@ describe("document browser controller", () => { await browser.select("a"); await browser.editDisplayedDocument(async () => null); await browser.close(); - expect(browser.shutdownEditNotice).toBe("Editor copy retained in recovery-directory."); + expect(browser.shutdownEditNotices).toEqual(["Editor copy retained in recovery-directory."]); + } finally { + await browser.close(); + } + }); + + test("a conflict notice survives a successful edit and refresh through shutdown", async () => { + const fixture = createTestSource(); + const conflict = "Document changed while editing. Editor copy retained in first-recovery."; + let result: string | null = conflict; + fixture.source.edit = async () => result; + const browser = new DocumentBrowserController(fixture.source); + + try { + await browser.initialize(); + await browser.select("a"); + expect(await browser.editDisplayedDocument(async () => null)).toBe(conflict); + result = null; + expect(await browser.editDisplayedDocument(async () => null)).toBeNull(); + await browser.refresh(); + expect(browser.getSnapshot().notice).toBeNull(); + await browser.close(); + expect(browser.shutdownEditNotices).toEqual([conflict]); + } finally { + await browser.close(); + } + }); + + test("multiple editor failures retain all unique notices without inspecting their wording", async () => { + const fixture = createTestSource(); + const conflict = "Conflict. Editor copy retained in first-recovery."; + const failure = "Editor exited with status 1."; + const laterConflict = "Conflict. Editor copy retained in second-recovery."; + let result = conflict; + fixture.source.edit = async () => { + if (result === failure) throw new Error(failure); + return result; + }; + const browser = new DocumentBrowserController(fixture.source); + + try { + await browser.initialize(); + await browser.select("a"); + for (const notice of [conflict, failure, laterConflict, conflict, "", failure]) { + result = notice; + expect(await browser.editDisplayedDocument(async () => null)).toBe(notice); + } + expect(browser.getSnapshot().notice).toBe(failure); + await browser.close(); + expect(browser.shutdownEditNotices).toEqual([conflict, failure, laterConflict, ""]); } finally { await browser.close(); } diff --git a/packages/hunk/src/ui/documents/controller.ts b/packages/hunk/src/ui/documents/controller.ts index 3b9d79cd5..22939c35e 100644 --- a/packages/hunk/src/ui/documents/controller.ts +++ b/packages/hunk/src/ui/documents/controller.ts @@ -61,7 +61,7 @@ export class DocumentBrowserController { private readController?: AbortController; private closePromise?: Promise; private editPromise: Promise | null = null; - private editNotice: string | null = null; + private retainedEditNotices = new Set(); private snapshot: DocumentBrowserSnapshot; constructor(readonly source: DocumentSource) { @@ -83,9 +83,11 @@ export class DocumentBrowserController { return this.closed; } - /** Retain editor failures so recovery paths survive teardown even when no final frame paints. */ - get shutdownEditNotice() { - return this.editNotice; + /** Report every unique editor failure in occurrence order, including failures settled during close. + * Status frames do not acknowledge failures; later edits and refreshes never discard their notices. + */ + get shutdownEditNotices(): readonly string[] { + return [...this.retainedEditNotices]; } getSnapshot = () => this.snapshot; @@ -183,7 +185,7 @@ export class DocumentBrowserController { .then(() => edit.call(this.source, key, launch)) .catch((error) => (error instanceof Error ? error.message : String(error))) .then((notice) => { - this.editNotice = notice; + if (notice !== null) this.retainedEditNotices.add(notice); this.publish({ notice }); return notice; }) diff --git a/packages/hunk/src/ui/documents/runDocumentBrowser.tsx b/packages/hunk/src/ui/documents/runDocumentBrowser.tsx index fdb7c30ac..366c022d4 100644 --- a/packages/hunk/src/ui/documents/runDocumentBrowser.tsx +++ b/packages/hunk/src/ui/documents/runDocumentBrowser.tsx @@ -29,7 +29,7 @@ export async function runDocumentBrowser(bootstrap: DocumentBrowserBootstrap) { }); } finally { await controller.close(); - if (controller.shutdownEditNotice) - process.stderr.write(`${sanitizeTerminalLine(controller.shutdownEditNotice)}\n`); + for (const notice of controller.shutdownEditNotices) + process.stderr.write(`${sanitizeTerminalLine(notice)}\n`); } } From 8ea518c56d496f47c3e8208f82f788355aa9c30d Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 6 Oct 2026 14:50:28 -0400 Subject: [PATCH 12/22] fix(documents): reuse unchanged watch subscriptions --- .changeset/stable-document-watch-demand.md | 5 + .../documents/controller.filesystem.test.ts | 80 ++++++++++++++ .../hunk/src/ui/documents/controller.test.ts | 101 ++++++++++++++++++ packages/hunk/src/ui/documents/controller.ts | 17 ++- 4 files changed, 198 insertions(+), 5 deletions(-) create mode 100644 .changeset/stable-document-watch-demand.md create mode 100644 packages/hunk/src/ui/documents/controller.filesystem.test.ts diff --git a/.changeset/stable-document-watch-demand.md b/.changeset/stable-document-watch-demand.md new file mode 100644 index 000000000..fa7da6899 --- /dev/null +++ b/.changeset/stable-document-watch-demand.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Keep document browser watch subscriptions alive across unchanged refreshes so files created during directory metadata queries appear automatically. diff --git a/packages/hunk/src/ui/documents/controller.filesystem.test.ts b/packages/hunk/src/ui/documents/controller.filesystem.test.ts new file mode 100644 index 000000000..7be9b6567 --- /dev/null +++ b/packages/hunk/src/ui/documents/controller.filesystem.test.ts @@ -0,0 +1,80 @@ +import { expect, test } from "bun:test"; +import { writeFileSync } from "node:fs"; +import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createFilesystemSource } from "../../app/documents/filesystemSource"; +import { DocumentBrowserController } from "./controller"; + +/** Wait for real filesystem debounce and Git metadata work without requesting another refresh. */ +async function waitForTestEntry(browser: DocumentBrowserController, name: string) { + const deadline = Date.now() + 3000; + while (Date.now() < deadline) { + if (browser.getSnapshot().rows.some((row) => row.entry.name === name)) return; + await Bun.sleep(20); + } + + expect(browser.getSnapshot().rows.map((row) => row.entry.name)).toContain(name); +} + +for (const trigger of ["initial expansion", "manual refresh"] as const) { + test(`a file created during ${trigger} metadata queries survives watch debounce`, async () => { + const fixture = await realpath(await mkdtemp(join(tmpdir(), "hunk-document-watch-"))); + const root = join(fixture, "collection"); + let browser: DocumentBrowserController | undefined; + + try { + await mkdir(root); + await writeFile(join(root, "old.txt"), "old"); + expect(Bun.spawnSync(["git", "init", "--quiet"], { cwd: root }).exitCode).toBe(0); + let inject = trigger === "initial expansion"; + let injections = 0; + const source = await createFilesystemSource(root, { + spawn: ((...args: Parameters) => { + const [command, options] = args; + if (inject && Array.isArray(command) && command.includes("check-ignore")) { + inject = false; + injections++; + // Enumeration has finished, but the successful Git query has not. The real watch + // notification starts a 100ms debounce that must outlive this listing's completion. + writeFileSync(join(root, "new.txt"), "new"); + return Bun.spawn( + [ + process.execPath, + "-e", + `await Bun.sleep(25); + const child = Bun.spawn(${JSON.stringify(command)}, { + stdin: "inherit", stdout: "inherit", stderr: "inherit" + }); + process.exit(await child.exited);`, + ], + options, + ); + } + return Bun.spawn(...args); + }) as typeof Bun.spawn, + }); + const observe = source.observe!; + let subscriptions = 0; + source.observe = (keys, onChange) => { + subscriptions++; + return observe(keys, onChange); + }; + browser = new DocumentBrowserController(source); + await browser.initialize(); + if (trigger === "manual refresh") { + inject = true; + await browser.refresh(); + } + + expect(injections).toBe(1); + await waitForTestEntry(browser, "new.txt"); + expect(browser.getSnapshot().notice).toBeNull(); + expect(browser.getSnapshot().rows.some((row) => row.entry.name === "old.txt")).toBe(true); + expect(subscriptions).toBe(1); + } finally { + await browser?.close(); + await rm(fixture, { recursive: true, force: true }); + } + }); +} diff --git a/packages/hunk/src/ui/documents/controller.test.ts b/packages/hunk/src/ui/documents/controller.test.ts index 19fd8eb8a..215e04e42 100644 --- a/packages/hunk/src/ui/documents/controller.test.ts +++ b/packages/hunk/src/ui/documents/controller.test.ts @@ -274,6 +274,107 @@ describe("document browser controller", () => { } }); + test("unchanged demand reuses observation across initialization, reads and refreshes", async () => { + const fixture = createTestSource(); + const observe = mock(fixture.source.observe!); + fixture.source.observe = observe; + const browser = new DocumentBrowserController(fixture.source); + + try { + await browser.initialize(); + await browser.refresh(); + browser.toggleExcluded(); + expect(observe.mock.calls.map(([keys]) => keys)).toEqual([["root"]]); + + await browser.select("a"); + await browser.select("a"); + await browser.select("folder"); + await browser.refresh(); + expect(observe.mock.calls.map(([keys]) => keys)).toEqual([["root"], ["root", "a"]]); + + await browser.activate("folder"); + await browser.refresh(); + expect(observe.mock.calls.map(([keys]) => keys)).toEqual([ + ["root"], + ["root", "a"], + ["root", "folder", "a"], + ]); + expect(fixture.subscriptions()).toBe(1); + await browser.close(); + expect(fixture.subscriptions()).toBe(0); + await browser.refresh(); + expect(observe).toHaveBeenCalledTimes(3); + } finally { + await browser.close(); + } + }); + + test("demand changes during a listing replace observation once without restoring stale keys", async () => { + const fixture = createTestSource(); + const observe = mock(fixture.source.observe!); + fixture.source.observe = observe; + const listing = createTestDeferred>>(); + const originalList = fixture.source.list; + fixture.source.list = (key, signal) => + key === "folder" ? listing.promise : originalList(key, signal); + const browser = new DocumentBrowserController(fixture.source); + + try { + await browser.initialize(); + const expansion = browser.activate("folder"); + await browser.select("a"); + await browser.activate("folder"); + listing.resolve({ kind: "entries", entries: [] }); + await expansion; + await browser.refresh(); + expect(observe.mock.calls.map(([keys]) => keys)).toEqual([ + ["root"], + ["root", "folder"], + ["root", "folder", "a"], + ["root", "a"], + ]); + expect(fixture.subscriptions()).toBe(1); + expect(browser.getSnapshot().expanded.has("folder")).toBe(false); + } finally { + listing.resolve({ kind: "entries", entries: [] }); + await browser.close(); + } + }); + + test("a watch installed during refresh keeps its demand and queues its notification", async () => { + const fixture = createTestSource(); + const observe = mock(fixture.source.observe!); + fixture.source.observe = observe; + const browser = new DocumentBrowserController(fixture.source); + const listing = createTestDeferred(); + + try { + await browser.initialize(); + const originalList = fixture.source.list; + let delayed = false; + fixture.source.list = async (key, signal) => { + if (!delayed) { + delayed = true; + await listing.promise; + } + return originalList(key, signal); + }; + const refresh = browser.refresh(); + await browser.select("a"); + fixture.changes(); + listing.resolve(); + await refresh; + + expect(observe.mock.calls.map(([keys]) => keys)).toEqual([["root"], ["root", "a"]]); + expect(fixture.listings).toEqual(["root", "root", "root"]); + expect(fixture.reads).toEqual(["a", "a", "a"]); + expect(fixture.subscriptions()).toBe(1); + } finally { + listing.resolve(); + await browser.close(); + } + }); + test("watch/manual refreshes serialize, retain selection, and reread only demanded content", async () => { const fixture = createTestSource(); const browser = new DocumentBrowserController(fixture.source); diff --git a/packages/hunk/src/ui/documents/controller.ts b/packages/hunk/src/ui/documents/controller.ts index 22939c35e..68f21c4e5 100644 --- a/packages/hunk/src/ui/documents/controller.ts +++ b/packages/hunk/src/ui/documents/controller.ts @@ -56,6 +56,7 @@ export class DocumentBrowserController { private refreshPending = false; private closed = false; private stopObservation?: () => void; + private observedKeys?: ReadonlySet; private listingRequests = new Map>(); private pendingReads = new Set>(); private readController?: AbortController; @@ -305,14 +306,20 @@ export class DocumentBrowserController { return this.refreshPromise; } - /** Replace observation demand whenever expansion or document selection changes. */ + /** Replace observation only when expansion or document selection changes the demanded key set. */ private observe() { - this.stopObservation?.(); if (this.closed) return; - const keys = [...this.snapshot.expanded]; - if (this.snapshot.documentKey) keys.push(this.snapshot.documentKey); - this.stopObservation = this.source.observe?.(keys, () => { + const keys = new Set(this.snapshot.expanded); + if (this.snapshot.documentKey) keys.add(this.snapshot.documentKey); + // Completing a read must not dispose unchanged demand: source cleanup can cancel a pending + // notification for a change made after enumeration but before the listing was published. + const observedKeys = this.observedKeys; + if (observedKeys?.size === keys.size && [...keys].every((key) => observedKeys.has(key))) return; + + this.stopObservation?.(); + this.observedKeys = keys; + this.stopObservation = this.source.observe?.([...keys], () => { void this.refresh(); }); } From 46774b17b075d1a8f1a8a275bc0c0be972ab9f98 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 6 Oct 2026 14:49:20 -0400 Subject: [PATCH 13/22] fix(ui): keep document keyboard burst state live --- .changeset/tidy-document-keyboard-bursts.md | 5 + .../documents/DocumentApp.keyboard.test.tsx | 165 ++++++++++++++++++ .../hunk/src/ui/documents/DocumentApp.tsx | 51 ++++-- .../hunk/src/ui/documents/commands.test.ts | 2 +- packages/hunk/src/ui/documents/commands.ts | 10 +- .../documents/useDocumentInteractionState.ts | 28 +++ .../src/ui/documents/useDocumentKeyboard.ts | 10 +- .../src/ui/hooks/useMenuController.test.tsx | 32 ++++ .../hunk/src/ui/hooks/useMenuController.ts | 7 +- .../hooks/useThemeSelectorController.test.tsx | 27 +++ .../ui/hooks/useThemeSelectorController.ts | 7 + test/pty/open-keyboard-integration.test.ts | 89 ++++++++++ 12 files changed, 405 insertions(+), 28 deletions(-) create mode 100644 .changeset/tidy-document-keyboard-bursts.md create mode 100644 packages/hunk/src/ui/documents/DocumentApp.keyboard.test.tsx create mode 100644 packages/hunk/src/ui/documents/useDocumentInteractionState.ts create mode 100644 test/pty/open-keyboard-integration.test.ts diff --git a/.changeset/tidy-document-keyboard-bursts.md b/.changeset/tidy-document-keyboard-bursts.md new file mode 100644 index 000000000..2054ff204 --- /dev/null +++ b/.changeset/tidy-document-keyboard-bursts.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Keep document focus, help, themes, and menus consistent when keyboard or mouse actions arrive before the next render. diff --git a/packages/hunk/src/ui/documents/DocumentApp.keyboard.test.tsx b/packages/hunk/src/ui/documents/DocumentApp.keyboard.test.tsx new file mode 100644 index 000000000..62a026032 --- /dev/null +++ b/packages/hunk/src/ui/documents/DocumentApp.keyboard.test.tsx @@ -0,0 +1,165 @@ +import { expect, mock, test } from "bun:test"; +import { testRender } from "@opentui/react/test-utils"; +import { act } from "react"; +import type { UserKeyBinding } from "../../core/run/config"; +import { ThemeController } from "../theme/controller"; +import { availableThemes } from "../themes"; +import { DocumentApp } from "./DocumentApp"; +import { DocumentBrowserController } from "./controller"; + +/** Mount real document chrome with independently observable source capabilities. */ +async function createTestKeyboardApp(keybindings: Record = {}) { + const edit = mock(async () => null); + const list = mock(async () => ({ + kind: "entries" as const, + entries: [ + { key: "a", name: "a.txt", kind: "file" as const, hidden: false }, + { key: "b", name: "b.txt", kind: "file" as const, hidden: false }, + ], + })); + const controller = new DocumentBrowserController({ + root: { key: "root", name: "Collection", kind: "directory", hidden: false }, + list, + async read(key) { + return { + kind: "text", + text: Array.from({ length: 100 }, (_, index) => `${key} ROW ${index + 1}`).join("\n"), + identity: key, + }; + }, + edit, + }); + await controller.initialize(); + await controller.select("a"); + const themeController = new ThemeController({ initialTheme: "github-dark-default" }); + const quit = mock(() => undefined); + const setup = await testRender( + , + { width: 90, height: 20 }, + ); + await act(async () => setup.renderOnce()); + + /** Deliver several decoded keys without allowing a React render between them. */ + const burst = async (...keys: string[]) => { + await act(async () => { + const sequences: Record = { + tab: "\t", + down: "\x1b[B", + up: "\x1b[A", + right: "\x1b[C", + left: "\x1b[D", + return: "\r", + escape: "\x1b[27u", + f10: "\x1b[21~", + }; + for (const key of keys) setup.mockInput.pressKey(sequences[key] ?? key); + await Bun.sleep(20); + }); + await act(async () => setup.renderOnce()); + }; + + /** Dispose mounted UI and source work even if a burst assertion fails. */ + const close = async () => { + await act(async () => setup.renderer.destroy()); + await controller.close(); + }; + + return { setup, controller, themeController, edit, list, quit, burst, close }; +} + +test("help owns later keys in its opening burst and releases them in its closing burst", async () => { + const app = await createTestKeyboardApp(); + try { + await app.burst("?", "e", "q", "down"); + expect(app.setup.captureCharFrame()).toContain("Controls help"); + expect(app.edit).not.toHaveBeenCalled(); + expect(app.quit).not.toHaveBeenCalled(); + expect(app.controller.getSnapshot().selectedKey).toBe("a"); + + await app.burst("?", "e"); + expect(app.setup.captureCharFrame()).not.toContain("Controls help"); + expect(app.edit).toHaveBeenCalledTimes(1); + } finally { + await app.close(); + } +}); + +test("focus and sidebar changes route subsequent movement to the live surface", async () => { + const app = await createTestKeyboardApp(); + try { + await app.burst("tab", "down"); + expect(app.controller.getSnapshot().selectedKey).toBe("a"); + expect(app.setup.captureCharFrame()).not.toMatch(/a ROW 1\s*$/m); + expect(app.setup.captureCharFrame()).toContain("a ROW 2"); + + await app.burst("tab", "down"); + expect(app.controller.getSnapshot().selectedKey).toBe("b"); + await app.burst("s", "g"); + expect(app.controller.getSnapshot().selectedKey).toBe("b"); + await app.burst("s", "g"); + expect(app.controller.getSnapshot().selectedKey).toBe("root"); + } finally { + await app.close(); + } +}); + +test("theme opening, acceptance and cancellation update modal ownership within a burst", async () => { + const app = await createTestKeyboardApp(); + try { + await app.burst("t", "down", "e", "q"); + expect(app.edit).not.toHaveBeenCalled(); + expect(app.quit).not.toHaveBeenCalled(); + expect(app.controller.getSnapshot().selectedKey).toBe("a"); + const themes = availableThemes([], null); + const initialIndex = themes.findIndex((theme) => theme.id === "github-dark-default"); + await app.burst("return", "e"); + expect(app.themeController.getSnapshot().themeId).toBe( + themes[(initialIndex + 1) % themes.length]!.id, + ); + expect(app.edit).toHaveBeenCalledTimes(1); + + await app.burst("t", "escape", "e"); + expect(app.edit).toHaveBeenCalledTimes(2); + } finally { + await app.close(); + } +}); + +test("menu toggles and menu-launched themes use live ownership before a render", async () => { + const app = await createTestKeyboardApp(); + try { + await app.burst("f10", "f10", "e"); + expect(app.edit).toHaveBeenCalledTimes(1); + await app.burst("f10", "right", "return", "e", "down", "return"); + expect(app.edit).toHaveBeenCalledTimes(1); + expect(app.controller.getSnapshot().selectedKey).toBe("a"); + expect(app.themeController.getSnapshot().themeId).not.toBe("github-dark-default"); + } finally { + await app.close(); + } +}); + +test("pointer focus and menu help actions share the keyboard's live state", async () => { + const app = await createTestKeyboardApp(); + try { + await act(async () => { + await app.setup.mockMouse.click(55, 2); + app.setup.mockInput.pressArrow("down"); + }); + expect(app.controller.getSnapshot().selectedKey).toBe("a"); + await app.burst("tab", "down"); + expect(app.controller.getSnapshot().selectedKey).toBe("b"); + + await app.burst("f10", "right", "right", "return", "e"); + expect(app.setup.captureCharFrame()).toContain("Controls help"); + expect(app.edit).not.toHaveBeenCalled(); + } finally { + await app.close(); + } +}); diff --git a/packages/hunk/src/ui/documents/DocumentApp.tsx b/packages/hunk/src/ui/documents/DocumentApp.tsx index 2dfe0550b..e6fead20f 100644 --- a/packages/hunk/src/ui/documents/DocumentApp.tsx +++ b/packages/hunk/src/ui/documents/DocumentApp.tsx @@ -20,6 +20,7 @@ import type { ThemeController } from "../theme/controller"; import { DocumentContent, DocumentStatusBar } from "./DocumentContent"; import { buildDocumentCommands, buildDocumentMenus } from "./commands"; import { useDocumentKeyboard } from "./useDocumentKeyboard"; +import { useDocumentInteractionState } from "./useDocumentInteractionState"; import type { DocumentBrowserController } from "./controller"; /** Coordinate document navigation, editor launches and shared chrome without owning source I/O. @@ -42,15 +43,24 @@ export function DocumentApp({ const renderer = useRenderer(); const snapshot = useSyncExternalStore(controller.subscribe, controller.getSnapshot); - const [focus, setFocus] = useState<"tree" | "document">( - controller.source.root.kind === "directory" ? "tree" : "document", - ); - const [sidebar, setSidebar] = useState( - options.sidebar !== false && controller.source.root.kind === "directory", - ); + const interaction = useDocumentInteractionState({ + focus: controller.source.root.kind === "directory" ? "tree" : "document", + sidebar: options.sidebar !== false && controller.source.root.kind === "directory", + help: false, + }); + const { focus, sidebar, help } = interaction.state; + + /** Read focus after all preceding keyboard or pointer actions, even before React renders. */ + const isTreeFocused = () => { + const current = interaction.getSnapshot(); + return current.sidebar && current.focus === "tree"; + }; + + /** Close help from either its keyboard owner or its pointer dismiss action. */ + const closeHelp = () => interaction.update((current) => ({ ...current, help: false })); + const [numbers, setNumbers] = useState(options.lineNumbers !== false); const [wrap, setWrap] = useState(options.wrapLines ?? false); - const [help, setHelp] = useState(false); const notice = useTimedNotice(4000); @@ -97,7 +107,7 @@ export function DocumentApp({ const commands = buildDocumentCommands({ controller, - treeFocused, + isTreeFocused, height, scrollRef, edit: () => { @@ -110,10 +120,15 @@ export function DocumentApp({ void controller.refresh(); }, "hunk.app.toggleFocusArea": () => - setFocus((focus) => (focus === "tree" ? "document" : "tree")), - "hunk.app.toggleHelp": () => setHelp((help) => !help), + interaction.update((current) => ({ + ...current, + focus: current.focus === "tree" ? "document" : "tree", + })), + "hunk.app.toggleHelp": () => + interaction.update((current) => ({ ...current, help: !current.help })), "hunk.view.openThemeSelector": themeSelector.openThemeSelector, - "hunk.view.toggleFilesPane": () => setSidebar((value) => !value), + "hunk.view.toggleFilesPane": () => + interaction.update((current) => ({ ...current, sidebar: !current.sidebar })), "hunk.view.toggleLineNumbers": () => setNumbers((value) => !value), "hunk.view.toggleLineWrap": () => setWrap((value) => !value), }, @@ -127,7 +142,13 @@ export function DocumentApp({ }); const menu = useMenuController(menus); - useDocumentKeyboard({ help, closeHelp: () => setHelp(false), themeSelector, menu, commands }); + useDocumentKeyboard({ + isHelpOpen: () => interaction.getSnapshot().help, + closeHelp, + themeSelector, + menu, + commands, + }); const overlay = help || themeSelector.themeSelectorOpen || menu.activeMenuId !== null; return ( @@ -139,7 +160,7 @@ export function DocumentApp({ theme={theme} topTitle={`Open · ${sanitizeTerminalLine(controller.source.root.name)}`} onHoverMenu={(id) => { - if (menu.activeMenuId) menu.openMenu(id); + if (menu.getActiveMenuId()) menu.openMenu(id); }} onToggleMenu={menu.toggleMenu} /> @@ -159,7 +180,7 @@ export function DocumentApp({ scrollRef, visibleLineRef, }} - onFocus={setFocus} + onFocus={(focus) => interaction.update((current) => ({ ...current, focus }))} onActivate={(key) => { void controller.activate(key); }} @@ -215,7 +236,7 @@ export function DocumentApp({ terminalHeight={terminal.height} terminalWidth={terminal.width} theme={theme} - onClose={() => setHelp(false)} + onClose={closeHelp} /> ) : null} diff --git a/packages/hunk/src/ui/documents/commands.test.ts b/packages/hunk/src/ui/documents/commands.test.ts index b8b0e8be4..87c7ff1ee 100644 --- a/packages/hunk/src/ui/documents/commands.test.ts +++ b/packages/hunk/src/ui/documents/commands.test.ts @@ -35,7 +35,7 @@ async function createTestDocumentCommands(treeFocused: boolean) { }); const commands = buildDocumentCommands({ controller, - treeFocused, + isTreeFocused: () => treeFocused, height: 12, scrollRef: { current: { scrollBy, scrollTo, scrollHeight: 100 } }, edit, diff --git a/packages/hunk/src/ui/documents/commands.ts b/packages/hunk/src/ui/documents/commands.ts index c790ac843..bbb20272b 100644 --- a/packages/hunk/src/ui/documents/commands.ts +++ b/packages/hunk/src/ui/documents/commands.ts @@ -15,7 +15,7 @@ type DocumentScrollRef = { /** Bind document navigation to the current focus and snapshot without branching on command ids. */ export function buildDocumentCommands({ controller, - treeFocused, + isTreeFocused, height, scrollRef, edit, @@ -23,7 +23,7 @@ export function buildDocumentCommands({ resolvedKeys, }: { controller: DocumentBrowserController; - treeFocused: boolean; + isTreeFocused: () => boolean; height: number; scrollRef: DocumentScrollRef; edit: () => void; @@ -38,13 +38,13 @@ export function buildDocumentCommands({ /** Move through the focused tree or document by physical rows. */ const step = (delta: number) => { - if (treeFocused) controller.move(delta); + if (isTreeFocused()) controller.move(delta); else scrollRef.current?.scrollBy(delta); }; /** Reveal the beginning or end of the focused surface. */ const jump = (end: boolean) => { - if (treeFocused) controller.move(controller.getSnapshot().rows.length * (end ? 1 : -1)); + if (isTreeFocused()) controller.move(controller.getSnapshot().rows.length * (end ? 1 : -1)); else scrollRef.current?.scrollTo(end ? scrollRef.current.scrollHeight : 0); }; @@ -52,7 +52,7 @@ export function buildDocumentCommands({ const horizontal = (expand: boolean) => { const selected = selectedEntry(); - if (!treeFocused) { + if (!isTreeFocused()) { scrollRef.current?.scrollBy({ x: expand ? 4 : -4, y: 0 }); return; } diff --git a/packages/hunk/src/ui/documents/useDocumentInteractionState.ts b/packages/hunk/src/ui/documents/useDocumentInteractionState.ts new file mode 100644 index 000000000..40073d57b --- /dev/null +++ b/packages/hunk/src/ui/documents/useDocumentInteractionState.ts @@ -0,0 +1,28 @@ +import { useCallback, useRef, useState } from "react"; + +interface DocumentInteractionState { + focus: "tree" | "document"; + sidebar: boolean; + help: boolean; +} + +/** Publish focus and help changes synchronously for keyboard bursts and pointer actions. + * React renders the projection; command routing reads the state updated by every action. + */ +export function useDocumentInteractionState(initial: DocumentInteractionState) { + const liveState = useRef(initial); + const [state, setState] = useState(initial); + + const update = useCallback( + (change: (current: DocumentInteractionState) => DocumentInteractionState) => { + const next = change(liveState.current); + liveState.current = next; + setState(next); + }, + [], + ); + + const getSnapshot = useCallback(() => liveState.current, []); + + return { state, update, getSnapshot }; +} diff --git a/packages/hunk/src/ui/documents/useDocumentKeyboard.ts b/packages/hunk/src/ui/documents/useDocumentKeyboard.ts index feb605d92..d01991dd8 100644 --- a/packages/hunk/src/ui/documents/useDocumentKeyboard.ts +++ b/packages/hunk/src/ui/documents/useDocumentKeyboard.ts @@ -35,20 +35,20 @@ function handleMenuKey(key: KeyEvent, menu: MenuController) { /** Give help, themes and menus keyboard priority before dispatching document commands. */ export function useDocumentKeyboard({ - help, + isHelpOpen, closeHelp, themeSelector, menu, commands, }: { - help: boolean; + isHelpOpen: () => boolean; closeHelp: () => void; themeSelector: ThemeSelectorController; menu: MenuController; commands: readonly AppCommand[]; }) { useKeyboard((key) => { - if (help) { + if (isHelpOpen()) { if (key.name === "escape" || key.sequence === "?") { closeHelp(); key.preventDefault(); @@ -57,10 +57,10 @@ export function useDocumentKeyboard({ return; } - if (themeSelector.themeSelectorOpen) { + if (themeSelector.getThemeSelectorOpen()) { handleThemeKey(key, themeSelector); } else if (key.name === "f10") { - if (menu.activeMenuId) menu.closeMenu(); + if (menu.getActiveMenuId()) menu.closeMenu(); else menu.openMenu("file"); } else if (menu.getActiveMenuId()) { handleMenuKey(key, menu); diff --git a/packages/hunk/src/ui/hooks/useMenuController.test.tsx b/packages/hunk/src/ui/hooks/useMenuController.test.tsx index d5ab11cbf..d21cce53e 100644 --- a/packages/hunk/src/ui/hooks/useMenuController.test.tsx +++ b/packages/hunk/src/ui/hooks/useMenuController.test.tsx @@ -213,6 +213,38 @@ describe("useMenuController", () => { } }); + test("pointer toggles and highlight updates are visible to the next action before a render", async () => { + let controller!: ReturnType; + const ran: string[] = []; + + function Probe() { + controller = useMenuController({ + file: [ + { kind: "item", label: "Refresh", action: () => ran.push("refresh") }, + { kind: "item", label: "Edit", action: () => ran.push("edit") }, + ], + }); + return null; + } + + const setup = await testRender(, { width: 80, height: 24 }); + try { + await act(async () => setup.renderOnce()); + await act(async () => { + controller.toggleMenu("file"); + controller.toggleMenu("file"); + expect(controller.getActiveMenuId()).toBeNull(); + controller.openMenu("file"); + controller.setActiveMenuItemIndex(1); + controller.activateCurrentMenuItem(); + }); + expect(ran).toEqual(["edit"]); + expect(controller.activeMenuId).toBeNull(); + } finally { + await act(async () => setup.renderer.destroy()); + } + }); + test("cycling skips menus the session does not show", async () => { let controller!: ReturnType; diff --git a/packages/hunk/src/ui/hooks/useMenuController.ts b/packages/hunk/src/ui/hooks/useMenuController.ts index b5e874d7a..7e77a0ec6 100644 --- a/packages/hunk/src/ui/hooks/useMenuController.ts +++ b/packages/hunk/src/ui/hooks/useMenuController.ts @@ -56,7 +56,7 @@ export function useMenuController(menus: AppMenus) { }; const toggleMenu = (menuId: MenuId) => { - if (openMenuId === menuId) { + if (liveMenuId.current === menuId) { closeMenu(); return; } @@ -140,7 +140,10 @@ export function useMenuController(menus: AppMenus) { menuSpecs, moveMenuItem, openMenu, - setActiveMenuItemIndex, + setActiveMenuItemIndex: (index: number) => { + liveItemIndex.current = index; + setActiveMenuItemIndex(index); + }, switchMenu, toggleMenu, }; diff --git a/packages/hunk/src/ui/hooks/useThemeSelectorController.test.tsx b/packages/hunk/src/ui/hooks/useThemeSelectorController.test.tsx index 855440fdb..96f47f3bb 100644 --- a/packages/hunk/src/ui/hooks/useThemeSelectorController.test.tsx +++ b/packages/hunk/src/ui/hooks/useThemeSelectorController.test.tsx @@ -84,6 +84,33 @@ const noNotice = () => {}; describe("useThemeSelectorController", () => { afterEach(() => setDetectedTerminalColors(undefined)); + test("modal ownership changes synchronously for keyboard and pointer acceptance", async () => { + const harness = await renderThemeSelectorController({ + initialTheme: "github-dark-default", + transparentBackground: false, + onTransientNotice: noNotice, + }); + try { + await act(async () => { + const controller = harness.controller; + controller.openThemeSelector(); + expect(controller.getThemeSelectorOpen()).toBe(true); + controller.closeThemeSelector(); + expect(controller.getThemeSelectorOpen()).toBe(false); + controller.openThemeSelector(); + controller.acceptThemeSelectorItem(0); + expect(controller.getThemeSelectorOpen()).toBe(false); + controller.openThemeSelector(); + controller.moveThemeSelector(1); + controller.acceptThemeSelector(); + expect(controller.getThemeSelectorOpen()).toBe(false); + }); + expect(harness.controller.themeSelectorOpen).toBe(false); + } finally { + await destroyController(harness.setup); + } + }); + test("resolves auto initialization from the detected light or dark terminal mode", async () => { const light = await renderThemeSelectorController({ initialTheme: "auto", diff --git a/packages/hunk/src/ui/hooks/useThemeSelectorController.ts b/packages/hunk/src/ui/hooks/useThemeSelectorController.ts index 87853948a..0c429fbcd 100644 --- a/packages/hunk/src/ui/hooks/useThemeSelectorController.ts +++ b/packages/hunk/src/ui/hooks/useThemeSelectorController.ts @@ -33,6 +33,9 @@ export function useThemeSelectorController({ selectedThemeId: null, })); + // Modal ownership changes at the action, not at React's next render. + const liveOpen = useRef(false); + // `terminalColors` is not passed along: the `terminal` theme reads the probed colors itself, // and listing it as a dependency re-derives every theme after the terminal switches schemes. const themeOptions = useMemo( @@ -107,6 +110,7 @@ export function useThemeSelectorController({ /** Open the selector on the resolved committed theme without starting a preview. */ const openThemeSelector = useCallback(() => { + liveOpen.current = true; selectedThemeIdRef.current = committedTheme.id; setState((current) => ({ ...current, @@ -118,6 +122,7 @@ export function useThemeSelectorController({ /** Cancel the selector and restore the committed theme projection. */ const closeThemeSelector = useCallback(() => { + liveOpen.current = false; setState((current) => ({ ...current, open: false, previewThemeId: null })); }, []); @@ -170,6 +175,7 @@ export function useThemeSelectorController({ /** Commit one validated item and clear its preview without an intermediate theme. */ const commitThemeSelectorItem = useCallback( (item: (typeof themeOptions)[number]) => { + liveOpen.current = false; selectedThemeIdRef.current = item.id; setState((current) => ({ ...current, @@ -204,6 +210,7 @@ export function useThemeSelectorController({ themeId: committedThemeId, themeSelectorItems: items, themeSelectorOpen: state.open, + getThemeSelectorOpen: () => liveOpen.current, themeSelectorSelectedIndex: selectedIndex, acceptThemeSelector, acceptThemeSelectorItem, diff --git a/test/pty/open-keyboard-integration.test.ts b/test/pty/open-keyboard-integration.test.ts new file mode 100644 index 000000000..3c6a3b4fd --- /dev/null +++ b/test/pty/open-keyboard-integration.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createPtyHarness } from "./harness"; + +const harness = createPtyHarness(); +const roots: string[] = []; +setDefaultTimeout(30_000); + +afterEach(() => { + harness.cleanup(); + roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })); +}); + +/** Create complete documents and an editor whose invocation is observable outside the terminal. */ +function createTestOpenKeyboardFixture() { + const root = mkdtempSync(join(tmpdir(), "hunk-open-keyboard-")); + roots.push(root); + writeFileSync( + join(root, "a.txt"), + Array.from({ length: 100 }, (_, index) => `FIRST_DOCUMENT_ROW_${index + 1}`).join("\n"), + ); + writeFileSync(join(root, "b.txt"), "SECOND_DOCUMENT\n"); + const marker = join(root, "editor-invoked"); + const editor = join(root, "editor.mjs"); + writeFileSync( + editor, + `import { writeFileSync } from "node:fs"; writeFileSync(${JSON.stringify(marker)}, "invoked");`, + ); + return { root, marker, env: { EDITOR: `"${process.execPath}" "${editor}"` } }; +} + +describe("open keyboard bursts", () => { + test("raw help and theme bursts block editor commands until their modal closes", async () => { + const fixture = createTestOpenKeyboardFixture(); + const session = await harness.launchHunk({ + args: ["open", join(fixture.root, "a.txt")], + cols: 100, + rows: 20, + env: fixture.env, + }); + try { + await session.waitForText("FIRST_DOCUMENT_ROW_1"); + await harness.ensureKeyboardIsLive(session); + session.writeRaw("?eq"); + await session.waitForText("Controls help"); + expect(existsSync(fixture.marker)).toBe(false); + await session.press("?"); + await harness.waitForSnapshot(session, (text) => !text.includes("Controls help")); + + session.writeRaw("t\x1b[Beq"); + await session.waitForText("Theme selector"); + expect(existsSync(fixture.marker)).toBe(false); + session.writeRaw("\re"); + await harness.waitForSnapshot( + session, + (text) => !text.includes("Theme selector") && existsSync(fixture.marker), + ); + await session.press("q"); + } finally { + session.close(); + } + }); + + test("raw Tab/Down scrolls the newly focused document instead of selecting the next file", async () => { + const fixture = createTestOpenKeyboardFixture(); + const session = await harness.launchHunk({ args: ["open", fixture.root], cols: 100, rows: 20 }); + try { + await session.waitForText("a.txt"); + await harness.ensureKeyboardIsLive(session); + await session.click(/a\.txt/, { first: true }); + await session.waitForText("FIRST_DOCUMENT_ROW_1"); + session.writeRaw("\t\x1b[B"); + const frame = await harness.waitForSnapshot( + session, + (text) => + text.includes("Document · read-only") && + text.includes("FIRST_DOCUMENT_ROW_2") && + !/FIRST_DOCUMENT_ROW_1\b/.test(text), + ); + expect(frame).not.toContain("SECOND_DOCUMENT"); + expect(frame).toContain("Document · read-only"); + await session.press("q"); + } finally { + session.close(); + } + }); +}); From 530cd24f6509183862d136221ea4ce8f9241250f Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 6 Oct 2026 14:52:05 -0400 Subject: [PATCH 14/22] fix(ui): dispatch document menu accelerators --- .../clear-document-menu-accelerators.md | 5 + .../documents/DocumentApp.keyboard.test.tsx | 123 +++++++++++++++++- .../src/ui/documents/useDocumentKeyboard.ts | 32 +++-- test/pty/open-keyboard-integration.test.ts | 91 ++++++++++++- 4 files changed, 240 insertions(+), 11 deletions(-) create mode 100644 .changeset/clear-document-menu-accelerators.md diff --git a/.changeset/clear-document-menu-accelerators.md b/.changeset/clear-document-menu-accelerators.md new file mode 100644 index 000000000..1ed530ff7 --- /dev/null +++ b/.changeset/clear-document-menu-accelerators.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Allow document menu shortcuts to refresh, edit, and quit with configured keybindings while keeping help and theme dialogs isolated. diff --git a/packages/hunk/src/ui/documents/DocumentApp.keyboard.test.tsx b/packages/hunk/src/ui/documents/DocumentApp.keyboard.test.tsx index 62a026032..c565f3ace 100644 --- a/packages/hunk/src/ui/documents/DocumentApp.keyboard.test.tsx +++ b/packages/hunk/src/ui/documents/DocumentApp.keyboard.test.tsx @@ -64,13 +64,26 @@ async function createTestKeyboardApp(keybindings: Record await act(async () => setup.renderOnce()); }; + /** Click the actual rendered menu entry so pointer and shortcut tests share command effects. */ + const clickMenuItem = async (label: string) => { + const lines = setup.captureCharFrame().split("\n"); + const y = lines.findIndex((line) => line.includes(label)); + if (y < 0) throw new Error(`Missing menu entry: ${label}`); + const x = lines[y]!.indexOf(label); + await act(async () => { + await setup.mockMouse.click(x + 1, y); + await Bun.sleep(20); + }); + await act(async () => setup.renderOnce()); + }; + /** Dispose mounted UI and source work even if a burst assertion fails. */ const close = async () => { await act(async () => setup.renderer.destroy()); await controller.close(); }; - return { setup, controller, themeController, edit, list, quit, burst, close }; + return { setup, controller, themeController, edit, list, quit, burst, clickMenuItem, close }; } test("help owns later keys in its opening burst and releases them in its closing burst", async () => { @@ -163,3 +176,111 @@ test("pointer focus and menu help actions share the keyboard's live state", asyn await app.close(); } }); + +test("advertised menu accelerators and pointer entries dispatch the same commands and close the menu", async () => { + const app = await createTestKeyboardApp(); + try { + const initialLists = app.list.mock.calls.length; + await app.burst("f10", "r"); + expect(app.list).toHaveBeenCalledTimes(initialLists + 1); + expect(app.setup.captureCharFrame()).not.toContain("Refresh documents"); + await app.burst("f10"); + await app.clickMenuItem("Refresh documents"); + expect(app.list).toHaveBeenCalledTimes(initialLists + 2); + expect(app.setup.captureCharFrame()).not.toContain("Refresh documents"); + + await app.burst("f10", "e"); + expect(app.edit).toHaveBeenCalledTimes(1); + expect(app.setup.captureCharFrame()).not.toContain("Open file in $EDITOR"); + await app.burst("f10"); + await app.clickMenuItem("Open file in $EDITOR"); + expect(app.edit).toHaveBeenCalledTimes(2); + expect(app.setup.captureCharFrame()).not.toContain("Open file in $EDITOR"); + + await app.burst("f10", "q"); + expect(app.quit).toHaveBeenCalledTimes(1); + expect(app.setup.captureCharFrame()).not.toContain("Refresh documents"); + await app.burst("f10"); + await app.clickMenuItem("Quit"); + expect(app.quit).toHaveBeenCalledTimes(2); + expect(app.setup.captureCharFrame()).not.toContain("Refresh documents"); + } finally { + await app.close(); + } +}); + +test("menu accelerators honor remaps while unrecognized keys leave the menu open", async () => { + const app = await createTestKeyboardApp({ + "hunk.app.refresh": "x", + "hunk.documents.edit": "o", + "hunk.app.quit": "z", + }); + try { + const initialLists = app.list.mock.calls.length; + await app.burst("f10", "r", "e", "q", "!"); + expect(app.list).toHaveBeenCalledTimes(initialLists); + expect(app.edit).not.toHaveBeenCalled(); + expect(app.quit).not.toHaveBeenCalled(); + expect(app.setup.captureCharFrame()).toMatch(/Refresh documents\s+x/); + expect(app.setup.captureCharFrame()).toMatch(/Open file in \$EDITOR\s+o/); + expect(app.setup.captureCharFrame()).toMatch(/Quit\s+z/); + + await app.burst("x"); + expect(app.list).toHaveBeenCalledTimes(initialLists + 1); + expect(app.setup.captureCharFrame()).not.toContain("Refresh documents"); + await app.burst("f10", "o"); + expect(app.edit).toHaveBeenCalledTimes(1); + await app.burst("f10", "z"); + expect(app.quit).toHaveBeenCalledTimes(1); + } finally { + await app.close(); + } +}); + +test("unbound menu commands remain callable by pointer or menu navigation but not their old keys", async () => { + const app = await createTestKeyboardApp({ + "hunk.app.refresh": false, + "hunk.documents.edit": false, + "hunk.app.quit": false, + }); + try { + const initialLists = app.list.mock.calls.length; + await app.burst("f10", "r", "e", "q"); + expect(app.list).toHaveBeenCalledTimes(initialLists); + expect(app.edit).not.toHaveBeenCalled(); + expect(app.quit).not.toHaveBeenCalled(); + expect(app.setup.captureCharFrame()).toContain("Refresh documents"); + + await app.burst("return"); + expect(app.list).toHaveBeenCalledTimes(initialLists + 1); + await app.burst("f10", "down", "return"); + expect(app.edit).toHaveBeenCalledTimes(1); + await app.burst("f10"); + await app.clickMenuItem("Quit"); + expect(app.quit).toHaveBeenCalledTimes(1); + } finally { + await app.close(); + } +}); + +test("menu navigation owns arrows and Tab while help and themes still isolate command accelerators", async () => { + const app = await createTestKeyboardApp(); + try { + await app.burst("f10", "down", "return"); + expect(app.edit).toHaveBeenCalledTimes(1); + expect(app.controller.getSnapshot().selectedKey).toBe("a"); + await app.burst("f10", "tab", "return", "e", "q"); + expect(app.setup.captureCharFrame()).toContain("Theme selector"); + expect(app.edit).toHaveBeenCalledTimes(1); + expect(app.quit).not.toHaveBeenCalled(); + + await app.burst("escape", "f10", "?", "e", "q"); + expect(app.setup.captureCharFrame()).toContain("Controls help"); + expect(app.edit).toHaveBeenCalledTimes(1); + expect(app.quit).not.toHaveBeenCalled(); + await app.burst("?"); + expect(app.setup.captureCharFrame()).not.toContain("Refresh documents"); + } finally { + await app.close(); + } +}); diff --git a/packages/hunk/src/ui/documents/useDocumentKeyboard.ts b/packages/hunk/src/ui/documents/useDocumentKeyboard.ts index d01991dd8..99fb14217 100644 --- a/packages/hunk/src/ui/documents/useDocumentKeyboard.ts +++ b/packages/hunk/src/ui/documents/useDocumentKeyboard.ts @@ -19,18 +19,24 @@ function handleThemeKey(key: KeyEvent, selector: ThemeSelectorController) { actions[key.name]?.(); } -/** Route open-menu navigation independently of document focus. */ +/** Consume menu navigation while leaving command accelerators available to the command table. */ function handleMenuKey(key: KeyEvent, menu: MenuController) { const actions: Record void> = { escape: menu.closeMenu, left: () => menu.switchMenu(-1), right: () => menu.switchMenu(1), + tab: () => menu.switchMenu(1), up: () => menu.moveMenuItem(-1), down: () => menu.moveMenuItem(1), return: menu.activateCurrentMenuItem, + enter: menu.activateCurrentMenuItem, }; - actions[key.name]?.(); + const action = actions[key.name]; + if (!action) return false; + + action(); + return true; } /** Give help, themes and menus keyboard priority before dispatching document commands. */ @@ -59,15 +65,25 @@ export function useDocumentKeyboard({ if (themeSelector.getThemeSelectorOpen()) { handleThemeKey(key, themeSelector); - } else if (key.name === "f10") { + key.preventDefault(); + return; + } + + if (key.name === "f10") { if (menu.getActiveMenuId()) menu.closeMenu(); else menu.openMenu("file"); - } else if (menu.getActiveMenuId()) { - handleMenuKey(key, menu); - } else { - dispatchAppCommand(commands, key); + key.preventDefault(); return; } - key.preventDefault(); + + if (menu.getActiveMenuId() && handleMenuKey(key, menu)) { + key.preventDefault(); + return; + } + + // Like history menus, a matched document command closes the dropdown; + // unbound or unknown keys neither run an action nor dismiss it. + const command = dispatchAppCommand(commands, key); + if (command) menu.closeMenu(); }); } diff --git a/test/pty/open-keyboard-integration.test.ts b/test/pty/open-keyboard-integration.test.ts index 3c6a3b4fd..eba39b9eb 100644 --- a/test/pty/open-keyboard-integration.test.ts +++ b/test/pty/open-keyboard-integration.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; -import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { createPtyHarness } from "./harness"; @@ -26,7 +26,7 @@ function createTestOpenKeyboardFixture() { const editor = join(root, "editor.mjs"); writeFileSync( editor, - `import { writeFileSync } from "node:fs"; writeFileSync(${JSON.stringify(marker)}, "invoked");`, + `import { appendFileSync } from "node:fs"; appendFileSync(${JSON.stringify(marker)}, "invoked\\n");`, ); return { root, marker, env: { EDITOR: `"${process.execPath}" "${editor}"` } }; } @@ -87,3 +87,90 @@ describe("open keyboard bursts", () => { } }); }); + +describe("open menu accelerators", () => { + test("mouse entries and raw refresh/edit/quit accelerators share menu closing and command effects", async () => { + const fixture = createTestOpenKeyboardFixture(); + const session = await harness.launchHunk({ + args: ["open", join(fixture.root, "a.txt")], + cols: 100, + rows: 20, + env: fixture.env, + }); + try { + await session.waitForText("FIRST_DOCUMENT_ROW_1"); + await harness.ensureKeyboardIsLive(session); + await session.click(/File/, { first: true }); + await session.waitForText("Refresh documents"); + await session.click(/Refresh documents/, { first: true }); + await harness.waitForSnapshot(session, (text) => !text.includes("Refresh documents")); + + await session.press("f10"); + await session.waitForText("Refresh documents"); + session.writeRaw("r"); + await harness.waitForSnapshot(session, (text) => !text.includes("Refresh documents")); + await session.click(/File/, { first: true }); + await session.waitForText("Open file in $EDITOR"); + await session.click(/Open file in \$EDITOR/, { first: true }); + await harness.waitForSnapshot( + session, + (text) => !text.includes("Refresh documents") && existsSync(fixture.marker), + ); + expect(readFileSync(fixture.marker, "utf8").trim().split("\n")).toHaveLength(1); + + session.writeRaw("\x1b[21~e"); + await harness.waitForSnapshot( + session, + (text) => + !text.includes("Refresh documents") && + readFileSync(fixture.marker, "utf8").trim().split("\n").length === 2, + ); + const quitOutputStart = session.getRawOutput().length; + session.writeRaw("\x1b[21~q"); + await harness.waitForSnapshot(session, () => + session.getRawOutput().slice(quitOutputStart).includes("\x1b[?1049l"), + ); + } finally { + session.close(); + } + }); + + test("raw menu accelerators respect configured remaps and ignore the replaced defaults", async () => { + const fixture = createTestOpenKeyboardFixture(); + const configHome = harness.createIsolatedConfigHome(); + const configDir = join(configHome, "hunk"); + mkdirSync(configDir, { recursive: true }); + writeFileSync( + join(configDir, "config.toml"), + '[keybindings]\n"hunk.app.refresh" = "x"\n"hunk.documents.edit" = "o"\n"hunk.app.quit" = "z"\n', + ); + const session = await harness.launchHunk({ + args: ["open", join(fixture.root, "a.txt")], + cols: 100, + rows: 20, + env: { ...fixture.env, XDG_CONFIG_HOME: configHome }, + }); + try { + await session.waitForText("FIRST_DOCUMENT_ROW_1"); + await harness.ensureKeyboardIsLive(session); + session.writeRaw("\x1b[21~req"); + await session.waitForText("Refresh documents"); + expect(existsSync(fixture.marker)).toBe(false); + expect(await session.text()).toMatch(/Open file in \$EDITOR\s+o/); + await session.press("x"); + await harness.waitForSnapshot(session, (text) => !text.includes("Refresh documents")); + session.writeRaw("\x1b[21~o"); + await harness.waitForSnapshot( + session, + (text) => !text.includes("Refresh documents") && existsSync(fixture.marker), + ); + const quitOutputStart = session.getRawOutput().length; + session.writeRaw("\x1b[21~z"); + await harness.waitForSnapshot(session, () => + session.getRawOutput().slice(quitOutputStart).includes("\x1b[?1049l"), + ); + } finally { + session.close(); + } + }); +}); From 0c27ac173c3c51767b854dcabff169be9b36faf8 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 6 Oct 2026 14:55:44 -0400 Subject: [PATCH 15/22] fix(ui): preserve logical document scroll anchors after layout --- .changeset/fresh-documents-scroll.md | 5 + .../src/ui/documents/DocumentPane.test.tsx | 185 +++++++++++++++++- .../hunk/src/ui/documents/DocumentPane.tsx | 31 ++- .../hunk/src/ui/documents/geometry.test.ts | 21 ++ packages/hunk/src/ui/documents/geometry.ts | 27 +++ .../ui/documents/useDocumentScrollAnchor.ts | 108 ++++++++++ .../hunk/src/ui/documents/useRowViewport.ts | 9 +- test/pty/open-scroll-integration.test.ts | 103 ++++++++++ 8 files changed, 465 insertions(+), 24 deletions(-) create mode 100644 .changeset/fresh-documents-scroll.md create mode 100644 packages/hunk/src/ui/documents/useDocumentScrollAnchor.ts create mode 100644 test/pty/open-scroll-integration.test.ts diff --git a/.changeset/fresh-documents-scroll.md b/.changeset/fresh-documents-scroll.md new file mode 100644 index 000000000..ce698a8a6 --- /dev/null +++ b/.changeset/fresh-documents-scroll.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Preserve the visible source line in complete documents when wrapping, resizing, toggling line numbers, or refreshing shortened files. diff --git a/packages/hunk/src/ui/documents/DocumentPane.test.tsx b/packages/hunk/src/ui/documents/DocumentPane.test.tsx index fe2a2f14d..30a141b3b 100644 --- a/packages/hunk/src/ui/documents/DocumentPane.test.tsx +++ b/packages/hunk/src/ui/documents/DocumentPane.test.tsx @@ -1,9 +1,10 @@ import { expect, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; -import { act } from "react"; +import { act, useState } from "react"; import type { ScrollBoxRenderable } from "@opentui/core"; import { THEMES } from "../themes"; import { DocumentPane } from "./DocumentPane"; +import { completeDocumentGeometry, completeDocumentLines } from "./geometry"; /** Flush native layout and viewport notifications without depending on syntax arrival. */ async function flushTestDocumentLayout(setup: Awaited>) { @@ -11,7 +12,9 @@ async function flushTestDocumentLayout(setup: Awaited { + await setup.renderOnce(); + }); } test("wrapped documents use the scrollbar-adjusted viewport and retain right-edge characters after resize", async () => { @@ -52,3 +55,181 @@ test("wrapped documents use the scrollbar-adjusted viewport and retain right-edg await act(async () => setup.renderer.destroy()); } }); + +/** Build identifiable, long source lines for deep logical anchoring tests. */ +function createTestDocumentText(count = 300, suffix = "x".repeat(90)) { + return Array.from( + { length: count }, + (_, index) => `LINE_${String(index + 1).padStart(3, "0")} ${suffix}`, + ).join("\n"); +} + +/** Mount a stateful pane so tests exercise React commits followed by real native layout. */ +async function createTestDocumentPane(wrap = false, text = createTestDocumentText()) { + const scrollRef = { current: null as ScrollBoxRenderable | null }; + const visibleLineRef = { current: () => 1 }; + const initial = { text, wrap, lineNumbers: false, documentKey: "file" }; + let update!: (patch: Partial) => void; + + function TestDocumentPane() { + const [options, setOptions] = useState(initial); + update = (patch) => setOptions((previous) => ({ ...previous, ...patch })); + return ( + + ); + } + + const setup = await testRender(, { width: 40, height: 12 }); + await flushTestDocumentLayout(setup); + return { + setup, + scrollRef, + visibleLineRef, + update: async (patch: Partial) => { + await act(async () => update(patch)); + }, + scroll: async (top: number) => { + await act(async () => scrollRef.current!.scrollTo(top)); + await flushTestDocumentLayout(setup); + }, + close: async () => { + await act(async () => setup.renderer.destroy()); + }, + }; +} + +test("deep wrap toggles wait for new bounds and preserve the source line in both directions", async () => { + const pane = await createTestDocumentPane(); + try { + await pane.scroll(250); + expect(pane.visibleLineRef.current()).toBe(251); + await act(async () => pane.scrollRef.current!.scrollTo({ x: 40, y: 250 })); + await pane.update({ wrap: true }); + // React has planned the new rows, but native content bounds still belong to the old plan. + expect(pane.scrollRef.current!.scrollTop).toBe(250); + await flushTestDocumentLayout(pane.setup); + expect(pane.scrollRef.current!.scrollLeft).toBe(0); + expect(pane.visibleLineRef.current()).toBe(251); + expect(pane.scrollRef.current!.scrollTop).toBeGreaterThan(288); + expect(pane.setup.captureCharFrame()).toContain("LINE_251"); + await pane.update({ wrap: false }); + await flushTestDocumentLayout(pane.setup); + expect(pane.scrollRef.current!.scrollTop).toBe(250); + expect(pane.visibleLineRef.current()).toBe(251); + } finally { + await pane.close(); + } +}); + +test("shrinking past the visible line clamps to the surviving end rather than the start", async () => { + const pane = await createTestDocumentPane(true); + try { + await pane.scroll(800); + expect(pane.visibleLineRef.current()).toBe(201); + await pane.update({ text: createTestDocumentText(100) }); + await flushTestDocumentLayout(pane.setup); + const scroll = pane.scrollRef.current!; + expect(scroll.scrollTop).toBe(scroll.scrollHeight - scroll.viewport.height); + expect(pane.setup.captureCharFrame()).toContain("LINE_100"); + expect(pane.visibleLineRef.current()).toBeGreaterThan(90); + await pane.update({ text: "" }); + await flushTestDocumentLayout(pane.setup); + expect(scroll.scrollTop).toBe(0); + expect(pane.setup.captureCharFrame()).toContain("Empty file."); + } finally { + await pane.close(); + } +}); + +test("width and line-number changes preserve a logical line and its surviving physical offset", async () => { + // At 39 cells this is three physical rows; the gutter adds a fourth. + const pane = await createTestDocumentPane(true, createTestDocumentText(300, "x".repeat(104))); + try { + const rows = completeDocumentGeometry( + completeDocumentLines(createTestDocumentText(300, "x".repeat(104))), + 39, + false, + true, + ).rows; + await pane.scroll(rows[200]!.start + 1); + expect(pane.visibleLineRef.current()).toBe(201); + await pane.update({ lineNumbers: true }); + await flushTestDocumentLayout(pane.setup); + expect(pane.visibleLineRef.current()).toBe(201); + expect(pane.scrollRef.current!.scrollTop).toBe(801); + await act(async () => pane.setup.resize(25, 12)); + await flushTestDocumentLayout(pane.setup); + expect(pane.visibleLineRef.current()).toBe(201); + const scroll = pane.scrollRef.current!; + const geometry = completeDocumentGeometry( + completeDocumentLines(createTestDocumentText(300, "x".repeat(104))), + scroll.viewport.width, + true, + true, + ); + expect(scroll.scrollTop).toBe(geometry.rows[200]!.start + 1); + expect(scroll.scrollHeight).toBe(geometry.totalHeight); + await act(async () => pane.setup.resize(80, 10)); + await flushTestDocumentLayout(pane.setup); + expect(pane.visibleLineRef.current()).toBe(201); + await pane.update({ lineNumbers: false, wrap: false }); + await flushTestDocumentLayout(pane.setup); + expect(scroll.scrollTop).toBe(200); + expect(pane.visibleLineRef.current()).toBe(201); + } finally { + await pane.close(); + } +}); + +test("same-document replacement retains the line but switching documents cancels restoration", async () => { + const pane = await createTestDocumentPane(); + try { + await pane.scroll(250); + await pane.update({ text: createTestDocumentText(300, "changed ".repeat(20)), wrap: true }); + await flushTestDocumentLayout(pane.setup); + expect(pane.visibleLineRef.current()).toBe(251); + await pane.update({ wrap: false }); + // Switch before native layout has applied the previous pending anchor. + await pane.update({ documentKey: "other", text: createTestDocumentText(100), wrap: true }); + await flushTestDocumentLayout(pane.setup); + expect(pane.scrollRef.current!.scrollTop).toBe(0); + expect(pane.visibleLineRef.current()).toBe(1); + expect(pane.setup.captureCharFrame()).toContain("LINE_001"); + } finally { + await pane.close(); + } +}); + +test("intentional scrolling cancels pending restoration and normal scrolling stays unaffected", async () => { + const pane = await createTestDocumentPane(); + try { + await pane.scroll(250); + await pane.update({ wrap: true }); + // A semantic scroll command issued before new native bounds wins over the old anchor. + await act(async () => pane.scrollRef.current!.scrollTo(20)); + await flushTestDocumentLayout(pane.setup); + expect(pane.scrollRef.current!.scrollTop).toBe(20); + await act(async () => pane.scrollRef.current!.scrollBy(7)); + await flushTestDocumentLayout(pane.setup); + expect(pane.scrollRef.current!.scrollTop).toBe(27); + await flushTestDocumentLayout(pane.setup); + expect(pane.scrollRef.current!.scrollTop).toBe(27); + await pane.scroll(0); + expect(pane.visibleLineRef.current()).toBe(1); + } finally { + await pane.close(); + } +}); diff --git a/packages/hunk/src/ui/documents/DocumentPane.tsx b/packages/hunk/src/ui/documents/DocumentPane.tsx index deafdee78..323036cc0 100644 --- a/packages/hunk/src/ui/documents/DocumentPane.tsx +++ b/packages/hunk/src/ui/documents/DocumentPane.tsx @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import type { ScrollBoxRenderable } from "@opentui/core"; import type { MutableRefObject } from "react"; import type { DocumentReadResult } from "../../core/documents/source"; @@ -10,6 +10,7 @@ import { } from "../syntax/documentHighlightService"; import type { AppTheme } from "../themes"; import { useRowViewport } from "./useRowViewport"; +import { useDocumentScrollAnchor } from "./useDocumentScrollAnchor"; import { completeDocumentLines, completeDocumentGeometry, @@ -76,20 +77,7 @@ export function DocumentPane({ ); const gutter = geometry.gutter; const window = completeDocumentWindow(geometry, viewport.top, viewport.height); - const previousGeometry = useRef({ documentKey, geometry }); - useLayoutEffect(() => { - const previous = previousGeometry.current; - const scroll = viewport.ref.current; - if (scroll && previous.documentKey === documentKey && previous.geometry !== geometry) { - const top = Math.floor(scroll.scrollTop); - const line = completeDocumentLineAt(previous.geometry, top); - scroll.scrollTo({ - x: layout.wrap ? 0 : scroll.scrollLeft, - y: geometry.rows[Math.max(0, line)]?.start ?? 0, - }); - } - previousGeometry.current = { documentKey, geometry }; - }, [documentKey, geometry, layout.wrap, viewport.ref]); + useDocumentScrollAnchor(viewport.ref, documentKey, geometry, layout.wrap); const result = paint?.text === safeText && paint.theme === theme && paint.key === documentKey ? paint.result @@ -100,9 +88,6 @@ export function DocumentPane({ scrollRef.current = null; }; }, [scrollRef, viewport.ref]); - useEffect(() => { - viewport.ref.current?.scrollTo(0); - }, [documentKey, viewport.ref]); useEffect(() => { if (!documentKey || document?.kind !== "text") return; const controller = new AbortController(); @@ -160,6 +145,8 @@ export function DocumentPane({ height="100%" scrollY={true} scrollX={!layout.wrap} + // scrollX sets this constraint only at construction; wrap toggles must update it too. + contentOptions={{ minWidth: "100%", maxWidth: layout.wrap ? "100%" : undefined }} focused={focused} // Semantic commands own keyboard navigation, including explicit unbindings. onKeyDown={(key) => key.preventDefault()} @@ -167,7 +154,13 @@ export function DocumentPane({ {placeholder ? ( {` ${placeholder}`} ) : ( - + // Rows and spacers partition this exact measured extent, independent of the mounted window. + {window.start > 0 ? ( ) : null} diff --git a/packages/hunk/src/ui/documents/geometry.test.ts b/packages/hunk/src/ui/documents/geometry.test.ts index a33d646a6..99ecfd772 100644 --- a/packages/hunk/src/ui/documents/geometry.test.ts +++ b/packages/hunk/src/ui/documents/geometry.test.ts @@ -3,6 +3,8 @@ import { completeDocumentGeometry, completeDocumentLines, completeDocumentWindow, + completeDocumentScrollAnchor, + completeDocumentScrollAnchorTop, } from "./geometry"; test("normalizes newlines, preserves empty logical lines and strips terminal controls", () => { @@ -27,6 +29,25 @@ test("measures complete-document wraps with the same native engine as file-view expect(unwrapped.width).toBe(11); }); +test("resolves logical anchors within surviving line extents and handles empty geometry", () => { + const previous = completeDocumentGeometry( + ["a".repeat(100), "b".repeat(100), "last"], + 10, + false, + true, + ); + const anchor = completeDocumentScrollAnchor(previous, previous.rows[1]!.start + 8); + expect(anchor).toEqual({ line: 1, offset: 8 }); + const next = completeDocumentGeometry(["a", "b".repeat(25)], 10, false, true); + expect(completeDocumentScrollAnchorTop(next, anchor)).toBe(3); + expect(completeDocumentScrollAnchorTop(next, { line: 200, offset: 0 })).toBe(1); + expect(completeDocumentScrollAnchorTop(next, { line: 200, offset: 99 })).toBe(3); + const empty = completeDocumentGeometry([], 10, false, true); + expect(completeDocumentScrollAnchorTop(empty, anchor)).toBe(0); + expect(completeDocumentScrollAnchor(empty, 100)).toEqual({ line: 0, offset: 0 }); + expect(completeDocumentScrollAnchor(next, 100)).toEqual({ line: 1, offset: 2 }); +}); + test("windows by physical rows and mounts an intersecting wrapped logical line", () => { const geometry = completeDocumentGeometry( Array.from({ length: 100 }, () => "hello world"), diff --git a/packages/hunk/src/ui/documents/geometry.ts b/packages/hunk/src/ui/documents/geometry.ts index 90a93039f..d59181101 100644 --- a/packages/hunk/src/ui/documents/geometry.ts +++ b/packages/hunk/src/ui/documents/geometry.ts @@ -70,6 +70,33 @@ export function completeDocumentLineAt(geometry: CompleteDocumentGeometry, offse return start; } +export interface CompleteDocumentScrollAnchor { + line: number; + offset: number; +} + +/** Capture a logical line and its physical offset using the currently mounted geometry. */ +export function completeDocumentScrollAnchor( + geometry: CompleteDocumentGeometry, + top: number, +): CompleteDocumentScrollAnchor { + const line = Math.max( + 0, + Math.min(geometry.rows.length - 1, completeDocumentLineAt(geometry, top)), + ); + const row = geometry.rows[line]; + return { line, offset: row ? Math.max(0, Math.min(row.height - 1, top - row.start)) : 0 }; +} + +/** Resolve an anchor to the nearest surviving line and physical row after remeasurement. */ +export function completeDocumentScrollAnchorTop( + geometry: CompleteDocumentGeometry, + anchor: CompleteDocumentScrollAnchor, +): number { + const row = geometry.rows[Math.max(0, Math.min(geometry.rows.length - 1, anchor.line))]; + return row ? row.start + Math.min(anchor.offset, row.height - 1) : 0; +} + /** Locate the logical-line window intersecting visible physical rows, including a small halo. */ export function completeDocumentWindow( geometry: CompleteDocumentGeometry, diff --git a/packages/hunk/src/ui/documents/useDocumentScrollAnchor.ts b/packages/hunk/src/ui/documents/useDocumentScrollAnchor.ts new file mode 100644 index 000000000..f9c444193 --- /dev/null +++ b/packages/hunk/src/ui/documents/useDocumentScrollAnchor.ts @@ -0,0 +1,108 @@ +import type { ScrollBoxRenderable } from "@opentui/core"; +import { useLayoutEffect, useRef, type MutableRefObject } from "react"; +import { + completeDocumentScrollAnchor, + completeDocumentScrollAnchorTop, + type CompleteDocumentGeometry, + type CompleteDocumentScrollAnchor, +} from "./geometry"; + +/** Preserve the visible source line when wrap, width, gutters or source text change. + * Native size callbacks own scrollbar bounds; geometry owns measured rows and spacers. + * Restore once those bounds match the plan, never on syntax arrival or ordinary scrolling. + */ +export function useDocumentScrollAnchor( + scrollRef: MutableRefObject, + documentKey: string | null, + geometry: CompleteDocumentGeometry, + wrap: boolean, +) { + const plan = useRef({ documentKey, geometry, wrap }); + const pending = useRef(null); + const apply = useRef<() => void>(() => {}); + + useLayoutEffect(() => { + const scroll = scrollRef.current; + if (!scroll) return; + let updatingBounds = false; + + /** Apply only after native content and scrollbar sizes agree with the measured extent. */ + const restore = () => { + const anchor = pending.current; + const { geometry, wrap } = plan.current; + if ( + !anchor || + scroll.content.height !== Math.max(geometry.totalHeight, scroll.viewport.height) || + scroll.content.width !== Math.max(geometry.width, scroll.viewport.width) || + scroll.verticalScrollBar.scrollSize !== scroll.content.height || + scroll.verticalScrollBar.viewportSize !== scroll.viewport.height || + scroll.horizontalScrollBar.scrollSize !== scroll.content.width || + scroll.horizontalScrollBar.viewportSize !== scroll.viewport.width + ) + return; + + pending.current = null; + scroll.scrollTo({ + x: wrap ? 0 : scroll.scrollLeft, + y: completeDocumentScrollAnchorTop(geometry, anchor), + }); + }; + + /** Distinguish native bounds clamping from an intentional keyboard, wheel or scrollbar move. */ + const onScroll = () => { + if (!updatingBounds) pending.current = null; + }; + + const previousContentSizeChange = scroll.content.onSizeChange; + const previousViewportSizeChange = scroll.viewport.onSizeChange; + + /** Let OpenTUI update both bars before resolving the logical anchor against their new limits. */ + const onContentSizeChange = () => { + updatingBounds = true; + try { + previousContentSizeChange?.call(scroll.content); + restore(); + } finally { + updatingBounds = false; + } + }; + + /** Observe viewport bounds too: resizing can change the limits without changing content height. */ + const onViewportSizeChange = () => { + updatingBounds = true; + try { + previousViewportSizeChange?.call(scroll.viewport); + restore(); + } finally { + updatingBounds = false; + } + }; + + apply.current = restore; + scroll.content.onSizeChange = onContentSizeChange; + scroll.viewport.onSizeChange = onViewportSizeChange; + scroll.verticalScrollBar.on("change", onScroll); + return () => { + apply.current = () => {}; + scroll.verticalScrollBar.off("change", onScroll); + if (scroll.content.onSizeChange === onContentSizeChange) + scroll.content.onSizeChange = previousContentSizeChange; + if (scroll.viewport.onSizeChange === onViewportSizeChange) + scroll.viewport.onSizeChange = previousViewportSizeChange; + }; + }, [scrollRef]); + + useLayoutEffect(() => { + const previous = plan.current; + const scroll = scrollRef.current; + if (previous.documentKey !== documentKey) { + pending.current = null; + scroll?.scrollTo(0); + } else if (scroll && previous.geometry !== geometry) { + // Carry the original logical anchor through scrollbar-adjusted width remeasurement. + pending.current ??= completeDocumentScrollAnchor(previous.geometry, scroll.scrollTop); + } + plan.current = { documentKey, geometry, wrap }; + apply.current(); + }, [documentKey, geometry, wrap, scrollRef]); +} diff --git a/packages/hunk/src/ui/documents/useRowViewport.ts b/packages/hunk/src/ui/documents/useRowViewport.ts index feb72b0a8..b4c519426 100644 --- a/packages/hunk/src/ui/documents/useRowViewport.ts +++ b/packages/hunk/src/ui/documents/useRowViewport.ts @@ -4,6 +4,9 @@ import { useLayoutEffect, useRef, useState } from "react"; /** Observe scroll geometry so complete-document and tree panes mount only a bounded row window. */ export function useRowViewport(estimatedHeight: number, estimatedWidth: number) { const ref = useRef(null); + // Keep callback ownership stable across resize; scroll anchoring chains these native callbacks. + const estimate = useRef({ height: estimatedHeight, width: estimatedWidth }); + estimate.current = { height: estimatedHeight, width: estimatedWidth }; const [viewport, setViewport] = useState({ top: 0, height: estimatedHeight, @@ -16,8 +19,8 @@ export function useRowViewport(estimatedHeight: number, estimatedWidth: number) setViewport((previous) => { const next = { top: Math.floor(scroll.scrollTop), - height: Math.max(1, scroll.viewport.height || estimatedHeight), - width: Math.max(1, scroll.viewport.width || estimatedWidth), + height: Math.max(1, scroll.viewport.height || estimate.current.height), + width: Math.max(1, scroll.viewport.width || estimate.current.width), }; return previous.top === next.top && previous.height === next.height && @@ -39,7 +42,7 @@ export function useRowViewport(estimatedHeight: number, estimatedWidth: number) if (scroll.viewport.onSizeChange === onSizeChange) scroll.viewport.onSizeChange = previousSizeChange; }; - }, [estimatedHeight, estimatedWidth]); + }, []); return { ref, ...viewport }; } diff --git a/test/pty/open-scroll-integration.test.ts b/test/pty/open-scroll-integration.test.ts new file mode 100644 index 000000000..1cab1346b --- /dev/null +++ b/test/pty/open-scroll-integration.test.ts @@ -0,0 +1,103 @@ +import { afterEach, expect, setDefaultTimeout, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createPtyHarness } from "./harness"; + +const harness = createPtyHarness(); +const roots: string[] = []; +setDefaultTimeout(30_000); + +afterEach(() => { + harness.cleanup(); + roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })); +}); + +/** Create long identifiable lines without depending on a repository or user configuration. */ +function createOpenScrollTestText(count: number, suffix = "x".repeat(90)) { + return Array.from( + { length: count }, + (_, index) => `LINE_${String(index + 1).padStart(3, "0")} ${suffix}`, + ).join("\n"); +} + +/** Read the first visible source line, ignoring document chrome and wrapped continuation rows. */ +function firstOpenScrollTestLine(frame: string) { + return Number(frame.match(/LINE_(\d+)/)?.[1]); +} + +/** Create a standalone document that the source-built PTY app can watch. */ +function createOpenScrollTestFixture() { + const root = mkdtempSync(join(tmpdir(), "hunk-open-scroll-")); + roots.push(root); + const path = join(root, "scroll.txt"); + writeFileSync(path, createOpenScrollTestText(300)); + return path; +} + +test("deep open scrolling retains the logical line through wrap, gutters and terminal resize", async () => { + const path = createOpenScrollTestFixture(); + const session = await harness.launchHunk({ + args: ["open", path, "--no-wrap", "--no-line-numbers"], + cols: 40, + rows: 12, + }); + try { + await session.waitForText("LINE_001"); + await harness.ensureKeyboardIsLive(session); + session.writeRaw("j".repeat(250)); + await harness.waitForSnapshot(session, (text) => firstOpenScrollTestLine(text) === 251); + await session.press("w"); + expect(firstOpenScrollTestLine(await session.text())).toBe(251); + await session.press("l"); + expect(firstOpenScrollTestLine(await session.text())).toBe(251); + session.resize({ cols: 25, rows: 12 }); + await harness.waitForSnapshot(session, (text) => firstOpenScrollTestLine(text) === 251); + session.resize({ cols: 65, rows: 16 }); + await harness.waitForSnapshot(session, (text) => firstOpenScrollTestLine(text) === 251); + await session.press("w"); + expect(firstOpenScrollTestLine(await session.text())).toBe(251); + await session.press("down"); + expect(firstOpenScrollTestLine(await session.text())).toBe(252); + await session.scrollDown(1); + expect(firstOpenScrollTestLine(await session.text())).toBeGreaterThan(252); + await session.press("g"); + await session.waitForText("LINE_001"); + await session.press("q"); + } finally { + session.close(); + } +}); + +test("watched source shrink clamps deep scrolling to the surviving end instead of line one", async () => { + const path = createOpenScrollTestFixture(); + const session = await harness.launchHunk({ + args: ["open", path, "--wrap", "--no-line-numbers"], + cols: 40, + rows: 12, + }); + try { + await session.waitForText("LINE_001"); + await harness.ensureKeyboardIsLive(session); + session.writeRaw("j".repeat(800)); + await harness.waitForSnapshot(session, (text) => firstOpenScrollTestLine(text) === 201); + writeFileSync(path, createOpenScrollTestText(100)); + await session.waitForText("LINE_100"); + expect(firstOpenScrollTestLine(await session.text())).toBeGreaterThan(90); + // The bottom clamp can land inside a wrapped line; align to a full line before comparing prefixes. + await session.press("g"); + session.writeRaw("j".repeat(356)); + await harness.waitForSnapshot(session, (text) => firstOpenScrollTestLine(text) === 90); + const survivingLine = firstOpenScrollTestLine(await session.text()); + writeFileSync(path, createOpenScrollTestText(300, "replacement ".repeat(12))); + await session.waitForText("replacement"); + expect(firstOpenScrollTestLine(await session.text())).toBe(survivingLine); + await session.press("w"); + expect(firstOpenScrollTestLine(await session.text())).toBe(survivingLine); + await session.press("g"); + await session.waitForText("LINE_001"); + await session.press("q"); + } finally { + session.close(); + } +}); From a00f17e46842881c99df53e56fa9799b591652ee Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 6 Oct 2026 21:45:38 -0400 Subject: [PATCH 16/22] fix(open): fill initial document and tree viewports --- .changeset/filled-document-viewports.md | 5 ++ .../src/ui/documents/DocumentPane.test.tsx | 39 +++++++++++++ .../src/ui/documents/DocumentTree.test.tsx | 57 +++++++++++++++++++ .../hunk/src/ui/documents/useRowViewport.ts | 3 +- test/pty/open-scroll-integration.test.ts | 21 +++++++ 5 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 .changeset/filled-document-viewports.md create mode 100644 packages/hunk/src/ui/documents/DocumentTree.test.tsx diff --git a/.changeset/filled-document-viewports.md b/.changeset/filled-document-viewports.md new file mode 100644 index 000000000..6506924c3 --- /dev/null +++ b/.changeset/filled-document-viewports.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Fill initial document and tree viewports without requiring scrolling when native layout temporarily reports a negative scroll position. diff --git a/packages/hunk/src/ui/documents/DocumentPane.test.tsx b/packages/hunk/src/ui/documents/DocumentPane.test.tsx index 30a141b3b..e4e938eba 100644 --- a/packages/hunk/src/ui/documents/DocumentPane.test.tsx +++ b/packages/hunk/src/ui/documents/DocumentPane.test.tsx @@ -17,6 +17,45 @@ async function flushTestDocumentLayout(setup: Awaited { + for (const wrap of [false, true]) { + for (const lineNumbers of [false, true]) { + const setup = await testRender( + `ROW_${index + 1}`).join("\n"), + identity: "stable", + }} + documentKey="file" + pathHint="file.txt" + height={17} + width={40} + wrap={wrap} + tabWidth={4} + theme={THEMES[0]!} + lineNumbers={lineNumbers} + focused={true} + scrollRef={{ current: null }} + visibleLineRef={{ current: () => 1 }} + />, + { width: 40, height: 17 }, + ); + + try { + await flushTestDocumentLayout(setup); + expect(setup.captureCharFrame().match(/ROW_\d+/g)).toHaveLength(17); + expect(setup.captureCharFrame()).toContain("ROW_17"); + await act(async () => setup.resize(40, 18)); + await flushTestDocumentLayout(setup); + expect(setup.captureCharFrame().match(/ROW_\d+/g)).toHaveLength(18); + } finally { + await act(async () => setup.renderer.destroy()); + } + } + } +}); + test("wrapped documents use the scrollbar-adjusted viewport and retain right-edge characters after resize", async () => { const scrollRef = { current: null as ScrollBoxRenderable | null }; const visibleLineRef = { current: () => 1 }; diff --git a/packages/hunk/src/ui/documents/DocumentTree.test.tsx b/packages/hunk/src/ui/documents/DocumentTree.test.tsx new file mode 100644 index 000000000..a1ac91454 --- /dev/null +++ b/packages/hunk/src/ui/documents/DocumentTree.test.tsx @@ -0,0 +1,57 @@ +import { expect, test } from "bun:test"; +import { testRender } from "@opentui/react/test-utils"; +import { act } from "react"; +import { THEMES } from "../themes"; +import { DocumentTree } from "./DocumentTree"; +import type { DocumentBrowserSnapshot } from "./controller"; + +test("the tree fills its initial viewport and remains filled after resize without navigation", async () => { + const snapshot: DocumentBrowserSnapshot = { + rows: Array.from({ length: 100 }, (_, index) => ({ + depth: 0, + entry: { + key: `file-${index + 1}`, + name: `FILE_${index + 1}`, + kind: "file", + hidden: false, + }, + })), + selectedKey: "file-1", + documentKey: null, + documentEntry: null, + document: null, + expanded: new Set(), + showExcluded: false, + loading: false, + notice: null, + }; + const setup = await testRender( + undefined} + />, + { width: 40, height: 17 }, + ); + + try { + await act(async () => { + await setup.renderOnce(); + await Bun.sleep(30); + await setup.renderOnce(); + }); + expect(setup.captureCharFrame().match(/FILE_\d+/g)).toHaveLength(17); + await act(async () => { + setup.resize(40, 18); + await setup.renderOnce(); + await Bun.sleep(30); + await setup.renderOnce(); + }); + expect(setup.captureCharFrame().match(/FILE_\d+/g)).toHaveLength(18); + } finally { + await act(async () => setup.renderer.destroy()); + } +}); diff --git a/packages/hunk/src/ui/documents/useRowViewport.ts b/packages/hunk/src/ui/documents/useRowViewport.ts index b4c519426..3ab015d7f 100644 --- a/packages/hunk/src/ui/documents/useRowViewport.ts +++ b/packages/hunk/src/ui/documents/useRowViewport.ts @@ -18,7 +18,8 @@ export function useRowViewport(estimatedHeight: number, estimatedWidth: number) const update = () => setViewport((previous) => { const next = { - top: Math.floor(scroll.scrollTop), + // Native layout can clamp against an empty content extent before the first row commit. + top: Math.max(0, Math.floor(scroll.scrollTop)), height: Math.max(1, scroll.viewport.height || estimate.current.height), width: Math.max(1, scroll.viewport.width || estimate.current.width), }; diff --git a/test/pty/open-scroll-integration.test.ts b/test/pty/open-scroll-integration.test.ts index 1cab1346b..801a3b71b 100644 --- a/test/pty/open-scroll-integration.test.ts +++ b/test/pty/open-scroll-integration.test.ts @@ -35,6 +35,27 @@ function createOpenScrollTestFixture() { return path; } +test("initial open fills the document viewport before keyboard input and after resizing", async () => { + const path = createOpenScrollTestFixture(); + writeFileSync(path, createOpenScrollTestText(100, "")); + const session = await harness.launchHunk({ + args: ["open", path, "--no-wrap", "--no-line-numbers"], + cols: 40, + rows: 20, + }); + + try { + const first = await session.waitForText("LINE_017"); + expect(first.match(/LINE_\d+/g)).toHaveLength(17); + session.resize({ cols: 42, rows: 24 }); + const resized = await session.waitForText("LINE_021"); + expect(resized.match(/LINE_\d+/g)).toHaveLength(21); + await session.press("q"); + } finally { + session.close(); + } +}); + test("deep open scrolling retains the logical line through wrap, gutters and terminal resize", async () => { const path = createOpenScrollTestFixture(); const session = await harness.launchHunk({ From dad0a929e9f7938dcb55304c118a2b341709486b Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 6 Oct 2026 21:49:07 -0400 Subject: [PATCH 17/22] fix(open): dismiss menus when focusing document content --- .changeset/dismissed-document-menus.md | 5 +++ .../documents/DocumentApp.keyboard.test.tsx | 36 +++++++++++++++++++ .../hunk/src/ui/documents/DocumentApp.tsx | 6 +++- test/pty/open-keyboard-integration.test.ts | 29 +++++++++++++++ 4 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 .changeset/dismissed-document-menus.md diff --git a/.changeset/dismissed-document-menus.md b/.changeset/dismissed-document-menus.md new file mode 100644 index 000000000..4fbebf2c5 --- /dev/null +++ b/.changeset/dismissed-document-menus.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Dismiss document-browser menus when clicking the document or tree so subsequent keys follow the newly focused content. diff --git a/packages/hunk/src/ui/documents/DocumentApp.keyboard.test.tsx b/packages/hunk/src/ui/documents/DocumentApp.keyboard.test.tsx index c565f3ace..f4e3e20e6 100644 --- a/packages/hunk/src/ui/documents/DocumentApp.keyboard.test.tsx +++ b/packages/hunk/src/ui/documents/DocumentApp.keyboard.test.tsx @@ -86,6 +86,42 @@ async function createTestKeyboardApp(keybindings: Record return { setup, controller, themeController, edit, list, quit, burst, clickMenuItem, close }; } +test("content clicks dismiss menus and route the next key to the document or tree", async () => { + const app = await createTestKeyboardApp(); + + try { + await app.burst("f10"); + expect(app.setup.captureCharFrame()).toContain("Refresh documents"); + await act(async () => { + await app.setup.mockMouse.click(55, 8); + app.setup.mockInput.pressArrow("down"); + app.setup.mockInput.pressKey("\r"); + await Bun.sleep(20); + }); + await act(async () => app.setup.renderOnce()); + expect(app.setup.captureCharFrame()).not.toContain("Refresh documents"); + expect(app.setup.captureCharFrame()).toContain("Document ·"); + expect(app.controller.getSnapshot().selectedKey).toBe("a"); + expect(app.edit).not.toHaveBeenCalled(); + expect(app.quit).not.toHaveBeenCalled(); + + // Help's dropdown leaves the tree's left edge uncovered for a genuine outside click. + await app.burst("f10", "right", "right"); + expect(app.setup.captureCharFrame()).toContain("Controls help"); + await act(async () => { + await app.setup.mockMouse.click(2, 2); + app.setup.mockInput.pressArrow("down"); + await Bun.sleep(20); + }); + await act(async () => app.setup.renderOnce()); + expect(app.setup.captureCharFrame()).not.toContain("Controls help"); + expect(app.controller.getSnapshot().selectedKey).toBe("b"); + expect(app.setup.captureCharFrame()).toContain("Tree ·"); + } finally { + await app.close(); + } +}); + test("help owns later keys in its opening burst and releases them in its closing burst", async () => { const app = await createTestKeyboardApp(); try { diff --git a/packages/hunk/src/ui/documents/DocumentApp.tsx b/packages/hunk/src/ui/documents/DocumentApp.tsx index e6fead20f..e7931bfce 100644 --- a/packages/hunk/src/ui/documents/DocumentApp.tsx +++ b/packages/hunk/src/ui/documents/DocumentApp.tsx @@ -180,7 +180,11 @@ export function DocumentApp({ scrollRef, visibleLineRef, }} - onFocus={(focus) => interaction.update((current) => ({ ...current, focus }))} + onFocus={(focus) => { + // Content clicks transfer keyboard ownership as well as the visible focus indicator. + menu.closeMenu(); + interaction.update((current) => ({ ...current, focus })); + }} onActivate={(key) => { void controller.activate(key); }} diff --git a/test/pty/open-keyboard-integration.test.ts b/test/pty/open-keyboard-integration.test.ts index eba39b9eb..8f80758d3 100644 --- a/test/pty/open-keyboard-integration.test.ts +++ b/test/pty/open-keyboard-integration.test.ts @@ -31,6 +31,35 @@ function createTestOpenKeyboardFixture() { return { root, marker, env: { EDITOR: `"${process.execPath}" "${editor}"` } }; } +test("clicking document content dismisses its menu before subsequent navigation and activation", async () => { + const fixture = createTestOpenKeyboardFixture(); + const session = await harness.launchHunk({ + args: ["open", fixture.root], + cols: 100, + rows: 20, + env: fixture.env, + }); + + try { + await session.waitForText("a.txt"); + await harness.ensureKeyboardIsLive(session); + await session.click(/a\.txt/, { first: true }); + await session.waitForText("FIRST_DOCUMENT_ROW_1"); + await session.press("f10"); + await session.waitForText("Refresh documents"); + // This point is in the document and outside File's dropdown; deliver click and Down together. + session.writeRaw("\x1b[<0;71;9M\x1b[<0;71;9m\x1b[B"); + const frame = await session.waitForText("FIRST_DOCUMENT_ROW_2"); + expect(frame).not.toContain("Refresh documents"); + expect(frame).toContain("Document ·"); + await session.press("enter"); + expect(existsSync(fixture.marker)).toBe(false); + await session.press("q"); + } finally { + session.close(); + } +}); + describe("open keyboard bursts", () => { test("raw help and theme bursts block editor commands until their modal closes", async () => { const fixture = createTestOpenKeyboardFixture(); From 618c16bbcb56c2b1666b492199173609747604be Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 6 Oct 2026 21:58:15 -0400 Subject: [PATCH 18/22] fix(open): capture editor targets before asynchronous preparation --- .../captured-document-editor-targets.md | 5 + .../DocumentApp.editor-target.test.tsx | 151 ++++++++++++++++++ .../hunk/src/ui/documents/DocumentApp.tsx | 5 +- test/pty/open-keyboard-integration.test.ts | 35 ++++ 4 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 .changeset/captured-document-editor-targets.md create mode 100644 packages/hunk/src/ui/documents/DocumentApp.editor-target.test.tsx diff --git a/.changeset/captured-document-editor-targets.md b/.changeset/captured-document-editor-targets.md new file mode 100644 index 000000000..38e9c18cf --- /dev/null +++ b/.changeset/captured-document-editor-targets.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Capture the document editor's file and visible line when editing is requested, so later scrolling or selection during copy preparation cannot retarget the launch. diff --git a/packages/hunk/src/ui/documents/DocumentApp.editor-target.test.tsx b/packages/hunk/src/ui/documents/DocumentApp.editor-target.test.tsx new file mode 100644 index 000000000..96db5fcf3 --- /dev/null +++ b/packages/hunk/src/ui/documents/DocumentApp.editor-target.test.tsx @@ -0,0 +1,151 @@ +import { expect, spyOn, test } from "bun:test"; +import { ScrollBoxRenderable, type BaseRenderable } from "@opentui/core"; +import { testRender } from "@opentui/react/test-utils"; +import { act } from "react"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createTestDeferred } from "../../../../../test/helpers/diff-helpers"; +import { ThemeController } from "../theme/controller"; +import { DocumentApp } from "./DocumentApp"; +import { DocumentBrowserController } from "./controller"; + +/** Find the document scrollbox after the sidebar without relying on generated native ids. */ +function findTestScrollboxes(node: BaseRenderable): ScrollBoxRenderable[] { + return [ + ...(node instanceof ScrollBoxRenderable ? [node] : []), + ...node.getChildren().flatMap(findTestScrollboxes), + ]; +} + +/** Pause source-owned copy preparation while observing the real editor argument builder. */ +async function createTestEditorTargetApp() { + const root = await mkdtemp(join(tmpdir(), "hunk-editor-target-")); + const copies = { a: join(root, "a.txt"), b: join(root, "b.txt") }; + await Promise.all(Object.values(copies).map((copy) => writeFile(copy, "copy"))); + const release = createTestDeferred(); + const keys: string[] = []; + const launches: string[][] = []; + const controller = new DocumentBrowserController({ + root: { key: "root", name: "Collection", kind: "directory", hidden: false }, + async list() { + return { + kind: "entries", + entries: ["a", "b"].map((key) => ({ + key, + name: `${key}.txt`, + kind: "file", + hidden: false, + })), + }; + }, + async read(key) { + return { + kind: "text", + text: Array.from({ length: 300 }, (_, index) => `${key} ROW_${index + 1}`).join("\n"), + identity: key, + }; + }, + async edit(key, launch) { + keys.push(key); + await release.promise; + return launch(copies[key as keyof typeof copies]); + }, + }); + await controller.initialize(); + await controller.select("a"); + const previousEditor = process.env.EDITOR; + process.env.EDITOR = "nvim"; + const spawn = spyOn(Bun, "spawnSync").mockImplementation((command) => { + launches.push(command as string[]); + return { exitCode: 0 } as ReturnType; + }); + const setup = await testRender( + undefined} + />, + { width: 100, height: 20 }, + ); + await act(async () => { + await setup.renderOnce(); + await Bun.sleep(30); + }); + const pane = findTestScrollboxes(setup.renderer.root).at(-1)!; + await act(async () => { + pane.scrollTo({ y: 200, x: 0 }); + await setup.renderOnce(); + }); + await act(async () => setup.renderOnce()); + + /** Release pending editing and restore process-local spies on every test exit. */ + const close = async () => { + release.resolve(); + await controller.close(); + spawn.mockRestore(); + if (previousEditor === undefined) delete process.env.EDITOR; + else process.env.EDITOR = previousEditor; + await act(async () => setup.renderer.destroy()); + await rm(root, { recursive: true, force: true }); + }; + return { setup, controller, release, keys, launches, copies, close }; +} + +for (const change of ["scroll", "document"] as const) { + test(`an edit keeps its dispatch-time file and line when later ${change} input arrives during preparation`, async () => { + const app = await createTestEditorTargetApp(); + + try { + expect(app.setup.captureCharFrame()).toContain("a ROW_201"); + if (change === "scroll") { + await act(async () => { + app.setup.mockInput.pressTab(); + await app.setup.renderOnce(); + }); + } + await act(async () => { + app.setup.mockInput.pressKey("e"); + if (change === "scroll") app.setup.mockInput.pressKey("g"); + else app.setup.mockInput.pressArrow("down"); + await Bun.sleep(20); + }); + await act(async () => app.setup.renderOnce()); + expect(app.launches).toEqual([]); + if (change === "document") expect(app.controller.getSnapshot().documentKey).toBe("b"); + else expect(app.setup.captureCharFrame()).toContain("a ROW_1"); + await act(async () => { + app.release.resolve(); + await Bun.sleep(30); + }); + expect(app.keys).toEqual(["a"]); + expect(app.launches).toEqual([["nvim", "+201", app.copies.a]]); + } finally { + await app.close(); + } + }); +} + +test("a document selected before editing in the same burst starts at its own first line", async () => { + const app = await createTestEditorTargetApp(); + + try { + expect(app.setup.captureCharFrame()).toContain("a ROW_201"); + await act(async () => { + app.setup.mockInput.pressArrow("down"); + app.setup.mockInput.pressKey("e"); + await Bun.sleep(20); + }); + expect(app.controller.getSnapshot().documentKey).toBe("b"); + await act(async () => { + app.release.resolve(); + await Bun.sleep(30); + }); + expect(app.keys).toEqual(["b"]); + expect(app.launches).toEqual([["nvim", "+1", app.copies.b]]); + } finally { + await app.close(); + } +}); diff --git a/packages/hunk/src/ui/documents/DocumentApp.tsx b/packages/hunk/src/ui/documents/DocumentApp.tsx index e7931bfce..5ba6cbb09 100644 --- a/packages/hunk/src/ui/documents/DocumentApp.tsx +++ b/packages/hunk/src/ui/documents/DocumentApp.tsx @@ -97,9 +97,12 @@ export function DocumentApp({ const edit = async () => { if (renderer.isDestroyed || controller.isClosed) return; + // Capture a coherent file/line target before copy preparation yields to later input. + const key = controller.getSnapshot().documentKey; + const line = key === snapshot.documentKey ? visibleLineRef.current() : 1; setNotice( await controller.editDisplayedDocument(async (filePath) => - openFileInEditor({ filePath, line: visibleLineRef.current(), renderer, wait: true }), + openFileInEditor({ filePath, line, renderer, wait: true }), ), ); await controller.refresh(); diff --git a/test/pty/open-keyboard-integration.test.ts b/test/pty/open-keyboard-integration.test.ts index 8f80758d3..0a93d345d 100644 --- a/test/pty/open-keyboard-integration.test.ts +++ b/test/pty/open-keyboard-integration.test.ts @@ -31,6 +31,41 @@ function createTestOpenKeyboardFixture() { return { root, marker, env: { EDITOR: `"${process.execPath}" "${editor}"` } }; } +test("editor arguments retain the visible line when later input scrolls during copy preparation", async () => { + const fixture = createTestOpenKeyboardFixture(); + const editor = join(fixture.root, "nvim"); + const target = join(fixture.root, "editor-target.json"); + writeFileSync( + editor, + `#!${process.execPath}\nimport { writeFileSync } from "node:fs"; writeFileSync(${JSON.stringify(target)}, JSON.stringify(process.argv.slice(2)));`, + { mode: 0o755 }, + ); + const session = await harness.launchHunk({ + args: ["open", join(fixture.root, "a.txt"), "--no-wrap", "--no-line-numbers"], + cols: 100, + rows: 20, + env: { EDITOR: `"${editor}"` }, + }); + + try { + await session.waitForText("FIRST_DOCUMENT_ROW_1"); + await harness.ensureKeyboardIsLive(session); + await session.press(["shift", "g"]); + const before = await session.text(); + const line = Number(before.match(/FIRST_DOCUMENT_ROW_(\d+)/)?.[1]); + expect(line).toBeGreaterThan(1); + session.writeRaw("eg"); + await harness.waitForSnapshot( + session, + (text) => existsSync(target) && text.includes("FIRST_DOCUMENT_ROW_1"), + ); + expect(JSON.parse(readFileSync(target, "utf8"))[0]).toBe(`+${line}`); + await session.press("q"); + } finally { + session.close(); + } +}); + test("clicking document content dismisses its menu before subsequent navigation and activation", async () => { const fixture = createTestOpenKeyboardFixture(); const session = await harness.launchHunk({ From e32740f4ba7c0883d458d582900aabcec5f3d2aa Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 6 Oct 2026 21:44:58 -0400 Subject: [PATCH 19/22] fix(documents): preserve pending changes across observation handover --- .changeset/quiet-watch-selection.md | 5 + .../filesystemSource.observe.test.ts | 111 ++++++++++++++++++ .../src/app/documents/filesystemSource.ts | 24 +++- .../documents/controller.filesystem.test.ts | 34 ++++++ 4 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 .changeset/quiet-watch-selection.md create mode 100644 packages/hunk/src/app/documents/filesystemSource.observe.test.ts diff --git a/.changeset/quiet-watch-selection.md b/.changeset/quiet-watch-selection.md new file mode 100644 index 000000000..4ef0d4592 --- /dev/null +++ b/.changeset/quiet-watch-selection.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Preserve pending filesystem refreshes when selecting a file or changing expanded directories in the document browser. diff --git a/packages/hunk/src/app/documents/filesystemSource.observe.test.ts b/packages/hunk/src/app/documents/filesystemSource.observe.test.ts new file mode 100644 index 000000000..432939dd9 --- /dev/null +++ b/packages/hunk/src/app/documents/filesystemSource.observe.test.ts @@ -0,0 +1,111 @@ +import { expect, test } from "bun:test"; +import { watch } from "node:fs"; +import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createFilesystemSource } from "./filesystemSource"; + +/** Perform a mutation and wait for native delivery, still inside the source's 100ms debounce. */ +async function receiveTestChange(directory: string, mutate: () => Promise) { + let received = false; + const watcher = watch(directory, () => { + received = true; + }); + + try { + await mutate(); + const deadline = Date.now() + 3000; + while (!received && Date.now() < deadline) await Bun.sleep(5); + expect(received).toBe(true); + // Let sibling native callbacks run before handing over demand. + await Bun.sleep(20); + } finally { + watcher.close(); + } +} + +/** Run an observation assertion inside a canonicalized, isolated collection. */ +async function withTestCollection(run: (root: string) => Promise) { + const fixture = await realpath(await mkdtemp(join(tmpdir(), "hunk-observe-"))); + const root = join(fixture, "collection"); + + try { + // Parent anchors must stay isolated from other tests creating siblings in the system temp dir. + await mkdir(root); + await run(root); + } finally { + await rm(fixture, { recursive: true, force: true }); + } +} + +test("a pending event transfers to new demand without calling the revoked subscriber", async () => { + await withTestCollection(async (root) => { + const source = await createFilesystemSource(root); + const file = join(root, "selected.txt"); + let revokedChanges = 0; + let changes = 0; + let stop = source.observe!([root], () => revokedChanges++); + + try { + await receiveTestChange(root, () => writeFile(file, "new")); + stop(); + stop = source.observe!([root, file], () => changes++); + await Bun.sleep(300); + expect(revokedChanges).toBe(0); + expect(changes).toBe(1); + } finally { + stop(); + } + }); +}); + +test("final cleanup cancels a pending event and does not replay it to a later observer", async () => { + await withTestCollection(async (root) => { + const source = await createFilesystemSource(root); + let changes = 0; + let stop = source.observe!([root], () => changes++); + + try { + await receiveTestChange(root, () => writeFile(join(root, "new.txt"), "new")); + stop(); + await Bun.sleep(200); + expect(changes).toBe(0); + stop = source.observe!([root], () => changes++); + await Bun.sleep(200); + expect(changes).toBe(0); + } finally { + stop(); + } + }); +}); + +test("repeated demand handovers replay one actual event and never create phantom refreshes", async () => { + await withTestCollection(async (root) => { + const folder = join(root, "folder"); + await mkdir(folder); + const source = await createFilesystemSource(root); + let changes = 0; + let stop = source.observe!([root], () => changes++); + const handover = () => { + for (let index = 0; index < 8; index++) { + stop(); + stop = source.observe!(index % 2 ? [root] : [root, folder], () => changes++); + } + }; + + try { + handover(); + await Bun.sleep(200); + expect(changes).toBe(0); + await receiveTestChange(root, () => writeFile(join(root, "new.txt"), "new")); + handover(); + await Bun.sleep(300); + expect(changes).toBe(1); + handover(); + await Bun.sleep(300); + expect(changes).toBe(1); + } finally { + stop(); + } + }); +}); diff --git a/packages/hunk/src/app/documents/filesystemSource.ts b/packages/hunk/src/app/documents/filesystemSource.ts index ce10583a4..2f952ae9d 100644 --- a/packages/hunk/src/app/documents/filesystemSource.ts +++ b/packages/hunk/src/app/documents/filesystemSource.ts @@ -266,6 +266,7 @@ export async function createFilesystemSource( } let editing = false; + let pendingObservationChange = false; return { root, @@ -288,10 +289,15 @@ export async function createFilesystemSource( }, observe(keys, onChange) { + let stopped = false; let timer: ReturnType | undefined; const changed = () => { + if (stopped) return; clearTimeout(timer); - timer = setTimeout(onChange, 100); + timer = setTimeout(() => { + timer = undefined; + if (!stopped) onChange(); + }, 100); }; const watchers: ReturnType[] = []; @@ -317,8 +323,24 @@ export async function createFilesystemSource( } } + if (pendingObservationChange) { + pendingObservationChange = false; + changed(); + } + return () => { + if (stopped) return; + stopped = true; + if (timer !== undefined) { + // Transfer only an actual unconsumed event to a synchronous demand handover. Final + // close cancels delivery; a later observer must not inherit a revoked session's event. + pendingObservationChange = true; + queueMicrotask(() => { + pendingObservationChange = false; + }); + } clearTimeout(timer); + timer = undefined; for (const watcher of watchers) watcher.close(); }; }, diff --git a/packages/hunk/src/ui/documents/controller.filesystem.test.ts b/packages/hunk/src/ui/documents/controller.filesystem.test.ts index 7be9b6567..70a9f3cc0 100644 --- a/packages/hunk/src/ui/documents/controller.filesystem.test.ts +++ b/packages/hunk/src/ui/documents/controller.filesystem.test.ts @@ -17,6 +17,40 @@ async function waitForTestEntry(browser: DocumentBrowserController, name: string expect(browser.getSnapshot().rows.map((row) => row.entry.name)).toContain(name); } +test("selecting a file during watch debounce preserves the pending tree change", async () => { + const fixture = await realpath(await mkdtemp(join(tmpdir(), "hunk-document-selection-"))); + const root = join(fixture, "collection"); + let browser: DocumentBrowserController | undefined; + + try { + await mkdir(root); + const file = join(root, "selected.txt"); + await writeFile(file, "selected"); + const source = await createFilesystemSource(root); + const observe = source.observe!; + let changes = 0; + source.observe = (keys, onChange) => + observe(keys, () => { + changes++; + onChange(); + }); + browser = new DocumentBrowserController(source); + await browser.initialize(); + await writeFile(join(root, "new.txt"), "new"); + await Bun.sleep(30); + await browser.select(file); + await waitForTestEntry(browser, "new.txt"); + expect(browser.getSnapshot().document).toMatchObject({ kind: "text", text: "selected" }); + expect(browser.getSnapshot().selectedKey).toBe(file); + expect(changes).toBe(1); + await Bun.sleep(300); + expect(changes).toBe(1); + } finally { + await browser?.close(); + await rm(fixture, { recursive: true, force: true }); + } +}); + for (const trigger of ["initial expansion", "manual refresh"] as const) { test(`a file created during ${trigger} metadata queries survives watch debounce`, async () => { const fixture = await realpath(await mkdtemp(join(tmpdir(), "hunk-document-watch-"))); From 09ebed6c71fd0347c8e7e2c0200ca3ecce6e3f91 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 6 Oct 2026 21:52:16 -0400 Subject: [PATCH 20/22] fix(documents): repair watches after same-path directory replacement --- .changeset/steady-directory-watch.md | 5 + .../filesystemSource.observe.test.ts | 144 +++++++++++++++++- .../src/app/documents/filesystemSource.ts | 94 +++++++++--- .../documents/controller.filesystem.test.ts | 93 ++++++++++- test/pty/open-watch-integration.test.ts | 66 ++++++++ 5 files changed, 380 insertions(+), 22 deletions(-) create mode 100644 .changeset/steady-directory-watch.md create mode 100644 test/pty/open-watch-integration.test.ts diff --git a/.changeset/steady-directory-watch.md b/.changeset/steady-directory-watch.md new file mode 100644 index 000000000..f405f79a8 --- /dev/null +++ b/.changeset/steady-directory-watch.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Keep document browsing updates live after watched directories are replaced or deleted and recreated at the same path. diff --git a/packages/hunk/src/app/documents/filesystemSource.observe.test.ts b/packages/hunk/src/app/documents/filesystemSource.observe.test.ts index 432939dd9..89e21bee6 100644 --- a/packages/hunk/src/app/documents/filesystemSource.observe.test.ts +++ b/packages/hunk/src/app/documents/filesystemSource.observe.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; import { watch } from "node:fs"; -import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, realpath, rename, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createFilesystemSource } from "./filesystemSource"; @@ -24,6 +24,15 @@ async function receiveTestChange(directory: string, mutate: () => Promise) } } +/** Wait for one debounced native notification without manually requesting refresh. */ +async function waitForTestChange(changes: () => number, before: number) { + const deadline = Date.now() + 3000; + while (changes() === before && Date.now() < deadline) await Bun.sleep(20); + expect(changes()).toBeGreaterThan(before); + // Let any additional native event settle before checking a subsequent mutation. + await Bun.sleep(150); +} + /** Run an observation assertion inside a canonicalized, isolated collection. */ async function withTestCollection(run: (root: string) => Promise) { const fixture = await realpath(await mkdtemp(join(tmpdir(), "hunk-observe-"))); @@ -109,3 +118,136 @@ test("repeated demand handovers replay one actual event and never create phantom } }); }); + +test("same-path directory replacements keep observing edits, children and atomic saves", async () => { + await withTestCollection(async (root) => { + const folder = join(root, "folder"); + const file = join(folder, "selected.txt"); + await mkdir(folder); + await writeFile(file, "initial"); + const source = await createFilesystemSource(root); + let changes = 0; + const count = () => changes; + const stop = source.observe!([folder, file], () => changes++); + + try { + for (let index = 0; index < 3; index++) { + const replacement = join(root, `replacement-${index}`); + const retired = join(root, `retired-${index}`); + await mkdir(replacement); + await writeFile(join(replacement, "selected.txt"), "replacement"); + let before = changes; + await rename(folder, retired); + await rename(replacement, folder); + await waitForTestChange(count, before); + + before = changes; + await writeFile(file, "later edit"); + await waitForTestChange(count, before); + before = changes; + await writeFile(join(folder, "new-child.txt"), "new child"); + await waitForTestChange(count, before); + before = changes; + const atomic = join(folder, "atomic.txt"); + await writeFile(atomic, "atomic save"); + await rename(atomic, file); + await waitForTestChange(count, before); + + // Old-inode activity must not revive a retired callback after rebind. + before = changes; + await writeFile(join(retired, "selected.txt"), "retired edit"); + await Bun.sleep(250); + expect(changes).toBe(before); + } + + const before = changes; + await receiveTestChange(folder, () => writeFile(file, "pending at shutdown")); + stop(); + await writeFile(join(folder, "post-close.txt"), "closed"); + await Bun.sleep(250); + expect(changes).toBe(before); + } finally { + stop(); + } + }); +}, 15_000); + +test("parent anchors restore a deleted demanded directory without replacing observation", async () => { + await withTestCollection(async (root) => { + const folder = join(root, "folder"); + await mkdir(folder); + const source = await createFilesystemSource(root); + let changes = 0; + const count = () => changes; + const stop = source.observe!([folder], () => changes++); + + try { + await rm(folder, { recursive: true }); + await waitForTestChange(count, 0); + let before = changes; + await mkdir(folder); + await waitForTestChange(count, before); + before = changes; + await writeFile(join(folder, "restored.txt"), "restored"); + await waitForTestChange(count, before); + } finally { + stop(); + } + }); +}); + +test("rebind preserves pending notifications and does not create a refresh loop", async () => { + await withTestCollection(async (root) => { + const folder = join(root, "folder"); + await mkdir(folder); + const source = await createFilesystemSource(root); + let changes = 0; + const stop = source.observe!([folder], () => changes++); + + try { + await receiveTestChange(folder, () => writeFile(join(folder, "pending.txt"), "pending")); + await rename(folder, join(root, "retired")); + await mkdir(folder); + await writeFile(join(folder, "fresh.txt"), "fresh"); + await Bun.sleep(350); + expect(changes).toBe(1); + await Bun.sleep(300); + expect(changes).toBe(1); + } finally { + stop(); + } + }); +}); + +test.skipIf(process.platform === "win32")( + "recreating a target never follows a final directory symlink", + async () => { + await withTestCollection(async (root) => { + const folder = join(root, "folder"); + const outside = join(root, "outside"); + await mkdir(folder); + await mkdir(outside); + const source = await createFilesystemSource(root); + let changes = 0; + const stop = source.observe!([folder], () => changes++); + + try { + await rm(folder, { recursive: true }); + await symlink(outside, folder, "dir"); + await waitForTestChange(() => changes, 0); + const before = changes; + await writeFile(join(outside, "secret.txt"), "outside"); + await Bun.sleep(300); + expect(changes).toBe(before); + await rm(folder); + await mkdir(folder); + await waitForTestChange(() => changes, before); + const restored = changes; + await writeFile(join(folder, "inside.txt"), "inside"); + await waitForTestChange(() => changes, restored); + } finally { + stop(); + } + }); + }, +); diff --git a/packages/hunk/src/app/documents/filesystemSource.ts b/packages/hunk/src/app/documents/filesystemSource.ts index 2f952ae9d..19c38fdcc 100644 --- a/packages/hunk/src/app/documents/filesystemSource.ts +++ b/packages/hunk/src/app/documents/filesystemSource.ts @@ -296,33 +296,86 @@ export async function createFilesystemSource( clearTimeout(timer); timer = setTimeout(() => { timer = undefined; - if (!stopped) onChange(); + if (stopped) return; + // A self-rename can arrive while the path is absent, before its replacement is visible. + // Reconcile again before delivery so later edits use live handles even if the runtime + // coalesces the corresponding parent event or cannot report a distinct file ID. + reconcile(); + pendingRearms.clear(); + onChange(); }, 100); }; - const watchers: ReturnType[] = []; - // Parent watches preserve atomic replacement observation without following file symlinks. - const targets = new Set(); - for (const key of keys) { - targets.add(dirname(key)); - try { - const info = lstatSync(key); - if (info.isDirectory() && !info.isSymbolicLink()) targets.add(key); - } catch { - /* Missing entries retain parent observation. */ - } + type DirectoryWatch = { + watcher: ReturnType; + dev: bigint; + ino: bigint; + }; + const watchers = new Map(); + const pendingRearms = new Set(); + // Keep missing directory candidates so parent events can restore them without new demand. + // Files and final symlinks never acquire a target watch; their parents observe replacement. + const targets = new Set(keys.flatMap((key) => [dirname(key), key])); + + /** Match native parent/self events even when the runtime cannot supply a filename or ID. */ + function eventRearmsTarget(target: string, origin?: string, filename?: string | null) { + const matchesName = filename == null || filename === basename(target); + return matchesName && (target === origin || dirname(target) === origin); } - for (const target of targets) { - try { - const watcher = watch(target, changed); - watcher.on("error", changed); - watchers.push(watcher); - } catch { - /* Deleted entries retain their parent's observation. */ + /** Repair directory handles after parent/self rename or error without replacing debounce. */ + function reconcile(origin?: string, filename?: string | null) { + if (stopped) return; + + for (const target of targets) { + const previous = watchers.get(target); + const nativeRearm = eventRearmsTarget(target, origin, filename); + if (nativeRearm) pendingRearms.add(target); + const rearm = nativeRearm || (origin === undefined && pendingRearms.has(target)); + let info; + try { + // BigInt avoids rounding Windows file IDs; relevant native events also force rearm + // when IDs are unavailable or unchanged, rather than relying on identity alone. + info = lstatSync(target, { bigint: true }); + } catch { + /* Missing targets retain their parent anchors. */ + } + if (!info?.isDirectory() || info.isSymbolicLink()) { + watchers.delete(target); + previous?.watcher.close(); + continue; + } + + if (previous && previous.dev === info.dev && previous.ino === info.ino && !rearm) + continue; + + // Keep parent anchors and the received-event timer armed while synchronously reopening + // this target. Bun shares same-path native handles: opening before closing the old watch + // would retain its inode and closing it would also disable the replacement. + watchers.delete(target); + previous?.watcher.close(); + try { + const watcher = watch(target, (event, name) => { + // A closed/replaced handle may still have native callbacks queued for its old inode. + if (stopped || watchers.get(target) !== current) return; + changed(); + if (event === "rename") reconcile(target, name); + }); + const current: DirectoryWatch = { watcher, dev: info.dev, ino: info.ino }; + watcher.on("error", () => { + if (stopped || watchers.get(target) !== current) return; + changed(); + reconcile(target, null); + }); + watchers.set(target, current); + } catch { + /* A surviving parent can retry a concurrently deleted or unavailable target. */ + } } } + reconcile(); + if (pendingObservationChange) { pendingObservationChange = false; changed(); @@ -341,7 +394,8 @@ export async function createFilesystemSource( } clearTimeout(timer); timer = undefined; - for (const watcher of watchers) watcher.close(); + for (const { watcher } of watchers.values()) watcher.close(); + watchers.clear(); }; }, }; diff --git a/packages/hunk/src/ui/documents/controller.filesystem.test.ts b/packages/hunk/src/ui/documents/controller.filesystem.test.ts index 70a9f3cc0..8ad396a1a 100644 --- a/packages/hunk/src/ui/documents/controller.filesystem.test.ts +++ b/packages/hunk/src/ui/documents/controller.filesystem.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; import { writeFileSync } from "node:fs"; -import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, realpath, rename, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createFilesystemSource } from "../../app/documents/filesystemSource"; @@ -51,6 +51,97 @@ test("selecting a file during watch debounce preserves the pending tree change", } }); +/** Wait for watched document content without requesting a manual refresh. */ +async function waitForTestDocument(browser: DocumentBrowserController, text: string) { + const deadline = Date.now() + 3000; + while (Date.now() < deadline) { + const document = browser.getSnapshot().document; + if (document?.kind === "text" && document.text === text) return; + await Bun.sleep(20); + } + + expect(browser.getSnapshot().document).toMatchObject({ kind: "text", text }); +} + +test("same-path folder replacement refreshes unchanged controller demand and subsequent saves", async () => { + const fixture = await realpath(await mkdtemp(join(tmpdir(), "hunk-document-rebind-"))); + const root = join(fixture, "collection"); + const folder = join(root, "folder"); + const file = join(folder, "selected.txt"); + let browser: DocumentBrowserController | undefined; + + try { + await mkdir(folder, { recursive: true }); + await writeFile(file, "initial"); + const source = await createFilesystemSource(root); + const observe = source.observe!; + let subscriptions = 0; + let changes = 0; + source.observe = (keys, onChange) => { + subscriptions++; + return observe(keys, () => { + changes++; + onChange(); + }); + }; + browser = new DocumentBrowserController(source); + await browser.initialize(); + await browser.activate(folder); + await browser.select(file); + const initialSubscriptions = subscriptions; + + for (let index = 0; index < 2; index++) { + const replacement = join(fixture, `replacement-${index}`); + await mkdir(replacement); + await writeFile(join(replacement, "selected.txt"), `replacement ${index}`); + await rename(folder, join(fixture, `retired-${index}`)); + await rename(replacement, folder); + await waitForTestDocument(browser, `replacement ${index}`); + await writeFile(file, `later edit ${index}`); + await waitForTestDocument(browser, `later edit ${index}`); + await writeFile(join(folder, `child-${index}.txt`), "child"); + await waitForTestEntry(browser, `child-${index}.txt`); + const atomic = join(folder, "atomic.txt"); + await writeFile(atomic, `atomic save ${index}`); + await rename(atomic, file); + await waitForTestDocument(browser, `atomic save ${index}`); + expect(subscriptions).toBe(initialSubscriptions); + expect(browser.getSnapshot().selectedKey).toBe(file); + expect(browser.getSnapshot().expanded.has(folder)).toBe(true); + } + + await rm(folder, { recursive: true }); + const deadline = Date.now() + 3000; + while (browser.getSnapshot().document?.kind !== "unavailable" && Date.now() < deadline) + await Bun.sleep(20); + expect(browser.getSnapshot().document).toMatchObject({ + kind: "unavailable", + reason: "missing", + }); + await mkdir(folder); + await writeFile(file, "recreated"); + await waitForTestDocument(browser, "recreated"); + await waitForTestEntry(browser, "folder"); + await browser.activate(folder); + await writeFile(join(folder, "after-recreation.txt"), "new"); + await waitForTestEntry(browser, "after-recreation.txt"); + await writeFile(file, "after recreation"); + await waitForTestDocument(browser, "after recreation"); + expect(browser.getSnapshot().notice).toBeNull(); + + await browser.close(); + const finalChanges = changes; + const finalSnapshot = browser.getSnapshot(); + await writeFile(file, "closed"); + await Bun.sleep(250); + expect(changes).toBe(finalChanges); + expect(browser.getSnapshot()).toBe(finalSnapshot); + } finally { + await browser?.close(); + await rm(fixture, { recursive: true, force: true }); + } +}, 15_000); + for (const trigger of ["initial expansion", "manual refresh"] as const) { test(`a file created during ${trigger} metadata queries survives watch debounce`, async () => { const fixture = await realpath(await mkdtemp(join(tmpdir(), "hunk-document-watch-"))); diff --git a/test/pty/open-watch-integration.test.ts b/test/pty/open-watch-integration.test.ts new file mode 100644 index 000000000..1362d1ef0 --- /dev/null +++ b/test/pty/open-watch-integration.test.ts @@ -0,0 +1,66 @@ +import { expect, setDefaultTimeout, test } from "bun:test"; +import { mkdir, mkdtemp, realpath, rename, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createPtyHarness } from "./harness"; + +setDefaultTimeout(30_000); + +test.skipIf(process.platform === "win32")( + "open keeps watching an expanded folder after same-path replacement", + async () => { + const harness = createPtyHarness(); + const fixture = await realpath(await mkdtemp(join(tmpdir(), "hunk-open-watch-pty-"))); + const root = join(fixture, "collection"); + const folder = join(root, "nested"); + const file = join(folder, "selected.txt"); + + try { + await mkdir(folder, { recursive: true }); + await writeFile(file, "INITIAL_WATCHED_DOCUMENT\n"); + const session = await harness.launchHunk({ args: ["open", root], cols: 120, rows: 24 }); + + try { + await session.waitForText("nested"); + await harness.ensureKeyboardIsLive(session); + await session.press("down"); + await session.press("right"); + await session.waitForText("selected.txt"); + await session.press("down"); + await session.waitForText("INITIAL_WATCHED_DOCUMENT"); + + for (let index = 0; index < 2; index++) { + const replacement = join(fixture, `replacement-${index}`); + await mkdir(replacement); + await writeFile(join(replacement, "selected.txt"), `REPLACED_DOCUMENT_${index}\n`); + await rename(folder, join(fixture, `retired-${index}`)); + await rename(replacement, folder); + await session.waitForText(`REPLACED_DOCUMENT_${index}`); + await writeFile(file, `LATER_EDIT_${index}\n`); + await session.waitForText(`LATER_EDIT_${index}`); + await writeFile(join(folder, `new-child-${index}.txt`), "new child\n"); + await session.waitForText(`new-child-${index}.txt`); + const atomic = join(folder, "atomic.txt"); + await writeFile(atomic, `ATOMIC_SAVE_${index}\n`); + await rename(atomic, file); + const frame = await session.waitForText(`ATOMIC_SAVE_${index}`); + expect(frame).not.toContain(`LATER_EDIT_${index}`); + } + + await rm(folder, { recursive: true }); + await session.waitForText("File no longer exists"); + await mkdir(folder); + await writeFile(file, "RECREATED_DOCUMENT\n"); + await session.waitForText("RECREATED_DOCUMENT"); + await writeFile(file, "EDIT_AFTER_RECREATION\n"); + await session.waitForText("EDIT_AFTER_RECREATION"); + await session.press("q"); + } finally { + session.close(); + } + } finally { + harness.cleanup(); + await rm(fixture, { recursive: true, force: true }); + } + }, +); From 094c3fd3b97457363b696412b8cbaecfd4d55b11 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 11 Oct 2026 16:30:40 -0400 Subject: [PATCH 21/22] fix(open): keep save coordination out of project markers --- .changeset/account-save-state.md | 5 +++ docs/open.md | 3 +- .../app/documents/documentSaveClaim.test.ts | 35 +++++++++++++++++-- .../src/app/documents/documentSaveClaim.ts | 3 +- 4 files changed, 42 insertions(+), 4 deletions(-) create mode 100644 .changeset/account-save-state.md diff --git a/.changeset/account-save-state.md b/.changeset/account-save-state.md new file mode 100644 index 000000000..e48fdb130 --- /dev/null +++ b/.changeset/account-save-state.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Store document save coordination in the account's Hunk state directory without creating a `.hunk` project marker in the user home. This preserves project discovery and reload bounds for directories beneath the home, including Windows temporary files. diff --git a/docs/open.md b/docs/open.md index 6163f90d2..c045922ff 100644 --- a/docs/open.md +++ b/docs/open.md @@ -71,7 +71,8 @@ failures/recovery paths are printed after the terminal is restored. `$EDITOR` it code, not sandboxed. Updated Hunk sessions in the same OS account and process namespace coordinate validation and writeback with -private, inode-keyed save claims under the OS account home's `.hunk/document-save-claims/` directory. +private, inode-keyed save claims under the OS account home's `.config/hunk/document-save-claims/` +directory. This state directory does not create a `.hunk` project marker in the account home. This location is independent of per-session `HOME`, XDG and temporary-directory overrides. Account lookup uses OS records, including built-in PowerShell without profiles on Windows; lookup or private directory failures refuse writeback and retain the edited copy. Competing diff --git a/packages/hunk/src/app/documents/documentSaveClaim.test.ts b/packages/hunk/src/app/documents/documentSaveClaim.test.ts index efaff7be8..147a87b4b 100644 --- a/packages/hunk/src/app/documents/documentSaveClaim.test.ts +++ b/packages/hunk/src/app/documents/documentSaveClaim.test.ts @@ -1,9 +1,21 @@ -import { afterEach, expect, test } from "bun:test"; -import { chmod, lstat, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { afterEach, expect, spyOn, test } from "bun:test"; +import { + chmod, + lstat, + mkdir, + mkdtemp, + readFile, + readdir, + realpath, + rm, + writeFile, +} from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { createTestDeferred } from "../../../../../test/helpers/diff-helpers"; import { withDocumentSaveClaim } from "./documentSaveClaim"; +import * as saveAccount from "./documentSaveAccount"; +import { findProjectRootCandidate } from "../../core/process/projectRoot"; const roots: string[] = []; const identity = { dev: 1, ino: 42 }; @@ -19,6 +31,25 @@ afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); +test("default save coordination does not turn the account home into a project root", async () => { + const home = await realpath(await createTestClaimDirectory()); + const launchDirectory = join(home, "AppData", "Local", "Temp", "document"); + await mkdir(launchDirectory, { recursive: true }); + const initialRoot = findProjectRootCandidate(launchDirectory); + const lookup = spyOn(saveAccount, "documentSaveAccountHome").mockResolvedValue(home); + + try { + await withDocumentSaveClaim(identity, async () => { + expect(findProjectRootCandidate(launchDirectory)).toBe(initialRoot); + const namespaces = await readdir(join(home, ".config", "hunk", "document-save-claims")); + expect(namespaces).toHaveLength(1); + }); + expect(findProjectRootCandidate(launchDirectory)).toBe(initialRoot); + } finally { + lookup.mockRestore(); + } +}); + test("a live reservation rejects the same inode while allowing unrelated documents", async () => { const directory = await createTestClaimDirectory(); const started = createTestDeferred(); diff --git a/packages/hunk/src/app/documents/documentSaveClaim.ts b/packages/hunk/src/app/documents/documentSaveClaim.ts index b93613cd9..006a8b690 100644 --- a/packages/hunk/src/app/documents/documentSaveClaim.ts +++ b/packages/hunk/src/app/documents/documentSaveClaim.ts @@ -21,7 +21,8 @@ async function documentSaveClaimDirectory() { // A PID is meaningful only in its namespace; shared container homes must not reap host claims. const namespace = process.platform === "linux" ? await readlink("/proc/self/ns/pid") : "host"; const host = createHash("sha256").update(`${hostname()}\0${namespace}`).digest("hex"); - return join(home, ".hunk", "document-save-claims", host); + // `.hunk` marks a project root; state storage must not promote the home or its temp subtree. + return join(home, ".config", "hunk", "document-save-claims", host); } /** Treat unknown process-access failures as live owners; only ESRCH permits stale-claim cleanup. */ From 5c575d3b913943db095036940567d837e414e208 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 11 Oct 2026 16:38:16 -0400 Subject: [PATCH 22/22] fix(open): accommodate Windows account lookup cold starts --- .changeset/patient-windows-account-lookup.md | 5 + .../src/app/documents/documentSaveAccount.ts | 3 +- .../app/documents/documentSaveClaim.test.ts | 109 +++++++++++------- .../app/documents/filesystemSource.test.ts | 54 +++++---- 4 files changed, 102 insertions(+), 69 deletions(-) create mode 100644 .changeset/patient-windows-account-lookup.md diff --git a/.changeset/patient-windows-account-lookup.md b/.changeset/patient-windows-account-lookup.md new file mode 100644 index 000000000..4521346da --- /dev/null +++ b/.changeset/patient-windows-account-lookup.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Allow bounded cold PowerShell startup time when locating the Windows account's document save namespace. Account lookup failures still refuse writeback and preserve recovery copies. diff --git a/packages/hunk/src/app/documents/documentSaveAccount.ts b/packages/hunk/src/app/documents/documentSaveAccount.ts index 836fb3cfc..3f9badf3d 100644 --- a/packages/hunk/src/app/documents/documentSaveAccount.ts +++ b/packages/hunk/src/app/documents/documentSaveAccount.ts @@ -10,7 +10,8 @@ async function accountMetadata(command: string, args: string[]) { const { stdout } = await execute(command, args, { encoding: "utf8", maxBuffer: 8192, - timeout: 5000, + // Cold PowerShell/.NET startup can exceed five seconds on shared Windows runners. + timeout: process.platform === "win32" ? 15_000 : 5000, windowsHide: true, }); return stdout.trim(); diff --git a/packages/hunk/src/app/documents/documentSaveClaim.test.ts b/packages/hunk/src/app/documents/documentSaveClaim.test.ts index 147a87b4b..9f99fc619 100644 --- a/packages/hunk/src/app/documents/documentSaveClaim.test.ts +++ b/packages/hunk/src/app/documents/documentSaveClaim.test.ts @@ -50,6 +50,24 @@ test("default save coordination does not turn the account home into a project ro } }); +test("account lookup failure refuses writeback before entering the save callback", async () => { + const lookup = spyOn(saveAccount, "documentSaveAccountHome").mockRejectedValue( + new Error("OS account metadata unavailable"), + ); + let saved = false; + + try { + await expect( + withDocumentSaveClaim(identity, async () => { + saved = true; + }), + ).rejects.toThrow("OS account metadata unavailable"); + expect(saved).toBe(false); + } finally { + lookup.mockRestore(); + } +}); + test("a live reservation rejects the same inode while allowing unrelated documents", async () => { const directory = await createTestClaimDirectory(); const started = createTestDeferred(); @@ -164,54 +182,59 @@ test("separate processes cannot enter the same inode's critical section", async } }); -test("default reservations remain shared across session home and temporary-directory overrides", async () => { - const fixture = await createTestClaimDirectory(); - const file = join(fixture, "original.txt"); - await writeFile(file, "original"); - const info = await lstat(file); - const identity = { dev: info.dev, ino: info.ino }; - const started = createTestDeferred(); - const release = createTestDeferred(); - const first = withDocumentSaveClaim(identity, async () => { - started.resolve(); - await release.promise; - }); - const module = new URL("./documentSaveClaim.ts", import.meta.url).href; - const output = join(fixture, "result.txt"); +test( + "default reservations remain shared across session home and temporary-directory overrides", + async () => { + const fixture = await createTestClaimDirectory(); + const file = join(fixture, "original.txt"); + await writeFile(file, "original"); + const info = await lstat(file); + const identity = { dev: info.dev, ino: info.ino }; + const started = createTestDeferred(); + const release = createTestDeferred(); + const first = withDocumentSaveClaim(identity, async () => { + started.resolve(); + await release.promise; + }); + const module = new URL("./documentSaveClaim.ts", import.meta.url).href; + const output = join(fixture, "result.txt"); - try { - await started.promise; - const child = Bun.spawn( - [ - process.execPath, - "-e", - `import { withDocumentSaveClaim } from ${JSON.stringify(module)}; + try { + // Surface lookup failure promptly rather than leaving a rejected claim behind a start gate. + await Promise.race([started.promise, first]); + const child = Bun.spawn( + [ + process.execPath, + "-e", + `import { withDocumentSaveClaim } from ${JSON.stringify(module)}; import { writeFile } from "node:fs/promises"; try { await withDocumentSaveClaim(${JSON.stringify(identity)}, async () => "unexpected"); process.exit(2); } catch(error) { await writeFile(${JSON.stringify(output)}, error.message); }`, - ], - { - stdout: "pipe", - stderr: "pipe", - env: { - ...process.env, - HOME: fixture, - USERPROFILE: fixture, - USER: "hunk-save-test", - TMPDIR: fixture, - TMP: fixture, - TEMP: fixture, - XDG_RUNTIME_DIR: fixture, + ], + { + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + HOME: fixture, + USERPROFILE: fixture, + USER: "hunk-save-test", + TMPDIR: fixture, + TMP: fixture, + TEMP: fixture, + XDG_RUNTIME_DIR: fixture, + }, }, - }, - ); - expect(await child.exited).toBe(0); - expect(await readFile(output, "utf8")).toContain("Another Hunk session is saving"); - } finally { - release.resolve(); - await first; - } -}); + ); + expect(await child.exited).toBe(0); + expect(await readFile(output, "utf8")).toContain("Another Hunk session is saving"); + } finally { + release.resolve(); + await first; + } + }, + process.platform === "win32" ? 40_000 : 5000, +); test.skipIf(process.platform === "win32")( "coordination refuses a publicly accessible namespace", diff --git a/packages/hunk/src/app/documents/filesystemSource.test.ts b/packages/hunk/src/app/documents/filesystemSource.test.ts index d82b2101e..21adce9ae 100644 --- a/packages/hunk/src/app/documents/filesystemSource.test.ts +++ b/packages/hunk/src/app/documents/filesystemSource.test.ts @@ -229,31 +229,35 @@ describe("filesystem documents", () => { await expect(source.read(root, abort.signal)).rejects.toThrow(); }, 30_000); - test("edits a private copy, preserves BOM bytes, and saves through the original handle", async () => { - const root = await createTestRoot(); - const file = join(root, "selected.txt"); - const original = Buffer.from("\ufeffbefore\n"); - await writeFile(file, original); - const source = await createFilesystemSource(root); - let copyPath = ""; - expect( - await source.edit?.(file, async (copy) => { - copyPath = copy; - expect(copy).not.toBe(file); - expect(await readFile(copy)).toEqual(original); - return null; - }), - ).toBeNull(); - expect(await readFile(file)).toEqual(original); - expect(existsSync(copyPath)).toBe(false); - expect( - await source.edit?.(file, async (copy) => { - await writeFile(copy, "after\n"); - return null; - }), - ).toBeNull(); - expect(await readFile(file, "utf8")).toBe("after\n"); - }); + test( + "edits a private copy, preserves BOM bytes, and saves through the original handle", + async () => { + const root = await createTestRoot(); + const file = join(root, "selected.txt"); + const original = Buffer.from("\ufeffbefore\n"); + await writeFile(file, original); + const source = await createFilesystemSource(root); + let copyPath = ""; + expect( + await source.edit?.(file, async (copy) => { + copyPath = copy; + expect(copy).not.toBe(file); + expect(await readFile(copy)).toEqual(original); + return null; + }), + ).toBeNull(); + expect(await readFile(file)).toEqual(original); + expect(existsSync(copyPath)).toBe(false); + expect( + await source.edit?.(file, async (copy) => { + await writeFile(copy, "after\n"); + return null; + }), + ).toBeNull(); + expect(await readFile(file, "utf8")).toBe("after\n"); + }, + process.platform === "win32" ? 40_000 : 5000, + ); test("rejects overlapping editors before launch and permits another edit after settlement", async () => { const root = await createTestRoot();