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/.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/.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/.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/.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/.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/.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/.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/.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/.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/.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/.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/.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/.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/.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/.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/.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/.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/.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/.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/AGENTS.md b/AGENTS.md index 8849a55de..41138fe1a 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, 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 - Add short JSDoc-style comments to functions and helpers. 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..c045922ff --- /dev/null +++ b/docs/open.md @@ -0,0 +1,105 @@ +# 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`: 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. + +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. 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. +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. + +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 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. 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 `.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 +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, +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 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/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/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..d1a55cdef --- /dev/null +++ b/packages/hunk/src/app/documents/documentBytes.ts @@ -0,0 +1,65 @@ +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/documentSaveAccount.ts b/packages/hunk/src/app/documents/documentSaveAccount.ts new file mode 100644 index 000000000..3f9badf3d --- /dev/null +++ b/packages/hunk/src/app/documents/documentSaveAccount.ts @@ -0,0 +1,71 @@ +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, + // Cold PowerShell/.NET startup can exceed five seconds on shared Windows runners. + timeout: process.platform === "win32" ? 15_000 : 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..9f99fc619 --- /dev/null +++ b/packages/hunk/src/app/documents/documentSaveClaim.test.ts @@ -0,0 +1,248 @@ +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 }; + +/** 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("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("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(); + 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 { + // 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, + }, + }, + ); + 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", + 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..006a8b690 --- /dev/null +++ b/packages/hunk/src/app/documents/documentSaveClaim.ts @@ -0,0 +1,97 @@ +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"); + // `.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. */ +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 new file mode 100644 index 000000000..b96886b36 --- /dev/null +++ b/packages/hunk/src/app/documents/editDocument.ts @@ -0,0 +1,123 @@ +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"; + +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. */ +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 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; + } 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.observe.test.ts b/packages/hunk/src/app/documents/filesystemSource.observe.test.ts new file mode 100644 index 000000000..89e21bee6 --- /dev/null +++ b/packages/hunk/src/app/documents/filesystemSource.observe.test.ts @@ -0,0 +1,253 @@ +import { expect, test } from "bun:test"; +import { watch } from "node:fs"; +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"; + +/** 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(); + } +} + +/** 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-"))); + 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(); + } + }); +}); + +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.test.ts b/packages/hunk/src/app/documents/filesystemSource.test.ts new file mode 100644 index 000000000..21adce9ae --- /dev/null +++ b/packages/hunk/src/app/documents/filesystemSource.test.ts @@ -0,0 +1,509 @@ +import { afterEach, describe, expect, test } from "bun:test"; +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 { dirname, 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() { + // 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; +} + +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", + }); + 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" }); + }, + ); + + 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(); + }, 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"); + }, + process.platform === "win32" ? 40_000 : 5000, + ); + + 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("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"); + 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, "..")); + 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(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")( + "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("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"); + 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..19c38fdcc --- /dev/null +++ b/packages/hunk/src/app/documents/filesystemSource.ts @@ -0,0 +1,402 @@ +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 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 { DOCUMENT_MAX_BYTES } from "./documentBytes"; +export const DIRECTORY_MAX_ENTRIES = 10_000; + +/** 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 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, + { + openDirectory = opendir, + spawn = Bun.spawn, + saveClaimDirectory, + }: { + openDirectory?: typeof opendir; + spawn?: typeof Bun.spawn; + saveClaimDirectory?: 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 baseIdentity = await lstat(base); + 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() || + baseInfo.dev !== baseIdentity.dev || + 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; + } + + /** Bind Linux opens to the collection handle and walk ancestors without following links. */ + 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( + `/proc/self/fd/${parent.fd}/${segment}`, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, + ); + 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, + ); + } finally { + if (!transferred) await parent.close(); + } + } + + /** 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 openCheckedPath( + 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.", + }; + return decodeDocumentBytes(await readBoundedDocumentBytes(handle, signal)); + } finally { + await handle.close(); + } + } catch (error) { + signal?.throwIfAborted(); + return unavailable(error); + } + } + + /** 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 { + 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."); + 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) => + 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 }; + } + } + + let editing = false; + let pendingObservationChange = false; + + return { + root, + read, + list, + async edit(key, launch) { + if (editing) return "An editor is already open."; + editing = true; + + try { + return await editDocumentCopy(key, launch, { + safePath, + openCheckedPath, + read, + saveClaimDirectory, + }); + } finally { + editing = false; + } + }, + + observe(keys, onChange) { + let stopped = false; + let timer: ReturnType | undefined; + const changed = () => { + if (stopped) return; + clearTimeout(timer); + timer = setTimeout(() => { + timer = undefined; + 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); + }; + + 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); + } + + /** 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(); + } + + 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.values()) watcher.close(); + watchers.clear(); + }; + }, + }; +} 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..d9f694b34 --- /dev/null +++ b/packages/hunk/src/app/documents/gitMetadata.test.ts @@ -0,0 +1,27 @@ +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..28a52539f --- /dev/null +++ b/packages/hunk/src/app/documents/gitMetadata.ts @@ -0,0 +1,179 @@ +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/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..81e46aa88 --- /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; + /** 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/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.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.keyboard.test.tsx b/packages/hunk/src/ui/documents/DocumentApp.keyboard.test.tsx new file mode 100644 index 000000000..f4e3e20e6 --- /dev/null +++ b/packages/hunk/src/ui/documents/DocumentApp.keyboard.test.tsx @@ -0,0 +1,322 @@ +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()); + }; + + /** 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, 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 { + 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(); + } +}); + +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/DocumentApp.test.tsx b/packages/hunk/src/ui/documents/DocumentApp.test.tsx new file mode 100644 index 000000000..762adafa4 --- /dev/null +++ b/packages/hunk/src/ui/documents/DocumentApp.test.tsx @@ -0,0 +1,167 @@ +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"; +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 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"); + await act(async () => { + setup.mockInput.pressKey("n"); + await Bun.sleep(20); + }); + 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."); + route.controller.source.edit = edit; + await act(async () => { + setup.mockInput.pressKey("e"); + await Bun.sleep(20); + }); + 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 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"); + 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 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 act(async () => setup.renderOnce()); + expect(setup.captureCharFrame()).toContain("const completeDocument = 42;"); + await act(async () => setup.mockInput.pressKey("n")); + await act(async () => 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(); + } +}); + +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.shutdownEditNotices).toEqual([ + "Conflict. Editor copy retained in 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 new file mode 100644 index 000000000..5ba6cbb09 --- /dev/null +++ b/packages/hunk/src/ui/documents/DocumentApp.tsx @@ -0,0 +1,251 @@ +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"; +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 { useMenuController } from "../hooks/useMenuController"; +import { useThemeSelectorController } from "../hooks/useThemeSelectorController"; +import { useTimedNotice } from "../hooks/useTimedNotice"; +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 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. + * Keep tree selection independent from the displayed document and preserve the mounted scroll refs. + */ +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 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 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 () => { + 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, renderer, wait: true }), + ), + ); + await controller.refresh(); + }; + + const commands = buildDocumentCommands({ + controller, + isTreeFocused, + height, + scrollRef, + edit: () => { + void edit(); + }, + resolvedKeys: keys.keys, + actions: { + "hunk.app.quit": onQuit, + "hunk.app.refresh": () => { + void controller.refresh(); + }, + "hunk.app.toggleFocusArea": () => + 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": () => + interaction.update((current) => ({ ...current, sidebar: !current.sidebar })), + "hunk.view.toggleLineNumbers": () => setNumbers((value) => !value), + "hunk.view.toggleLineWrap": () => setWrap((value) => !value), + }, + }); + + const menus = buildDocumentMenus(commands, { + sidebar, + numbers, + wrap, + showExcluded: snapshot.showExcluded, + }); + const menu = useMenuController(menus); + + useDocumentKeyboard({ + isHelpOpen: () => interaction.getSnapshot().help, + closeHelp, + themeSelector, + menu, + commands, + }); + const overlay = help || themeSelector.themeSelectorOpen || menu.activeMenuId !== null; + + return ( + + { + if (menu.getActiveMenuId()) menu.openMenu(id); + }} + onToggleMenu={menu.toggleMenu} + /> + { + // 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); + }} + /> + + {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={closeHelp} + /> + ) : null} + + ); +} diff --git a/packages/hunk/src/ui/documents/DocumentContent.tsx b/packages/hunk/src/ui/documents/DocumentContent.tsx new file mode 100644 index 000000000..bfc27d5c6 --- /dev/null +++ b/packages/hunk/src/ui/documents/DocumentContent.tsx @@ -0,0 +1,111 @@ +import type { ComponentProps } from "react"; +import { sanitizeTerminalLine } from "../../lib/terminalText"; +import type { AppTheme } from "../themes"; +import { fitText } from "../lib/text"; +import { DocumentPane } from "./DocumentPane"; +import { DocumentTree } from "./DocumentTree"; +import type { DocumentBrowserSnapshot } from "./controller"; + +/** Resolve a display hint without treating document identity as a filesystem path. */ +function documentHint(snapshot: DocumentBrowserSnapshot, fallback: string) { + return snapshot.documentEntry?.displayPath ?? snapshot.documentEntry?.name ?? fallback; +} + +/** Place the directory tree beside the complete document while retaining independent focus. */ +export function DocumentContent({ + snapshot, + sidebar, + treeWidth, + terminalWidth, + treeFocused, + overlay, + pane, + onFocus, + onActivate, +}: { + snapshot: DocumentBrowserSnapshot; + sidebar: boolean; + treeWidth: number; + terminalWidth: number; + treeFocused: boolean; + overlay: boolean; + pane: Omit< + ComponentProps, + "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/DocumentPane.test.tsx b/packages/hunk/src/ui/documents/DocumentPane.test.tsx new file mode 100644 index 000000000..e4e938eba --- /dev/null +++ b/packages/hunk/src/ui/documents/DocumentPane.test.tsx @@ -0,0 +1,274 @@ +import { expect, test } from "bun:test"; +import { testRender } from "@opentui/react/test-utils"; +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>) { + await act(async () => { + await setup.renderOnce(); + await Bun.sleep(30); + }); + await act(async () => { + await setup.renderOnce(); + }); +} + +test("initial layout fills every visible row before any scrolling for wrap and gutter modes", async () => { + 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 }; + 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()); + } +}); + +/** 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 new file mode 100644 index 000000000..323036cc0 --- /dev/null +++ b/packages/hunk/src/ui/documents/DocumentPane.tsx @@ -0,0 +1,191 @@ +import { useEffect, useMemo, 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 { useDocumentScrollAnchor } from "./useDocumentScrollAnchor"; +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, width); + 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, viewport.width, lineNumbers, wrap), wrap }; + } catch { + return { + geometry: completeDocumentGeometry(lines, viewport.width, lineNumbers, false), + wrap: false, + }; + } + }, [lines, viewport.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); + useDocumentScrollAnchor(viewport.ref, documentKey, geometry, layout.wrap); + 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(() => { + 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}`} + ) : ( + // Rows and spacers partition this exact measured extent, independent of the mounted window. + + {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.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/DocumentTree.tsx b/packages/hunk/src/ui/documents/DocumentTree.tsx new file mode 100644 index 000000000..a85222440 --- /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, width); + 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}` : ""}`, + viewport.width, + )} + + + ); + })} + {window.end < snapshot.rows.length ? ( + + ) : null} + + ); +} 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..87c7ff1ee --- /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, + isTreeFocused: () => 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..bbb20272b --- /dev/null +++ b/packages/hunk/src/ui/documents/commands.ts @@ -0,0 +1,126 @@ +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, + isTreeFocused, + height, + scrollRef, + edit, + actions, + resolvedKeys, +}: { + controller: DocumentBrowserController; + isTreeFocused: () => 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 (isTreeFocused()) controller.move(delta); + else scrollRef.current?.scrollBy(delta); + }; + + /** Reveal the beginning or end of the focused surface. */ + const jump = (end: boolean) => { + if (isTreeFocused()) 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 (!isTreeFocused()) { + 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/controller.filesystem.test.ts b/packages/hunk/src/ui/documents/controller.filesystem.test.ts new file mode 100644 index 000000000..8ad396a1a --- /dev/null +++ b/packages/hunk/src/ui/documents/controller.filesystem.test.ts @@ -0,0 +1,205 @@ +import { expect, test } from "bun:test"; +import { writeFileSync } from "node:fs"; +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"; +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); +} + +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 }); + } +}); + +/** 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-"))); + 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 new file mode 100644 index 000000000..215e04e42 --- /dev/null +++ b/packages/hunk/src/ui/documents/controller.test.ts @@ -0,0 +1,392 @@ +import { describe, expect, mock, test } from "bun:test"; +import { createTestDeferred } from "../../../../../test/helpers/diff-helpers"; +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("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.shutdownEditNotices).toEqual([ + "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.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(); + } + }); + + 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); + 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..68f21c4e5 --- /dev/null +++ b/packages/hunk/src/ui/documents/controller.ts @@ -0,0 +1,344 @@ +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 observedKeys?: ReadonlySet; + private listingRequests = new Map>(); + private pendingReads = new Set>(); + private readController?: AbortController; + private closePromise?: Promise; + private editPromise: Promise | null = null; + private retainedEditNotices = new Set(); + 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; + } + + /** 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; + + 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; + const expanded = new Set(this.snapshot.expanded); + this.pruneRemovedDirectories(key, result, expanded); + this.directories.set(key, result); + this.publish({ expanded, 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; + } + + /** 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) => { + if (notice !== null) this.retainedEditNotices.add(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(); + 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)) { + this.forgetDirectory(key, expanded); + } 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 only when expansion or document selection changes the demanded key set. */ + private observe() { + if (this.closed) return; + + 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(); + }); + } + + /** Cancel pending reads, settle the editor transaction and release observers before 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] : []), + ...(this.editPromise ? [this.editPromise] : []), + ]).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..99ecfd772 --- /dev/null +++ b/packages/hunk/src/ui/documents/geometry.test.ts @@ -0,0 +1,62 @@ +import { expect, test } from "bun:test"; +import { + completeDocumentGeometry, + completeDocumentLines, + completeDocumentWindow, + completeDocumentScrollAnchor, + completeDocumentScrollAnchorTop, +} 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("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"), + 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..d59181101 --- /dev/null +++ b/packages/hunk/src/ui/documents/geometry.ts @@ -0,0 +1,110 @@ +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; +} + +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, + 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..366c022d4 --- /dev/null +++ b/packages/hunk/src/ui/documents/runDocumentBrowser.tsx @@ -0,0 +1,35 @@ +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) { + 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(); + for (const notice of controller.shutdownEditNotices) + process.stderr.write(`${sanitizeTerminalLine(notice)}\n`); + } +} 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 new file mode 100644 index 000000000..99fb14217 --- /dev/null +++ b/packages/hunk/src/ui/documents/useDocumentKeyboard.ts @@ -0,0 +1,89 @@ +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]?.(); +} + +/** 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, + }; + + const action = actions[key.name]; + if (!action) return false; + + action(); + return true; +} + +/** Give help, themes and menus keyboard priority before dispatching document commands. */ +export function useDocumentKeyboard({ + isHelpOpen, + closeHelp, + themeSelector, + menu, + commands, +}: { + isHelpOpen: () => boolean; + closeHelp: () => void; + themeSelector: ThemeSelectorController; + menu: MenuController; + commands: readonly AppCommand[]; +}) { + useKeyboard((key) => { + if (isHelpOpen()) { + if (key.name === "escape" || key.sequence === "?") { + closeHelp(); + key.preventDefault(); + } + // The focused help scrollbox owns navigation; document commands remain blocked. + return; + } + + if (themeSelector.getThemeSelectorOpen()) { + handleThemeKey(key, themeSelector); + key.preventDefault(); + return; + } + + if (key.name === "f10") { + if (menu.getActiveMenuId()) menu.closeMenu(); + else menu.openMenu("file"); + key.preventDefault(); + return; + } + + 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/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 new file mode 100644 index 000000000..3ab015d7f --- /dev/null +++ b/packages/hunk/src/ui/documents/useRowViewport.ts @@ -0,0 +1,54 @@ +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, 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, + width: estimatedWidth, + }); + useLayoutEffect(() => { + const scroll = ref.current; + if (!scroll) return; + const update = () => + setViewport((previous) => { + const next = { + // 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), + }; + 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); + return () => { + scroll.verticalScrollBar.off("change", update); + if (scroll.viewport.onSizeChange === onSizeChange) + scroll.viewport.onSizeChange = previousSizeChange; + }; + }, []); + 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/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/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 956a135b5..12ce1cfaa 100644 --- a/packages/hunk/src/ui/lib/openInEditor.ts +++ b/packages/hunk/src/ui/lib/openInEditor.ts @@ -161,22 +161,35 @@ 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, }); +} + +/** 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/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..374218fd6 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,12 @@ export function HunkSessionHost({ preparationControllerRef.current?.abort( new Error("Hunk surface preparation was cancelled during shutdown."), ); - if (routeRef.current.kind === "history" && !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], ); @@ -397,6 +414,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..863d9eb2e --- /dev/null +++ b/test/pty/open-integration.test.ts @@ -0,0 +1,250 @@ +import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, 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("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("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"); + 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/test/pty/open-keyboard-integration.test.ts b/test/pty/open-keyboard-integration.test.ts new file mode 100644 index 000000000..0a93d345d --- /dev/null +++ b/test/pty/open-keyboard-integration.test.ts @@ -0,0 +1,240 @@ +import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, 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 { appendFileSync } from "node:fs"; appendFileSync(${JSON.stringify(marker)}, "invoked\\n");`, + ); + 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({ + 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(); + 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(); + } + }); +}); + +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(); + } + }); +}); diff --git a/test/pty/open-scroll-integration.test.ts b/test/pty/open-scroll-integration.test.ts new file mode 100644 index 000000000..801a3b71b --- /dev/null +++ b/test/pty/open-scroll-integration.test.ts @@ -0,0 +1,124 @@ +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("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({ + 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(); + } +}); 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 }); + } + }, +); 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