From b855a88f6d13ffcd14ece99cf02e84a26449b355 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 8 Sep 2026 20:28:54 -0400 Subject: [PATCH 1/4] feat(status): add a workspace compass with sibling worktrees --- .changeset/tidy-status-workspaces.md | 5 + README.md | 6 + docs/extensions.md | 48 +- docs/status.md | 108 +++ packages/hunk-git/src/index.ts | 2 + packages/hunk-git/src/status.test.ts | 295 +++++++ packages/hunk-git/src/status.ts | 510 ++++++++++++ packages/hunk-vcs/src/async-process.test.ts | 41 + packages/hunk-vcs/src/async-process.ts | 65 +- packages/hunk/skills/hunk-extensions/SKILL.md | 39 +- packages/hunk/src/app/cli.test.ts | 42 + packages/hunk/src/app/cli.ts | 63 +- packages/hunk/src/app/sessionBootstrap.ts | 10 +- packages/hunk/src/app/startup.ts | 39 + packages/hunk/src/app/statusBootstrap.test.ts | 151 ++++ packages/hunk/src/app/statusBootstrap.ts | 317 ++++++++ packages/hunk/src/app/statusRouting.test.ts | 109 +++ packages/hunk/src/core/run/cliCommandNames.ts | 1 + packages/hunk/src/core/run/commandInputs.ts | 10 + .../hunk/src/core/run/statusCommandCatalog.ts | 41 + packages/hunk/src/core/vcs/types.ts | 2 + packages/hunk/src/extension-api/index.ts | 12 + packages/hunk/src/extension-api/types.ts | 134 +++- .../hunk/src/extensions/runExtension.test.ts | 4 +- packages/hunk/src/extensions/runExtension.ts | 3 + .../hunk/src/extensions/vcsStatus.test.ts | 106 +++ packages/hunk/src/extensions/vcsStatus.ts | 256 ++++++ packages/hunk/src/main.tsx | 12 + packages/hunk/src/ui/App.tsx | 25 +- .../src/ui/AppHost.dynamic-mount.test.tsx | 2 +- packages/hunk/src/ui/AppHost.tsx | 17 +- packages/hunk/src/ui/log/LogApp.tsx | 14 +- packages/hunk/src/ui/log/colorPolicy.test.ts | 9 +- packages/hunk/src/ui/log/colorPolicy.ts | 5 +- .../session/HunkSessionHost.status.test.tsx | 546 +++++++++++++ .../hunk/src/ui/session/HunkSessionHost.tsx | 272 ++++++- packages/hunk/src/ui/status/StatusApp.tsx | 528 ++++++++++++ packages/hunk/src/ui/status/commands.ts | 28 + .../hunk/src/ui/status/controller.test.ts | 319 ++++++++ packages/hunk/src/ui/status/controller.ts | 336 ++++++++ packages/hunk/src/ui/status/geometry.test.ts | 210 +++++ packages/hunk/src/ui/status/geometry.ts | 213 +++++ .../hunk/src/ui/status/pathGroups.test.ts | 44 + packages/hunk/src/ui/status/pathGroups.ts | 40 + .../src/ui/status/runInteractiveStatus.tsx | 55 ++ .../hunk/src/ui/status/runStaticStatus.ts | 70 ++ .../src/ui/status/staticProjection.test.ts | 245 ++++++ .../hunk/src/ui/status/staticProjection.ts | 207 +++++ packages/hunk/src/ui/status/types.ts | 55 ++ packages/hunk/src/ui/themes/types.ts | 2 + scripts/generate/generate-docs.test.ts | 5 +- test/cli/status.test.ts | 176 ++++ test/helpers/status-runtime.ts | 169 ++++ test/helpers/vcsStatus.ts | 18 + test/pty/status-integration.test.ts | 756 ++++++++++++++++++ test/smoke/tty.test.ts | 68 +- .../src/content/docs/docs/reference/cli.md | 51 ++ 57 files changed, 6839 insertions(+), 77 deletions(-) create mode 100644 .changeset/tidy-status-workspaces.md create mode 100644 docs/status.md create mode 100644 packages/hunk-git/src/status.test.ts create mode 100644 packages/hunk-git/src/status.ts create mode 100644 packages/hunk/src/app/statusBootstrap.test.ts create mode 100644 packages/hunk/src/app/statusBootstrap.ts create mode 100644 packages/hunk/src/app/statusRouting.test.ts create mode 100644 packages/hunk/src/core/run/statusCommandCatalog.ts create mode 100644 packages/hunk/src/extensions/vcsStatus.test.ts create mode 100644 packages/hunk/src/extensions/vcsStatus.ts create mode 100644 packages/hunk/src/ui/session/HunkSessionHost.status.test.tsx create mode 100644 packages/hunk/src/ui/status/StatusApp.tsx create mode 100644 packages/hunk/src/ui/status/commands.ts create mode 100644 packages/hunk/src/ui/status/controller.test.ts create mode 100644 packages/hunk/src/ui/status/controller.ts create mode 100644 packages/hunk/src/ui/status/geometry.test.ts create mode 100644 packages/hunk/src/ui/status/geometry.ts create mode 100644 packages/hunk/src/ui/status/pathGroups.test.ts create mode 100644 packages/hunk/src/ui/status/pathGroups.ts create mode 100644 packages/hunk/src/ui/status/runInteractiveStatus.tsx create mode 100644 packages/hunk/src/ui/status/runStaticStatus.ts create mode 100644 packages/hunk/src/ui/status/staticProjection.test.ts create mode 100644 packages/hunk/src/ui/status/staticProjection.ts create mode 100644 packages/hunk/src/ui/status/types.ts create mode 100644 test/cli/status.test.ts create mode 100644 test/helpers/status-runtime.ts create mode 100644 test/helpers/vcsStatus.ts create mode 100644 test/pty/status-integration.test.ts diff --git a/.changeset/tidy-status-workspaces.md b/.changeset/tidy-status-workspaces.md new file mode 100644 index 000000000..897346652 --- /dev/null +++ b/.changeset/tidy-status-workspaces.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Add a live read-only workspace status view with sibling inspection, shared-theme navigation into Log and full diff reviews, indented tracked/untracked groups with readable theme-colored staging facts and independent ten-file expansion, clearly separated sibling worktrees, and static or versioned JSON output. diff --git a/README.md b/README.md index f89abd0d7..51ffe2a29 100644 --- a/README.md +++ b/README.md @@ -89,8 +89,14 @@ hunk show # review the latest commit hunk show HEAD~1 # review an earlier commit hunk log # browse history on a terminal; print when redirected hunk log --static # force static output, paging when needed +hunk status # live workspace compass and sibling worktrees (Git) +hunk status --static # print one read-only workspace snapshot +hunk status --json # emit one versioned workspace snapshot ``` +`hunk status` uses provider-owned read-only workspace facts; no fetch or repository mutation is performed. +See [workspace status](docs/status.md) for navigation, live refresh, JSON and fetch-age provenance. + `hunk log` is one auto-responsive, read-only history surface, not a repository manager. On a terminal it opens the desktop history browser; pipes and redirects receive shell-native static records automatically, and `--static` forces static output that pages only when needed. The selected VCS adapter diff --git a/docs/extensions.md b/docs/extensions.md index 3b30dc1fc..dcbb83d13 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -302,8 +302,9 @@ and retires the replaced instance at that explicit ownership boundary. ### `hunk.apiVersion` -The API generation this Hunk speaks (currently `25`). Branch on it if you want -one file to support several Hunk versions. Version 25 adds Promise-returning watch signatures and +The API generation this Hunk speaks (currently `26`). Branch on it if you want +one file to support several Hunk versions. Version 26 adds optional read-only workspace status, +sibling inspection and status review planning; version 25 adds Promise-returning watch signatures and watch cancellation; version 24 adds review metadata to VCS patch results and short display revisions to commit descriptors; version 23 adds canonical unified-layout fields while preserving the previous event vocabulary; version 22 adds frame-derived pane preferred sizing, @@ -498,6 +499,49 @@ those two reserved extensions are skipped with a notice. This API selects a language already available to Pierre/Shiki. It does not load a new syntax grammar; an unknown language remains plain text. +### Workspace status capability + +API version 26 adds optional `ExtensionVcsAdapter.status`, independent of `history` and +review operations. Providers without it report unsupported for `hunk status`; detection never +falls back from a native JJ/Sapling workspace to Git status semantics. The bundled Git provider +implements the capability in `packages/hunk-git/src/status.ts`. + +- `read({ targetPath? }, context)` returns an `ExtensionVcsStatusSnapshot` with `schemaVersion: 1`. + `context.cwd` is the launch repository authority; a target may inspect only that repository's + worktrees. A missing repository/worktree rejects. No process-wide cwd change, extension loading, + fetch, checkout, staging or other mutation belongs in this capability. +- `readSiblings(snapshot, context)` returns bounded same-repository summaries. Return an + unavailable/error row, not zero changes, when a sibling cannot be read. The initial snapshot + can publish `siblings: { state: "loading" }` before this slower scan completes. +- `planReview(snapshot, actionId, context)` revalidates source/target identity and the opaque + snapshot token, then returns `{ cwd, input }` for the existing full working-tree comparison. + The UI passes only a provider-offered action id; it does not construct revision expressions or + narrow the comparison to a selected file. A live working tree can still change after planning; + the token is not content attestation. +- Optional async `watchPlan(snapshot, context)` uses the existing watch-plan vocabulary for + worktree and shared/per-worktree metadata. Hosts keep periodic polling for missed events, + serialize/coalesce refreshes, cancel superseded reads and reject late generations. + +Every asynchronous operation receives `context.signal` and must honor cancellation, bound its +I/O, and reap subprocesses. Status results allow at most 20,000 unique changed paths, 100 sibling +rows and 32 review actions. Oversized current status fails explicitly; bounded sibling lists set +`truncated`. Fields marked unknown/error must not become clean, aligned, or zero-count results. +The extension boundary validates and copies snapshots before publishing them. Paths retain +literal Unicode, tabs and newlines for navigation/JSON; renderers escape control characters. +Providers without an index omit `paths[].index` rather than inventing Git staging states. + +`changedPathCount` counts unique destination paths, including rename destinations and untracked +files. A partially staged path appears once with independent index/worktree states. Submodule +commit/tracked/untracked changes and conflicts stay explicit. No summed numstat is presented as +a net change; detailed comparisons belong in the existing diff view. + +Fetch timestamps are optional facts with provenance, not observation timestamps. Git retains the +inspected worktree's local `FETCH_HEAD` mtime with `local-fetch-head-mtime` provenance in structured +facts: it is not authoritative for the named upstream and may describe a different remote or a +manually modified file. Human output therefore omits age; `(last fetched 18m ago)` may appear only +when provenance supports that claim. Hunk never fetches to populate it. Missing/unreadable metadata +remains unknown/error. See [workspace status](status.md) for command and scan policy. + ### `hunk.registerVcsAdapter(adapter)` Contribute an additional VCS backend. This is the same call Hunk's own bundled diff --git a/docs/status.md b/docs/status.md new file mode 100644 index 000000000..8821cdee1 --- /dev/null +++ b/docs/status.md @@ -0,0 +1,108 @@ +# Workspace status + +`hunk status --static` prints a compact current-worktree snapshot followed by sibling worktrees. +`hunk status --json` prints the same normalized facts as a JSON object with `schemaVersion: 1`, +without color or paging. Redirects automatically select static output; `--static` pages only +when the snapshot exceeds terminal height, using the ordinary Hunk pager policy. + +In a terminal, `hunk status` opens one live workspace compass. Select rows with ↑/↓ (or J/K), +press Enter to open a path in its full comparison or inspect a sibling, U for working-tree changes, +S for staged changes, and L for the existing Log. F10 opens the normal menus, T selects a theme, +R refreshes, and W expands/collapses the bounded worktree list. Menu actions and clickable review +labels provide the same actions with a mouse; double-click a path or sibling to open it. + +Q returns from diff to its caller, from Log to status, or from an inspected sibling to its origin; +at the root it quits. Escape/Backspace also return from sibling inspection. Pending preparation +can be cancelled with Q; failures leave the caller usable. Cleanup failures while returning from +Log are reported on status without requiring a second Q or blocking shutdown. Ctrl-C and OS shutdown signals end the +whole session. These journeys share one renderer, the ordinary Pierre-backed multi-file review +stream and the existing Log, not nested terminal applications. Selection and scrolling survive +returns and refresh. Narrow terminals stack path facts rather than adding a file inspector; +arrow/page keys and the mouse wheel scroll within a row taller than the viewport before moving +to another row, keeping all wrapped facts reachable. + +Status uses normal config, theme/custom-theme, extension enablement and trust resolution. Its +bootstrap retains one ExtensionSession, launch options, keybindings, session initialization and +view-preference baseline. Inspecting another worktree changes only the provider's explicit target; +it does not load or auto-trust that sibling's extensions or change the shell cwd. Custom palettes, +keybindings and experimental launch opt-ins remain owned by the launch session, including during +review reloads. The resolved transparent-background preference also spans status, Log and diff +through the shared surface-theme derivation. Theme and review preference changes seed later reviews opened directly or through +Log, and the root status surface owns the normal save-preferences prompt. Only a mounted review +registers with the session broker; its source and cwd refer to the inspected target. + +## Facts and failures + +Git is the initial provider. Native JJ/Sapling detection remains authoritative: unsupported +providers exit nonzero, rather than silently using Git. Explicit `--vcs git` selects Git when +that is the intended comparison model. Outside-repository and bare/no-worktree invocations exit +nonzero with a diagnostic on stderr (including with `--json`). Partial sibling failures remain +in the snapshot and do not fail a readable current worktree. + +Paths appear in two counted groups: **Tracked changes** and **Untracked files**. Indented rows +initially follow provider order; live updates retain surviving paths in place within each group and +append new paths. Each group independently shows up to ten files (not wrapped lines). When more +exist, select **and N more files** and press Enter or Space, or click it, to expand that group; +**Show fewer** collapses it again. Exactly ten files need no toggle. Expansion stays local to the +status presentation and survives refresh, Log/diff visits, and sibling inspection/back. Expanding +does not open a file or move focus. Collapsing a group with a now-hidden selected file moves focus +to the group's toggle and brings it into view. Static output keeps the groups and indentation but +prints every file; JSON remains the complete, unchanged fact snapshot. + +Tracked paths use readable Git-familiar facts such as `modified (staged)`, `new file (staged)`, +`deleted (unstaged)`, and `renamed (staged)`. Mixed staging displays both sides explicitly, counting +the path only once. There are no XY markers, leading unchanged dots, or marker legends; ordinary +untracked rows need only their filename under **Untracked files**. Staged facts use the active theme's +positive (green-family) sign color; unstaged/untracked facts use its negative (red-family) sign color, +regardless of change type. Custom sign-color overrides are honored. Single-state filenames share +that color; mixed filenames stay neutral with both facts colored independently. Text stays meaningful +without color. Conflicts retain an explicit label and attention color. Rename origins, type changes, +submodule facts and unavailable/error states remain visible, with hanging indentation on wrapped file +rows. A spaced, ruled Other worktrees heading separates the sections in the scrollable stream; +sibling branch identity is emphasized over muted location and facts. No selected-file inspector or +line-count aggregate duplicates the existing diff view. The primary action strip offers the existing +review actions and, only while inspecting a sibling, Back. Log and Quit remain available through +their existing keys and menus rather than occupying that strip. +Snapshot tokens revalidate identity/status when planning reviews, not immutable file contents; +opening a live diff reads the actual working tree through the existing review loader. An explicit +untracked-row open includes untracked files in that full comparison even when launch config sets +`exclude_untracked = true`; its refreshes keep that effective input. Aggregate review actions +and later independent opens still honor launch config. Ignore rules and source safety limits +remain unchanged. + +Upstream text omits zero counters: `3 ahead · 1 behind origin/main`. Aligned, absent, deleted +upstream, detached HEAD and unborn branches have distinct facts. Human output omits fetch age: +the current Git provenance records only the inspected worktree's local `FETCH_HEAD` mtime, +which may refer to another remote or have been manually changed. Structured facts retain that +timestamp and `local-fetch-head-mtime` provenance, or explicit unknown/error states when metadata +is missing/unreadable. The approved suffix `(last fetched 18m ago)` requires provenance that +supports that claim; local file mtime does not. Observation time is never used as fetch time, +and no network fetch runs. + +## Bounds and refresh seam + +Current status loads first; sibling enumeration is a separate cancellable call. Git queries are +shell-free with optional index locking disabled, a 5-second timeout, and an 8 MiB combined output +limit per subprocess. Current paths are capped at 20,000 (exceeding the cap is an error, not a +truncated clean snapshot); sibling scans return at most 100 rows with four concurrent reads and +an explicit truncation flag. Invalid/truncated porcelain and non-UTF-8 paths fail explicitly. +All counts represent unique destination paths, not overlapping staged/unstaged totals. + +Linked worktree metadata is resolved separately from the common Git directory. Locked but +accessible worktrees remain inspectable read-only; bare, prunable, missing and inaccessible +worktrees remain distinguishable. Failure to read operation metadata is not reported as idle. + +`StatusBootstrap.load`, `loadSiblings`, `planReview` and `watchPlan` accept route cancellation. +`close()` cancels and drains active provider reads; the session host separately shuts down its +ExtensionSession exactly once. Watch plans cover the current tree plus shared/per-worktree Git +metadata and prune object storage and Git-ignored directories. The controller coalesces event bursts, +serializes current reads, and polls every five seconds while watching (more frequently if watching fails). +Current facts appear before sibling summaries. Current refreshes do not wait for a secondary +scan, and same-target safety polls let that scan finish even when it spans multiple poll intervals. +Secondary results update only sibling facts, never newer current observations; the next refresh +after completion starts another scan. Suspending or changing targets cancels and drains the scan. Refreshing/stale/error states stay explicit; prior +sibling rows remain visible during refresh rather than vanishing under the cursor. Target changes +cancel and drain reads/watchers. Observations suspend during Log/diff and resume on return without +resetting navigation. Static CLI never starts a status watcher. + +The renderer-free public API is documented in [extensions](extensions.md#workspace-status-capability). diff --git a/packages/hunk-git/src/index.ts b/packages/hunk-git/src/index.ts index e70e8da75..18b9bbe3f 100644 --- a/packages/hunk-git/src/index.ts +++ b/packages/hunk-git/src/index.ts @@ -27,6 +27,7 @@ import { openGitHistory, planGitHistoryRangeReview, } from "./history"; +import { createGitStatusCapability } from "./status"; import { gitEndpointSourceSpec, readGitFileSource } from "./source"; import { commitReviewInfo, comparisonReviewInfo } from "@hunk/vcs/review-info"; import { @@ -352,6 +353,7 @@ export function createGitVcsAdapter({ name: "Git", detect: detectGitRepo, detectionPriority: HUNK_VCS_DETECTION_BASELINE_PRIORITY, + status: createGitStatusCapability(gitExecutable), history: { open(input, { cwd }) { return openGitHistory(input, { cwd, gitExecutable }); diff --git a/packages/hunk-git/src/status.test.ts b/packages/hunk-git/src/status.test.ts new file mode 100644 index 000000000..a1654f11e --- /dev/null +++ b/packages/hunk-git/src/status.test.ts @@ -0,0 +1,295 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, utimesSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createGitStatusCapability, parseGitStatus, parseGitStatusWorktrees } from "./status"; + +const dirs: string[] = []; +/** Create a portable Git fixture with local identity and a deterministic branch. */ +function createTestRepo(commit = true) { + const cwd = mkdtempSync(join(tmpdir(), "hunk-status-test-")); + dirs.push(cwd); + testGit(cwd, "init", "-q", "-b", "main"); + testGit(cwd, "config", "user.name", "Status Test"); + testGit(cwd, "config", "user.email", "status@example.com"); + if (commit) { + writeFileSync(join(cwd, "mixed.txt"), "one\n"); + testGit(cwd, "add", "."); + testGit(cwd, "commit", "-qm", "initial"); + } + return cwd; +} +/** Execute fixture mutations outside the read-only provider under test. */ +function testGit(cwd: string, ...args: string[]) { + const result = Bun.spawnSync(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" }); + if (result.exitCode) throw new Error(result.stderr.toString()); + return result.stdout.toString().trim(); +} +const capability = createGitStatusCapability(); +const header = `# branch.oid ${"a".repeat(40)}\0# branch.head main\0`; +const object = "a".repeat(40); +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("Git status porcelain", () => { + test("retains mixed states, literal rename paths, Unicode, conflicts and submodules", () => { + const result = parseGitStatus( + header + + `1 MM N... 100644 100644 100644 ${object} ${object} mixed path\0` + + `2 RM N... 100644 100644 100644 ${object} ${object} R100 new\t名\npath\0old \\ path\0` + + `u UU N... 100644 100644 100644 100644 ${object} ${object} ${object} conflict\0` + + `1 .M SCMU 160000 160000 160000 ${object} ${object} submodule\0` + + `? --not-an-option\0`, + ); + expect(result.paths).toHaveLength(5); + expect(result.paths[0]).toMatchObject({ index: "modified", worktree: "modified" }); + expect(result.paths[1]).toMatchObject({ + path: "new\t名\npath", + previousPath: "old \\ path", + index: "renamed", + worktree: "modified", + }); + expect(result.paths[2]!.conflict).toBe(true); + expect(result.paths[3]!.submodule).toEqual({ + commitChanged: true, + trackedChanges: true, + untrackedChanges: true, + }); + expect(result.paths[4]).toMatchObject({ path: "--not-an-option", worktree: "untracked" }); + }); + test("refuses truncation, duplicate paths and malformed states", () => { + expect(() => parseGitStatus(header + "? partial")).toThrow("truncated"); + expect(() => parseGitStatus(header + "? same\0? same\0")).toThrow("duplicate"); + expect(parseGitStatus(header + "? \ufffd\0").paths[0]!.path).toBe("\ufffd"); + expect(() => + parseGitStatus(header + `1 ZZ N... 100644 100644 100644 ${object} ${object} bad\0`), + ).toThrow("invalid status"); + expect(() => + parseGitStatus(header + `2 R. N... 100644 100644 100644 ${object} ${object} R100 target\0`), + ).toThrow("rename source"); + }); + test("refuses oversized current status instead of publishing a partial path count", () => { + const paths = Array.from({ length: 20_001 }, (_, index) => `? file-${index}\0`).join(""); + expect(() => parseGitStatus(header + paths)).toThrow("exceeds 20000"); + }); + test("parses NUL worktree metadata without treating path newlines as records", () => { + expect( + parseGitStatusWorktrees( + "worktree root\n名 \\path\0HEAD abc\0branch refs/heads/main\0locked reason\nline\0\0worktree gone\0HEAD abc\0detached\0prunable missing\0\0", + ), + ).toEqual([ + { + path: "root\n名 \\path", + branch: "main", + detached: false, + bare: false, + locked: "reason\nline", + }, + { path: "gone", detached: true, bare: false, prunable: "missing" }, + ]); + expect(() => parseGitStatusWorktrees("worktree cut\0")).toThrow("truncated"); + }); +}); + +describe("Git workspace status reads", () => { + test("counts mixed staging once, plans full comparisons and refuses stale plans without mutating the index", async () => { + const cwd = createTestRepo(); + writeFileSync(join(cwd, "mixed.txt"), "two\n"); + testGit(cwd, "add", "mixed.txt"); + writeFileSync(join(cwd, "mixed.txt"), "three\n"); + writeFileSync(join(cwd, "新 file.txt"), "new\n"); + const before = statSync(join(cwd, ".git", "index")).mtimeMs; + const snapshot = await capability.read({}, { cwd }); + expect(snapshot.changedPathCount).toBe(2); + expect(snapshot.paths[0]).toMatchObject({ + path: "mixed.txt", + index: "modified", + worktree: "modified", + }); + expect(snapshot.siblings).toEqual({ state: "loading" }); + expect(snapshot.reviewActions.map((action) => action.id)).toEqual(["staged", "unstaged"]); + expect(await capability.planReview(snapshot, "staged", { cwd })).toEqual({ + cwd: snapshot.worktree.path, + input: { kind: "vcs", staged: true, options: {} }, + }); + expect(statSync(join(cwd, ".git", "index")).mtimeMs).toBe(before); + testGit(cwd, "add", "新 file.txt"); + await expect(capability.planReview(snapshot, "staged", { cwd })).rejects.toThrow("refresh"); + }); + test("reports detached and unborn HEAD explicitly", async () => { + const cwd = createTestRepo(false); + expect((await capability.read({}, { cwd })).head).toEqual({ kind: "unborn", name: "main" }); + writeFileSync(join(cwd, "file"), "one"); + testGit(cwd, "add", "."); + testGit(cwd, "commit", "-qm", "first"); + testGit(cwd, "checkout", "-q", "--detach"); + const snapshot = await capability.read({}, { cwd }); + expect(snapshot.head.kind).toBe("detached"); + expect(snapshot.upstream).toEqual({ state: "ready", value: { kind: "detached" } }); + }); + test("distinguishes tracked, deleted and absent upstreams and uses only FETCH_HEAD mtime", async () => { + const cwd = createTestRepo(); + expect((await capability.read({}, { cwd })).upstream).toEqual({ + state: "ready", + value: { kind: "none" }, + }); + testGit(cwd, "config", "remote.origin.url", "https://example.invalid/not-contacted"); + testGit(cwd, "config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"); + testGit(cwd, "update-ref", "refs/remotes/origin/main", "HEAD"); + testGit(cwd, "branch", "--set-upstream-to=origin/main"); + let snapshot = await capability.read({}, { cwd }); + expect(snapshot.upstream).toMatchObject({ + state: "ready", + value: { kind: "tracked", ahead: 0, behind: 0, fetch: { state: "unknown" } }, + }); + const fetchedAt = new Date("2026-01-02T03:04:05Z"); + writeFileSync(join(cwd, ".git", "FETCH_HEAD"), "local fixture\n"); + utimesSync(join(cwd, ".git", "FETCH_HEAD"), fetchedAt, fetchedAt); + snapshot = await capability.read({}, { cwd }); + expect(snapshot.upstream).toMatchObject({ + state: "ready", + value: { + fetch: { + state: "ready", + value: { timestamp: fetchedAt.toISOString(), provenance: "local-fetch-head-mtime" }, + }, + }, + }); + testGit(cwd, "update-ref", "-d", "refs/remotes/origin/main"); + expect((await capability.read({}, { cwd })).upstream).toMatchObject({ + state: "ready", + value: { kind: "missing", name: "origin/main" }, + }); + }); + test("contains sibling failures, locked and missing worktrees and isolates per-worktree operations", async () => { + const cwd = createTestRepo(); + const sibling = join(cwd, "..", `${cwd.split(/[\\/]/).at(-1)}-sibling`); + dirs.push(sibling); + const missing = `${sibling}-missing`; + dirs.push(missing); + testGit(cwd, "worktree", "add", "-qb", "sibling", sibling); + testGit(cwd, "worktree", "add", "-qb", "missing", missing); + testGit(cwd, "worktree", "lock", "--reason", "keep me", sibling); + writeFileSync(join(sibling, "untracked.txt"), "change"); + const gitDir = testGit(sibling, "rev-parse", "--absolute-git-dir"); + mkdirSync(join(gitDir, "rebase-merge")); + rmSync(missing, { force: true, recursive: true }); + const snapshot = await capability.read({}, { cwd }); + const result = await capability.readSiblings(snapshot, { cwd }); + expect(result.worktrees).toHaveLength(2); + expect(result.worktrees.find((row) => row.branch === "sibling")).toMatchObject({ + locked: "keep me", + inspectable: true, + status: { + state: "ready", + changedPathCount: 1, + operations: { state: "ready", value: ["rebase"] }, + }, + }); + expect(result.worktrees.find((row) => row.branch === "missing")).toMatchObject({ + inspectable: false, + status: { state: "unavailable" }, + }); + expect(snapshot.operations).toEqual({ state: "ready", value: [] }); + const target = await capability.read({ targetPath: sibling }, { cwd }); + expect(target.worktree.repositoryId).toBe(snapshot.worktree.repositoryId); + expect( + (await capability.readSiblings(target, { cwd })).worktrees.some( + (row) => row.worktree.path === snapshot.worktree.path, + ), + ).toBe(true); + mkdirSync(join(sibling, "ignored-build")); + writeFileSync(join(sibling, "ignored-build", "output.txt"), "ignored"); + writeFileSync(join(sibling, ".gitignore"), "ignored-build/\n"); + const watch = await capability.watchPlan!(target, { cwd }); + expect(watch.targets.map((target) => target.directory)).toContain(gitDir); + expect(watch.targets.find((entry) => entry.directory === sibling)).toMatchObject({ + ignoredRoots: [join(sibling, ".git"), join(sibling, "ignored-build")], + }); + }); + test("limits concurrent sibling reads and contains an error to its row", async () => { + const cwd = createTestRepo(); + const parent = mkdtempSync(join(tmpdir(), "hunk-status-siblings-test-")); + dirs.push(parent); + for (let index = 0; index < 6; index++) + testGit(cwd, "worktree", "add", "-qb", `sibling-${index}`, join(parent, `sibling-${index}`)); + const provider = createGitStatusCapability(); + const snapshot = await provider.read({}, { cwd }); + const read = provider.read; + let active = 0; + let maximum = 0; + provider.read = async (input, context) => { + active++; + maximum = Math.max(maximum, active); + try { + await Bun.sleep(5); + if (input.targetPath?.endsWith("sibling-2")) throw new Error("Sibling access failed"); + return await read(input, context); + } finally { + active--; + } + }; + const result = await provider.readSiblings(snapshot, { cwd }); + expect(maximum).toBe(4); + expect(active).toBe(0); + expect(result.worktrees.filter((row) => row.status.state === "ready")).toHaveLength(5); + expect(result.worktrees.find((row) => row.branch === "sibling-2")).toMatchObject({ + inspectable: false, + status: { state: "error", message: "Error: Sibling access failed" }, + }); + }); + + test("recognizes conflict and operation markers without showing clean", async () => { + const cwd = createTestRepo(); + for (const [marker, kind] of [ + ["MERGE_HEAD", "merge"], + ["CHERRY_PICK_HEAD", "cherry-pick"], + ["REVERT_HEAD", "revert"], + ] as const) { + writeFileSync(join(cwd, ".git", marker), `${testGit(cwd, "rev-parse", "HEAD")}\n`); + expect((await capability.read({}, { cwd })).operations).toMatchObject({ + state: "ready", + value: [kind], + }); + rmSync(join(cwd, ".git", marker)); + } + mkdirSync(join(cwd, ".git", "sequencer")); + writeFileSync(join(cwd, ".git", "sequencer", "todo"), `pick ${object} next\n`); + expect((await capability.read({}, { cwd })).operations).toEqual({ + state: "ready", + value: ["cherry-pick"], + }); + writeFileSync(join(cwd, ".git", "sequencer", "todo"), "x".repeat(65_537)); + expect((await capability.read({}, { cwd })).operations.state).toBe("error"); + rmSync(join(cwd, ".git", "sequencer"), { recursive: true }); + testGit(cwd, "checkout", "-qb", "other"); + writeFileSync(join(cwd, "mixed.txt"), "other\n"); + testGit(cwd, "commit", "-qam", "other"); + testGit(cwd, "checkout", "-q", "main"); + writeFileSync(join(cwd, "mixed.txt"), "main\n"); + testGit(cwd, "commit", "-qam", "main"); + Bun.spawnSync(["git", "merge", "other"], { cwd, stdout: "pipe", stderr: "pipe" }); + const snapshot = await capability.read({}, { cwd }); + expect(snapshot.changedPathCount).toBe(1); + expect(snapshot.paths[0]!.conflict).toBe(true); + expect(snapshot.operations).toEqual({ state: "ready", value: ["merge"] }); + }); + test("rejects outside repositories, bare repositories, foreign targets and pre-cancelled reads", async () => { + const cwd = createTestRepo(); + const foreign = createTestRepo(); + await expect(capability.read({ targetPath: foreign }, { cwd })).rejects.toThrow( + "same Git repository", + ); + const outside = mkdtempSync(join(tmpdir(), "hunk-outside-test-")); + dirs.push(outside); + await expect(capability.read({}, { cwd: outside })).rejects.toThrow("not a git repository"); + testGit(outside, "init", "--bare", "-q"); + await expect(capability.read({}, { cwd: outside })).rejects.toThrow("bare"); + const abort = new AbortController(); + abort.abort(new Error("cancelled status")); + await expect(capability.read({}, { cwd, signal: abort.signal })).rejects.toThrow( + "cancelled status", + ); + }); +}); diff --git a/packages/hunk-git/src/status.ts b/packages/hunk-git/src/status.ts new file mode 100644 index 000000000..e7bce44b7 --- /dev/null +++ b/packages/hunk-git/src/status.ts @@ -0,0 +1,510 @@ +import { createHash } from "node:crypto"; +import { open, stat, realpath } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { buildGitIgnoredDirectoryArgs, parseGitIgnoredDirectoryRoots } from "./commands"; +import { runAbortableCommand } from "@hunk/vcs/async-process"; +import type { + ExtensionVcsStatusCapability, + ExtensionVcsStatusFact, + ExtensionVcsStatusHead, + ExtensionVcsStatusOperation, + ExtensionVcsStatusPath, + ExtensionVcsStatusPathState, + ExtensionVcsStatusUpstream, + ExtensionVcsStatusWorktreeSummary, +} from "hunkdiff/extension"; + +const MAX_PATHS = 20_000; +const MAX_WORKTREES = 100; +const MAX_OUTPUT_BYTES = 8 * 1024 * 1024; +const QUERY_TIMEOUT_MS = 5_000; +const SIBLING_CONCURRENCY = 4; + +interface GitStatusContext { + cwd: string; + gitExecutable: string; + signal?: AbortSignal; +} + +const states: Record = { + ".": "unchanged", + M: "modified", + A: "added", + D: "deleted", + R: "renamed", + C: "copied", + T: "type-changed", + U: "unmerged", + "?": "untracked", +}; + +/** Consume fixed machine fields without splitting whitespace inside the final path. */ +function fieldsAndPath(record: string, fieldCount: number) { + const fields: string[] = []; + let offset = 0; + for (let i = 0; i < fieldCount; i++) { + const end = record.indexOf(" ", offset); + if (end < 0) throw new Error("Git returned an incomplete status record."); + fields.push(record.slice(offset, end)); + offset = end + 1; + } + const path = record.slice(offset); + if (!path) { + throw new Error("Git status contains an empty path."); + } + return { fields, path }; +} + +/** Parse NUL porcelain v2, retaining mixed staging states, rename sources and submodules. */ +export function parseGitStatus(text: string) { + if (text && !text.endsWith("\0")) throw new Error("Git returned truncated status output."); + const records = text.split("\0"); + records.pop(); + const headers = new Map(); + const paths: ExtensionVcsStatusPath[] = []; + const seen = new Set(); + for (let i = 0; i < records.length; i++) { + const record = records[i]!; + if (record.startsWith("# ")) { + const end = record.indexOf(" ", 2); + if (end < 0) throw new Error("Git returned an incomplete status header."); + headers.set(record.slice(2, end), record.slice(end + 1)); + continue; + } + const kind = record[0]; + if (!["1", "2", "u", "?"].includes(kind ?? "")) { + throw new Error("Git returned an unsupported status record."); + } + const { fields, path } = fieldsAndPath( + record, + kind === "1" ? 8 : kind === "2" ? 9 : kind === "u" ? 10 : 1, + ); + if (seen.has(path)) throw new Error("Git returned duplicate status paths."); + seen.add(path); + if (paths.length >= MAX_PATHS) + throw new Error(`Git status exceeds ${MAX_PATHS} changed paths.`); + if (kind === "?") { + paths.push({ path, index: "unchanged", worktree: "untracked", conflict: false }); + continue; + } + const xy = fields[1]!; + const sub = fields[2]!; + if ( + xy.length !== 2 || + !states[xy[0]!] || + !states[xy[1]!] || + !/^(N\.\.\.|S[.C][.M][.U])$/.test(sub) + ) { + throw new Error("Git returned invalid status states."); + } + const previousPath = kind === "2" ? records[++i] : undefined; + if (kind === "2" && !previousPath) { + throw new Error("Git returned an incomplete rename source."); + } + paths.push({ + path, + ...(previousPath === undefined ? {} : { previousPath }), + index: states[xy[0]!]!, + worktree: states[xy[1]!]!, + conflict: kind === "u", + ...(sub[0] === "S" + ? { + submodule: { + commitChanged: sub[1] === "C", + trackedChanges: sub[2] === "M", + untrackedChanges: sub[3] === "U", + }, + } + : {}), + }); + } + const oid = headers.get("branch.oid"); + const branch = headers.get("branch.head"); + if (!oid || !branch || (oid !== "(initial)" && !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(oid))) { + throw new Error("Git returned incomplete branch identity."); + } + const head: ExtensionVcsStatusHead = + oid === "(initial)" + ? { kind: "unborn", name: branch } + : branch === "(detached)" + ? { kind: "detached", revisionId: oid } + : { kind: "branch", name: branch, revisionId: oid }; + return { + paths, + head, + upstream: headers.get("branch.upstream"), + divergence: headers.get("branch.ab"), + }; +} + +export interface GitStatusWorktreeRecord { + path: string; + branch?: string; + detached: boolean; + bare: boolean; + locked?: string; + prunable?: string; +} + +/** Parse `worktree list --porcelain -z`; newlines and spaces remain literal path bytes. */ +export function parseGitStatusWorktrees(text: string): GitStatusWorktreeRecord[] { + if (text && !text.endsWith("\0\0")) throw new Error("Git returned truncated worktree output."); + const result: GitStatusWorktreeRecord[] = []; + let current: GitStatusWorktreeRecord | undefined; + for (const field of text.split("\0")) { + if (!field) { + if (current) result.push(current); + current = undefined; + continue; + } + const separator = field.indexOf(" "); + const key = separator < 0 ? field : field.slice(0, separator); + const value = separator < 0 ? "" : field.slice(separator + 1); + if (key === "worktree") { + if (current || !value) throw new Error("Git returned invalid worktree identity."); + current = { path: value, detached: false, bare: false }; + } else { + if (!current) throw new Error("Git returned incomplete worktree metadata."); + if (key === "branch") current.branch = value.replace(/^refs\/heads\//, ""); + else if (key === "detached") current.detached = true; + else if (key === "bare") current.bare = true; + else if (key === "locked") current.locked = value; + else if (key === "prunable") current.prunable = value; + else if (key !== "HEAD") throw new Error("Git returned unsupported worktree metadata."); + } + } + return result; +} + +/** Run read-only Git with explicit time/output limits and process-tree cancellation. */ +async function query(args: string[], context: GitStatusContext, accepted = [0]) { + const result = await runAbortableCommand([context.gitExecutable, ...args], { + cwd: context.cwd, + signal: context.signal, + env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, + maxOutputBytes: MAX_OUTPUT_BYTES, + timeoutMs: QUERY_TIMEOUT_MS, + strictUtf8: true, + }); + if (!accepted.includes(result.exitCode)) { + throw new Error(result.stderr.trim().split("\n")[0] || "Git could not read workspace status."); + } + return result; +} + +/** Remove only Git's record terminator, never whitespace belonging to a directory name. */ +function outputPath(text: string) { + const path = text.endsWith("\n") ? text.slice(0, -1) : text; + if (!path) throw new Error("Git returned an invalid metadata path."); + return path; +} + +/** Resolve linked-worktree metadata independently of the launch authority's working directory. */ +async function metadata(context: GitStatusContext) { + const bare = + (await query(["rev-parse", "--is-bare-repository"], context)).stdout.trim() === "true"; + if (bare) throw new Error("Status requires a worktree; this Git repository is bare."); + const path = await realpath( + outputPath((await query(["rev-parse", "--show-toplevel"], context)).stdout), + ); + const gitDir = await realpath( + outputPath((await query(["rev-parse", "--absolute-git-dir"], context)).stdout), + ); + const commonDir = await realpath( + resolve( + context.cwd, + outputPath((await query(["rev-parse", "--git-common-dir"], context)).stdout), + ), + ); + context.signal?.throwIfAborted(); + return { path, gitDir, commonDir }; +} + +/** Distinguish missing optional metadata from unreadable metadata. */ +async function optionalStat(path: string) { + try { + return await stat(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } +} + +/** Read a small optional operation file without retaining an unbounded metadata payload. */ +async function operationText(path: string) { + let file; + try { + file = await open(path, "r"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } + try { + const buffer = Buffer.alloc(65_537); + let offset = 0; + while (offset < buffer.length) { + const { bytesRead } = await file.read(buffer, offset, buffer.length - offset, offset); + if (!bytesRead) break; + offset += bytesRead; + } + if (offset > 65_536) throw new Error("Git operation metadata exceeds 64 KiB."); + return buffer.subarray(0, offset).toString("utf8"); + } finally { + await file.close(); + } +} + +/** Observe operation markers in the per-worktree directory, retaining access failures. */ +async function operations( + gitDir: string, +): Promise> { + const markers: [string, ExtensionVcsStatusOperation][] = [ + ["MERGE_HEAD", "merge"], + ["rebase-merge", "rebase"], + ["rebase-apply/rebasing", "rebase"], + ["CHERRY_PICK_HEAD", "cherry-pick"], + ["REVERT_HEAD", "revert"], + ["BISECT_LOG", "bisect"], + ]; + try { + const found = await Promise.all( + markers.map(async ([name, operation]) => + (await optionalStat(join(gitDir, name))) ? operation : undefined, + ), + ); + // Sequencers may remain active between commits without CHERRY_PICK_HEAD or REVERT_HEAD. + const todo = await operationText(join(gitDir, "sequencer", "todo")); + const next = todo?.split("\n").find((line) => line.trim() && !line.startsWith("#")); + if (next?.startsWith("pick ")) found.push("cherry-pick"); + else if (next?.startsWith("revert ")) found.push("revert"); + else if (todo !== undefined) + return { state: "unknown", reason: "Git sequencer operation could not be identified." }; + if (await optionalStat(join(gitDir, "rebase-apply", "applying"))) { + return { state: "unknown", reason: "Git is applying mailbox patches." }; + } + return { + state: "ready", + value: [ + ...new Set( + found.filter((value): value is ExtensionVcsStatusOperation => value !== undefined), + ), + ], + }; + } catch (error) { + return { state: "error", message: String(error) }; + } +} + +/** Read divergence and local FETCH_HEAD mtime without attributing it to an individual upstream. */ +async function upstream( + parsed: ReturnType, + gitDir: string, +): Promise> { + if (parsed.head.kind !== "branch") return { state: "ready", value: { kind: parsed.head.kind } }; + if (!parsed.upstream) return { state: "ready", value: { kind: "none" } }; + if (!parsed.divergence) + return { state: "ready", value: { kind: "missing", name: parsed.upstream } }; + const counts = /^\+(\d+) -(\d+)$/.exec(parsed.divergence); + if (!counts) return { state: "error", message: "Git returned invalid upstream divergence." }; + let fetch: Extract["fetch"]; + try { + const fetched = await optionalStat(join(gitDir, "FETCH_HEAD")); + fetch = + fetched && fetched.size > 0 + ? { + state: "ready", + value: { timestamp: fetched.mtime.toISOString(), provenance: "local-fetch-head-mtime" }, + } + : { state: "unknown", reason: "No local FETCH_HEAD metadata." }; + } catch (error) { + fetch = { state: "error", message: String(error) }; + } + return { + state: "ready", + value: { + kind: "tracked", + name: parsed.upstream, + ahead: Number(counts[1]), + behind: Number(counts[2]), + fetch, + }, + }; +} + +/** Create bounded Git status reads; callers own refresh scheduling and coherent generations. */ +export function createGitStatusCapability(gitExecutable = "git"): ExtensionVcsStatusCapability { + /** Verify every inspected target still belongs to the launch repository. */ + const targetMetadata = async (targetPath: string | undefined, context: GitStatusContext) => { + const origin = await metadata(context); + const target = + targetPath && resolve(targetPath) !== origin.path + ? await metadata({ ...context, cwd: targetPath }) + : origin; + if (target.commonDir !== origin.commonDir) + throw new Error("Status target is no longer in the same Git repository."); + return target; + }; + const capability: ExtensionVcsStatusCapability = { + async read({ targetPath }, context) { + const options = { ...context, gitExecutable }; + const target = await targetMetadata(targetPath, options); + const raw = ( + await query( + [ + "-c", + "status.relativePaths=false", + "status", + "--porcelain=v2", + "--branch", + "--ahead-behind", + "--renames", + "-z", + "--untracked-files=all", + "--ignore-submodules=none", + ], + { ...options, cwd: target.path }, + ) + ).stdout; + const parsed = parseGitStatus(raw); + const [operationState, upstreamState] = await Promise.all([ + operations(target.gitDir), + upstream(parsed, target.gitDir), + ]); + context.signal?.throwIfAborted(); + const reviewActions = [ + ...(parsed.paths.some((path) => path.index !== "unchanged") + ? [{ id: "staged", label: "Staged changes" }] + : []), + ...(parsed.paths.some((path) => path.worktree !== "unchanged") + ? [{ id: "unstaged", label: "Working tree changes" }] + : []), + ]; + return { + schemaVersion: 1, + observedAt: new Date().toISOString(), + worktree: { id: target.path, path: target.path, repositoryId: target.commonDir }, + token: createHash("sha256") + .update(target.path) + .update("\0") + .update(target.commonDir) + .update("\0") + .update(raw) + .update(JSON.stringify(operationState)) + .digest("hex"), + head: parsed.head, + upstream: upstreamState, + operations: operationState, + paths: parsed.paths, + changedPathCount: parsed.paths.length, + reviewActions, + siblings: { state: "loading" }, + }; + }, + async readSiblings(snapshot, context) { + const options = { ...context, gitExecutable }; + const target = await targetMetadata(snapshot.worktree.path, options); + if (target.commonDir !== snapshot.worktree.repositoryId) + throw new Error("Status repository identity changed."); + const records = parseGitStatusWorktrees( + (await query(["worktree", "list", "--porcelain", "-z"], { ...options, cwd: target.path })) + .stdout, + ).filter((record) => resolve(record.path) !== target.path); + const selected = records.slice(0, MAX_WORKTREES); + const results: ExtensionVcsStatusWorktreeSummary[] = []; + let next = 0; + await Promise.all( + Array.from({ length: Math.min(SIBLING_CONCURRENCY, selected.length) }, async () => { + for (;;) { + context.signal?.throwIfAborted(); + const index = next++; + const record = selected[index]; + if (!record) return; + const summary: ExtensionVcsStatusWorktreeSummary = { + branch: record.branch, + detached: record.detached, + bare: record.bare, + locked: record.locked, + prunable: record.prunable, + worktree: { id: record.path, path: record.path, repositoryId: target.commonDir }, + inspectable: false, + status: { state: "unavailable", message: "Worktree unavailable." }, + }; + if (record.bare || record.prunable !== undefined) { + summary.status = { + state: "unavailable", + message: record.bare + ? "Bare repository has no worktree." + : record.prunable || "Prunable worktree.", + }; + } else { + try { + const sibling = await capability.read({ targetPath: record.path }, context); + summary.inspectable = true; + summary.branch = sibling.head.kind === "detached" ? undefined : sibling.head.name; + summary.detached = sibling.head.kind === "detached"; + summary.status = { + state: "ready", + observedAt: sibling.observedAt, + changedPathCount: sibling.changedPathCount, + conflictCount: sibling.paths.filter((path) => path.conflict).length, + operations: sibling.operations, + }; + } catch (error) { + context.signal?.throwIfAborted(); + summary.status = { state: "error", message: String(error) }; + } + } + results[index] = summary; + } + }), + ); + return { worktrees: results, truncated: records.length > selected.length }; + }, + async planReview(snapshot, actionId, context) { + const current = await capability.read({ targetPath: snapshot.worktree.path }, context); + if ( + current.token !== snapshot.token || + current.worktree.repositoryId !== snapshot.worktree.repositoryId + ) { + throw new Error("Workspace status changed; refresh before opening this review."); + } + if (!current.reviewActions.some((action) => action.id === actionId)) + throw new Error("This status review action is unavailable."); + return { + cwd: current.worktree.path, + input: { kind: "vcs", staged: actionId === "staged", options: {} }, + }; + }, + async watchPlan(snapshot, context) { + const target = await targetMetadata(snapshot.worktree.path, { ...context, gitExecutable }); + if (target.commonDir !== snapshot.worktree.repositoryId) + throw new Error("Status repository identity changed."); + const ignored = await query(buildGitIgnoredDirectoryArgs(), { + ...context, + cwd: target.path, + gitExecutable, + }); + return { + coverage: "hybrid", + targets: [ + { + kind: "directory-tree", + directory: target.path, + ignoredRoots: [ + join(target.path, ".git"), + ...parseGitIgnoredDirectoryRoots(ignored.stdout, target.path), + ], + sources: ["worktree"], + }, + ...[...new Set([target.gitDir, target.commonDir])].map((directory) => ({ + kind: "directory-tree" as const, + directory, + ignoredRoots: [join(directory, "objects")], + sources: ["vcs-metadata" as const], + })), + ], + }; + }, + }; + return capability; +} diff --git a/packages/hunk-vcs/src/async-process.test.ts b/packages/hunk-vcs/src/async-process.test.ts index 526bb8ce2..4a2bde0b8 100644 --- a/packages/hunk-vcs/src/async-process.test.ts +++ b/packages/hunk-vcs/src/async-process.test.ts @@ -29,6 +29,47 @@ describe("abortable bundled VCS subprocesses", () => { expect(Date.now() - startedAt).toBeLessThan(2_000); }); + test("bounds combined output and reaps an overflowing child", async () => { + await expect( + runAbortableCommand( + [ + process.execPath, + "-e", + 'process.stdout.write("x".repeat(4096)); setInterval(()=>{},1000)', + ], + { cwd: process.cwd(), maxOutputBytes: 1024, terminationGraceMs: 25 }, + ), + ).rejects.toThrow("output bytes"); + }); + + test("times out and escalates a child that ignores termination", async () => { + const start = Date.now(); + await expect( + runAbortableCommand( + [process.execPath, "-e", 'process.on("SIGTERM",()=>{}); setInterval(()=>{},1000)'], + { cwd: process.cwd(), timeoutMs: 100, terminationGraceMs: 25 }, + ), + ).rejects.toThrow("timeout"); + expect(Date.now() - start).toBeLessThan(2000); + }); + + test("refuses invalid UTF-8 machine output without rejecting a literal replacement character", async () => { + await expect( + runAbortableCommand([process.execPath, "-e", "process.stdout.write(Buffer.from([255]))"], { + cwd: process.cwd(), + strictUtf8: true, + }), + ).rejects.toThrow(); + expect( + ( + await runAbortableCommand([process.execPath, "-e", 'process.stdout.write("\\ufffd")'], { + cwd: process.cwd(), + strictUtf8: true, + }) + ).stdout, + ).toBe("\ufffd"); + }); + test("collects output and exit status on normal completion", async () => { const result = await runAbortableCommand( [process.execPath, "-e", 'process.stdout.write("ok"); process.stderr.write("note")'], diff --git a/packages/hunk-vcs/src/async-process.ts b/packages/hunk-vcs/src/async-process.ts index 6dce708f9..3d024b871 100644 --- a/packages/hunk-vcs/src/async-process.ts +++ b/packages/hunk-vcs/src/async-process.ts @@ -14,14 +14,27 @@ export async function runAbortableCommand( env, signal, terminationGraceMs = DEFAULT_TERMINATION_GRACE_MS, + maxOutputBytes, + timeoutMs, + strictUtf8 = false, }: { cwd: string; env?: Record; signal?: AbortSignal; terminationGraceMs?: number; + /** Bound combined stdout/stderr bytes; exceeding this rejects after reaping the child. */ + maxOutputBytes?: number; + timeoutMs?: number; + /** Refuse undecodable machine paths instead of replacing their bytes with U+FFFD. */ + strictUtf8?: boolean; }, ): Promise { signal?.throwIfAborted(); + for (const value of [maxOutputBytes, timeoutMs]) { + if (value !== undefined && (!Number.isSafeInteger(value) || value <= 0)) { + throw new Error("Command bounds must be positive integers."); + } + } const ownsProcessGroup = process.platform !== "win32"; const proc = Bun.spawn(command, { cwd, @@ -77,22 +90,58 @@ export async function runAbortableCommand( // Close the race between the pre-spawn check and listener registration. if (signal?.aborted) abort(); + let failure: Error | undefined; + let outputBytes = 0; + const timeout = + timeoutMs === undefined + ? undefined + : setTimeout(() => { + failure ??= new Error(`Command exceeded ${timeoutMs}ms timeout.`); + abort(); + }, timeoutMs); + /** Drain terminated pipes without retaining output beyond the shared byte budget. */ + const collect = async (stream: ReadableStream) => { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + outputBytes += value.byteLength; + if (maxOutputBytes !== undefined && outputBytes > maxOutputBytes) { + failure ??= new Error(`Command exceeded ${maxOutputBytes} output bytes.`); + abort(); + } + if (!failure) chunks.push(value); + } + const output = Buffer.concat(chunks); + return strictUtf8 + ? new TextDecoder("utf-8", { fatal: true }).decode(output) + : output.toString("utf8"); + } catch (error) { + abort(); + throw error; + } finally { + reader.releaseLock(); + } + }; + const stdoutResult = collect(proc.stdout); + const stderrResult = collect(proc.stderr); try { - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - proc.exited, - ]); + const [stdout, stderr, exitCode] = await Promise.all([stdoutResult, stderrResult, proc.exited]); signal?.throwIfAborted(); + if (failure) throw failure; return { stdout, stderr, exitCode }; } catch (error) { if (signal?.aborted) signal.throwIfAborted(); throw error; } finally { signal?.removeEventListener("abort", abort); + if (timeout) clearTimeout(timeout); + // Keep escalation live until both pipes and the process are reaped, including read errors. + await Promise.allSettled([stdoutResult, stderrResult, proc.exited]); if (killTimer) clearTimeout(killTimer); - // `proc.exited` also reaps a child terminated during stream collection. Windows - // tree-kill helpers are awaited as well so cancellation leaves no owned processes. - await Promise.all([proc.exited.catch(() => undefined), ...treeTerminationTasks]); + // Windows tree-kill helpers may have been added by escalation while reaping. + await Promise.all(treeTerminationTasks); } } diff --git a/packages/hunk/skills/hunk-extensions/SKILL.md b/packages/hunk/skills/hunk-extensions/SKILL.md index dc33e7a17..a1c09a692 100644 --- a/packages/hunk/skills/hunk-extensions/SKILL.md +++ b/packages/hunk/skills/hunk-extensions/SKILL.md @@ -93,25 +93,26 @@ bad or duplicate id is skipped with a startup notice. ## Pick the touchpoint -| To do this | Call | -| -------------------------------------------------------- | -------------------------------------------- | -| Keep demo/training view settings temporary | `hunk.configureSession(options)` | -| Add a selectable color theme | `hunk.registerTheme(theme)` | -| Highlight an extension, exact filename, or filename glob | `hunk.registerFileLanguage(matcher, lang)` | -| Support another VCS (`git`/`jj`/`sl` are reserved) | `hunk.registerVcsAdapter(adapter)` | -| Add a navigation/list/status pane beside the review | `hunk.registerPane(pane)` | -| Present a file as something other than a raw diff | `hunk.registerFileView(view)` (experimental) | -| Mark character ranges inside diff lines | `hunk.registerLineHighlighter(highlighter)` | -| Interpret review keys as a temporary global mode | `hunk.registerKeyboardMode(mode)` | -| Add a generic top-level CLI command tree | `hunk.registerCliCommand(command, handler)` | -| Bind a key / add an Extensions-menu entry | `hunk.registerCommand(command, handler)` | -| Hide, reorder, retitle files before review | `hunk.transformChangeset(fn)` | -| React to loads, selection, view movement, notes, reloads | `hunk.on(event, handler)` | -| Coordinate with another loaded extension | `hunk.events.emit` / `hunk.events.on` | -| Reload after an external agent changes reviewed inputs | `ctx.review.requestReload()` in an event | -| Read user-supplied settings | `hunk.config` (`[extension.]` table) | -| Snapshot stable files and every saved review note | `ctx.review.snapshot()` in a command | -| Branch on the API generation (currently `25`) | `hunk.apiVersion` | +| To do this | Call | +| -------------------------------------------------------- | ------------------------------------------------------------- | +| Keep demo/training view settings temporary | `hunk.configureSession(options)` | +| Add a selectable color theme | `hunk.registerTheme(theme)` | +| Highlight an extension, exact filename, or filename glob | `hunk.registerFileLanguage(matcher, lang)` | +| Support another VCS (`git`/`jj`/`sl` are reserved) | `hunk.registerVcsAdapter(adapter)` | +| Report read-only workspace and sibling status | Optional `registerVcsAdapter({ status })` capability (API 26) | +| Add a navigation/list/status pane beside the review | `hunk.registerPane(pane)` | +| Present a file as something other than a raw diff | `hunk.registerFileView(view)` (experimental) | +| Mark character ranges inside diff lines | `hunk.registerLineHighlighter(highlighter)` | +| Interpret review keys as a temporary global mode | `hunk.registerKeyboardMode(mode)` | +| Add a generic top-level CLI command tree | `hunk.registerCliCommand(command, handler)` | +| Bind a key / add an Extensions-menu entry | `hunk.registerCommand(command, handler)` | +| Hide, reorder, retitle files before review | `hunk.transformChangeset(fn)` | +| React to loads, selection, view movement, notes, reloads | `hunk.on(event, handler)` | +| Coordinate with another loaded extension | `hunk.events.emit` / `hunk.events.on` | +| Reload after an external agent changes reviewed inputs | `ctx.review.requestReload()` in an event | +| Read user-supplied settings | `hunk.config` (`[extension.]` table) | +| Snapshot stable files and every saved review note | `ctx.review.snapshot()` in a command | +| Branch on the API generation (currently `26`) | `hunk.apiVersion` | Registration is only valid while the factory runs — Hunk seals the API object afterwards. diff --git a/packages/hunk/src/app/cli.test.ts b/packages/hunk/src/app/cli.test.ts index 856bf88d5..061e6a81b 100644 --- a/packages/hunk/src/app/cli.test.ts +++ b/packages/hunk/src/app/cli.test.ts @@ -2517,3 +2517,45 @@ describe("parseCli extension management commands", () => { ).rejects.toThrow(/review command/); }); }); + +describe("status command parsing", () => { + test("accepts static/JSON and preserves shared launch preferences", async () => { + expect( + await parseCli([ + "bun", + "hunk", + "--experimental", + "--no-extensions", + "status", + "--json", + "--theme", + "nord", + "--no-line-numbers", + ]), + ).toMatchObject({ + kind: "status", + json: true, + static: false, + color: "auto", + options: { experimental: true, extensions: false, theme: "nord", lineNumbers: false }, + }); + expect(await parseCli(["bun", "hunk", "status", "--static"])).toMatchObject({ + kind: "status", + static: true, + json: false, + }); + }); + test("rejects revisions, bad color modes and nested session reload", async () => { + await expect(parseCli(["bun", "hunk", "status", "HEAD"])).rejects.toThrow(); + await expect(parseCli(["bun", "hunk", "status", "--color", "rainbow"])).rejects.toThrow( + "Invalid color", + ); + await expect( + parseCli(["bun", "hunk", "session", "reload", "session-test", "--", "status"]), + ).rejects.toThrow("review command"); + expect(await parseCli(["bun", "hunk", "status", "--help"])).toMatchObject({ + kind: "help", + text: expect.stringContaining("--json"), + }); + }); +}); diff --git a/packages/hunk/src/app/cli.ts b/packages/hunk/src/app/cli.ts index f5ed164a7..b8cca70e3 100644 --- a/packages/hunk/src/app/cli.ts +++ b/packages/hunk/src/app/cli.ts @@ -205,6 +205,34 @@ export const CLI_REFERENCE_COMMANDS = { commonReviewOptions: true, watch: true, }, + status: { + path: "status", + summary: "inspect the current workspace and sibling worktrees", + synopsis: ["hunk status [--static | --json]"], + details: [ + "Terminals open live workspace status; redirects receive one static snapshot.", + "Enter inspects a row; S/U review staged/working-tree changes; L opens Log; Q returns or quits.", + "F10 opens menus, T chooses a theme, R refreshes, and W expands other worktrees.", + "--json emits the versioned status snapshot without paging or terminal color.", + "Status never fetches or modifies the repository; providers without status report unsupported.", + ], + options: [ + ...COMMON_REVIEW_OPTIONS.filter( + (option) => + option.flag !== "--pager" && + option.flag !== AUXILIARY_AGENT_OPTIONS.agentContext.flag && + option.flag !== "--vcs ", + ), + { flag: "--vcs ", description: "select a VCS status provider" }, + { flag: "--static", description: "print one snapshot, paging when needed" }, + { flag: "--json", description: "emit a version 1 JSON snapshot" }, + { + flag: "--color ", + description: "color output: auto, always, never", + commanderDefault: "auto", + }, + ], + }, log: { path: "log", // Release preparation enables this when an installable build contains history browsing. @@ -579,6 +607,7 @@ function renderCliHelp() { " hunk diff --staged [-- ] review staged changes", " hunk diff --files compare two concrete files", " hunk show [target] [-- ] review the last commit or a given target", + " hunk status [--static | --json] inspect this workspace and sibling worktrees", " hunk log [target] [-- ] browse an attractive repository history", " hunk stash show [ref] review a stash entry (git only)", " hunk patch [file] review a patch file or stdin", @@ -1091,6 +1120,29 @@ async function parseHistoryCommand( }; } +/** Parse status through the shared launch-option grammar without accepting revision inputs. */ +async function parseStatusCommand(tokens: string[], argv: string[]): Promise { + const command = createCliReferenceCommand("status"); + let options: Record = {}; + command.action((parsed: Record) => { + options = parsed; + }); + if (tokens.includes("--help") || tokens.includes("-h")) { + return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` }; + } + await parseStandaloneCommand(command, tokens); + if (options.color !== "auto" && options.color !== "always" && options.color !== "never") { + throw new Error(`Invalid color mode: ${String(options.color)}`); + } + return { + kind: "status", + static: Boolean(options.static), + json: Boolean(options.json), + color: options.color, + options: buildCommonOptions(options, argv), + }; +} + /** Parse the patch-file / stdin patch entrypoint. */ async function parsePatchCommand(tokens: string[], argv: string[]): Promise { const command = createCliReferenceCommand("patch").argument("[file]"); @@ -1186,6 +1238,7 @@ function requireReloadableCliInput(input: ParsedCliInput): CliInput { input.kind === "extension-manage" || input.kind === "extension-cli" || input.kind === "history" || + input.kind === "status" || input.kind === "update" ) { throw new Error( @@ -2207,7 +2260,7 @@ async function parseStashCommand( } const REVIEW_COMMAND_NAMES = new Set(["diff", "show", "patch", "pager", "difftool", "stash"]); -const EXTENSION_AWARE_COMMAND_NAMES = new Set([...REVIEW_COMMAND_NAMES, "log"]); +const EXTENSION_AWARE_COMMAND_NAMES = new Set([...REVIEW_COMMAND_NAMES, "log", "status"]); interface LeadingCliFlags { args: string[]; @@ -2312,7 +2365,11 @@ export async function parseCli(argv: string[]): Promise { return parseDiffCommand([...extensionFlagTokens, ...args], argv); } - if (prefixedReviewFlags.length > 0 && !REVIEW_COMMAND_NAMES.has(commandName)) { + if ( + prefixedReviewFlags.length > 0 && + !REVIEW_COMMAND_NAMES.has(commandName) && + commandName !== "status" + ) { throw new Error(`\`${prefixedReviewFlags[0]}\` must be used with a Hunk review command.`); } @@ -2337,6 +2394,8 @@ export async function parseCli(argv: string[]): Promise { return parseDiffCommand(reviewRest, argv); case "show": return parseShowCommand(reviewRest, argv); + case "status": + return parseStatusCommand(reviewRest, argv); case "log": return parseHistoryCommand(reviewRest, extensionsEnabled); case "patch": diff --git a/packages/hunk/src/app/sessionBootstrap.ts b/packages/hunk/src/app/sessionBootstrap.ts index dd717c722..131dcb420 100644 --- a/packages/hunk/src/app/sessionBootstrap.ts +++ b/packages/hunk/src/app/sessionBootstrap.ts @@ -25,6 +25,8 @@ import { import type { ExtensionLoadResult } from "../extensions/types"; export interface SessionBootstrapOptions { + /** Retain a routed session's launch-owned palette while loading another source target. */ + sessionCustomThemes?: AppBootstrap["customThemes"]; configured: HunkConfigResolution; cwd: string; extensions?: ExtensionLoadResult; @@ -65,15 +67,15 @@ export async function loadConfiguredSessionBootstrap({ loadAppBootstrapImpl = loadAppBootstrap, baseVcsCatalog = getBundledVcsCatalog(), signal, + sessionCustomThemes, }: SessionBootstrapOptions): Promise { signal?.throwIfAborted(); const previousFileLanguages = fileLanguageRegistrationSnapshot(); try { - const sessionThemes = collectSessionCustomThemes( - configured.customThemes, - extensions?.registry.themes, - ); + const sessionThemes = sessionCustomThemes + ? { themes: [...sessionCustomThemes], notices: [] } + : collectSessionCustomThemes(configured.customThemes, extensions?.registry.themes); const applied = applyExtensionRegistrations(extensions, baseVcsCatalog); const sessionVcs = resolveSessionVcsId(configured.input.options.vcs, cwd, applied.vcsCatalog); let input = configured.input; diff --git a/packages/hunk/src/app/startup.ts b/packages/hunk/src/app/startup.ts index cdcc28068..db4b34c4a 100644 --- a/packages/hunk/src/app/startup.ts +++ b/packages/hunk/src/app/startup.ts @@ -60,6 +60,10 @@ export type StartupPlan = kind: "session-command"; input: SessionCommandInput; } + | { + kind: "status-static" | "status-interactive"; + bootstrap: import("./statusBootstrap").StatusBootstrap; + } | { kind: "history-static" | "history-interactive"; bootstrap: import("./historyBootstrap").HistoryBootstrap; @@ -416,6 +420,41 @@ export async function prepareStartupPlan( }); } + if (parsedCliInput.kind === "status") { + const { loadStatusBootstrap } = await import("./statusBootstrap"); + const bootstrap = await loadStatusBootstrap({ + input: parsedCliInput, + cwd: startupCwd, + env, + baseVcsCatalog: await loadBaseVcsCatalog(), + previousLoad: preloadedExtensions, + signal: deps.signal, + }); + preloadedExtensions = undefined; + const interactive = shouldUseInteractiveHistory({ + forceStatic: parsedCliInput.static || parsedCliInput.json, + stdinIsTTY, + stdoutIsTTY, + }); + try { + if (deps.terminalThemeMode) + bootstrap.initialization.theme.initialThemeMode = deps.terminalThemeMode; + else if (interactive && bootstrap.launchOptions.theme === "auto") { + bootstrap.initialization.theme.initialThemeMode = + (await detectTerminalThemeModeFromBackgroundImpl({ + input: process.stdin, + output: stdout, + })) ?? undefined; + } + deps.signal?.throwIfAborted(); + return { kind: interactive ? "status-interactive" : "status-static", bootstrap }; + } catch (error) { + await bootstrap.close(); + await bootstrap.extensionSession.shutdown(); + throw error; + } + } + if (parsedCliInput.kind === "history") { const baseVcsCatalog = await loadBaseVcsCatalog(); const { loadHistoryBootstrap } = await import("./historyBootstrap"); diff --git a/packages/hunk/src/app/statusBootstrap.test.ts b/packages/hunk/src/app/statusBootstrap.test.ts new file mode 100644 index 000000000..f3c7a81ad --- /dev/null +++ b/packages/hunk/src/app/statusBootstrap.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createTestStatusSnapshot } from "../../../../test/helpers/vcsStatus"; +import type { VcsAdapter, VcsCatalog } from "../core/vcs/types"; +import { loadStatusBootstrap } from "./statusBootstrap"; + +/** Create isolated launch/config fixtures and a provider with controllable status reads. */ +function createTestStatusBootstrapFixture() { + const cwd = mkdtempSync(join(tmpdir(), "hunk-status-bootstrap-")); + const configHome = mkdtempSync(join(tmpdir(), "hunk-status-config-")); + mkdirSync(join(configHome, "hunk")); + const configPath = join(configHome, "hunk", "config.toml"); + writeFileSync( + configPath, + 'theme = "nord"\nline_numbers = false\nwrap = true\nprompt_save_view_preferences = false\n\n[keybindings]\n"hunk.history.nextCommit" = "ctrl+n"\n', + ); + const adapter: VcsAdapter = { + id: "test", + name: "Test", + detect: () => ({ id: "test", repoRoot: cwd }), + operations: {}, + status: { + async read({ targetPath }) { + return createTestStatusSnapshot(targetPath ?? cwd); + }, + async readSiblings() { + return { worktrees: [], truncated: false }; + }, + async planReview(snapshot) { + return { cwd: snapshot.worktree.path, input: { kind: "vcs", staged: false, options: {} } }; + }, + }, + }; + const catalog: VcsCatalog = { + adapters: [adapter], + defaultAdapterId: "test", + reservedIds: new Set(["test"]), + }; + return { + cwd, + adapter, + configPath, + load: () => + loadStatusBootstrap({ + input: { + kind: "status", + static: false, + json: false, + color: "never", + options: { + vcs: "test", + extensions: false, + experimental: true, + theme: "github-light-default", + }, + }, + cwd, + env: { ...process.env, XDG_CONFIG_HOME: configHome }, + baseVcsCatalog: catalog, + }), + cleanup() { + rmSync(cwd, { recursive: true, force: true }); + rmSync(configHome, { recursive: true, force: true }); + }, + }; +} + +describe("status bootstrap launch authority", () => { + test("retains resolved launch preferences/extensions while target reads stay explicit", async () => { + const fixture = createTestStatusBootstrapFixture(); + const bootstrap = await fixture.load(); + try { + expect(bootstrap.launchOptions).toMatchObject({ + experimental: true, + theme: "github-light-default", + lineNumbers: false, + }); + expect(bootstrap.initialization.viewPreferences).toMatchObject({ + theme: "github-light-default", + showLineNumbers: false, + }); + expect(bootstrap.initialization.theme.initialTheme).toBe("github-light-default"); + expect(bootstrap.keybindings).toEqual({ "hunk.history.nextCommit": "ctrl+n" }); + expect(bootstrap.viewPreferencesConfigPath).toBe(fixture.configPath); + expect(bootstrap.promptSaveViewPreferences).toBe(false); + const registry = bootstrap.extensionSession.current.registry; + const target = join(fixture.cwd, "sibling"); + expect((await bootstrap.load(target)).worktree.path).toBe(target); + expect(bootstrap.startupCwd).toBe(fixture.cwd); + expect(bootstrap.extensionSession.cwd).toBe(fixture.cwd); + expect(bootstrap.extensionSession.current.registry).toBe(registry); + expect((await bootstrap.loadSiblings(bootstrap.snapshot)).siblings).toEqual({ + state: "ready", + value: { worktrees: [], truncated: false }, + }); + expect(await bootstrap.watchPlan(bootstrap.snapshot)).toEqual({ + coverage: "poll-only", + targets: [], + }); + await bootstrap.close(); + await bootstrap.close(); + expect(registry.eventBusPhase).toBe("ready"); + await expect(bootstrap.load()).rejects.toThrow("closed"); + await bootstrap.extensionSession.shutdown(); + expect(registry.eventBusPhase).toBe("closed"); + } finally { + await bootstrap.close(); + await bootstrap.extensionSession.shutdown(); + fixture.cleanup(); + } + }); + test("contains sibling enumeration failure and drains reads cancelled at session close", async () => { + const fixture = createTestStatusBootstrapFixture(); + fixture.adapter.status!.readSiblings = async () => { + throw new Error("partial scan unavailable"); + }; + const bootstrap = await fixture.load(); + try { + expect((await bootstrap.loadSiblings(bootstrap.snapshot)).siblings).toEqual({ + state: "error", + message: "partial scan unavailable", + }); + fixture.adapter.status!.read = async (_input, { signal }) => { + signal?.throwIfAborted(); + await new Promise((resolve) => + signal!.addEventListener("abort", () => resolve(), { once: true }), + ); + return createTestStatusSnapshot(); + }; + const late = bootstrap.load(); + void late.catch(() => undefined); + await bootstrap.close(); + await expect(late).rejects.toThrow("closed"); + } finally { + await bootstrap.close(); + await bootstrap.extensionSession.shutdown(); + fixture.cleanup(); + } + }); + test("reports unsupported providers rather than substituting Git index semantics", async () => { + const fixture = createTestStatusBootstrapFixture(); + delete fixture.adapter.status; + try { + await expect(fixture.load()).rejects.toThrow("does not support workspace status"); + } finally { + fixture.cleanup(); + } + }); +}); diff --git a/packages/hunk/src/app/statusBootstrap.ts b/packages/hunk/src/app/statusBootstrap.ts new file mode 100644 index 000000000..3f9b30894 --- /dev/null +++ b/packages/hunk/src/app/statusBootstrap.ts @@ -0,0 +1,317 @@ +import type { CliInput, CommonOptions, StatusCommandInput } from "../core/run/commandInputs"; +import { + persistedViewPreferencesFromOptions, + type PersistedViewPreferences, + type UserKeyBinding, +} from "../core/run/config"; +import { collectSessionCustomThemes } from "../core/theme/customThemes"; +import { + createInteractiveSessionInitialization, + type InteractiveSessionInitialization, +} from "../core/session/initialization"; +import { detectVcs, extendVcsCatalog, getDefaultVcsAdapter, getVcsAdapter } from "../core/vcs"; +import type { VcsCatalog } from "../core/vcs/types"; +import type { + ExtensionVcsHistoryReviewAction, + ExtensionVcsStatusCapability, + ExtensionVcsStatusSnapshot, +} from "../extension-api/types"; +import { resolveExtensionVcsAdapters } from "../extensions/apply"; +import { createExtensionSession, type ExtensionSession } from "../extensions/session"; +import { mergeStartupNotices } from "../extensions/startup"; +import type { ExtensionLoadResult } from "../extensions/types"; +import type { AppBootstrap } from "../core/bootstrap"; +import type { HistoryBootstrap } from "./historyBootstrap"; +import { loadConfiguredSessionBootstrap } from "./sessionBootstrap"; +import { openVcsHistory, planVcsHistoryReview, planVcsHistoryRangeReview } from "../core/vcs"; +import { resolveConfiguredExtensions } from "./extensionBootstrap"; + +/** Retain launch config/trust authority while status targets and mounted review routes change. */ +export interface StatusBootstrap { + input: StatusCommandInput; + snapshot: ExtensionVcsStatusSnapshot; + providerId: string; + providerName: string; + startupCwd: string; + repoRoot: string; + launchOptions: CommonOptions; + extensionSession: ExtensionSession; + initialization: InteractiveSessionInitialization; + keybindings: Readonly>; + initialViewPreferences: PersistedViewPreferences; + viewPreferencesConfigPath?: string; + promptSaveViewPreferences: boolean; + notices: readonly string[]; + load(targetPath?: string, signal?: AbortSignal): Promise; + loadSiblings( + snapshot: ExtensionVcsStatusSnapshot, + signal?: AbortSignal, + ): Promise; + planReview( + snapshot: ExtensionVcsStatusSnapshot, + actionId: string, + signal?: AbortSignal, + ): ReturnType; + watchPlan( + snapshot: ExtensionVcsStatusSnapshot, + signal?: AbortSignal, + ): ReturnType>; + openHistory(targetPath: string, signal?: AbortSignal): Promise; + prepareReview( + input: CliInput, + cwd: string, + signal?: AbortSignal, + ): Promise>; + prepareHistoryReview( + action: ExtensionVcsHistoryReviewAction, + cwd: string, + signal?: AbortSignal, + ): Promise>; + /** Cancel and drain provider work; the session host separately retires extension authority. */ + close(): Promise; +} + +/** Resolve ordinary launch config/extensions and read current status before scanning siblings. */ +export async function loadStatusBootstrap({ + input, + cwd = process.cwd(), + env = process.env, + baseVcsCatalog, + previousLoad, + signal, +}: { + input: StatusCommandInput; + cwd?: string; + env?: NodeJS.ProcessEnv; + baseVcsCatalog: VcsCatalog; + previousLoad?: ExtensionLoadResult; + signal?: AbortSignal; +}): Promise { + signal?.throwIfAborted(); + const resolved = await resolveConfiguredExtensions({ + runtimeInput: { kind: "vcs", staged: false, options: input.options }, + cwd, + env, + baseVcsCatalog, + previousLoad, + assertActive: () => signal?.throwIfAborted(), + }); + const extensionSession = createExtensionSession(resolved.extensions, cwd); + const lifetime = new AbortController(); + const pending = new Set>(); + /** Join route cancellation to session shutdown; reject late results instead of publishing them. */ + const run = ( + requestSignal: AbortSignal | undefined, + task: (signal: AbortSignal) => Promise, + disposeLate?: (result: T) => void | Promise, + ) => { + const combined = requestSignal + ? AbortSignal.any([requestSignal, lifetime.signal]) + : lifetime.signal; + const promise = (async () => { + combined.throwIfAborted(); + const result = await task(combined); + if (combined.aborted) await disposeLate?.(result); + combined.throwIfAborted(); + return result; + })(); + pending.add(promise); + void promise.then( + () => pending.delete(promise), + () => pending.delete(promise), + ); + return promise; + }; + try { + const extensionAdapters = resolveExtensionVcsAdapters( + resolved.extensions.registry, + baseVcsCatalog, + ); + const catalog = extendVcsCatalog(baseVcsCatalog, extensionAdapters.adapters); + const explicit = input.options.vcs ?? resolved.configured.explicitVcsId; + const providerId = + explicit && explicit !== "auto" + ? explicit + : (detectVcs(cwd, catalog)?.id ?? getDefaultVcsAdapter(catalog).id); + const adapter = getVcsAdapter(providerId, catalog); + const capability = adapter.status; + if (!capability) throw new Error(`${adapter.name} does not support workspace status.`); + const context = (signal: AbortSignal) => ({ cwd, signal }); + const load = (targetPath?: string, signal?: AbortSignal) => + run(signal, (active) => capability.read({ targetPath }, context(active))); + const snapshot = await load(undefined, signal); + const sessionThemes = collectSessionCustomThemes( + resolved.configured.customThemes, + resolved.extensions.registry.themes, + ); + const launchOptions = { ...resolved.configured.input.options, vcs: providerId }; + const initialViewPreferences = persistedViewPreferencesFromOptions(launchOptions); + extensionSession.startCurrent(cwd); + // Keep trusted launch configuration fixed; inspected targets supply only source/cwd facts. + const prepareReview = (input: CliInput, targetCwd: string, signal?: AbortSignal) => + run(signal, async (active) => { + const result = await loadConfiguredSessionBootstrap({ + configured: { + ...resolved.configured, + input: { ...input, options: { ...launchOptions, ...input.options, vcs: providerId } }, + }, + cwd: targetCwd, + extensions: extensionSession.current, + loadAtCwd: true, + baseVcsCatalog, + signal: active, + }); + return result.bootstrap as AppBootstrap; + }); + const bootstrap: StatusBootstrap = { + input: { ...input, options: launchOptions }, + snapshot, + providerId: adapter.id, + providerName: adapter.name, + startupCwd: cwd, + repoRoot: snapshot.worktree.path, + launchOptions, + extensionSession, + initialization: createInteractiveSessionInitialization({ + theme: { initialTheme: launchOptions.theme, customThemes: sessionThemes.themes }, + viewPreferences: initialViewPreferences, + }), + keybindings: resolved.configured.keybindings, + initialViewPreferences, + viewPreferencesConfigPath: resolved.configured.viewPreferencesConfigPath, + promptSaveViewPreferences: launchOptions.promptSaveViewPreferences !== false, + notices: [ + ...(mergeStartupNotices(resolved.configured.startupNotices, resolved.extensions) ?? []).map( + (notice) => notice.message, + ), + ...sessionThemes.notices.map((notice) => notice.message), + ...extensionAdapters.issues.map((issue) => issue.message), + ], + load, + loadSiblings: (current, signal) => + run(signal, async (active) => { + try { + const siblings = await capability.readSiblings(current, context(active)); + return { ...current, siblings: { state: "ready" as const, value: siblings } }; + } catch (error) { + active.throwIfAborted(); + return { + ...current, + siblings: { + state: "error" as const, + message: error instanceof Error ? error.message : String(error), + }, + }; + } + }), + planReview: (current, actionId, signal) => + run(signal, (active) => capability.planReview(current, actionId, context(active))), + watchPlan: (current, signal) => + run( + signal, + (active) => + capability.watchPlan?.(current, context(active)) ?? + Promise.resolve({ coverage: "poll-only", targets: [] }), + ), + prepareReview, + prepareHistoryReview(action, targetCwd, signal) { + return prepareReview( + action.kind === "revision-show" + ? { kind: "show", ref: action.revisionId, options: {} } + : { + kind: "vcs", + staged: false, + rangeEndpoints: { from: action.fromRevisionId, to: action.toRevisionId }, + options: {}, + }, + targetCwd, + signal, + ); + }, + openHistory(targetPath, signal) { + return run( + signal, + async (active) => { + const current = await capability.read({ targetPath }, context(active)); + if (current.worktree.repositoryId !== snapshot.worktree.repositoryId) + throw new Error("Status repository identity changed."); + const targetCwd = current.worktree.path; + const open = (signal?: AbortSignal) => + openVcsHistory(adapter, {}, { cwd: targetCwd, signal }, catalog); + let source = await open(active); + if (active.aborted) { + await source.close(); + active.throwIfAborted(); + } + let closed = false; + return { + input: { + kind: "history", + color: input.color, + format: "medium", + ascii: false, + static: false, + extensionsEnabled: launchOptions.extensions !== false, + extensionPaths: launchOptions.extensionPaths ?? [], + }, + source, + providerId, + providerName: adapter.name, + startupCwd: cwd, + repoRoot: targetCwd, + extensionSession, + notices: [], + customThemes: sessionThemes.themes, + initialization: bootstrap.initialization, + keybindings: bootstrap.keybindings, + initialViewPreferences, + promptSaveViewPreferences: false, + planReview: (commit, options, signal) => + planVcsHistoryReview(adapter, commit, { cwd: targetCwd, signal }, options), + ...(adapter.history?.planRangeReview + ? { + planRangeReview: (selection, options, signal) => + planVcsHistoryRangeReview( + adapter, + selection, + { cwd: targetCwd, signal }, + options, + ), + } + : {}), + async reopenSource(signal) { + if (closed) throw new Error("History session is closed."); + const previous = source; + const replacement = await open(signal); + if (closed || source !== previous || signal?.aborted) { + await replacement.close(); + throw new Error("History session changed while refreshing."); + } + source = replacement; + await previous.close(); + return replacement; + }, + async close() { + if (!closed) { + closed = true; + await source.close(); + } + }, + } satisfies HistoryBootstrap; + }, + (history) => history.close(), + ); + }, + async close() { + lifetime.abort(new Error("Status session closed.")); + await Promise.allSettled(pending); + }, + }; + return bootstrap; + } catch (error) { + lifetime.abort(); + await Promise.allSettled(pending); + await extensionSession.shutdown(); + throw error; + } +} diff --git a/packages/hunk/src/app/statusRouting.test.ts b/packages/hunk/src/app/statusRouting.test.ts new file mode 100644 index 000000000..268946181 --- /dev/null +++ b/packages/hunk/src/app/statusRouting.test.ts @@ -0,0 +1,109 @@ +import { expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getBundledVcsCatalog } from "./vcsCatalog"; +import { loadStatusBootstrap } from "./statusBootstrap"; +import { createSessionRegistration } from "./session/registration"; +import { ReviewProducer } from "./review/producer"; + +/** Run shell-free Git fixture commands with explicit author identity. */ +function runStatusTestGit(cwd: string, args: string[]) { + const result = Bun.spawnSync(["git", ...args], { + cwd, + env: { + ...process.env, + GIT_AUTHOR_NAME: "Status", + GIT_AUTHOR_EMAIL: "status@example.com", + GIT_COMMITTER_NAME: "Status", + GIT_COMMITTER_EMAIL: "status@example.com", + }, + stdout: "pipe", + stderr: "pipe", + }); + if (result.exitCode) throw new Error(result.stderr.toString()); +} + +test("sibling log/diff source facts reach real bootstraps and broker registration without adopting sibling config/extensions", async () => { + const root = mkdtempSync(join(tmpdir(), "hunk-status-routing-")); + const cwd = join(root, "origin"); + const sibling = join(root, "sibling"); + const config = join(root, "config"); + mkdirSync(cwd); + mkdirSync(join(config, "hunk"), { recursive: true }); + writeFileSync( + join(config, "hunk", "config.toml"), + 'theme = "status-custom"\nline_numbers = false\n\n[themes.status-custom]\nlabel = "Status custom"\naccent = "#123456"\n', + ); + runStatusTestGit(cwd, ["init", "-qb", "main"]); + writeFileSync(join(cwd, "alpha.ts"), "export const alpha = 1;\n"); + runStatusTestGit(cwd, ["add", "."]); + runStatusTestGit(cwd, ["commit", "-qm", "Origin commit"]); + runStatusTestGit(cwd, ["worktree", "add", "-qb", "sibling", sibling]); + writeFileSync(join(sibling, "alpha.ts"), "export const alpha = 2;\n"); + mkdirSync(join(sibling, ".hunk", "extensions"), { recursive: true }); + writeFileSync( + join(sibling, ".hunk", "extensions", "untrusted.ts"), + 'throw new Error("Sibling extension must never load");', + ); + writeFileSync(join(sibling, ".hunk", "config.toml"), 'theme = "nord"\nline_numbers = true\n'); + const runtime = await loadStatusBootstrap({ + input: { + kind: "status", + static: false, + json: false, + color: "always", + options: { vcs: "git", extensions: false, experimental: true, fast: true }, + }, + cwd, + env: { ...process.env, XDG_CONFIG_HOME: config }, + baseVcsCatalog: getBundledVcsCatalog(), + }); + try { + const owner = runtime.extensionSession.current; + const snapshot = await runtime.load(sibling); + const action = await runtime.planReview(snapshot, "unstaged"); + const bootstrap = await runtime.prepareReview(action.input, action.cwd); + expect(bootstrap.reloadContext.cwd).toBe(sibling); + expect(bootstrap.input.options).toMatchObject({ + experimental: true, + fast: true, + extensions: false, + lineNumbers: false, + }); + expect(bootstrap.initialTheme).toBe("status-custom"); + expect(bootstrap.extensions).toBe(owner); + expect(bootstrap.changeset.files.some((file) => file.path === "alpha.ts")).toBe(true); + const producer = new ReviewProducer({ + files: bootstrap.changeset.files, + sourceLabel: bootstrap.changeset.sourceLabel, + }); + const registration = createSessionRegistration( + bootstrap, + producer.getPublication(), + action.cwd, + ); + expect(registration.cwd).toBe(sibling); + expect(registration.repoRoot).toBe(sibling); + const history = await runtime.openHistory(sibling); + try { + expect(history.extensionSession).toBe(runtime.extensionSession); + const page = await history.source.read({ limit: 10 }); + expect(page.commits[0]!.subject).toBe("Origin commit"); + const planned = await history.planReview(page.commits[0]!); + const review = await runtime.prepareHistoryReview(planned, history.repoRoot); + expect(review.reloadContext.cwd).toBe(sibling); + expect(review.extensions).toBe(owner); + expect(review.input.options.experimental).toBe(true); + expect(review.initialTheme).toBe("status-custom"); + } finally { + await history.close(); + } + expect(runtime.extensionSession.cwd).toBe(cwd); + expect(owner.registry.eventBusPhase).toBe("ready"); + } finally { + await runtime.close(); + await runtime.extensionSession.shutdown(); + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/hunk/src/core/run/cliCommandNames.ts b/packages/hunk/src/core/run/cliCommandNames.ts index a0baa3659..91e5d8968 100644 --- a/packages/hunk/src/core/run/cliCommandNames.ts +++ b/packages/hunk/src/core/run/cliCommandNames.ts @@ -3,6 +3,7 @@ export const BUILT_IN_CLI_COMMAND_NAMES = new Set([ "diff", "show", "log", + "status", "patch", "pager", "difftool", diff --git a/packages/hunk/src/core/run/commandInputs.ts b/packages/hunk/src/core/run/commandInputs.ts index 8892f7e90..2c41f4a04 100644 --- a/packages/hunk/src/core/run/commandInputs.ts +++ b/packages/hunk/src/core/run/commandInputs.ts @@ -149,6 +149,15 @@ export interface HistoryCommandInput { extensionPaths: string[]; } +/** Read-only workspace status; launch preferences seed all subsequently routed surfaces. */ +export interface StatusCommandInput { + kind: "status"; + static: boolean; + json: boolean; + color: HistoryColorMode; + options: CommonOptions; +} + export interface HelpCommandInput { kind: "help"; text: string; @@ -419,6 +428,7 @@ export type ExtensionManageCommandInput = export type ParsedCliInput = | CliInput | HistoryCommandInput + | StatusCommandInput | HelpCommandInput | PagerCommandInput | DaemonServeCommandInput diff --git a/packages/hunk/src/core/run/statusCommandCatalog.ts b/packages/hunk/src/core/run/statusCommandCatalog.ts new file mode 100644 index 000000000..089ba5033 --- /dev/null +++ b/packages/hunk/src/core/run/statusCommandCatalog.ts @@ -0,0 +1,41 @@ +import { builtinAppCommand } from "./commandCatalog"; + +/** Declare read-only status actions using shared app identities for theme, help and quit. */ +export const STATUS_COMMAND_CATALOG = [ + { + id: "hunk.status.openSelection", + title: "Open selected path / inspect worktree / toggle file group", + defaultKeys: ["enter"], + }, + { + id: "hunk.status.togglePathGroup", + title: "Expand / collapse selected file group", + defaultKeys: ["space"], + }, + { id: "hunk.status.reviewStaged", title: "Review staged changes", defaultKeys: ["s"] }, + { id: "hunk.status.reviewUnstaged", title: "Review unstaged changes", defaultKeys: ["u"] }, + { id: "hunk.status.openLog", title: "Open log", defaultKeys: ["l"] }, + { id: "hunk.status.refresh", title: "Refresh status", defaultKeys: ["r"] }, + { + id: "hunk.status.back", + title: "Back to originating worktree", + defaultKeys: ["escape", "backspace"], + }, + { id: "hunk.status.previousRow", title: "Previous row", defaultKeys: ["up", "k"] }, + { id: "hunk.status.nextRow", title: "Next row", defaultKeys: ["down", "j"] }, + { id: "hunk.status.pageUp", title: "Page up", defaultKeys: ["pageup"] }, + { id: "hunk.status.pageDown", title: "Page down", defaultKeys: ["pagedown"] }, + { + id: "hunk.status.toggleWorktrees", + title: "Expand / collapse other worktrees", + defaultKeys: ["w"], + }, + { ...builtinAppCommand("hunk.view.openThemeSelector"), id: "hunk.view.openThemeSelector" }, + { ...builtinAppCommand("hunk.app.toggleHelp"), id: "hunk.app.toggleHelp" }, + { ...builtinAppCommand("hunk.app.quit"), id: "hunk.app.quit" }, +] as const; +export type StatusCommandId = (typeof STATUS_COMMAND_CATALOG)[number]["id"]; + +export const STATUS_COMMAND_NAMES: ReadonlySet = new Set( + STATUS_COMMAND_CATALOG.map((entry) => entry.id), +); diff --git a/packages/hunk/src/core/vcs/types.ts b/packages/hunk/src/core/vcs/types.ts index f12c711d1..90d14ead7 100644 --- a/packages/hunk/src/core/vcs/types.ts +++ b/packages/hunk/src/core/vcs/types.ts @@ -1,5 +1,6 @@ import type { ExtensionReviewDescriptor, + ExtensionVcsStatusCapability, ExtensionVcsHistoryCommit, ExtensionVcsHistoryInput, ExtensionVcsHistoryPage, @@ -108,6 +109,7 @@ export interface VcsAdapter { detect(cwd: string): VcsDetection | null; operations: VcsOperations; history?: VcsHistoryCapability; + status?: ExtensionVcsStatusCapability; /** Detection order weight; higher is consulted first. See the public contract. */ detectionPriority?: number; } diff --git a/packages/hunk/src/extension-api/index.ts b/packages/hunk/src/extension-api/index.ts index 39348f3ca..a014b4c5f 100644 --- a/packages/hunk/src/extension-api/index.ts +++ b/packages/hunk/src/extension-api/index.ts @@ -33,6 +33,18 @@ export { HunkExtensionUserError, } from "./types.js"; export type { + ExtensionVcsStatusFact, + ExtensionVcsStatusPathState, + ExtensionVcsStatusPath, + ExtensionVcsStatusHead, + ExtensionVcsStatusFetch, + ExtensionVcsStatusUpstream, + ExtensionVcsStatusOperation, + ExtensionVcsStatusWorktree, + ExtensionVcsStatusWorktreeSummary, + ExtensionVcsStatusSiblings, + ExtensionVcsStatusSnapshot, + ExtensionVcsStatusCapability, AgentAnnotation, AgentFileContext, ChangesetTransform, diff --git a/packages/hunk/src/extension-api/types.ts b/packages/hunk/src/extension-api/types.ts index e014f47e5..b6cbc51c1 100644 --- a/packages/hunk/src/extension-api/types.ts +++ b/packages/hunk/src/extension-api/types.ts @@ -21,7 +21,7 @@ * Extensions can branch on `hunk.apiVersion` so a newer Hunk can keep loading * older extensions without guessing at their expectations. */ -export const HUNK_EXTENSION_API_VERSION = 25; +export const HUNK_EXTENSION_API_VERSION = 26; export type HunkExtensionApiVersion = typeof HUNK_EXTENSION_API_VERSION; export type ExtensionNotifyType = "info" | "warning" | "error"; @@ -865,6 +865,136 @@ export interface ExtensionVcsHistoryCapability { ): ExtensionVcsHistoryRangeReviewAction | Promise; } +/** Describe an observed fact without turning unavailable metadata into a clean result. */ +export type ExtensionVcsStatusFact = + | { state: "ready"; value: T } + | { state: "unknown"; reason: string } + | { state: "error"; message: string }; + +export type ExtensionVcsStatusPathState = + | "unchanged" + | "modified" + | "added" + | "deleted" + | "renamed" + | "copied" + | "type-changed" + | "unmerged" + | "untracked"; + +/** Keep index and working-copy changes independent; providers without an index omit it. */ +export interface ExtensionVcsStatusPath { + path: string; + previousPath?: string; + index?: ExtensionVcsStatusPathState; + worktree: ExtensionVcsStatusPathState; + conflict: boolean; + submodule?: { commitChanged: boolean; trackedChanges: boolean; untrackedChanges: boolean }; +} + +export type ExtensionVcsStatusHead = + | { kind: "branch"; name: string; revisionId: string } + | { kind: "detached"; revisionId: string } + | { kind: "unborn"; name: string }; + +/** Report only locally observable fetch metadata, never the status observation time. */ +export type ExtensionVcsStatusFetch = ExtensionVcsStatusFact<{ + timestamp: string; + provenance: "local-fetch-head-mtime"; +}>; + +export type ExtensionVcsStatusUpstream = + | { kind: "none" | "detached" | "unborn" } + | { kind: "missing"; name: string } + | { + kind: "tracked"; + name: string; + ahead: number; + behind: number; + fetch: ExtensionVcsStatusFetch; + }; + +export type ExtensionVcsStatusOperation = "merge" | "rebase" | "cherry-pick" | "revert" | "bisect"; + +/** Identify one worktree without changing the process cwd or launch extension authority. */ +export interface ExtensionVcsStatusWorktree { + id: string; + path: string; + /** Opaque same-repository identity shared by linked worktrees. */ + repositoryId: string; +} + +/** Contain a sibling failure to its own row; absent counts never mean clean. */ +export interface ExtensionVcsStatusWorktreeSummary { + worktree: ExtensionVcsStatusWorktree; + branch?: string; + detached: boolean; + bare: boolean; + locked?: string; + prunable?: string; + inspectable: boolean; + status: + | { + state: "ready"; + observedAt: string; + changedPathCount: number; + conflictCount: number; + operations: ExtensionVcsStatusFact; + } + | { state: "unavailable" | "error"; message: string }; +} + +export interface ExtensionVcsStatusSiblings { + worktrees: ExtensionVcsStatusWorktreeSummary[]; + /** More worktrees exist than the bounded scan returned. */ + truncated: boolean; +} + +/** Publish a bounded, JSON-safe current-worktree observation before optional sibling reads. */ +export interface ExtensionVcsStatusSnapshot { + schemaVersion: 1; + observedAt: string; + worktree: ExtensionVcsStatusWorktree; + /** Opaque source/target validation token, not a content attestation for a future live diff. */ + token: string; + head: ExtensionVcsStatusHead; + upstream: ExtensionVcsStatusFact; + operations: ExtensionVcsStatusFact; + paths: ExtensionVcsStatusPath[]; + /** Unique destination paths, not overlapping index/worktree group totals. */ + changedPathCount: number; + reviewActions: { id: string; label: string }[]; + siblings: ExtensionVcsStatusFact | { state: "loading" }; +} + +/** Offer read-only status independently of history or any provider's index semantics. */ +export interface ExtensionVcsStatusCapability { + /** Reject outside-repository/no-worktree reads; never silently select a different provider. */ + read( + input: { targetPath?: string }, + context: ExtensionVcsLoadContext, + ): Promise; + /** Bound concurrency and return partial failures. The caller owns cancellation/generations. */ + readSiblings( + snapshot: ExtensionVcsStatusSnapshot, + context: ExtensionVcsLoadContext, + ): Promise; + /** Revalidate the target and token, then return the existing full-comparison review input. */ + planReview( + snapshot: ExtensionVcsStatusSnapshot, + actionId: string, + context: ExtensionVcsLoadContext, + ): Promise<{ + cwd: string; + input: ExtensionVcsDiffInput; + }>; + /** Cover working-copy and shared/per-worktree metadata; hosts retain periodic polling. */ + watchPlan?( + snapshot: ExtensionVcsStatusSnapshot, + context: ExtensionVcsLoadContext, + ): Promise; +} + /** Stash review request, as extension adapters receive it. */ export interface ExtensionVcsStashShowInput { kind: "stash-show"; @@ -1117,6 +1247,8 @@ export interface ExtensionVcsAdapter { operations?: ExtensionVcsOperations; /** Optional static/interactive history enumeration capability. */ history?: ExtensionVcsHistoryCapability; + /** Optional read-only workspace status and sibling inspection capability. */ + status?: ExtensionVcsStatusCapability; /** * Where this adapter sits in detection order; higher is consulted first. * diff --git a/packages/hunk/src/extensions/runExtension.test.ts b/packages/hunk/src/extensions/runExtension.test.ts index 2e9df1191..e8be7ebf7 100644 --- a/packages/hunk/src/extensions/runExtension.test.ts +++ b/packages/hunk/src/extensions/runExtension.test.ts @@ -13,8 +13,8 @@ function bundledMetadata(id: string) { } describe("runExtensionFactory", () => { - test("advertises async watch signatures through extension API v25", () => { - expect(HUNK_EXTENSION_API_VERSION).toBe(25); + test("advertises workspace status and async watch signatures through extension API v26", () => { + expect(HUNK_EXTENSION_API_VERSION).toBe(26); }); test("applies a synchronous factory before returning, with nothing to await", () => { diff --git a/packages/hunk/src/extensions/runExtension.ts b/packages/hunk/src/extensions/runExtension.ts index 714bf1aa2..661a8c369 100644 --- a/packages/hunk/src/extensions/runExtension.ts +++ b/packages/hunk/src/extensions/runExtension.ts @@ -26,6 +26,7 @@ import { } from "./types"; import { parseKeyChord, toKeyChordList } from "../lib/commandKeys"; import { toUserFacingError } from "../core/run/errors"; +import { toInternalVcsStatus } from "./vcsStatus"; import { toInternalVcsPatchResult } from "./vcsPatchResult"; import type { ExtensionVcsHistoryCommit, @@ -485,6 +486,7 @@ export function toInternalVcsAdapter( "name", "operations", "history", + "status", "detect", "detectionPriority", ]); @@ -568,6 +570,7 @@ export function toInternalVcsAdapter( return { id: adapterId, repoRoot: detectionFields.repoRoot }; }, operations: internalOperations, + status: toInternalVcsStatus(adapterFields.status), ...(history && { history: { async open(input, context) { diff --git a/packages/hunk/src/extensions/vcsStatus.test.ts b/packages/hunk/src/extensions/vcsStatus.test.ts new file mode 100644 index 000000000..f5d9cb4da --- /dev/null +++ b/packages/hunk/src/extensions/vcsStatus.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "bun:test"; +import { createTestStatusSnapshot } from "../../../../test/helpers/vcsStatus"; +import { normalizeVcsStatusSnapshot, toInternalVcsStatus } from "./vcsStatus"; +import { toInternalVcsAdapter } from "./runExtension"; + +/** Create a capability that returns fresh status fixtures for boundary tests. */ +function createTestStatusCapability() { + return { + async read() { + return createTestStatusSnapshot(); + }, + async readSiblings() { + return { worktrees: [], truncated: false }; + }, + async planReview() { + return { cwd: "workspace", input: { kind: "vcs" as const, staged: false, options: {} } }; + }, + }; +} + +describe("extension status boundary", () => { + test("remains optional and rejects malformed capabilities", () => { + expect(toInternalVcsStatus(undefined)).toBeUndefined(); + expect(() => toInternalVcsStatus({ read() {} })).toThrow("readSiblings"); + expect( + toInternalVcsAdapter({ + id: "test", + name: "Test", + detect: () => null, + status: createTestStatusCapability(), + }).status, + ).toBeDefined(); + }); + test("copies normalized facts, rejects inconsistent counts and enforces collection limits", () => { + const original = createTestStatusSnapshot(); + const normalized = normalizeVcsStatusSnapshot(original); + original.head = { kind: "unborn", name: "changed" }; + expect(normalized.head.kind).toBe("branch"); + expect(() => normalizeVcsStatusSnapshot({ ...normalized, changedPathCount: 1 })).toThrow( + "unique changed paths", + ); + expect(() => + normalizeVcsStatusSnapshot({ + ...normalized, + paths: Array(20_001).fill({ path: "x", worktree: "untracked", conflict: false }), + }), + ).toThrow(); + }); + test("rejects foreign siblings and late cancelled provider results", async () => { + const provider = createTestStatusCapability(); + const current = createTestStatusSnapshot(); + const wrapped = toInternalVcsStatus({ + ...provider, + async readSiblings() { + return { + worktrees: [ + { + worktree: { id: "other", path: "other", repositoryId: "foreign" }, + bare: false, + detached: false, + inspectable: false, + status: { state: "error", message: "unreadable" }, + }, + ], + truncated: false, + }; + }, + })!; + await expect(wrapped.readSiblings(current, { cwd: "workspace" })).rejects.toThrow( + "inspected repository", + ); + const controller = new AbortController(); + const late = toInternalVcsStatus({ + ...provider, + async read() { + controller.abort(new Error("late status")); + return current; + }, + })!; + await expect(late.read({}, { cwd: "workspace", signal: controller.signal })).rejects.toThrow( + "late status", + ); + }); + test("keeps full-comparison review targets and rejects unsupported actions", async () => { + const current = createTestStatusSnapshot(); + current.reviewActions = [{ id: "working", label: "Working changes" }]; + const capability = toInternalVcsStatus(createTestStatusCapability())!; + expect((await capability.planReview(current, "working", { cwd: "workspace" })).input).toEqual({ + kind: "vcs", + staged: false, + options: {}, + }); + await expect(capability.planReview(current, "other", { cwd: "workspace" })).rejects.toThrow( + "Unknown status review action", + ); + const foreign = toInternalVcsStatus({ + ...createTestStatusCapability(), + async planReview() { + return { cwd: "foreign", input: { kind: "vcs", staged: false, options: {} } }; + }, + })!; + await expect(foreign.planReview(current, "working", { cwd: "workspace" })).rejects.toThrow( + "inspected target", + ); + }); +}); diff --git a/packages/hunk/src/extensions/vcsStatus.ts b/packages/hunk/src/extensions/vcsStatus.ts new file mode 100644 index 000000000..4c0e2ee4d --- /dev/null +++ b/packages/hunk/src/extensions/vcsStatus.ts @@ -0,0 +1,256 @@ +import { z } from "zod"; +import type { + ExtensionVcsStatusCapability, + ExtensionVcsStatusSnapshot, +} from "../extension-api/types"; +import { toUserFacingError } from "../core/run/errors"; + +const text = z + .string() + .min(1) + .max(65_536) + .refine((value) => !value.includes("\0")); +const count = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER); +const timestamp = z.iso.datetime(); +/** Copy a bounded status fact while retaining unknown and error semantics. */ +function fact(value: T) { + return z.discriminatedUnion("state", [ + z.object({ state: z.literal("ready"), value }), + z.object({ state: z.literal("unknown"), reason: text }), + z.object({ state: z.literal("error"), message: text }), + ]); +} +const operation = fact( + z.array(z.enum(["merge", "rebase", "cherry-pick", "revert", "bisect"])).max(5), +); +const worktree = z.object({ id: text, path: text, repositoryId: text }); +const head = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("branch"), name: text, revisionId: text }), + z.object({ kind: z.literal("detached"), revisionId: text }), + z.object({ kind: z.literal("unborn"), name: text }), +]); +const pathState = z.enum([ + "unchanged", + "modified", + "added", + "deleted", + "renamed", + "copied", + "type-changed", + "unmerged", + "untracked", +]); +const siblings = z.object({ + truncated: z.boolean(), + worktrees: z + .array( + z.object({ + worktree, + branch: text.optional(), + detached: z.boolean(), + bare: z.boolean(), + locked: z.string().max(65_536).optional(), + prunable: z.string().max(65_536).optional(), + inspectable: z.boolean(), + status: z.discriminatedUnion("state", [ + z.object({ + state: z.literal("ready"), + observedAt: timestamp, + changedPathCount: count, + conflictCount: count, + operations: operation, + }), + z.object({ state: z.enum(["unavailable", "error"]), message: text }), + ]), + }), + ) + .max(100), +}); +const snapshot = z + .object({ + schemaVersion: z.literal(1), + observedAt: timestamp, + worktree, + token: text, + head, + upstream: fact( + z.discriminatedUnion("kind", [ + z.object({ kind: z.enum(["none", "detached", "unborn"]) }), + z.object({ kind: z.literal("missing"), name: text }), + z.object({ + kind: z.literal("tracked"), + name: text, + ahead: count, + behind: count, + fetch: fact(z.object({ timestamp, provenance: z.literal("local-fetch-head-mtime") })), + }), + ]), + ), + operations: operation, + paths: z + .array( + z.object({ + path: text, + previousPath: text.optional(), + index: pathState.optional(), + worktree: pathState, + conflict: z.boolean(), + submodule: z + .object({ + commitChanged: z.boolean(), + trackedChanges: z.boolean(), + untrackedChanges: z.boolean(), + }) + .optional(), + }), + ) + .max(20_000), + changedPathCount: count, + reviewActions: z.array(z.object({ id: text, label: text })).max(32), + siblings: z.union([fact(siblings), z.object({ state: z.literal("loading") })]), + }) + .superRefine((value, context) => { + if ( + new Set(value.paths.map((path) => path.path)).size !== value.paths.length || + value.changedPathCount !== value.paths.length + ) { + context.addIssue({ code: "custom", message: "Status must count unique changed paths." }); + } + if ( + new Set(value.reviewActions.map((action) => action.id)).size !== value.reviewActions.length + ) { + context.addIssue({ code: "custom", message: "Status review action ids must be unique." }); + } + }); + +/** Validate and detach provider results before any status surface consumes them. */ +export function normalizeVcsStatusSnapshot(value: unknown): ExtensionVcsStatusSnapshot { + return snapshot.parse(value); +} + +/** Wrap the optional public capability with bounded result checks and error translation. */ +export function toInternalVcsStatus(value: unknown): ExtensionVcsStatusCapability | undefined { + if (value === undefined) return undefined; + if (!value || typeof value !== "object") + throw new Error("VCS status must be a capability object."); + const capability = value as ExtensionVcsStatusCapability; + const { read, readSiblings, planReview, watchPlan } = capability; + if ( + typeof read !== "function" || + typeof readSiblings !== "function" || + typeof planReview !== "function" || + (watchPlan !== undefined && typeof watchPlan !== "function") + ) { + throw new Error("VCS status must provide read(), readSiblings(), and planReview() functions."); + } + /** Translate failures without swallowing cancellation or publishing late results. */ + const run = async (signal: AbortSignal | undefined, task: () => Promise) => { + signal?.throwIfAborted(); + try { + const result = await task(); + signal?.throwIfAborted(); + return result; + } catch (error) { + signal?.throwIfAborted(); + throw toUserFacingError(error); + } + }; + return { + read: (input, context) => + run(context.signal, async () => + normalizeVcsStatusSnapshot(await read.call(capability, { ...input }, context)), + ), + readSiblings: (current, context) => + run(context.signal, async () => { + const result = siblings.parse( + await readSiblings.call(capability, normalizeVcsStatusSnapshot(current), context), + ); + if ( + result.worktrees.some( + (row) => row.worktree.repositoryId !== current.worktree.repositoryId, + ) + ) { + throw new Error("Status siblings must belong to the inspected repository."); + } + return result; + }), + planReview: (current, actionId, context) => + run(context.signal, async () => { + if (!current.reviewActions.some((action) => action.id === actionId)) + throw new Error("Unknown status review action."); + const result = z + .object({ + cwd: text, + input: z.object({ + kind: z.literal("vcs"), + staged: z.boolean(), + range: text.optional(), + rangeEndpoints: z.object({ from: text, to: text }).optional(), + options: z.object({ + excludeUntracked: z.boolean().optional(), + colorMoved: z.boolean().optional(), + }), + }), + }) + .parse( + await planReview.call( + capability, + normalizeVcsStatusSnapshot(current), + actionId, + context, + ), + ); + if ( + result.cwd !== current.worktree.path || + (result.input.range !== undefined && result.input.rangeEndpoints !== undefined) + ) { + throw new Error("Status review must retain its inspected target and one comparison."); + } + const { range, rangeEndpoints, ...input } = result.input; + return { + cwd: result.cwd, + input: rangeEndpoints + ? { ...input, rangeEndpoints } + : { ...input, ...(range === undefined ? {} : { range }) }, + }; + }), + ...(watchPlan + ? { + watchPlan: (current, context) => + run(context.signal, async () => { + const result = await watchPlan.call( + capability, + normalizeVcsStatusSnapshot(current), + context, + ); + const source = z + .array(z.enum(["content", "sidecar", "worktree", "vcs-metadata"])) + .max(4); + return z + .object({ + coverage: z.enum(["hybrid", "poll-only"]), + targets: z + .array( + z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("directory-tree"), + directory: text, + ignoredRoots: z.array(text).max(20_000), + sources: source, + }), + z.object({ + kind: z.literal("directory-entries"), + directory: text, + entries: z.array(text).max(20_000), + sources: source, + }), + ]), + ) + .max(256), + }) + .parse(result); + }), + } + : {}), + }; +} diff --git a/packages/hunk/src/main.tsx b/packages/hunk/src/main.tsx index 80a6caff0..85fa15859 100644 --- a/packages/hunk/src/main.tsx +++ b/packages/hunk/src/main.tsx @@ -88,6 +88,18 @@ async function main() { ); } + if (startupPlan.kind === "status-static") { + const { runStaticStatus } = await import("./ui/status/runStaticStatus"); + await runStaticStatus(startupPlan.bootstrap); + return; + } + + if (startupPlan.kind === "status-interactive") { + const { runInteractiveStatus } = await import("./ui/status/runInteractiveStatus"); + await runInteractiveStatus(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 d709866ca..b4e3e3f5a 100644 --- a/packages/hunk/src/ui/App.tsx +++ b/packages/hunk/src/ui/App.tsx @@ -15,6 +15,7 @@ import { useState, } from "react"; import type { PersistedViewPreferences } from "../core/run/config"; +import { STATUS_COMMAND_NAMES } from "../core/run/statusCommandCatalog"; import { HISTORY_COMMAND_NAMES } from "../core/run/historyCommandCatalog"; import type { ExtensionReviewReloadResult } from "../extension-api/types"; import { experimentalFeatureEnabled, resolveExperimentalDiffFiles } from "../core/run/experimental"; @@ -154,7 +155,8 @@ export function App({ reviewProducer, runWorkspaceWrite, themeController, - returnToHistory = process.env.HUNK_RETURN_TO_HISTORY === "1", + initialFilePath, + returnToSurface = process.env.HUNK_RETURN_TO_HISTORY === "1" ? "history" : undefined, watchRuntime, workspaceFileWriter, }: { @@ -186,8 +188,10 @@ export function App({ runWorkspaceWrite: WorkspaceWriteRunner; /** Session-owned committed theme state shared across routed surfaces. */ themeController?: ThemeController; - /** Present quit as returning to the owning history surface. */ - returnToHistory?: boolean; + /** Present quit as returning to the owning status or history surface. */ + returnToSurface?: "history" | "status"; + /** Reveal a launch-selected path within the full comparison, only on first mount. */ + initialFilePath?: string; watchRuntime?: WatchedInputRuntime; workspaceFileWriter?: WorkspaceFileWriter; }) { @@ -218,6 +222,13 @@ export function App({ sourceLabel: bootstrap.changeset.sourceLabel, stmlEnabled, }); + const initialNavigationApplied = useRef(false); + useEffect(() => { + if (initialNavigationApplied.current) return; + initialNavigationApplied.current = true; + const file = reviewFiles.find((file) => file.path === initialFilePath); + if (file) review.selectFile(file.id, { alignFileHeaderTop: true }); + }, [initialFilePath, reviewFiles, review.selectFile]); // The producer plans brokered actions against the store this controller owns, so a // remote action and a key press reach the same state through the same intent path. // AppHost detaches the previous store while committing a reload; this child layout @@ -410,7 +421,7 @@ export function App({ configPath: bootstrap.viewPreferencesConfigPath, pagerMode, promptSaveViewPreferences: - bootstrap.input.options.promptSaveViewPreferences !== false && !returnToHistory, + bootstrap.input.options.promptSaveViewPreferences !== false && !returnToSurface, transientViewPreferences: extensionSessionOptions.transientViewPreferences, onQuit, showNotice: showSessionNotice, @@ -637,7 +648,7 @@ export function App({ ...builtinCommandKeyDefaults(), ...extensionCommandKeyDefaults(registeredExtensionCommands), ], - inactiveCommandNames: HISTORY_COMMAND_NAMES, + inactiveCommandNames: new Set([...HISTORY_COMMAND_NAMES, ...STATUS_COMMAND_NAMES]), userBindings: bootstrap.keybindings, }), [bootstrap.keybindings, registeredExtensionCommands], @@ -1208,8 +1219,8 @@ export function App({ triggerEditSelectedFile, triggerRefreshCurrentInput, }).map((command) => - returnToHistory && command.id === "hunk.app.quit" - ? { ...command, title: "Back to history" } + returnToSurface && command.id === "hunk.app.quit" + ? { ...command, title: `Back to ${returnToSurface}` } : command, ), ...extensionAppCommands.commands, diff --git a/packages/hunk/src/ui/AppHost.dynamic-mount.test.tsx b/packages/hunk/src/ui/AppHost.dynamic-mount.test.tsx index 9147f27a8..4aea04437 100644 --- a/packages/hunk/src/ui/AppHost.dynamic-mount.test.tsx +++ b/packages/hunk/src/ui/AppHost.dynamic-mount.test.tsx @@ -28,7 +28,7 @@ function DynamicReviewHost({ onReady }: { onReady: () => void }) { ], })} onFirstFrameReady={onReady} - returnToHistory + returnToSurface="history" /> ); } diff --git a/packages/hunk/src/ui/AppHost.tsx b/packages/hunk/src/ui/AppHost.tsx index 74265bb5a..e8064e197 100644 --- a/packages/hunk/src/ui/AppHost.tsx +++ b/packages/hunk/src/ui/AppHost.tsx @@ -63,7 +63,9 @@ export function AppHost({ onActiveBootstrapChange, onFirstFrameReady, onViewPreferencesChange, - returnToHistory = false, + returnToSurface, + initialFilePath, + sessionCustomThemes, extensionSession, extensionOwnership, onRequestSessionShutdown, @@ -85,8 +87,12 @@ export function AppHost({ onFirstFrameReady?: () => void; /** Publish live preferences to the owner of a routed review surface. */ onViewPreferencesChange?: (preferences: PersistedViewPreferences) => void; - /** Present quit as returning to an owning history surface. */ - returnToHistory?: boolean; + /** Present quit as returning to an owning status or history surface. */ + returnToSurface?: "history" | "status"; + /** Reveal a launch-selected path within the full comparison, only on first mount. */ + initialFilePath?: string; + /** Keep the owning session's custom palettes across source-target reloads. */ + sessionCustomThemes?: AppBootstrap["customThemes"]; /** Session authority shared by every routed surface in this process. */ extensionSession: ExtensionSession; /** Whether this surface may ask the session to adopt a replacement registry. */ @@ -329,6 +335,7 @@ export function AppHost({ configured, cwd, extensions, + sessionCustomThemes, loadAtCwd: true, baseVcsCatalog, }); @@ -464,6 +471,7 @@ export function AppHost({ [ extensionLifecycleEnabled, extensionOwnership, + sessionCustomThemes, activeExtensionSession, activeThemeController, hostClient, @@ -607,7 +615,8 @@ export function AppHost({ onQuit={quitAfterShutdownEvent} onFirstFrameReady={onFirstFrameReady} onViewPreferencesChange={onViewPreferencesChange} - returnToHistory={returnToHistory} + returnToSurface={returnToSurface} + initialFilePath={initialFilePath} onRegisterWorkspaceRefreshRequest={registerWorkspaceRefreshRequest} onReloadSession={reloadSession} onRequestExtensionReviewReload={requestExtensionReviewReload} diff --git a/packages/hunk/src/ui/log/LogApp.tsx b/packages/hunk/src/ui/log/LogApp.tsx index e7786c201..ef9811fc0 100644 --- a/packages/hunk/src/ui/log/LogApp.tsx +++ b/packages/hunk/src/ui/log/LogApp.tsx @@ -2,6 +2,7 @@ import type { KeyEvent, MouseEvent as TuiMouseEvent } from "@opentui/core"; import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"; import { basename } from "node:path"; import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { STATUS_COMMAND_NAMES } from "../../core/run/statusCommandCatalog"; import { APP_COMMAND_NAMES } from "../../core/run/commandCatalog"; import type { PersistedViewPreferences } from "../../core/run/config"; import type { @@ -78,6 +79,8 @@ export function LogApp({ themeController, useColor, quitScheduler, + returnToStatus = false, + transparentBackground = false, }: { controller: LogController; runtime: InteractiveHistoryRuntime; @@ -87,6 +90,9 @@ export function LogApp({ themeController: ThemeController; useColor: boolean; quitScheduler?: ViewPreferenceQuitScheduler; + returnToStatus?: boolean; + /** Retain the caller's resolved surface preference without changing the shared palette. */ + transparentBackground?: boolean; }) { const snapshot = useSyncExternalStore(controller.subscribe, controller.getSnapshot); const terminal = useTerminalDimensions(); @@ -110,7 +116,7 @@ export function LogApp({ const themeSelector = useThemeSelectorController({ onTransientNotice: setTransientNotice, themeController, - transparentBackground: false, + transparentBackground, }); const terminalThemeMode = renderer.themeMode ?? "dark"; const theme = useColor @@ -137,7 +143,7 @@ export function LogApp({ }, configPath: runtime.viewPreferencesConfigPath, pagerMode: false, - promptSaveViewPreferences: runtime.promptSaveViewPreferences, + promptSaveViewPreferences: runtime.promptSaveViewPreferences && !returnToStatus, transientViewPreferences: resolveExtensionSessionOptions( runtime.extensionSession.current.registry, ).transientViewPreferences, @@ -258,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, ...STATUS_COMMAND_NAMES]); for (const registered of resolveExtensionCommands(runtime.extensionSession.current.registry) .commands) { names.add(`${registered.extensionId}.${registered.command.id}`); @@ -348,7 +354,7 @@ export function LogApp({ return { kind: "item", commandId: id, - label: definition.title, + label: id === "hunk.app.quit" && returnToStatus ? "Back to status" : definition.title, ...(command.keyLabels.length ? { hint: command.keyLabels.join(" / ") } : {}), disabled: command.isEnabled ? !command.isEnabled() : false, action: () => executeCommand(id), diff --git a/packages/hunk/src/ui/log/colorPolicy.test.ts b/packages/hunk/src/ui/log/colorPolicy.test.ts index ce94d8428..385a22354 100644 --- a/packages/hunk/src/ui/log/colorPolicy.test.ts +++ b/packages/hunk/src/ui/log/colorPolicy.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { resolveTheme } from "../themes"; +import { resolveTheme, withTransparentSurfaces } from "../themes"; import { interactiveLogUsesColor, monochromeLogTheme, @@ -52,3 +52,10 @@ describe("interactive log color policy", () => { expect(neutral.syntaxScopeOverrides).toBeUndefined(); }); }); + +test("monochrome status/history retain the shared transparent surface derivation", () => { + const neutral = monochromeLogTheme(withTransparentSurfaces(resolveTheme("nord", null)), "dark"); + expect(neutral.background).toBe("transparent"); + expect(neutral.panel).toBe("transparent"); + expect(neutral.text).toBe("#ffffff"); +}); diff --git a/packages/hunk/src/ui/log/colorPolicy.ts b/packages/hunk/src/ui/log/colorPolicy.ts index 98082ca86..c36d47e5a 100644 --- a/packages/hunk/src/ui/log/colorPolicy.ts +++ b/packages/hunk/src/ui/log/colorPolicy.ts @@ -1,6 +1,6 @@ import type { ThemeMode } from "@opentui/core"; import type { HistoryColorMode } from "../../core/run/commandInputs"; -import type { AppTheme } from "../themes"; +import { TRANSPARENT_BACKGROUND, withTransparentSurfaces, type AppTheme } from "../themes"; import { resolveHistoryColor } from "../history/staticProjection"; export interface InteractiveLogPalette { @@ -51,7 +51,7 @@ export function monochromeLogTheme(theme: AppTheme, terminalMode: ThemeMode): Ap const background = light ? "#ffffff" : "#000000"; const foreground = light ? "#000000" : "#ffffff"; const selection = light ? "#d0d0d0" : "#404040"; - return { + const neutral: AppTheme = { ...theme, id: "terminal-monochrome", label: "Terminal monochrome", @@ -95,4 +95,5 @@ export function monochromeLogTheme(theme: AppTheme, terminalMode: ThemeMode): Ap ) as AppTheme["syntaxColors"], syntaxScopeOverrides: undefined, }; + return theme.background === TRANSPARENT_BACKGROUND ? withTransparentSurfaces(neutral) : neutral; } diff --git a/packages/hunk/src/ui/session/HunkSessionHost.status.test.tsx b/packages/hunk/src/ui/session/HunkSessionHost.status.test.tsx new file mode 100644 index 000000000..da4048220 --- /dev/null +++ b/packages/hunk/src/ui/session/HunkSessionHost.status.test.tsx @@ -0,0 +1,546 @@ +import { expect, mock, test } from "bun:test"; +import { testRender } from "@opentui/react/test-utils"; +import { act } from "react"; +import { createTestStatusRuntime } from "../../../../../test/helpers/status-runtime"; +import type { AppBootstrap } from "../../core/bootstrap"; +import { StatusController } from "../status/controller"; +import { HunkSessionHost } from "./HunkSessionHost"; +import { availableThemes } from "../themes"; + +/** Flush route preparation and the existing review commit/quit lifecycle. */ +async function settleStatusTest(setup: Awaited>) { + await act(async () => { + await Bun.sleep(30); + await setup.renderOnce(); + await Bun.sleep(30); + await setup.renderOnce(); + }); +} + +/** Mount the actual three-surface host while substituting only broker process resources. */ +async function createTestStatusHost(runtime = createTestStatusRuntime()) { + const controller = new StatusController(runtime); + const abort = new AbortController(); + const quit = mock(() => undefined); + const mounted: { + bootstrap: AppBootstrap; + cwd: string | undefined; + stop: ReturnType; + }[] = []; + const setup = await testRender( + { + const stop = mock(() => undefined); + mounted.push({ bootstrap, cwd, stop }); + return { stop, hostClient: undefined, reviewProducer: undefined }; + }) as never, + }} + />, + { width: 120, height: 24 }, + ); + await settleStatusTest(setup); + return { + setup, + controller, + abort, + quit, + mounted, + async close() { + setup.renderer.destroy(); + await controller.close(); + await runtime.extensionSession.shutdown(); + }, + }; +} + +test("status -> full diff -> status retains selection, then log -> diff -> log -> status", async () => { + const runtime = createTestStatusRuntime(); + const host = await createTestStatusHost(runtime); + const { setup, mounted, controller } = host; + try { + expect(setup.captureCharFrame()).toContain("alpha.ts modified (staged) · modified (unstaged)"); + await act(async () => setup.mockInput.pressArrow("down")); + expect(controller.getSnapshot().selected).toBe("path:beta.ts"); + await act(async () => setup.mockInput.pressEnter()); + await settleStatusTest(setup); + expect(mounted).toHaveLength(1); + expect(mounted[0]!.bootstrap.changeset.files.map((file) => file.path)).toEqual([ + "alpha.ts", + "beta.ts", + ]); + expect(mounted[0]!.cwd).toBe(runtime.snapshot.worktree.path); + expect(mounted[0]!.bootstrap.extensions).toBe(runtime.extensionSession.current); + await act(async () => setup.mockInput.pressKey("q")); + await settleStatusTest(setup); + expect(mounted[0]!.stop).toHaveBeenCalledTimes(1); + expect(controller.getSnapshot().selected).toBe("path:beta.ts"); + expect(setup.captureCharFrame()).toContain("Other worktrees"); + await act(async () => setup.mockInput.pressKey("l")); + await settleStatusTest(setup); + expect(setup.captureCharFrame()).toContain("Status history commit"); + await act(async () => setup.mockInput.pressEnter()); + await settleStatusTest(setup); + expect(mounted).toHaveLength(2); + await act(async () => setup.mockInput.pressKey("q")); + await settleStatusTest(setup); + expect(setup.captureCharFrame()).toContain("Status history commit"); + await act(async () => setup.mockInput.pressKey("q")); + await settleStatusTest(setup); + expect(setup.captureCharFrame()).toContain("Other worktrees"); + expect(host.quit).not.toHaveBeenCalled(); + } finally { + await host.close(); + } +}); + +test("custom theme and review preferences seed subsequent reviews in both route directions", async () => { + const runtime = createTestStatusRuntime(); + const host = await createTestStatusHost(runtime); + const { setup, mounted } = host; + try { + await act(async () => setup.mockInput.pressKey("t")); + await setup.renderOnce(); + const index = availableThemes().findIndex((theme) => theme.id === "nord"); + await act(async () => { + for (let i = 0; i <= index; i++) setup.mockInput.pressArrow("up"); + }); + await setup.renderOnce(); + expect(setup.captureCharFrame()).toContain("Status custom"); + await act(async () => setup.mockInput.pressEnter()); + await setup.renderOnce(); + await act(async () => setup.mockInput.pressKey("u")); + await settleStatusTest(setup); + expect(mounted[0]!.bootstrap.initialTheme).toBe("status-custom"); + await act(async () => setup.mockInput.pressKey("l")); + await act(async () => setup.mockInput.pressKey("w")); + await act(async () => setup.mockInput.pressKey("q")); + await settleStatusTest(setup); + await act(async () => setup.mockInput.pressKey("l")); + await settleStatusTest(setup); + await act(async () => setup.mockInput.pressEnter()); + await settleStatusTest(setup); + expect(mounted[1]!.bootstrap.initialTheme).toBe("status-custom"); + expect(mounted[1]!.bootstrap.initialShowLineNumbers).toBe(false); + expect(mounted[1]!.bootstrap.initialWrapLines).toBe(!runtime.initialViewPreferences.wrapLines); + expect(mounted[1]!.bootstrap.input.options.experimental).toBe(true); + expect(mounted[1]!.bootstrap.customThemes).toEqual(runtime.initialization.theme.customThemes); + } finally { + await host.close(); + } +}); + +test("sibling inspection and back retain launch authority while reviews target the sibling", async () => { + const runtime = createTestStatusRuntime(); + const owner = runtime.extensionSession.current; + const host = await createTestStatusHost(runtime); + try { + await act(async () => { + host.setup.mockInput.pressArrow("down"); + host.setup.mockInput.pressArrow("down"); + }); + await act(async () => host.setup.mockInput.pressEnter()); + await settleStatusTest(host.setup); + expect(host.controller.getSnapshot().snapshot.worktree.id).toBe("sibling"); + const target = host.controller.getSnapshot().snapshot.worktree.path; + await act(async () => host.setup.mockInput.pressKey("s")); + await settleStatusTest(host.setup); + expect(host.mounted[0]!.cwd).toBe(target); + expect(host.mounted[0]!.bootstrap.extensions).toBe(owner); + await act(async () => host.setup.mockInput.pressKey("q")); + await settleStatusTest(host.setup); + expect(host.controller.getSnapshot().snapshot.worktree.path).toBe(target); + await act(async () => host.setup.mockInput.pressKey("q")); + await settleStatusTest(host.setup); + expect(host.controller.getSnapshot().snapshot.worktree.path).toBe( + runtime.snapshot.worktree.path, + ); + expect(host.quit).not.toHaveBeenCalled(); + } finally { + await host.close(); + } +}); + +test("cancelled/late status preparation never mounts a runtime and global shutdown ends the session", async () => { + const runtime = createTestStatusRuntime(); + let resolve!: (value: Awaited>) => void; + const original = runtime.prepareReview; + let signal: AbortSignal | undefined; + runtime.prepareReview = mock((_input, _cwd, active) => { + signal = active; + return new Promise>>((done) => { + resolve = done; + }); + }); + const host = await createTestStatusHost(runtime); + try { + await act(async () => { + host.setup.mockInput.pressKey("s"); + host.setup.mockInput.pressKey("s"); + }); + await settleStatusTest(host.setup); + expect(runtime.prepareReview).toHaveBeenCalledTimes(1); + await act(async () => host.abort.abort()); + expect(signal?.aborted).toBe(true); + expect(host.quit).not.toHaveBeenCalled(); + await act(async () => + resolve( + await original({ kind: "vcs", staged: true, options: {} }, runtime.snapshot.worktree.path), + ), + ); + await settleStatusTest(host.setup); + expect(host.mounted).toHaveLength(0); + expect(host.quit).toHaveBeenCalledTimes(1); + } finally { + await host.close(); + } +}); + +test("failed status preparation leaves the caller usable and a later open succeeds", async () => { + const runtime = createTestStatusRuntime(); + const original = runtime.planReview; + runtime.planReview = async () => { + throw new Error("Workspace changed; refresh"); + }; + const host = await createTestStatusHost(runtime); + try { + await act(async () => host.setup.mockInput.pressKey("s")); + await settleStatusTest(host.setup); + expect(host.mounted).toHaveLength(0); + expect(host.setup.captureCharFrame()).toContain("Workspace changed; refresh"); + expect(host.setup.captureCharFrame()).toContain("Other worktrees"); + runtime.planReview = original; + await act(async () => host.setup.mockInput.pressKey("s")); + await settleStatusTest(host.setup); + expect(host.mounted).toHaveLength(1); + } finally { + await host.close(); + } +}); + +test("global shutdown cancels the first history page before log mounts and closes its source", async () => { + const runtime = createTestStatusRuntime(); + const openHistory = runtime.openHistory; + const sourceClosed = mock(() => undefined); + let readSignal: AbortSignal | undefined; + runtime.openHistory = async (path) => { + const history = await openHistory(path); + history.source.read = ({ signal }) => { + readSignal = signal; + return new Promise((resolve, reject) => { + if (signal?.aborted) reject(signal.reason); + else + signal?.addEventListener("abort", () => resolve({ commits: [], done: true }), { + once: true, + }); + }); + }; + history.close = async () => { + sourceClosed(); + }; + return history; + }; + const host = await createTestStatusHost(runtime); + try { + await act(async () => host.setup.mockInput.pressKey("l")); + await settleStatusTest(host.setup); + expect(readSignal).toBeDefined(); + await act(async () => host.abort.abort()); + await settleStatusTest(host.setup); + expect(readSignal!.aborted).toBe(true); + expect(sourceClosed).toHaveBeenCalledTimes(1); + expect(host.quit).toHaveBeenCalledTimes(1); + expect(host.mounted).toHaveLength(0); + } finally { + await host.close(); + } +}); + +test("detected light theme persists and only root status asks to save changed preferences", async () => { + const runtime = createTestStatusRuntime(); + runtime.initialization.theme.initialTheme = "auto"; + runtime.initialization.theme.initialThemeMode = "light"; + runtime.promptSaveViewPreferences = true; + const host = await createTestStatusHost(runtime); + try { + await act(async () => host.setup.mockInput.pressKey("u")); + await settleStatusTest(host.setup); + expect(host.mounted[0]!.bootstrap.initialTheme).toBe("github-light-default"); + await act(async () => host.setup.mockInput.pressKey("l")); + await act(async () => host.setup.mockInput.pressKey("q")); + await settleStatusTest(host.setup); + expect(host.setup.captureCharFrame()).not.toContain("Save view preferences?"); + await act(async () => host.setup.mockInput.pressKey("l")); + await settleStatusTest(host.setup); + await act(async () => host.setup.mockInput.pressKey("q")); + await settleStatusTest(host.setup); + expect(host.setup.captureCharFrame()).not.toContain("Save view preferences?"); + await act(async () => host.setup.mockInput.pressKey("q")); + await settleStatusTest(host.setup); + expect(host.setup.captureCharFrame()).toContain("Save view preferences?"); + expect(host.quit).not.toHaveBeenCalled(); + } finally { + await host.close(); + } +}); + +test("explicit untracked row overrides launch exclusion only for its full comparison", async () => { + const runtime = createTestStatusRuntime(); + runtime.launchOptions.excludeUntracked = true; + runtime.snapshot.paths[1]!.worktree = "untracked"; + const host = await createTestStatusHost(runtime); + try { + await act(async () => host.setup.mockInput.pressArrow("down")); + await act(async () => host.setup.mockInput.pressEnter()); + await settleStatusTest(host.setup); + expect(host.mounted[0]!.bootstrap.input.options.excludeUntracked).toBe(false); + expect(host.mounted[0]!.bootstrap.changeset.files).toHaveLength(2); + await act(async () => host.setup.mockInput.pressKey("q")); + await settleStatusTest(host.setup); + await act(async () => host.setup.mockInput.pressKey("u")); + await settleStatusTest(host.setup); + expect(host.mounted[1]!.bootstrap.input.options.excludeUntracked).toBe(true); + expect(runtime.launchOptions.excludeUntracked).toBe(true); + } finally { + await host.close(); + } +}); + +for (const global of [false, true]) { + test(`rejecting history cleanup settles ${global ? "global shutdown" : "local cancellation"} exactly once`, async () => { + const runtime = createTestStatusRuntime(); + const openHistory = runtime.openHistory; + const closed = mock(() => { + throw new Error("provider close rejected"); + }); + runtime.openHistory = async (path) => { + const history = await openHistory(path); + history.source.read = ({ signal }) => + new Promise((resolve) => { + signal!.addEventListener("abort", () => resolve({ commits: [], done: true }), { + once: true, + }); + }); + history.close = async () => { + closed(); + }; + return history; + }; + const host = await createTestStatusHost(runtime); + try { + await act(async () => host.setup.mockInput.pressKey("l")); + await settleStatusTest(host.setup); + await act(async () => { + if (global) host.abort.abort(); + else host.setup.mockInput.pressKey("q"); + }); + await settleStatusTest(host.setup); + expect(closed).toHaveBeenCalledTimes(1); + expect(host.mounted).toHaveLength(0); + expect(host.controller.getSnapshot().notice).toContain("provider close rejected"); + if (global) expect(host.quit).toHaveBeenCalledTimes(1); + else { + expect(host.quit).not.toHaveBeenCalled(); + expect(host.setup.captureCharFrame()).not.toContain("Preparing…"); + await act(async () => host.setup.mockInput.pressKey("u")); + await settleStatusTest(host.setup); + expect(host.mounted).toHaveLength(1); + await act(async () => host.setup.mockInput.pressKey("q")); + await settleStatusTest(host.setup); + await act(async () => host.setup.mockInput.pressKey("q")); + await settleStatusTest(host.setup); + expect(host.quit).toHaveBeenCalledTimes(1); + } + } finally { + await host.close(); + } + }); +} + +for (const transparent of [false, true]) { + test(`resolved transparency ${transparent} survives status/log/review returns and theme changes`, async () => { + const runtime = createTestStatusRuntime(); + runtime.launchOptions.transparentBackground = transparent; + const host = await createTestStatusHost(runtime); + // Inspect actual mounted surface paint, not only propagated bootstrap options. + const backgroundAlpha = () => + (host.setup.renderer.root.getChildren()[0] as import("@opentui/core").BoxRenderable) + .backgroundColor.a; + try { + expect(backgroundAlpha()).toBe(transparent ? 0 : 1); + await act(async () => host.setup.mockInput.pressKey("u")); + await settleStatusTest(host.setup); + expect(host.mounted[0]!.bootstrap.input.options.transparentBackground).toBe(transparent); + expect(backgroundAlpha()).toBe(transparent ? 0 : 1); + await act(async () => host.setup.mockInput.pressKey("q")); + await settleStatusTest(host.setup); + expect(backgroundAlpha()).toBe(transparent ? 0 : 1); + await act(async () => host.setup.mockInput.pressKey("l")); + await settleStatusTest(host.setup); + expect(backgroundAlpha()).toBe(transparent ? 0 : 1); + await act(async () => host.setup.mockInput.pressKey("t")); + await act(async () => host.setup.mockInput.pressArrow("down")); + await act(async () => host.setup.mockInput.pressEnter()); + await settleStatusTest(host.setup); + expect(backgroundAlpha()).toBe(transparent ? 0 : 1); + await act(async () => host.setup.mockInput.pressEnter()); + await settleStatusTest(host.setup); + expect(backgroundAlpha()).toBe(transparent ? 0 : 1); + await act(async () => host.setup.mockInput.pressKey("q")); + await settleStatusTest(host.setup); + await act(async () => host.setup.mockInput.pressKey("q")); + await settleStatusTest(host.setup); + expect(backgroundAlpha()).toBe(transparent ? 0 : 1); + } finally { + await host.close(); + } + }); +} + +for (const exit of ["local", "global-race", "unmount"] as const) { + test(`mounted non-EOF Log handles rejecting cleanup on ${exit} without stranding its caller`, async () => { + const runtime = createTestStatusRuntime(); + const openHistory = runtime.openHistory; + const runtimeClose = runtime.close; + runtime.close = mock(runtimeClose); + let rejectClose: ((error: Error) => void) | undefined; + const closed = mock(async () => { + if (exit === "global-race") + await new Promise((_resolve, reject) => { + rejectClose = reject; + }); + else throw new Error("mounted source close rejected"); + }); + runtime.openHistory = async (path) => { + const history = await openHistory(path); + const first = await history.source.read({ limit: 10 }); + let read = false; + history.source.read = async ({ signal }) => { + if (!read) { + read = true; + return { ...first, done: false }; + } + return new Promise((resolve) => { + if (signal?.aborted) resolve({ commits: [], done: false }); + else + signal?.addEventListener("abort", () => resolve({ commits: [], done: false }), { + once: true, + }); + }); + }; + history.close = closed; + return history; + }; + const host = await createTestStatusHost(runtime); + try { + await act(async () => host.setup.mockInput.pressKey("l")); + await settleStatusTest(host.setup); + expect(host.setup.captureCharFrame()).toContain("Status history commit"); + if (exit === "unmount") { + await act(async () => { + host.setup.renderer.destroy(); + await Bun.sleep(0); + }); + } else { + await act(async () => host.setup.mockInput.pressKey("q")); + await settleStatusTest(host.setup); + if (exit === "global-race") { + expect(closed).toHaveBeenCalledTimes(1); + await act(async () => host.abort.abort()); + await settleStatusTest(host.setup); + expect(host.quit).not.toHaveBeenCalled(); + await act(async () => rejectClose!(new Error("mounted source close rejected"))); + await settleStatusTest(host.setup); + expect(host.quit).toHaveBeenCalledTimes(1); + expect(runtime.close).toHaveBeenCalledTimes(1); + } else { + expect(host.setup.captureCharFrame()).toContain("Other worktrees"); + expect(host.quit).not.toHaveBeenCalled(); + await act(async () => host.setup.mockInput.pressKey("u")); + await settleStatusTest(host.setup); + expect(host.mounted).toHaveLength(1); + } + } + expect(closed).toHaveBeenCalledTimes(1); + expect(host.controller.getSnapshot().notice).toContain("mounted source close rejected"); + } finally { + await host.close(); + } + expect(closed).toHaveBeenCalledTimes(1); + }); +} + +test("transparent monochrome status keeps menu and dialog backings opaque", async () => { + const runtime = createTestStatusRuntime(); + runtime.launchOptions.transparentBackground = true; + runtime.input.color = "never"; + const host = await createTestStatusHost(runtime); + try { + const surface = + host.setup.renderer.root.getChildren()[0] as import("@opentui/core").BoxRenderable; + expect(surface.backgroundColor.a).toBe(0); + for (const key of ["F10", "t"]) { + await act(async () => host.setup.mockInput.pressKey(key)); + await settleStatusTest(host.setup); + // The dropdown and framed modal are direct absolute children, above the transparent surface. + const backing = surface + .getChildren() + .find( + (child) => child.zIndex === (key === "F10" ? 40 : 60), + ) as import("@opentui/core").BoxRenderable; + expect(backing).toBeDefined(); + expect(backing.backgroundColor.a).toBe(1); + expect(surface.backgroundColor.a).toBe(0); + await act(async () => host.setup.mockInput.pressEscape()); + await settleStatusTest(host.setup); + } + } finally { + await host.close(); + } +}); + +test("group Enter/Space activation stays local, hidden paths cannot open, and child routes retain expansion", async () => { + const runtime = createTestStatusRuntime(); + runtime.snapshot.paths = Array.from({ length: 11 }, (_, i) => ({ + path: `file-${i}.ts`, + index: "unchanged", + worktree: "modified", + conflict: false, + })); + const host = await createTestStatusHost(runtime); + const { setup, controller, mounted } = host; + try { + await act(async () => controller.select("toggle:tracked")); + await act(async () => setup.mockInput.pressEnter()); + expect(controller.getSnapshot().expandedPathGroups.tracked).toBe(true); + expect(controller.getSnapshot().selected).toBe("toggle:tracked"); + expect(mounted).toHaveLength(0); + await settleStatusTest(setup); + expect(setup.captureCharFrame()).toContain("Show fewer"); + await act(async () => setup.mockInput.pressKey(" ")); + expect(controller.getSnapshot().expandedPathGroups.tracked).toBe(false); + expect(mounted).toHaveLength(0); + await act(async () => { + controller.select("path:file-10.ts"); + setup.mockInput.pressEnter(); + }); + expect(mounted).toHaveLength(0); + await act(async () => controller.togglePathGroup("tracked")); + await act(async () => setup.mockInput.pressKey("l")); + await settleStatusTest(setup); + expect(setup.captureCharFrame()).toContain("Status history commit"); + await act(async () => setup.mockInput.pressKey("q")); + await settleStatusTest(setup); + expect(controller.getSnapshot().expandedPathGroups).toEqual({ + tracked: true, + untracked: false, + }); + expect(setup.captureCharFrame()).toContain("Show fewer"); + } finally { + await host.close(); + } +}); diff --git a/packages/hunk/src/ui/session/HunkSessionHost.tsx b/packages/hunk/src/ui/session/HunkSessionHost.tsx index 6a5f350a1..b4eb22cb7 100644 --- a/packages/hunk/src/ui/session/HunkSessionHost.tsx +++ b/packages/hunk/src/ui/session/HunkSessionHost.tsx @@ -22,15 +22,25 @@ import type { InteractiveHistoryRuntime } from "../history/types"; import type { ViewPreferenceQuitScheduler } from "../hooks/useViewPreferenceQuitController"; import { interactiveLogUsesColor } from "../log/colorPolicy"; import { LogApp, type LogAppOutcome } from "../log/LogApp"; -import type { LogController } from "../log/controller"; +import { LogController } from "../log/controller"; import { resolveHistoryAuthorLabel } from "../log/formatting"; import { ThemeController } from "../theme/controller"; +import { StatusApp, type StatusOutcome } from "../status/StatusApp"; +import type { StatusController } from "../status/controller"; +import type { StatusRuntime } from "../status/types"; import { applySessionViewPreferences } from "./viewPreferences"; export interface HistorySurfaceRoute { kind: "history"; controller: LogController; runtime: InteractiveHistoryRuntime; + returnRoute?: StatusSurfaceRoute; +} + +export interface StatusSurfaceRoute { + kind: "status"; + controller: StatusController; + runtime: StatusRuntime; } export interface StandaloneReviewSurfaceRoute { @@ -41,16 +51,20 @@ export interface StandaloneReviewSurfaceRoute { extensionSession: ExtensionSession; } -export type HunkSurfaceRoute = HistorySurfaceRoute | StandaloneReviewSurfaceRoute; +export type HunkSurfaceRoute = + | StatusSurfaceRoute + | HistorySurfaceRoute + | StandaloneReviewSurfaceRoute; interface ActiveReviewSurfaceRoute extends StandaloneReviewSurfaceRoute { extensionOwnership: "owned" | "borrowed"; - quitBehavior: "return-to-history" | "quit-session"; + quitBehavior: "return-to-caller" | "quit-session"; mountMode: "initial" | "dynamic"; - returnRoute?: HistorySurfaceRoute; + returnRoute?: HistorySurfaceRoute | StatusSurfaceRoute; + initialFilePath?: string; } -type ActiveSurfaceRoute = HistorySurfaceRoute | ActiveReviewSurfaceRoute; +type ActiveSurfaceRoute = StatusSurfaceRoute | HistorySurfaceRoute | ActiveReviewSurfaceRoute; export interface HunkSessionHostDeps { prepareReview?: typeof prepareEmbeddedHistoryReview; @@ -120,9 +134,9 @@ function historyReviewDescriptor( } /** - * Route retained history and fresh review surfaces inside one stable React root. + * Route retained status/history and fresh review surfaces inside one stable React root. * - * History selections and standalone review startup converge here. The host owns route preparation + * Workspace inspection, history selections and standalone review startup converge here. The host owns route preparation * and review-surface disposal; `runHunkSession` retains terminal ownership, while `AppHost` retains * review reload and extension-event commit ordering. */ @@ -152,7 +166,7 @@ export function HunkSessionHost({ }), ); const [route, setRoute] = useState(() => - initialRoute.kind === "history" + initialRoute.kind !== "review" ? initialRoute : { ...initialRoute, @@ -188,11 +202,46 @@ export function HunkSessionHost({ } }, []); + const historyClosuresRef = useRef(new WeakMap>()); + /** Drain each status-owned history once across return, shutdown, preparation and unmount. */ + const closeStatusHistory = useCallback((history: LogController, status: StatusController) => { + const previous = historyClosuresRef.current.get(history); + if (previous) return previous; + const closing = Promise.resolve() + .then(() => history.close()) + .catch((error) => { + status.setNotice( + `History cleanup failed: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + historyClosuresRef.current.set(history, closing); + return closing; + }, []); + const completeQuit = useCallback(() => { if (quitRequestedRef.current) return; quitRequestedRef.current = true; - onQuit(pendingExitCodeRef.current); - }, [onQuit]); + const current = routeRef.current; + const history = + current.kind === "history" + ? current + : current.kind === "review" && current.returnRoute?.kind === "history" + ? current.returnRoute + : undefined; + const status = + current.kind === "status" + ? current + : (history?.returnRoute ?? + (current.kind === "review" && current.returnRoute?.kind === "status" + ? current.returnRoute + : undefined)); + void (async () => { + if (history?.returnRoute) + await closeStatusHistory(history.controller, history.returnRoute.controller); + await status?.controller.close(); + onQuit(pendingExitCodeRef.current); + })().catch(() => onQuit(pendingExitCodeRef.current ?? 1)); + }, [onQuit, closeStatusHistory]); const requestQuit = useCallback( (exitCode?: number) => { @@ -202,7 +251,7 @@ export function HunkSessionHost({ preparationControllerRef.current?.abort( new Error("Hunk surface preparation was cancelled during shutdown."), ); - if (routeRef.current.kind === "history" && !preparingRef.current) completeQuit(); + if (routeRef.current.kind !== "review" && !preparingRef.current) completeQuit(); }, [completeQuit], ); @@ -238,6 +287,17 @@ export function HunkSessionHost({ return; } if (outcome.kind === "quit") { + if (historyRoute.returnRoute && !externalQuitSignal.aborted && !shutdownPendingRef.current) { + await closeStatusHistory(historyRoute.controller, historyRoute.returnRoute.controller); + if (!mountedRef.current || routeRef.current !== historyRoute) return; + if (externalQuitSignal.aborted || shutdownPendingRef.current) { + completeQuit(); + return; + } + routeRef.current = historyRoute.returnRoute; + setRoute(historyRoute.returnRoute); + return; + } requestQuit(outcome.exitCode); return; } @@ -289,7 +349,20 @@ export function HunkSessionHost({ themeId: themeController.getSnapshot().themeId, themeMode: themeController.themeMode, }; - plan = await prepareReview(request, { signal }); + if (historyRoute.returnRoute) { + const bootstrap = await historyRoute.returnRoute.runtime.prepareHistoryReview( + action, + historyRoute.runtime.repoRoot, + signal, + ); + plan = { + bootstrap, + initialization, + borrowsExtensions: + bootstrap.extensions?.registry === + historyRoute.runtime.extensionSession.current.registry, + }; + } else plan = await prepareReview(request, { signal }); const historyReview = historyReviewDescriptor(historyRoute.runtime, outcome, action); if (historyReview) { plan.bootstrap.review = historyReview; @@ -319,7 +392,10 @@ export function HunkSessionHost({ ...sessionViewPreferencesRef.current, theme: themeController.getSnapshot().themeId, }); - const reviewRuntime = createReviewRuntime(reviewBootstrap, startupCwd); + const reviewRuntime = createReviewRuntime( + reviewBootstrap, + historyRoute.returnRoute ? historyRoute.runtime.repoRoot : startupCwd, + ); themeController.replaceCustomThemes(plan.initialization.theme.customThemes); const reviewRoute: ActiveReviewSurfaceRoute = { kind: "review", @@ -328,7 +404,7 @@ export function HunkSessionHost({ runtime: reviewRuntime, extensionSession: historyRoute.runtime.extensionSession, extensionOwnership: "borrowed", - quitBehavior: "return-to-history", + quitBehavior: "return-to-caller", mountMode: "dynamic", returnRoute: historyRoute, }; @@ -355,12 +431,143 @@ export function HunkSessionHost({ preparationSettlementRef.current = null; } settlePreparation(); - if (shutdownPendingRef.current && routeRef.current.kind === "history") { + if (shutdownPendingRef.current && routeRef.current.kind !== "review") { completeQuit(); } } }; + /** Prepare status child routes with launch-owned extensions and inspected-target source facts. */ + const handleStatusOutcome = async (status: StatusSurfaceRoute, outcome: StatusOutcome) => { + if (outcome.kind === "cancel-prepare") { + preparationGenerationRef.current++; + preparationControllerRef.current?.abort(); + await preparationSettlementRef.current; + return; + } + if (outcome.kind === "quit") { + requestQuit(); + return; + } + if ( + !mountedRef.current || + shutdownPendingRef.current || + externalQuitSignal.aborted || + preparingRef.current || + routeRef.current !== status + ) + return; + preparingRef.current = true; + const generation = ++preparationGenerationRef.current; + const abort = new AbortController(); + preparationControllerRef.current = abort; + const signal = AbortSignal.any([abort.signal, externalQuitSignal]); + let settle!: () => void; + const settlement = new Promise((resolve) => { + settle = resolve; + }); + preparationSettlementRef.current = settlement; + let history: LogController | undefined; + let historyClosing: Promise | undefined; + const cancelHistory = () => { + const closing = history; + if (!closing || historyClosing) return; + historyClosing = closeStatusHistory(closing, status.controller); + }; + signal.addEventListener("abort", cancelHistory, { once: true }); + try { + await status.controller.suspend(); + signal.throwIfAborted(); + const snapshot = status.controller.getSnapshot().snapshot; + if (outcome.kind === "open-log") { + const runtime = await status.runtime.openHistory(snapshot.worktree.path, signal); + history = new LogController(runtime); + if (signal.aborted) cancelHistory(); + signal.throwIfAborted(); + await history.loadMore(); + signal.throwIfAborted(); + if ( + !mountedRef.current || + generation !== preparationGenerationRef.current || + routeRef.current !== status + ) + return; + const next: HistorySurfaceRoute = { + kind: "history", + runtime, + controller: history, + returnRoute: status, + }; + routeRef.current = next; + setRoute(next); + history = undefined; + } else { + const action = await status.runtime.planReview(snapshot, outcome.actionId, signal); + signal.throwIfAborted(); + // An explicit visible untracked row overrides the launch exclusion only for this full + // comparison; the effective input also survives AppHost manual/watch reloads. + const input = + outcome.filePath && + snapshot.paths.some( + (path) => path.path === outcome.filePath && path.worktree === "untracked", + ) + ? { ...action.input, options: { ...action.input.options, excludeUntracked: false } } + : action.input; + const bootstrap = await status.runtime.prepareReview(input, action.cwd, signal); + if (bootstrap.extensions?.registry !== status.runtime.extensionSession.current.registry) { + if (bootstrap.extensions) { + status.runtime.extensionSession.trackPrepared(bootstrap.extensions); + await status.runtime.extensionSession.retirePrepared(bootstrap.extensions); + } + throw new Error("An embedded review cannot replace the owning extension session."); + } + signal.throwIfAborted(); + if ( + !mountedRef.current || + generation !== preparationGenerationRef.current || + routeRef.current !== status + ) + return; + const reviewBootstrap = applySessionViewPreferences(bootstrap, { + ...sessionViewPreferencesRef.current, + theme: themeController.getSnapshot().themeId, + }); + const next: ActiveReviewSurfaceRoute = { + kind: "review", + instanceId: nextInstanceRef.current++, + bootstrap: reviewBootstrap, + runtime: createReviewRuntime(reviewBootstrap, action.cwd), + extensionSession: status.runtime.extensionSession, + extensionOwnership: "borrowed", + quitBehavior: "return-to-caller", + mountMode: "dynamic", + returnRoute: status, + initialFilePath: outcome.filePath, + }; + routeRef.current = next; + setRoute(next); + } + } catch (error) { + if (!signal.aborted) throw error; + } finally { + signal.removeEventListener("abort", cancelHistory); + try { + cancelHistory(); + await historyClosing; + } finally { + // Cleanup errors must never strand a cancelling caller or global shutdown. + if (preparationControllerRef.current === abort) preparationControllerRef.current = null; + preparingRef.current = false; + if (preparationSettlementRef.current === settlement) + preparationSettlementRef.current = null; + settle(); + if (shutdownPendingRef.current && routeRef.current.kind !== "review") completeQuit(); + else if (mountedRef.current && routeRef.current === status && !externalQuitSignal.aborted) + status.controller.resume(); + } + } + }; + useEffect(() => { const requestExternalQuit = () => requestQuit(); if (externalQuitSignal.aborted) requestExternalQuit(); @@ -380,9 +587,17 @@ export function HunkSessionHost({ ); const current = routeRef.current; if (current.kind === "review") stopReviewRuntime(current.runtime); + const history = + current.kind === "history" + ? current + : current.kind === "review" && current.returnRoute?.kind === "history" + ? current.returnRoute + : undefined; + if (history?.returnRoute) + void closeStatusHistory(history.controller, history.returnRoute.controller); for (const runtime of failedReviewStopsRef.current) stopReviewRuntime(runtime); }, - [stopReviewRuntime], + [stopReviewRuntime, closeStatusHistory], ); if (route.kind === "review") { @@ -393,11 +608,18 @@ export function HunkSessionHost({ externalQuitSignal={externalQuitSignal} hostClient={route.runtime.hostClient} onQuit={retireReview} - {...(route.quitBehavior === "return-to-history" + {...(route.quitBehavior === "return-to-caller" ? { onViewPreferencesChange: retainSessionViewPreferences } : {})} {...(route.mountMode === "dynamic" ? { onFirstFrameReady: () => undefined } : {})} - returnToHistory={route.quitBehavior === "return-to-history"} + returnToSurface={route.returnRoute?.kind} + initialFilePath={route.initialFilePath} + sessionCustomThemes={ + route.returnRoute?.kind === "status" || + (route.returnRoute?.kind === "history" && route.returnRoute.returnRoute) + ? initialization.theme.customThemes + : undefined + } extensionSession={route.extensionSession} extensionOwnership={route.extensionOwnership} onRequestSessionShutdown={ @@ -412,10 +634,24 @@ export function HunkSessionHost({ ); } + if (route.kind === "status") + return ( + handleStatusOutcome(route, outcome)} + quitScheduler={deps.viewPreferenceQuitScheduler} + /> + ); + return ( handleHistoryOutcome(route, outcome)} quitScheduler={deps.viewPreferenceQuitScheduler} diff --git a/packages/hunk/src/ui/status/StatusApp.tsx b/packages/hunk/src/ui/status/StatusApp.tsx new file mode 100644 index 000000000..b760f8b09 --- /dev/null +++ b/packages/hunk/src/ui/status/StatusApp.tsx @@ -0,0 +1,528 @@ +import { useKeyboard, useTerminalDimensions } from "@opentui/react"; +import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import type { PersistedViewPreferences } from "../../core/run/config"; +import { APP_COMMAND_NAMES } from "../../core/run/commandCatalog"; +import { HISTORY_COMMAND_CATALOG } from "../../core/run/historyCommandCatalog"; +import { resolveExtensionCommands, resolveExtensionSessionOptions } from "../../extensions/apply"; +import { MenuBar } from "../components/chrome/MenuBar"; +import { MenuDropdown } from "../components/chrome/MenuDropdown"; +import type { AppMenus, MenuEntry } from "../components/chrome/menu"; +import { ThemeSelectorDialog } from "../components/chrome/ThemeSelectorDialog"; +import { HelpDialog } from "../components/chrome/HelpDialog"; +import { ViewPreferenceQuitDialog } from "../components/chrome/ViewPreferenceQuitDialog"; +import { + useViewPreferenceQuitController, + type ViewPreferenceQuitScheduler, +} from "../hooks/useViewPreferenceQuitController"; +import { useMenuController } from "../hooks/useMenuController"; +import { useThemeSelectorController } from "../hooks/useThemeSelectorController"; +import { dispatchAppCommand, executeAppCommand } from "../lib/appCommands"; +import { resolveCommandKeys } from "../lib/keymap"; +import { fitText } from "../lib/text"; +import { handleViewPreferenceQuitPromptKey } from "../lib/viewPreferenceQuitKeys"; +import { interactiveLogUsesColor, monochromeLogTheme } from "../log/colorPolicy"; +import type { ThemeController } from "../theme/controller"; +import { STATUS_COMMANDS, buildStatusCommands, type StatusCommandId } from "./commands"; +import type { StatusController } from "./controller"; +import { moveStatusFocus, planStatusViewport, projectStatusRows } from "./geometry"; +import { formatStatusUpstream, statusDisplayText, statusTextColor } from "./staticProjection"; +import type { StatusRuntime } from "./types"; + +export type StatusOutcome = + | { kind: "quit" } + | { kind: "cancel-prepare" } + | { kind: "open-log" } + | { kind: "open-review"; actionId: string; filePath?: string }; + +/** Render one workspace compass with sibling inspection and normal Hunk menus, never a file inspector. */ +export function StatusApp({ + controller, + runtime, + themeController, + sessionViewPreferences, + onOutcome, + quitScheduler, +}: { + controller: StatusController; + runtime: StatusRuntime; + themeController: ThemeController; + sessionViewPreferences: PersistedViewPreferences; + onOutcome: (outcome: StatusOutcome) => void | Promise; + quitScheduler?: ViewPreferenceQuitScheduler; +}) { + const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot); + const terminal = useTerminalDimensions(); + const [showHelp, setShowHelp] = useState(false); + const [pending, setPending] = useState(false); + const pendingRef = useRef(false); + const lastClick = useRef({ id: "", at: 0 }); + const themeSelector = useThemeSelectorController({ + themeController, + transparentBackground: runtime.launchOptions.transparentBackground ?? false, + onTransientNotice: (notice) => controller.setNotice(notice), + }); + const useColor = interactiveLogUsesColor(runtime.input.color, process.env); + const theme = useColor + ? themeSelector.activeTheme + : monochromeLogTheme(themeSelector.activeTheme, themeController.themeMode ?? "dark"); + const chromeTheme = useColor + ? themeSelector.baseTheme + : monochromeLogTheme(themeSelector.baseTheme, themeController.themeMode ?? "dark"); + const preferences = useMemo( + () => ({ ...sessionViewPreferences, theme: themeSelector.themeId }), + [sessionViewPreferences, themeSelector.themeId], + ); + const quit = useViewPreferenceQuitController({ + currentPreferences: preferences, + initialPreferences: { + ...runtime.initialViewPreferences, + theme: themeController.initialThemeId, + }, + configPath: runtime.viewPreferencesConfigPath, + pagerMode: false, + promptSaveViewPreferences: runtime.promptSaveViewPreferences, + transientViewPreferences: resolveExtensionSessionOptions( + runtime.extensionSession.current.registry, + ).transientViewPreferences, + onQuit: () => { + void onOutcome({ kind: "quit" }); + }, + showNotice: (notice) => controller.setNotice(notice), + showError: (notice) => controller.setNotice(notice), + closeHelp: () => setShowHelp(false), + homeDirectory: process.env.HOME, + quitScheduler, + }); + const snapshot = state.snapshot; + const rows = useMemo(() => projectStatusRows(state, terminal.width), [state, terminal.width]); + const navigable = rows.filter((row) => row.kind !== "heading"); + const selected = rows.find((row) => row.id === state.selected); + const hasAction = (id: string) => snapshot.reviewActions.some((action) => action.id === id); + const open = async (outcome: StatusOutcome) => { + if (pendingRef.current) return; + pendingRef.current = true; + setPending(true); + try { + await onOutcome(outcome); + } catch (error) { + controller.setNotice(error instanceof Error ? error.message : String(error)); + } finally { + pendingRef.current = false; + setPending(false); + } + }; + const inspect = async (path: string) => { + if (pendingRef.current) return; + pendingRef.current = true; + setPending(true); + try { + await controller.inspect(path); + } finally { + pendingRef.current = false; + setPending(false); + } + }; + const openSelection = () => { + const current = controller.getSnapshot(); + const visible = projectStatusRows(current, terminal.width).find( + (row) => row.id === current.selected, + ); + if (!visible || visible.disabled) return; + if (visible.group) { + controller.togglePathGroup(visible.group); + return; + } + const id = visible.id; + if (id?.startsWith("worktree:") && snapshot.siblings.state === "ready") { + const sibling = snapshot.siblings.value.worktrees.find( + (row) => `worktree:${row.worktree.id}` === id, + ); + if (sibling?.inspectable) void inspect(sibling.worktree.path); + } else if (id?.startsWith("path:")) { + const path = snapshot.paths.find((path) => `path:${path.path}` === id); + if (!path) return; + const action = + path?.worktree !== "unchanged" && hasAction("unstaged") + ? "unstaged" + : hasAction("staged") + ? "staged" + : snapshot.reviewActions[0]?.id; + if (action) void open({ kind: "open-review", actionId: action, filePath: path?.path }); + } + }; + const move = (delta: number) => { + const current = controller.getSnapshot(); + const next = moveStatusFocus(rows, current.selected, current.top, bodyHeight, delta); + controller.select(next.selected, next.top); + }; + const requestBack = async () => { + if (pendingRef.current) { + await onOutcome({ kind: "cancel-prepare" }); + await controller.suspend(); + controller.resume(); + return; + } + if (state.backPath) await controller.back(); + else quit.requestQuit(); + }; + const keymap = useMemo( + () => + resolveCommandKeys({ + defaults: STATUS_COMMANDS, + inactiveCommandNames: new Set([ + ...APP_COMMAND_NAMES, + ...HISTORY_COMMAND_CATALOG.map((entry) => entry.id), + ...resolveExtensionCommands(runtime.extensionSession.current.registry).commands.map( + (entry) => `${entry.extensionId}.${entry.command.id}`, + ), + ]), + userBindings: runtime.keybindings, + }), + [runtime], + ); + useEffect(() => { + if (keymap.issues.length) + controller.setNotice(keymap.issues.map((issue) => issue.message).join(" · ")); + }, [controller, keymap]); + const commands = buildStatusCommands( + { + "hunk.status.openSelection": openSelection, + "hunk.status.togglePathGroup": () => { + if (selected?.group) controller.togglePathGroup(selected.group); + }, + "hunk.status.reviewStaged": () => { + void open({ kind: "open-review", actionId: "staged" }); + }, + "hunk.status.reviewUnstaged": () => { + void open({ kind: "open-review", actionId: "unstaged" }); + }, + "hunk.status.openLog": () => { + void open({ kind: "open-log" }); + }, + "hunk.status.refresh": () => { + void controller.refresh(); + }, + "hunk.status.back": () => { + void requestBack(); + }, + "hunk.status.previousRow": () => move(-1), + "hunk.status.nextRow": () => move(1), + "hunk.status.pageUp": () => move(-Math.max(1, terminal.height - 7)), + "hunk.status.pageDown": () => move(Math.max(1, terminal.height - 7)), + "hunk.status.toggleWorktrees": () => controller.toggleWorktrees(), + "hunk.view.openThemeSelector": themeSelector.openThemeSelector, + "hunk.app.toggleHelp": () => setShowHelp(true), + "hunk.app.quit": () => { + void requestBack(); + }, + }, + keymap.keys, + (id) => { + if (id === "hunk.app.quit" || id === "hunk.status.back") return true; + if (pending) return false; + if (id === "hunk.status.reviewStaged") return hasAction("staged"); + if (id === "hunk.status.reviewUnstaged") return hasAction("unstaged"); + if (id === "hunk.status.togglePathGroup") return Boolean(selected?.group); + if (id === "hunk.status.openSelection") + return Boolean( + selected && + !selected.disabled && + (selected.kind === "toggle" || + selected.kind === "worktree" || + snapshot.reviewActions.length), + ); + return true; + }, + ); + const commandItem = (id: StatusCommandId): Extract => { + const command = commands.find((command) => command.id === id)!; + return { + kind: "item", + commandId: id, + label: + id === "hunk.app.quit" && state.backPath ? "Back to originating worktree" : command.title, + hint: command.keyLabels.join(" / "), + disabled: !command.isEnabled?.(), + action: () => { + executeAppCommand(commands, id); + }, + }; + }; + const menus: AppMenus = { + file: [ + commandItem("hunk.status.openSelection"), + ...snapshot.reviewActions.map((action) => ({ + kind: "item" as const, + label: statusDisplayText(action.label), + disabled: pending, + action: () => { + void open({ kind: "open-review", actionId: action.id }); + }, + })), + commandItem("hunk.status.openLog"), + commandItem("hunk.status.refresh"), + commandItem("hunk.app.quit"), + ], + view: [commandItem("hunk.view.openThemeSelector"), commandItem("hunk.status.toggleWorktrees")], + navigate: [ + commandItem("hunk.status.previousRow"), + commandItem("hunk.status.nextRow"), + commandItem("hunk.status.back"), + ], + help: [commandItem("hunk.app.toggleHelp")], + }; + const menu = useMenuController(menus); + useEffect(() => { + controller.resume(); + return () => { + void controller.suspend(); + }; + }, [controller]); + const operation = + snapshot.operations.state === "ready" + ? snapshot.operations.value.join(" · ") + : "Operation state unavailable"; + const conflicts = snapshot.paths.filter((path) => path.conflict).length; + const interruption = [ + operation, + conflicts ? `${conflicts} conflicted path${conflicts === 1 ? "" : "s"}` : "", + ] + .filter(Boolean) + .join(" · "); + const menuVisible = preferences.showMenuBar || Boolean(menu.activeMenuId); + const headerHeight = (menuVisible ? 1 : 0) + 3 + (interruption ? 1 : 0); + const bodyHeight = Math.max(1, terminal.height - headerHeight - 1); + const viewport = planStatusViewport(rows, state.selected, state.top, bodyHeight); + useEffect(() => { + const chosen = navigable.some((row) => row.id === state.selected) + ? state.selected + : (navigable[0]?.id ?? null); + if (chosen !== state.selected || viewport.top !== state.top) + controller.select(chosen, viewport.top); + }, [controller, navigable, state.selected, state.top, viewport.top]); + useKeyboard((key) => { + const consume = () => { + key.preventDefault(); + key.stopPropagation(); + }; + if (quit.saveConfigPromptOpen) { + handleViewPreferenceQuitPromptKey(key, quit); + consume(); + return; + } + if (themeSelector.themeSelectorOpen) { + if (key.name === "escape") themeSelector.closeThemeSelector(); + else if (key.name === "up") themeSelector.moveThemeSelector(-1); + else if (key.name === "down" || key.name === "tab") + themeSelector.moveThemeSelector(key.shift ? -1 : 1); + else if (key.name === "return" || key.name === "enter") themeSelector.acceptThemeSelector(); + consume(); + return; + } + if (showHelp) { + if (key.name === "escape" || key.name === "q") setShowHelp(false); + consume(); + return; + } + if (menu.activeMenuId) { + if (key.name === "escape") menu.closeMenu(); + else if (key.name === "left") menu.switchMenu(-1); + else if (key.name === "right" || key.name === "tab") menu.switchMenu(1); + else if (key.name === "up") menu.moveMenuItem(-1); + else if (key.name === "down") menu.moveMenuItem(1); + else if (key.name === "return" || key.name === "enter") menu.activateCurrentMenuItem(); + else if (dispatchAppCommand(commands, key)) menu.closeMenu(); + consume(); + return; + } + if (key.name === "f10") { + menu.openMenu("file"); + consume(); + return; + } + if (dispatchAppCommand(commands, key)) consume(); + }); + const head = + snapshot.head.kind === "detached" + ? `detached ${snapshot.head.revisionId.slice(0, 12)}` + : `${snapshot.head.name}${snapshot.head.kind === "unborn" ? " (unborn)" : ""}`; + const attention = + snapshot.siblings.state === "ready" + ? `Other worktrees: ${snapshot.siblings.value.worktrees.length}${snapshot.siblings.value.worktrees.some((row) => row.status.state !== "ready" || row.status.changedPathCount || row.status.operations.state !== "ready" || row.status.operations.value.length) ? " · attention" : ""}` + : `Other worktrees: ${snapshot.siblings.state}`; + return ( + + {menuVisible ? ( + { + if (menu.activeMenuId) menu.openMenu(id); + }} + onToggleMenu={menu.toggleMenu} + /> + ) : null} + + {fitText(statusDisplayText(`${head} · ${snapshot.worktree.path}`), terminal.width)} + + + {fitText(formatStatusUpstream(snapshot.upstream), terminal.width)} + + {interruption ? {fitText(interruption, terminal.width)} : null} + + {snapshot.reviewActions.map((action) => ( + { + if (!pending) void open({ kind: "open-review", actionId: action.id }); + }} + > + {statusDisplayText( + terminal.width < 65 && (action.id === "staged" || action.id === "unstaged") + ? action.id === "staged" + ? "Staged" + : "Unstaged" + : action.label, + )} + + ))} + {state.backPath ? ( + { + void requestBack(); + }} + > + Back + + ) : null} + + { + if (!pending) move(event.scroll?.direction === "up" ? -3 : 3); + }} + > + {viewport.lines.map(({ row, spans, offset }) => ( + { + menu.closeMenu(); + if (pending) return; + if (row.id === "more" || row.id === "worktrees") { + controller.toggleWorktrees(); + return; + } + if (row.group) { + controller.togglePathGroup(row.group); + return; + } + if (row.kind === "heading") return; + controller.select(row.id); + const now = Date.now(); + if ( + lastClick.current.id === row.id && + now - lastClick.current.at < 400 && + !row.disabled + ) + openSelection(); + lastClick.current = { id: row.id, at: now }; + }} + > + {spans.map((span, index) => ( + + {span.text} + + ))} + + ))} + + + {fitText( + statusDisplayText( + pending + ? "Preparing… · Q cancel" + : state.notice || + `${state.loading || state.siblingsLoading ? "Refreshing… · " : ""}${attention} · F10 menu`, + ), + terminal.width, + )} + + {menu.activeMenuId && menu.activeMenuSpec ? ( + { + if (!entry.disabled) entry.action(); + menu.closeMenu(); + }} + /> + ) : null} + {themeSelector.themeSelectorOpen ? ( + + ) : null} + {showHelp ? ( + command.keyLabels.length) + .map((command) => ({ + keys: command.keyLabels.join(" / "), + description: command.title, + })), + }, + ]} + terminalHeight={terminal.height} + terminalWidth={terminal.width} + theme={chromeTheme} + onClose={() => setShowHelp(false)} + /> + ) : null} + {quit.saveConfigPromptOpen ? ( + + ) : null} + + ); +} diff --git a/packages/hunk/src/ui/status/commands.ts b/packages/hunk/src/ui/status/commands.ts new file mode 100644 index 000000000..d08e4ea11 --- /dev/null +++ b/packages/hunk/src/ui/status/commands.ts @@ -0,0 +1,28 @@ +import { STATUS_COMMAND_CATALOG, type StatusCommandId } from "../../core/run/statusCommandCatalog"; +export { + STATUS_COMMAND_CATALOG as STATUS_COMMANDS, + type StatusCommandId, +} from "../../core/run/statusCommandCatalog"; +import { matchesAnyKeyChord } from "../../lib/commandKeys"; +import type { AppCommand, ResolvedCommandKeys } from "../lib/appCommands"; +import { formatKeyChord } from "../lib/keymap"; + +/** Bind the same effective chords and availability to menus, help and keyboard dispatch. */ +export function buildStatusCommands( + handlers: Record void>, + keys: ResolvedCommandKeys, + enabled: (id: StatusCommandId) => boolean, +): AppCommand[] { + return STATUS_COMMAND_CATALOG.map((entry) => { + const resolved = keys.get(entry.id) ?? entry.defaultKeys; + return { + ...entry, + keys: resolved, + keyLabels: resolved.map(formatKeyChord), + publicToExtensions: false, + isEnabled: () => enabled(entry.id), + match: matchesAnyKeyChord(resolved), + run: handlers[entry.id], + }; + }); +} diff --git a/packages/hunk/src/ui/status/controller.test.ts b/packages/hunk/src/ui/status/controller.test.ts new file mode 100644 index 000000000..cd1b627a0 --- /dev/null +++ b/packages/hunk/src/ui/status/controller.test.ts @@ -0,0 +1,319 @@ +import { expect, mock, test } from "bun:test"; +import { createTestStatusRuntime } from "../../../../../test/helpers/status-runtime"; +import { StatusController } from "./controller"; +import { createWatchController } from "../../core/watch/controller"; + +/** Hold one async provider response until a test explicitly releases it. */ +function createTestDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +test("current refresh remains independent while same-target siblings progress and merge only their facts", async () => { + const runtime = createTestStatusRuntime(); + const first = createTestDeferred(); + const second = createTestDeferred(); + const signals: AbortSignal[] = []; + runtime.loadSiblings = mock(async (_snapshot, signal) => { + signals.push(signal!); + return signals.length === 1 ? first.promise : second.promise; + }); + const controller = new StatusController(runtime); + controller.select("path:beta.ts", 1); + controller.resume(); + await Bun.sleep(0); + expect(signals).toHaveLength(1); + runtime.load = async () => ({ + ...runtime.snapshot, + changedPathCount: 3, + paths: [...runtime.snapshot.paths].reverse(), + }); + await controller.refresh(); + expect(signals[0]!.aborted).toBe(false); + expect(signals).toHaveLength(1); + expect(controller.getSnapshot().snapshot.changedPathCount).toBe(3); + expect(controller.getSnapshot().siblingsLoading).toBe(true); + expect(controller.getSnapshot().selected).toBe("path:beta.ts"); + expect(controller.getSnapshot().snapshot.paths.map((path) => path.path)).toEqual([ + "alpha.ts", + "beta.ts", + ]); + first.resolve({ + ...runtime.snapshot, + changedPathCount: 999, + siblings: { state: "error", message: "first scan" }, + }); + await Bun.sleep(0); + expect(controller.getSnapshot().snapshot.changedPathCount).toBe(3); + expect(controller.getSnapshot().snapshot.siblings).toEqual({ + state: "error", + message: "first scan", + }); + await controller.refresh(); + expect(signals).toHaveLength(2); + second.resolve({ + ...runtime.snapshot, + changedPathCount: 888, + siblings: { state: "error", message: "current sibling error" }, + }); + await Bun.sleep(0); + expect(controller.getSnapshot().snapshot.changedPathCount).toBe(3); + expect(controller.getSnapshot().snapshot.siblings).toEqual({ + state: "error", + message: "current sibling error", + }); + expect(controller.getSnapshot().siblingsLoading).toBe(false); + await controller.close(); +}); + +test("suspension drains cancelled sibling scans and never publishes after target change or quit", async () => { + const runtime = createTestStatusRuntime(); + const late = createTestDeferred(); + let signal: AbortSignal | undefined; + runtime.loadSiblings = async (_snapshot, active) => { + signal = active; + return late.promise; + }; + const controller = new StatusController(runtime); + controller.resume(); + await Bun.sleep(0); + let drained = false; + const inspecting = controller.inspect("sibling-target").then(() => { + drained = true; + }); + await Bun.sleep(0); + expect(signal!.aborted).toBe(true); + expect(drained).toBe(false); + runtime.loadSiblings = async (snapshot) => snapshot; + late.resolve({ ...runtime.snapshot, changedPathCount: 999 }); + await inspecting; + await controller.refresh(); + expect(controller.getSnapshot().snapshot.worktree.path).toBe("sibling-target"); + expect(controller.getSnapshot().snapshot.changedPathCount).toBe(2); + await controller.close(); + expect(controller.getSnapshot().snapshot.changedPathCount).toBe(2); +}); + +test("inspects and returns within retained status navigation without changing cwd or extension authority", async () => { + const runtime = createTestStatusRuntime(); + const controller = new StatusController(runtime); + const cwd = process.cwd(); + const authority = runtime.extensionSession.current; + controller.resume(); + await controller.refresh(); + controller.select("path:beta.ts", 2); + controller.toggleWorktrees(); + await controller.inspect("sibling-target"); + expect(controller.getSnapshot().snapshot.worktree.path).toBe("sibling-target"); + expect(controller.getSnapshot().backPath).toBe(runtime.snapshot.worktree.path); + await controller.back(); + await controller.refresh(); + expect(controller.getSnapshot()).toMatchObject({ + selected: "path:beta.ts", + expandedWorktrees: true, + top: 2, + }); + expect(process.cwd()).toBe(cwd); + expect(runtime.extensionSession.current).toBe(authority); + await controller.close(); +}); + +test("suspends and closes watchers, cancels stale reads and never publishes after shutdown", async () => { + const runtime = createTestStatusRuntime(); + const late = createTestDeferred(); + runtime.watchPlan = async () => ({ coverage: "hybrid", targets: [] }); + const close = mock(() => undefined); + const controller = new StatusController(runtime, { + createObserver: (_plan, callbacks) => { + callbacks.onReady?.(); + return { close, ready: Promise.resolve(), closed: Promise.resolve() }; + }, + }); + controller.resume(); + await controller.refresh(); + await Bun.sleep(0); + runtime.load = async () => late.promise; + void controller.refresh(); + const before = controller.getSnapshot().snapshot; + const closing = controller.close(); + late.resolve({ ...runtime.snapshot, changedPathCount: 999 }); + await closing; + expect(close).toHaveBeenCalledTimes(1); + expect(controller.getSnapshot().snapshot).toBe(before); + controller.resume(); + await controller.refresh(); + expect(controller.getSnapshot().snapshot).toBe(before); +}); + +test("keeps failed target inspection and refresh usable with explicit stale errors", async () => { + const runtime = createTestStatusRuntime(); + const controller = new StatusController(runtime); + controller.resume(); + await controller.refresh(); + runtime.load = async () => { + throw new Error("Worktree removed"); + }; + await controller.inspect("removed"); + await controller.refresh(); + expect(controller.getSnapshot().snapshot.worktree.path).toBe(runtime.snapshot.worktree.path); + expect(controller.getSnapshot().notice).toContain("Worktree removed"); + expect(controller.getSnapshot().loading).toBe(false); + await controller.close(); +}); + +test("burst current refreshes coalesce independently and quit drains a cancelled secondary scan", async () => { + const runtime = createTestStatusRuntime(); + const current = createTestDeferred(); + const sibling = createTestDeferred(); + let reads = 0; + let siblingSignal: AbortSignal | undefined; + runtime.load = async () => (++reads === 1 ? current.promise : runtime.snapshot); + runtime.loadSiblings = mock(async (_snapshot, signal) => { + siblingSignal = signal; + return sibling.promise; + }); + const controller = new StatusController(runtime); + controller.resume(); + const refresh = controller.refresh(); + void controller.refresh(); + current.resolve(runtime.snapshot); + await refresh; + expect(reads).toBe(2); + expect(runtime.loadSiblings).toHaveBeenCalledTimes(1); + const before = controller.getSnapshot().snapshot; + let closed = false; + const closing = controller.close().then(() => { + closed = true; + }); + await Bun.sleep(0); + expect(siblingSignal!.aborted).toBe(true); + expect(closed).toBe(false); + sibling.resolve({ ...runtime.snapshot, changedPathCount: 999 }); + await closing; + expect(controller.getSnapshot().snapshot).toBe(before); +}); + +for (const coverage of ["poll-only", "hybrid"] as const) { + test(`real ${coverage} safety timers publish slow siblings repeatedly despite changing observation tokens`, async () => { + const runtime = createTestStatusRuntime(); + let reads = 0; + let scans = 0; + let completed = 0; + let cancelled = 0; + if (runtime.snapshot.siblings.state !== "ready") throw new Error("Missing test sibling"); + const sibling = runtime.snapshot.siblings.value.worktrees[0]!; + runtime.watchPlan = async () => ({ coverage, targets: [] }); + runtime.load = async () => ({ + ...runtime.snapshot, + token: `current-${++reads}`, + observedAt: new Date().toISOString(), + siblings: { state: "loading" }, + }); + runtime.loadSiblings = async (snapshot, signal) => { + const scan = ++scans; + await new Promise((resolve, reject) => { + const abort = () => { + cancelled++; + clearTimeout(timer); + reject(signal!.reason); + }; + const timer = setTimeout(() => { + signal?.removeEventListener("abort", abort); + resolve(); + }, 140); + signal?.addEventListener("abort", abort, { once: true }); + }); + completed++; + return { + ...snapshot, + siblings: { + state: "ready", + value: { + worktrees: [ + { + ...sibling, + status: { state: "error", message: `scan-${scan}` }, + }, + ], + truncated: false, + }, + }, + }; + }; + const controller = new StatusController(runtime, { + createWatch: (options) => + createWatchController({ ...options, healthyCheckMs: 20, degradedCheckMs: 20 }), + createObserver: (_plan, callbacks) => { + callbacks.onReady?.(); + return { close() {}, ready: Promise.resolve(), closed: Promise.resolve() }; + }, + }); + try { + controller.resume(); + const deadline = Date.now() + 2500; + while (completed < 2 && Date.now() < deadline) await Bun.sleep(10); + expect(completed).toBeGreaterThanOrEqual(2); + expect(reads).toBeGreaterThanOrEqual(4); + expect(cancelled).toBe(0); + const snapshot = controller.getSnapshot().snapshot; + expect(snapshot.token).toBe(`current-${reads}`); + expect(snapshot.siblings.state).toBe("ready"); + if (snapshot.siblings.state === "ready") + expect(snapshot.siblings.value.worktrees[0]!.status).toEqual({ + state: "error", + message: "scan-2", + }); + expect(scans).toBeLessThanOrEqual(completed + 1); + } finally { + await controller.close(); + } + const finalReads = reads; + const finalScans = scans; + await Bun.sleep(50); + expect(reads).toBe(finalReads); + expect(scans).toBe(finalScans); + }); +} + +test("independent group expansion survives refresh, child suspension and sibling inspect/back", async () => { + const runtime = createTestStatusRuntime(); + runtime.snapshot.paths = Array.from({ length: 25 }, (_, i) => ({ + path: `file-${i}`, + index: "unchanged", + worktree: "modified", + conflict: false, + })); + const controller = new StatusController(runtime); + try { + controller.resume(); + await controller.refresh(); + controller.togglePathGroup("tracked"); + controller.select("path:file-24", 20); + await controller.refresh(); + await controller.suspend(); + controller.resume(); + await controller.refresh(); + expect(controller.getSnapshot()).toMatchObject({ + expandedPathGroups: { tracked: true, untracked: false }, + selected: "path:file-24", + top: 20, + }); + await controller.inspect("sibling-target"); + controller.togglePathGroup("untracked"); + controller.togglePathGroup("tracked"); + await controller.back(); + await controller.refresh(); + expect(controller.getSnapshot()).toMatchObject({ + expandedPathGroups: { tracked: true, untracked: false }, + selected: "path:file-24", + top: 20, + }); + controller.togglePathGroup("tracked"); + expect(controller.getSnapshot().selected).toBe("toggle:tracked"); + } finally { + await controller.close(); + } +}); diff --git a/packages/hunk/src/ui/status/controller.ts b/packages/hunk/src/ui/status/controller.ts new file mode 100644 index 000000000..8031cbbef --- /dev/null +++ b/packages/hunk/src/ui/status/controller.ts @@ -0,0 +1,336 @@ +import { createWatchController, type WatchController } from "../../core/watch/controller"; +import { createWatchObserver, type WatchObserver } from "../../core/watch/observer"; +import { assertReliableWatchRuntime } from "../../core/watch/runtime"; +import type { ExtensionVcsStatusSnapshot } from "../../extension-api/types"; +import type { StatusRuntime } from "./types"; +import { reconcileStatusPathSelection, type StatusPathGroupId } from "./pathGroups"; + +/** Preserve surviving row order and append newly observed identities after them. */ +function retainStatusOrder( + previous: readonly T[], + next: readonly T[], + identity: (row: T) => string, +) { + const remaining = new Map(next.map((row) => [identity(row), row])); + const retained = previous.flatMap((row) => { + const replacement = remaining.get(identity(row)); + remaining.delete(identity(row)); + return replacement ? [replacement] : []; + }); + return [...retained, ...remaining.values()]; +} + +export interface StatusState { + snapshot: ExtensionVcsStatusSnapshot; + selected: string | null; + top: number; + expandedWorktrees: boolean; + expandedPathGroups: Record; + loading: boolean; + siblingsLoading: boolean; + notice: string; + backPath?: string; +} + +/** Retain navigation while edits, refresh and sibling inspection rebuild read-only status facts. + * Suspend reads and watchers during routed log/review visits; the host owns those surfaces and + * extension authority. Publish current facts before bounded sibling scans, never late generations. + */ +export class StatusController { + private state: StatusState; + private listeners = new Set<() => void>(); + private active = false; + private closed = false; + private closing?: Promise; + private generation = 0; + private abort = new AbortController(); + private pending?: Promise; + private queued = false; + private siblingGeneration = 0; + private siblingAbort?: AbortController; + private siblingTasks = new Set>(); + private watch?: WatchController; + private observer?: WatchObserver; + private watcherClosing: Promise = Promise.resolve(); + private watchTask?: Promise; + private retained: StatusState[] = []; + + constructor( + readonly runtime: StatusRuntime, + private readonly deps: { + createObserver?: typeof createWatchObserver; + createWatch?: typeof createWatchController; + } = {}, + ) { + this.state = { + snapshot: runtime.snapshot, + selected: runtime.snapshot.paths[0] ? `path:${runtime.snapshot.paths[0].path}` : null, + top: 0, + expandedWorktrees: false, + expandedPathGroups: { tracked: false, untracked: false }, + loading: false, + siblingsLoading: false, + notice: runtime.notices.join(" · "), + }; + } + /** Return a stable observable snapshot. */ + getSnapshot = () => this.state; + /** Subscribe the mounted status component. */ + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + private publish(update: Partial) { + this.state = { ...this.state, ...update }; + for (const listener of this.listeners) listener(); + } + /** Keep provider errors visible without replacing usable prior facts. */ + setNotice(notice: string) { + this.publish({ notice }); + } + /** Retain focused row and viewport independently of provider ordering. */ + select(selected: string | null, top = this.state.top) { + this.publish({ selected, top }); + } + /** Expand the bounded worktree list in this same surface. */ + toggleWorktrees() { + this.publish({ expandedWorktrees: !this.state.expandedWorktrees }); + } + /** Toggle one local file group, moving hidden focus to its action instead of opening invisible files. */ + togglePathGroup(group: StatusPathGroupId) { + const expandedPathGroups = { + ...this.state.expandedPathGroups, + [group]: !this.state.expandedPathGroups[group], + }; + this.publish({ + expandedPathGroups, + selected: reconcileStatusPathSelection( + this.state.snapshot.paths, + expandedPathGroups, + this.state.selected, + ), + }); + } + /** Resume observations after mount or return from a child surface. */ + resume() { + if (this.closed || this.active) return; + assertReliableWatchRuntime(Bun.version); + this.active = true; + this.abort = new AbortController(); + void this.refresh(); + } + /** Stop observation synchronously and drain cancelled reads before changing targets. */ + async suspend() { + this.active = false; + this.generation++; + this.abort.abort(); + this.cancelSiblings(); + this.queued = false; + const watch = this.watch; + watch?.close(); + this.watch = undefined; + const observer = this.observer; + this.observer = undefined; + if (!watch) observer?.close(); + this.watcherClosing = Promise.all([this.watcherClosing, observer?.closed]).then( + () => undefined, + ); + await Promise.all([this.pending, this.watcherClosing, this.watchTask, ...this.siblingTasks]); + } + /** Invalidate secondary observations when suspending a target, retaining tasks until drained. */ + private cancelSiblings() { + this.siblingGeneration++; + this.siblingAbort?.abort(); + this.siblingAbort = undefined; + } + /** Refresh only sibling facts; late scans cannot replace a newer current observation. */ + private scanSiblings(snapshot: ExtensionVcsStatusSnapshot) { + const generation = this.siblingGeneration; + const abort = new AbortController(); + this.siblingAbort = abort; + const signal = AbortSignal.any([abort.signal, this.abort.signal]); + const current = () => this.active && !signal.aborted && generation === this.siblingGeneration; + this.publish({ siblingsLoading: true }); + const task = (async () => { + try { + const full = await this.runtime.loadSiblings(snapshot, signal); + if (!current()) return; + if (full.siblings.state === "ready" && this.state.snapshot.siblings.state === "ready") { + full.siblings.value.worktrees = retainStatusOrder( + this.state.snapshot.siblings.value.worktrees, + full.siblings.value.worktrees, + (row) => row.worktree.id, + ); + } + this.publish({ snapshot: { ...this.state.snapshot, siblings: full.siblings } }); + } catch (error) { + if (current()) + this.publish({ + snapshot: { + ...this.state.snapshot, + siblings: { + state: "error", + message: error instanceof Error ? error.message : String(error), + }, + }, + }); + } finally { + if (current()) { + this.siblingAbort = undefined; + this.publish({ siblingsLoading: false }); + } + } + })(); + this.siblingTasks.add(task); + void task.finally(() => this.siblingTasks.delete(task)); + } + /** Coalesce current reads independently of cancellable secondary sibling scans. */ + refresh = (): Promise => { + if (!this.active || this.closed) return Promise.resolve(); + if (this.pending) { + this.queued = true; + return this.pending; + } + const generation = this.generation; + const signal = this.abort.signal; + const current = () => this.active && !signal.aborted && generation === this.generation; + this.pending = (async () => { + do { + this.queued = false; + this.publish({ loading: true }); + try { + const snapshot = await this.runtime.load(this.state.snapshot.worktree.path, signal); + if (!current()) return; + if (snapshot.worktree.repositoryId !== this.runtime.snapshot.worktree.repositoryId) + throw new Error("Status repository identity changed."); + // Preserve relative order of surviving paths so updates cannot sort under the cursor. + snapshot.paths = retainStatusOrder( + this.state.snapshot.paths, + snapshot.paths, + (path) => path.path, + ); + if (this.state.snapshot.siblings.state === "ready") + snapshot.siblings = this.state.snapshot.siblings; + this.publish({ + snapshot, + selected: reconcileStatusPathSelection( + snapshot.paths, + this.state.expandedPathGroups, + this.state.selected, + ), + notice: this.state.notice.startsWith("Status stale:") ? "" : this.state.notice, + }); + if (!this.watch && !this.watchTask) { + this.watchTask = this.startWatch(snapshot, generation, signal).finally(() => { + this.watchTask = undefined; + }); + } + // Same-target safety polls must let a bounded secondary scan finish, even when its + // provider token or observation timestamp changes on every current read. + if (!this.queued && !this.siblingAbort) this.scanSiblings(snapshot); + } catch (error) { + if (current()) + this.publish({ + notice: `Status stale: ${error instanceof Error ? error.message : String(error)}`, + }); + } finally { + if (current()) this.publish({ loading: false }); + } + } while (this.queued && current()); + })().finally(() => { + this.pending = undefined; + }); + return this.pending; + }; + private async startWatch( + snapshot: ExtensionVcsStatusSnapshot, + generation: number, + signal: AbortSignal, + ) { + try { + const plan = await this.runtime.watchPlan(snapshot, signal); + if (!this.active || signal.aborted || generation !== this.generation) return; + let tick = 0; + this.watch = (this.deps.createWatch ?? createWatchController)({ + initialSignature: "0", + getSignature: () => String(++tick), + refresh: this.refresh, + pollOnly: plan.coverage === "poll-only", + healthyCheckMs: 5000, + createEventSource: + plan.coverage === "poll-only" + ? undefined + : (callbacks) => { + this.observer = (this.deps.createObserver ?? createWatchObserver)(plan, callbacks); + return this.observer; + }, + reportError: () => this.setNotice("Watching unavailable; polling status."), + }); + } catch { + if (!signal.aborted && this.active && generation === this.generation) { + this.setNotice("Watching unavailable; polling status."); + let tick = 0; + this.watch = (this.deps.createWatch ?? createWatchController)({ + initialSignature: "0", + getSignature: () => String(++tick), + refresh: this.refresh, + pollOnly: true, + }); + } + } + } + /** Inspect a validated sibling without changing launch cwd or extension ownership. */ + async inspect(path: string) { + if (this.closed || !this.active) return; + const previous = this.state; + const suspending = this.suspend(); + const generation = this.generation; + await suspending; + if (this.closed || generation !== this.generation) return; + this.abort = new AbortController(); + this.publish({ loading: true }); + this.pending = (async () => { + try { + const snapshot = await this.runtime.load(path, this.abort.signal); + if (this.closed || generation !== this.generation || this.abort.signal.aborted) return; + if (snapshot.worktree.repositoryId !== this.runtime.snapshot.worktree.repositoryId) + throw new Error("Status repository identity changed."); + this.retained.push(previous); + this.publish({ + snapshot, + selected: snapshot.paths[0] ? `path:${snapshot.paths[0].path}` : null, + top: 0, + backPath: previous.snapshot.worktree.path, + notice: "", + }); + } catch (error) { + if (!this.abort.signal.aborted) this.setNotice(String(error)); + } finally { + if (!this.closed && generation === this.generation) this.publish({ loading: false }); + } + })(); + await this.pending; + this.pending = undefined; + if (!this.closed && generation === this.generation) this.resume(); + } + /** Return to the retained originating target, then reconcile with fresh facts. */ + async back() { + if (!this.retained.length || !this.active) return; + const suspending = this.suspend(); + const generation = this.generation; + await suspending; + if (this.closed || generation !== this.generation) return; + this.publish(this.retained.pop()!); + this.resume(); + } + /** Dispose timers, watcher handles and provider reads exactly once. */ + close() { + if (this.closing) return this.closing; + this.closed = true; + this.closing = (async () => { + await this.suspend(); + await this.runtime.close(); + })(); + return this.closing; + } +} diff --git a/packages/hunk/src/ui/status/geometry.test.ts b/packages/hunk/src/ui/status/geometry.test.ts new file mode 100644 index 000000000..6b2bc8fc9 --- /dev/null +++ b/packages/hunk/src/ui/status/geometry.test.ts @@ -0,0 +1,210 @@ +import { expect, test } from "bun:test"; +import { createTestStatusRuntime } from "../../../../../test/helpers/status-runtime"; +import { StatusController } from "./controller"; +import { moveStatusFocus, planStatusViewport, projectStatusRows } from "./geometry"; +import { statusPlainText } from "./staticProjection"; +import { measureTextWidth } from "../lib/text"; + +test("wide and narrow rows expose mixed states without selection-dependent metadata", () => { + const runtime = createTestStatusRuntime(); + runtime.snapshot.paths[0]!.path = "界界\nfile.ts"; + const controller = new StatusController(runtime); + for (const width of [28, 60, 140]) { + const before = projectStatusRows(controller.getSnapshot(), width); + controller.select("path:beta.ts"); + expect(projectStatusRows(controller.getSnapshot(), width)).toEqual(before); + for (const row of before) + for (const line of row.lines) + expect(measureTextWidth(statusPlainText(line))).toBeLessThanOrEqual(width - 2); + expect( + before + .flatMap((row) => row.lines) + .map(statusPlainText) + .join(" "), + ).toContain("\\nfile.ts"); + expect( + before + .flatMap((row) => row.lines) + .map(statusPlainText) + .join(" "), + ).toContain("modified (staged)"); + expect( + before + .flatMap((row) => row.lines) + .map(statusPlainText) + .join(" "), + ).toContain("beta.ts"); + } +}); + +test("short viewport retains focused row and bounded expansion stays in the same row stream", () => { + const controller = new StatusController(createTestStatusRuntime()); + const rows = projectStatusRows(controller.getSnapshot(), 60); + const viewport = planStatusViewport(rows, "path:beta.ts", 0, 2); + expect(viewport.lines.some((line) => line.row.id === "path:beta.ts")).toBe(true); + expect(viewport.lines.length).toBeLessThanOrEqual(2); +}); + +test("oversized focused rows retain intra-row scrolling through resize and reach both ends", () => { + const rows = [ + { + id: "long", + kind: "path" as const, + lines: Array.from({ length: 20 }, (_, i) => [{ text: String(i), role: "text" as const }]), + }, + ]; + expect(planStatusViewport(rows, "long", 8, 3).top).toBe(8); + expect(planStatusViewport(rows, "long", 18, 3).lines.map((line) => line.text)).toEqual([ + "17", + "18", + "19", + ]); + expect(planStatusViewport(rows, "long", 8, 6).top).toBe(8); + let position = { selected: "long" as string | null, top: 0 }; + for (let i = 0; i < 20; i++) + position = moveStatusFocus(rows, position.selected, position.top, 3, 1); + expect(position).toEqual({ selected: "long", top: 17 }); + for (let i = 0; i < 6; i++) + position = moveStatusFocus(rows, position.selected, position.top, 3, -3); + expect(position).toEqual({ selected: "long", top: 0 }); +}); + +test("styled wrapping preserves complete rename/submodule facts and the in-flow worktree boundary", () => { + const runtime = createTestStatusRuntime(); + runtime.snapshot.paths = [ + { + path: "nested/界界/ new name.ts", + previousPath: "old/ name.ts", + index: "renamed", + worktree: "modified", + conflict: true, + submodule: { commitChanged: true, trackedChanges: true, untrackedChanges: true }, + }, + ]; + const controller = new StatusController(runtime); + for (const width of [20, 30, 42, 120]) { + const rows = projectStatusRows(controller.getSnapshot(), width); + const path = rows.find((row) => row.kind === "path")!; + const content = path.lines.map((line) => statusPlainText(line).slice(2)).join(""); + expect(content).toContain("old/ name.ts -> nested/界界/ new name.ts"); + expect(content).toContain( + "conflict · submodule commit changed tracked changes untracked changes", + ); + expect( + path.lines + .flat() + .filter((span) => span.role === "staged") + .map((span) => span.text) + .join(""), + ).toBe("renamed (staged)"); + expect( + path.lines + .flat() + .filter((span) => span.role === "unstaged") + .map((span) => span.text) + .join(""), + ).toBe("modified (unstaged)"); + const boundary = rows.find((row) => row.id === "worktrees")!; + expect(boundary.lines[0]).toEqual([]); + expect(boundary.lines.map(statusPlainText).join("")).toBe("── Other worktrees ──"); + const sibling = rows.find((row) => row.kind === "worktree")!; + expect( + sibling.lines + .flat() + .filter((span) => span.role === "accent") + .map((span) => span.text) + .join(""), + ).toBe("sibling-branch"); + expect( + sibling.lines + .flat() + .filter((span) => span.role === "muted") + .map((span) => span.text) + .join(""), + ).toContain("status-test-sibling"); + for (const row of rows) + for (const line of row.lines) + expect(measureTextWidth(statusPlainText(line))).toBeLessThanOrEqual(width - 2); + } +}); + +for (const tracked of [0, 1, 10, 11, 25]) { + for (const untracked of [0, 1, 10, 11, 25]) { + test(`file groups bound records independently: ${tracked} tracked / ${untracked} untracked`, () => { + const runtime = createTestStatusRuntime(); + runtime.snapshot.paths = [ + ...Array.from({ length: tracked }, (_, i) => ({ + path: `tracked/${i}/a-long-name.ts`, + index: "modified" as const, + worktree: "modified" as const, + conflict: false, + })), + ...Array.from({ length: untracked }, (_, i) => ({ + path: `untracked/${i}/a-long-name.ts`, + index: "unchanged" as const, + worktree: "untracked" as const, + conflict: false, + })), + ]; + // Duplicate destinations cannot inflate a group count or consume its visible record cap. + if (runtime.snapshot.paths[0]) runtime.snapshot.paths.push(runtime.snapshot.paths[0]); + const controller = new StatusController(runtime); + const rows = () => projectStatusRows(controller.getSnapshot(), 22); + const paths = () => rows().filter((row) => row.kind === "path"); + expect(paths()).toHaveLength(Math.min(10, tracked) + Math.min(10, untracked)); + for (const [id, count] of [ + ["tracked", tracked], + ["untracked", untracked], + ] as const) { + const group = rows().find((row) => row.id === `group:${id}`)!; + expect(group.lines.map(statusPlainText).join("")).toContain(`(${count})`); + expect(rows().some((row) => row.id === `toggle:${id}`)).toBe(count > 10); + } + for (const path of paths()) { + expect(path.lines.length).toBeGreaterThan(1); + for (const line of path.lines) { + expect(statusPlainText(line).startsWith(" ")).toBe(true); + expect(measureTextWidth(statusPlainText(line))).toBeLessThanOrEqual(20); + } + } + const selected = controller.getSnapshot().selected; + controller.togglePathGroup("tracked"); + expect(controller.getSnapshot().selected).toBe(selected); + expect(paths()).toHaveLength(tracked + Math.min(10, untracked)); + controller.togglePathGroup("untracked"); + expect(paths()).toHaveLength(tracked + untracked); + controller.togglePathGroup("tracked"); + expect(paths()).toHaveLength(Math.min(10, tracked) + untracked); + expect(rows().find((row) => row.id === "worktrees")!.lines[0]).toEqual([]); + }); + } +} + +test("collapsing a selected hidden path focuses its toggle and reconciles the viewport", () => { + const runtime = createTestStatusRuntime(); + runtime.snapshot.paths = Array.from({ length: 25 }, (_, i) => ({ + path: `file-${i}.ts`, + index: "unchanged", + worktree: "modified", + conflict: false, + })); + const controller = new StatusController(runtime); + controller.togglePathGroup("tracked"); + controller.select("path:file-24.ts", 100); + controller.togglePathGroup("tracked"); + const state = controller.getSnapshot(); + expect(state.selected).toBe("toggle:tracked"); + const rows = projectStatusRows(state, 45); + const viewport = planStatusViewport(rows, state.selected, state.top, 8); + expect(viewport.top).toBeLessThan(100); + expect(viewport.lines.some((line) => line.text.includes("and 15 more files"))).toBe(true); + expect(rows.some((row) => row.id === "path:file-24.ts")).toBe(false); + controller.togglePathGroup("tracked"); + expect(controller.getSnapshot().selected).toBe("toggle:tracked"); + expect( + projectStatusRows(controller.getSnapshot(), 45) + .find((row) => row.id === "toggle:tracked")! + .lines.map(statusPlainText) + .join(""), + ).toBe(" Show fewer"); +}); diff --git a/packages/hunk/src/ui/status/geometry.ts b/packages/hunk/src/ui/status/geometry.ts new file mode 100644 index 000000000..b058fd633 --- /dev/null +++ b/packages/hunk/src/ui/status/geometry.ts @@ -0,0 +1,213 @@ +import type { StatusState } from "./controller"; +import { groupStatusPaths, STATUS_PATH_GROUP_LIMIT, type StatusPathGroupId } from "./pathGroups"; +import { + formatStatusPath, + formatStatusWorktree, + statusDisplayText, + statusPlainText, + STATUS_WORKTREES_HEADING, + type StatusTextFacts, + type StatusTextSpan, +} from "./staticProjection"; +import { wrapTextByWidth, measureTextWidth } from "../lib/text"; + +export interface StatusRow { + id: string; + lines: StatusTextSpan[][]; + kind: "path" | "worktree" | "heading" | "toggle"; + group?: StatusPathGroupId; + disabled?: boolean; +} + +/** Wrap styled text without losing path whitespace or the role of each status fact. */ +export function wrapStatusSpans(spans: StatusTextSpan[], width: number): StatusTextSpan[][] { + const lines: StatusTextSpan[][] = [[]]; + let used = 0; + for (const span of spans) { + for (const chunk of wrapTextByWidth(span.text, width, width - used, used > 0)) { + if (chunk.startsNewLine) { + lines.push([]); + used = 0; + } + lines[lines.length - 1]!.push({ text: chunk.text, role: span.role }); + used += chunk.width; + } + } + return lines; +} + +/** Stack secondary facts when needed and preserve terminal-cell widths without an inspector. */ +export function projectStatusRows(state: StatusState, width: number): StatusRow[] { + const available = Math.max(1, width - 2); + const heading = (text: string, role: StatusTextSpan["role"] = "muted") => + wrapStatusSpans([{ text, role }], available); + const facts = ({ primary, secondary }: StatusTextFacts, indent = false) => { + const padding = indent ? " ".repeat(Math.min(2, available - 1)) : ""; + const factWidth = available - padding.length; + const wrap = (spans: StatusTextSpan[]) => wrapStatusSpans(spans, factWidth); + const indented = (lines: StatusTextSpan[][]) => + lines.map((line) => (padding ? [{ text: padding, role: "muted" as const }, ...line] : line)); + if (!secondary.length) return indented(wrap(primary)); + const combined: StatusTextSpan[] = [...primary, { text: " ", role: "muted" }, ...secondary]; + return indented( + measureTextWidth(statusPlainText(combined)) <= factWidth + ? [combined] + : [...wrap(primary), ...wrap(secondary)], + ); + }; + const snapshot = state.snapshot; + const groups = groupStatusPaths(snapshot.paths); + const changedPathCount = groups.reduce((count, group) => count + group.paths.length, 0); + const rows: StatusRow[] = [ + { + id: "changes", + kind: "heading", + lines: [ + ...heading( + changedPathCount + ? `${changedPathCount} changed path${changedPathCount === 1 ? "" : "s"}` + : "Clean working tree", + "text", + ), + ], + }, + ]; + for (const group of groups) { + rows.push({ + id: `group:${group.id}`, + kind: "heading", + lines: heading(`${group.title} (${group.paths.length})`, "text"), + }); + const expanded = state.expandedPathGroups[group.id]; + for (const path of expanded ? group.paths : group.paths.slice(0, STATUS_PATH_GROUP_LIMIT)) + rows.push({ + id: `path:${path.path}`, + kind: "path", + lines: facts(formatStatusPath(path), true), + }); + if (group.paths.length > STATUS_PATH_GROUP_LIMIT) + rows.push({ + id: `toggle:${group.id}`, + kind: "toggle", + group: group.id, + lines: facts( + { + primary: [ + { + text: expanded + ? "Show fewer" + : `and ${group.paths.length - STATUS_PATH_GROUP_LIMIT} more file${group.paths.length - STATUS_PATH_GROUP_LIMIT === 1 ? "" : "s"}`, + role: "accent", + }, + ], + secondary: [], + }, + true, + ), + }); + } + rows.push({ + id: "worktrees", + kind: "heading", + lines: [[], ...heading(STATUS_WORKTREES_HEADING, "accent")], + }); + const siblings = snapshot.siblings; + if (siblings.state === "ready") { + const visible = state.expandedWorktrees + ? siblings.value.worktrees + : siblings.value.worktrees.slice(0, 4); + for (const sibling of visible) { + rows.push({ + id: `worktree:${sibling.worktree.id}`, + kind: "worktree", + lines: facts(formatStatusWorktree(sibling)), + disabled: !sibling.inspectable, + }); + } + if (!visible.length) rows.push({ id: "none", kind: "heading", lines: heading("None") }); + if (visible.length < siblings.value.worktrees.length || siblings.value.truncated) + rows.push({ + id: "more", + kind: "heading", + lines: heading( + `${siblings.value.worktrees.length - visible.length} more · W expand${siblings.value.truncated ? " · scan limit reached" : ""}`, + ), + }); + } else + rows.push({ + id: "siblings-state", + kind: "heading", + lines: heading( + siblings.state === "loading" + ? "Loading worktrees…" + : statusDisplayText(siblings.state === "error" ? siblings.message : siblings.reason), + ), + }); + return rows; +} + +/** Scroll oversized rows before moving selection, so every wrapped fact remains reachable. */ +export function moveStatusFocus( + rows: StatusRow[], + selected: string | null, + requestedTop: number, + height: number, + delta: number, +) { + const viewport = planStatusViewport(rows, selected, requestedTop, height); + let start = 0; + for (const row of rows) { + if (row.id === selected) { + const lastTop = start + row.lines.length - height; + if ( + row.lines.length > height && + (delta > 0 ? viewport.top < lastTop : viewport.top > start) + ) { + return { selected, top: Math.max(start, Math.min(lastTop, viewport.top + delta)) }; + } + break; + } + start += row.lines.length; + } + const navigable = rows.filter((row) => row.kind !== "heading"); + const index = navigable.findIndex((row) => row.id === selected); + const next = navigable[Math.max(0, Math.min(navigable.length - 1, index + delta))]?.id ?? null; + return { selected: next, top: planStatusViewport(rows, next, viewport.top, height).top }; +} + +/** Keep the focused row visible through resize while retaining the user's requested scroll. */ +export function planStatusViewport( + rows: StatusRow[], + selected: string | null, + requestedTop: number, + height: number, +) { + const starts: number[] = []; + let total = 0; + for (const row of rows) { + starts.push(total); + total += row.lines.length; + } + let top = Math.max(0, Math.min(requestedTop, total - height)); + const focus = rows.findIndex((row) => row.id === selected); + if (focus >= 0) { + const start = starts[focus]!; + const end = start + rows[focus]!.lines.length; + if (end - start > height) top = Math.max(start, Math.min(top, end - height)); + else if (start < top) top = start; + else if (end > top + height) top = end - height; + } + return { + top, + lines: rows + .flatMap((row, index) => + row.lines.map((spans, line) => ({ + row, + spans, + text: statusPlainText(spans), + offset: starts[index]! + line, + })), + ) + .filter((line) => line.offset >= top && line.offset < top + height), + }; +} diff --git a/packages/hunk/src/ui/status/pathGroups.test.ts b/packages/hunk/src/ui/status/pathGroups.test.ts new file mode 100644 index 000000000..8d86ff5cd --- /dev/null +++ b/packages/hunk/src/ui/status/pathGroups.test.ts @@ -0,0 +1,44 @@ +import { expect, test } from "bun:test"; +import type { ExtensionVcsStatusPath } from "../../extension-api/types"; +import { groupStatusPaths, reconcileStatusPathSelection } from "./pathGroups"; + +/** Build an explicit changed path for grouping-policy tests. */ +function createTestGroupPath( + path: string, + facts: Partial = {}, +): ExtensionVcsStatusPath { + return { path, index: "unchanged", worktree: "modified", conflict: false, ...facts }; +} + +test("unique group membership retains provider order and does not hide tracked or conflict facts", () => { + const paths = [ + createTestGroupPath("new-b", { worktree: "untracked" }), + createTestGroupPath("tracked-b"), + createTestGroupPath("new-a", { worktree: "untracked", index: undefined }), + createTestGroupPath("tracked-a", { index: "renamed", previousPath: "old-a" }), + createTestGroupPath("new-b", { worktree: "untracked" }), + createTestGroupPath("mixed", { worktree: "untracked", index: "added" }), + createTestGroupPath("conflict", { worktree: "untracked", conflict: true }), + ]; + const groups = groupStatusPaths(paths); + expect(groups[0]!.paths.map((path) => path.path)).toEqual([ + "tracked-b", + "tracked-a", + "mixed", + "conflict", + ]); + expect(groups[1]!.paths.map((path) => path.path)).toEqual(["new-b", "new-a"]); + expect(groups[0]!.paths[1]!.previousPath).toBe("old-a"); +}); + +test("refresh can move a selected path into a collapsed group without leaving hidden focus", () => { + const paths = Array.from({ length: 11 }, (_, i) => + createTestGroupPath(`new-${i}`, { worktree: "untracked" }), + ); + const expanded = { tracked: true, untracked: false }; + expect(reconcileStatusPathSelection(paths, expanded, "path:new-10")).toBe("toggle:untracked"); + expect(reconcileStatusPathSelection(paths, expanded, "path:new-9")).toBe("path:new-9"); + expect(reconcileStatusPathSelection(paths, { ...expanded, untracked: true }, "path:new-10")).toBe( + "path:new-10", + ); +}); diff --git a/packages/hunk/src/ui/status/pathGroups.ts b/packages/hunk/src/ui/status/pathGroups.ts new file mode 100644 index 000000000..a01318adc --- /dev/null +++ b/packages/hunk/src/ui/status/pathGroups.ts @@ -0,0 +1,40 @@ +import type { ExtensionVcsStatusPath } from "../../extension-api/types"; + +export type StatusPathGroupId = "tracked" | "untracked"; +export const STATUS_PATH_GROUP_LIMIT = 10; + +/** Group unique destination paths without changing their provider/stable order within each group. */ +export function groupStatusPaths(paths: readonly ExtensionVcsStatusPath[]) { + const tracked: ExtensionVcsStatusPath[] = []; + const untracked: ExtensionVcsStatusPath[] = []; + const seen = new Set(); + for (const path of paths) { + if (seen.has(path.path)) continue; + seen.add(path.path); + const isUntracked = + path.worktree === "untracked" && + (!path.index || path.index === "unchanged") && + !path.conflict; + (isUntracked ? untracked : tracked).push(path); + } + return [ + { id: "tracked" as const, title: "Tracked changes", paths: tracked }, + { id: "untracked" as const, title: "Untracked files", paths: untracked }, + ]; +} + +/** Move a now-hidden file selection to its group's visible expansion action. */ +export function reconcileStatusPathSelection( + paths: readonly ExtensionVcsStatusPath[], + expanded: Record, + selected: string | null, +) { + for (const group of groupStatusPaths(paths)) { + if ( + !expanded[group.id] && + group.paths.slice(STATUS_PATH_GROUP_LIMIT).some((path) => `path:${path.path}` === selected) + ) + return `toggle:${group.id}`; + } + return selected; +} diff --git a/packages/hunk/src/ui/status/runInteractiveStatus.tsx b/packages/hunk/src/ui/status/runInteractiveStatus.tsx new file mode 100644 index 000000000..93348427f --- /dev/null +++ b/packages/hunk/src/ui/status/runInteractiveStatus.tsx @@ -0,0 +1,55 @@ +import { HunkUserError } from "../../core/run/errors"; +import { assertReliableWatchRuntime } from "../../core/watch/runtime"; +import { HunkSessionHost } from "../session/HunkSessionHost"; +import { runHunkSession } from "../session/runHunkSession"; +import { LOG_SHUTDOWN_SIGNALS, logSignalExitCode } from "../log/runInteractiveLog"; +import { StatusController } from "./controller"; +import type { StatusRuntime } from "./types"; + +/** Run status, retained log and fresh reviews in one renderer with one extension lifetime. */ +export async function runInteractiveStatus( + runtime: StatusRuntime, + { + stdin = process.stdin, + stdout = process.stdout, + }: { stdin?: NodeJS.ReadStream; stdout?: NodeJS.WriteStream } = {}, +) { + const controller = new StatusController(runtime); + let cleaned = false; + const cleanup = async () => { + if (cleaned) return; + cleaned = true; + try { + await controller.close(); + } finally { + await runtime.extensionSession.shutdown(); + } + }; + try { + if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function") + throw new HunkUserError("The `hunk status` browser requires a terminal.", [ + "Use `hunk status --static` for scrollback output.", + ]); + assertReliableWatchRuntime(Bun.version); + const exitCode = await runHunkSession({ + stdin, + stdout, + useMouse: true, + signals: LOG_SHUTDOWN_SIGNALS, + signalExitCode: logSignalExitCode, + interruptExitCode: 130, + beforeTeardown: cleanup, + render: ({ externalQuitSignal, finish }) => ( + + ), + }); + if (exitCode !== undefined) process.exitCode = exitCode; + } finally { + await cleanup(); + } +} diff --git a/packages/hunk/src/ui/status/runStaticStatus.ts b/packages/hunk/src/ui/status/runStaticStatus.ts new file mode 100644 index 000000000..9ea92dbed --- /dev/null +++ b/packages/hunk/src/ui/status/runStaticStatus.ts @@ -0,0 +1,70 @@ +import type { StatusCommandInput } from "../../core/run/commandInputs"; +import type { InteractiveSessionInitialization } from "../../core/session/initialization"; +import type { ExtensionVcsStatusSnapshot } from "../../extension-api/types"; +import { pagePlainText } from "../../core/process/pager"; +import { writeStdout } from "../../core/process/stdout"; +import { sanitizeTerminalLine } from "../../lib/terminalText"; +import { resolveTheme } from "../themes"; +import { resolveHistoryColor } from "../history/staticProjection"; +import { projectStaticStatus } from "./staticProjection"; + +/** Describe only the snapshot, launch theme and cleanup operations static output consumes. */ +export interface StaticStatusRuntime { + input: StatusCommandInput; + initialization: InteractiveSessionInitialization; + snapshot: ExtensionVcsStatusSnapshot; + notices: readonly string[]; + loadSiblings(snapshot: ExtensionVcsStatusSnapshot): Promise; + close(): Promise; + extensionSession: { shutdown(): Promise }; +} + +/** Print one complete bounded snapshot, using ordinary paging and retiring all owned resources. */ +export async function runStaticStatus( + bootstrap: StaticStatusRuntime, + { + stdout = process.stdout, + stderr = process.stderr, + env = process.env, + write = writeStdout, + pageText = pagePlainText, + }: { + stdout?: Pick; + stderr?: Pick; + env?: NodeJS.ProcessEnv; + write?: (text: string) => void; + pageText?: typeof pagePlainText; + } = {}, +) { + try { + for (const notice of bootstrap.notices) + stderr.write(`hunk: warning: ${sanitizeTerminalLine(notice)}\n`); + const snapshot = await bootstrap.loadSiblings(bootstrap.snapshot); + if (bootstrap.input.json) { + write(`${JSON.stringify(snapshot, null, 2)}\n`); + return; + } + const theme = resolveTheme( + bootstrap.initialization.theme.initialTheme, + bootstrap.initialization.theme.initialThemeMode ?? null, + bootstrap.initialization.theme.customThemes, + ); + const text = projectStaticStatus(snapshot, { + theme, + color: resolveHistoryColor({ + mode: bootstrap.input.color, + stdoutIsTTY: Boolean(stdout.isTTY), + env, + }), + }); + if (stdout.isTTY && text.split("\n").length - 1 > Math.max(1, (stdout.rows || 24) - 1)) + await pageText(text, env); + else write(text); + } finally { + try { + await bootstrap.close(); + } finally { + await bootstrap.extensionSession.shutdown(); + } + } +} diff --git a/packages/hunk/src/ui/status/staticProjection.test.ts b/packages/hunk/src/ui/status/staticProjection.test.ts new file mode 100644 index 000000000..1691a5f30 --- /dev/null +++ b/packages/hunk/src/ui/status/staticProjection.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, test } from "bun:test"; +import { createTestStatusSnapshot } from "../../../../../test/helpers/vcsStatus"; +import { formatStatusUpstream, projectStaticStatus } from "./staticProjection"; +import { runStaticStatus, type StaticStatusRuntime } from "./runStaticStatus"; +import { resolveTheme } from "../themes"; +import { persistedViewPreferencesFromOptions } from "../../core/run/config"; + +describe("static workspace status", () => { + test("formats divergence without zero counters or invented fetch times", () => { + const tracked = { + kind: "tracked" as const, + name: "origin/main", + ahead: 0, + behind: 1, + fetch: { state: "unknown" as const, reason: "No metadata" }, + }; + expect(formatStatusUpstream({ state: "ready", value: tracked })).toBe("1 behind origin/main"); + expect(formatStatusUpstream({ state: "ready", value: { ...tracked, ahead: 3 } })).toBe( + "3 ahead · 1 behind origin/main", + ); + expect(formatStatusUpstream({ state: "ready", value: { ...tracked, behind: 0 } })).toBe( + "Aligned with origin/main", + ); + const snapshot = createTestStatusSnapshot(); + snapshot.upstream = { + state: "ready", + value: { + ...tracked, + fetch: { + state: "ready", + value: { timestamp: "2026-01-02T03:04:05Z", provenance: "local-fetch-head-mtime" }, + }, + }, + }; + expect(formatStatusUpstream(snapshot.upstream)).toBe("1 behind origin/main"); + const output = projectStaticStatus(snapshot); + expect(output).not.toContain("local fetch"); + expect(output).not.toContain("last fetched"); + expect(JSON.parse(JSON.stringify(snapshot)).upstream.value.fetch).toEqual({ + state: "ready", + value: { timestamp: "2026-01-02T03:04:05Z", provenance: "local-fetch-head-mtime" }, + }); + }); + test("shows mixed states without a selected-file inspector and escapes untrusted paths", () => { + const snapshot = createTestStatusSnapshot("worktree\nforged"); + snapshot.paths = [ + { + path: "new\t名", + previousPath: "old\nname", + index: "renamed", + worktree: "modified", + conflict: false, + }, + ]; + snapshot.changedPathCount = 1; + const output = projectStaticStatus(snapshot); + expect(output).toContain("worktree\\nforged"); + expect(output).toContain("old\\nname -> new\\t名 renamed (staged) · modified (unstaged)"); + expect(output).toContain("1 changed path"); + expect(output).not.toContain("Selected"); + const theme = resolveTheme("status-test", null, [ + { id: "status-test", base: "nord", accent: "#123456" }, + ]); + expect(projectStaticStatus(snapshot, { theme, color: true })).toContain("\x1b[38;2;18;52;86m"); + }); + test("JSON uses the same facts, skips paging/color, and cleans up after output failure", async () => { + const snapshot = createTestStatusSnapshot(); + let closed = 0; + let shutdown = 0; + let pages = 0; + let output = ""; + const runtime: StaticStatusRuntime = { + input: { kind: "status", json: true, static: false, color: "always", options: {} }, + initialization: { + theme: { customThemes: [] }, + viewPreferences: persistedViewPreferencesFromOptions({}), + }, + snapshot, + notices: [], + async loadSiblings(value) { + return { ...value, siblings: { state: "error", message: "partial" } }; + }, + async close() { + closed++; + }, + extensionSession: { + async shutdown() { + shutdown++; + }, + }, + }; + await runStaticStatus(runtime, { + stdout: { isTTY: true, rows: 1 }, + write(text) { + output = text; + }, + async pageText() { + pages++; + }, + }); + expect(JSON.parse(output)).toMatchObject({ + schemaVersion: 1, + siblings: { state: "error", message: "partial" }, + }); + expect(output).not.toContain("\x1b"); + expect(pages).toBe(0); + expect(closed).toBe(1); + expect(shutdown).toBe(1); + await expect( + runStaticStatus(runtime, { + write() { + throw new Error("output failed"); + }, + }), + ).rejects.toThrow("output failed"); + expect(closed).toBe(2); + expect(shutdown).toBe(2); + runtime.input.json = false; + await runStaticStatus(runtime, { + stdout: { isTTY: true, rows: 1 }, + async pageText() { + pages++; + }, + }); + expect(pages).toBe(1); + }); +}); + +test("status labels color staging state, not modification type, in both themed and plain output", () => { + const theme = resolveTheme("status-signs", null, [ + { + id: "status-signs", + base: "nord", + addedSignColor: "#123456", + removedSignColor: "#654321", + accent: "#abcdef", + text: "#eeeeee", + fileDeleted: "#010203", + fileUntracked: "#030201", + }, + ]); + const snapshot = createTestStatusSnapshot(); + snapshot.paths = [ + { path: "staged-delete", index: "deleted", worktree: "unchanged", conflict: false }, + { path: "unstaged-add", index: "unchanged", worktree: "added", conflict: false }, + { path: "mixed", index: "modified", worktree: "modified", conflict: false }, + { path: "untracked", index: "unchanged", worktree: "untracked", conflict: false }, + { path: "conflicted", index: "unmerged", worktree: "unmerged", conflict: true }, + { path: "no-index", worktree: "modified", conflict: false }, + ]; + snapshot.changedPathCount = snapshot.paths.length; + const output = projectStaticStatus(snapshot, { theme, color: true }); + const green = "\x1b[38;2;18;52;86m"; + const red = "\x1b[38;2;101;67;33m"; + const attention = "\x1b[38;2;171;205;239m"; + expect(output).toContain(`${green}deleted (staged)\x1b[0m`); + expect(output).toContain(`${green}staged-delete\x1b[0m`); + expect(output).toContain(`${red}new file (unstaged)\x1b[0m`); + expect(output).toContain(`${red}unstaged-add\x1b[0m`); + expect(output).toContain(`${green}modified (staged)\x1b[0m`); + expect(output).toContain(`${red}modified (unstaged)\x1b[0m`); + expect(output).toContain("\x1b[38;2;238;238;238mmixed\x1b[0m"); + expect(output).toContain(`${red}untracked\x1b[0m`); + expect(output).toContain(`${attention}unmerged (staged)\x1b[0m`); + expect(output).toContain(`${attention}conflict\x1b[0m`); + const plain = projectStaticStatus(snapshot, { theme, color: false }); + expect(plain).not.toContain("\x1b"); + for (const row of [ + " staged-delete deleted (staged)", + " unstaged-add new file (unstaged)", + " mixed modified (staged) · modified (unstaged)", + "Untracked files (1)\n untracked\n", + " conflicted unmerged (staged) · unmerged (unstaged) · conflict", + " no-index modified", + ]) + expect(plain).toContain(row); + expect(plain).not.toContain("Index / worktree"); + expect(plain).not.toContain("unchanged"); + expect(plain).toContain("\n\n── Other worktrees ──\n"); + expect(plain).not.toContain("index:"); +}); + +test("static groups retain every unique path without unusable expansion actions", () => { + const snapshot = createTestStatusSnapshot(); + snapshot.paths = Array.from({ length: 25 }, (_, i) => [ + { + path: `tracked-${i}`, + index: "modified" as const, + worktree: "unchanged" as const, + conflict: false, + }, + { + path: `new-${i}`, + index: "unchanged" as const, + worktree: "untracked" as const, + conflict: false, + }, + ]).flat(); + snapshot.paths.push(snapshot.paths[0]!); + const before = JSON.stringify(snapshot); + const output = projectStaticStatus(snapshot); + expect(output).toContain("50 changed paths\nTracked changes (25)"); + expect(output).toContain("Untracked files (25)"); + for (let i = 0; i < 25; i++) { + expect(output).toContain(` tracked-${i} modified (staged)\n`); + expect(output).toContain(` new-${i}\n`); + } + expect(output.match(/ tracked-0 /g)).toHaveLength(1); + expect(output).not.toMatch(/more files|Show fewer|unchanged|Index \/ worktree/); + expect(JSON.stringify(snapshot)).toBe(before); +}); + +test("tracked facts distinguish rename, copy, type changes and submodules without unchanged labels", () => { + const snapshot = createTestStatusSnapshot(); + snapshot.paths = [ + { + path: "renamed", + previousPath: "old-name", + index: "renamed", + worktree: "unchanged", + conflict: false, + }, + { + path: "copy", + previousPath: "source", + index: "copied", + worktree: "type-changed", + conflict: false, + }, + { + path: "module", + index: "unchanged", + worktree: "modified", + conflict: false, + submodule: { commitChanged: true, trackedChanges: true, untrackedChanges: true }, + }, + ]; + const output = projectStaticStatus(snapshot); + expect(output).toContain(" old-name -> renamed renamed (staged)"); + expect(output).toContain(" source -> copy copied (staged) · type changed (unstaged)"); + expect(output).toContain( + " module modified (unstaged) · submodule commit changed tracked changes untracked changes", + ); + expect(output).not.toMatch(/unchanged|Index \/ worktree|XY/); +}); diff --git a/packages/hunk/src/ui/status/staticProjection.ts b/packages/hunk/src/ui/status/staticProjection.ts new file mode 100644 index 000000000..3a06d1064 --- /dev/null +++ b/packages/hunk/src/ui/status/staticProjection.ts @@ -0,0 +1,207 @@ +import type { + ExtensionVcsStatusFact, + ExtensionVcsStatusPath, + ExtensionVcsStatusPathState, + ExtensionVcsStatusWorktreeSummary, + ExtensionVcsStatusSnapshot, + ExtensionVcsStatusUpstream, +} from "../../extension-api/types"; +import { sanitizeTerminalLine } from "../../lib/terminalText"; +import type { AppTheme } from "../themes"; +import { foreground } from "../history/staticProjection"; +import { groupStatusPaths } from "./pathGroups"; + +/** Escape control characters visibly so legal paths cannot forge extra status rows. */ +export function statusDisplayText(value: string) { + return sanitizeTerminalLine( + value.replaceAll("\n", "\\n").replaceAll("\r", "\\r").replaceAll("\t", "\\t"), + ); +} + +/** Format upstream relations without treating local FETCH_HEAD metadata as an attested fetch age. */ +export function formatStatusUpstream(fact: ExtensionVcsStatusFact) { + if (fact.state !== "ready") + return `Upstream ${fact.state}: ${statusDisplayText(fact.state === "error" ? fact.message : fact.reason)}`; + const upstream = fact.value; + if (upstream.kind === "none") return "No upstream"; + if (upstream.kind === "detached") return "Detached HEAD"; + if (upstream.kind === "unborn") return "No commits yet"; + if (upstream.kind === "missing") + return `Missing upstream ref: ${statusDisplayText(upstream.name)}`; + if (upstream.kind !== "tracked") return "Upstream unknown"; + const relation = [ + upstream.ahead ? `${upstream.ahead} ahead` : "", + upstream.behind ? `${upstream.behind} behind` : "", + ] + .filter(Boolean) + .join(" · "); + // The current provenance attests only a local file mtime, not when this upstream was fetched. + return `${relation || "Aligned with"} ${statusDisplayText(upstream.name)}`; +} + +export type StatusTextRole = "text" | "muted" | "accent" | "staged" | "unstaged" | "conflict"; + +export interface StatusTextSpan { + text: string; + role: StatusTextRole; +} + +export interface StatusTextFacts { + primary: StatusTextSpan[]; + secondary: StatusTextSpan[]; +} + +export const STATUS_WORKTREES_HEADING = "── Other worktrees ──"; +const PATH_LABELS: Record = { + unchanged: "", + modified: "modified", + added: "new file", + deleted: "deleted", + renamed: "renamed", + copied: "copied", + "type-changed": "type changed", + unmerged: "unmerged", + untracked: "untracked", +}; + +/** Resolve staging roles from shared theme signs, never from file change-type colors. */ +export function statusTextColor(theme: AppTheme, role: StatusTextRole) { + if (role === "staged") return theme.addedSignColor; + if (role === "unstaged") return theme.removedSignColor; + if (role === "conflict") return theme.accent; + return theme[role]; +} + +/** Join semantic spans for uncolored output and terminal measurement. */ +export function statusPlainText(spans: readonly StatusTextSpan[]) { + return spans.map((span) => span.text).join(""); +} + +/** Name each changed staging side explicitly; group membership labels ordinary untracked files. */ +export function formatStatusPath(path: ExtensionVcsStatusPath): StatusTextFacts { + const staged = path.index !== undefined && path.index !== "unchanged"; + const unstaged = path.worktree !== "unchanged"; + const conflict = path.conflict || path.index === "unmerged" || path.worktree === "unmerged"; + const nameRole: StatusTextRole = conflict + ? "conflict" + : staged && unstaged + ? "text" + : staged + ? "staged" + : unstaged + ? "unstaged" + : "text"; + const primary: StatusTextSpan[] = []; + if (path.previousPath) + primary.push({ text: `${statusDisplayText(path.previousPath)} -> `, role: "muted" }); + primary.push({ text: statusDisplayText(path.path), role: nameRole }); + const secondary: StatusTextSpan[] = []; + const fact = (text: string, role: StatusTextRole) => { + if (secondary.length) secondary.push({ text: " · ", role: "muted" }); + secondary.push({ text, role }); + }; + if (staged) + fact(`${PATH_LABELS[path.index!]} (staged)`, path.index === "unmerged" ? "conflict" : "staged"); + if (unstaged && (path.worktree !== "untracked" || staged || conflict)) + fact( + `${PATH_LABELS[path.worktree]}${path.index === undefined ? "" : " (unstaged)"}`, + path.worktree === "unmerged" ? "conflict" : "unstaged", + ); + if (conflict) fact("conflict", "conflict"); + if (path.submodule) + fact( + `submodule${path.submodule.commitChanged ? " commit changed" : ""}${path.submodule.trackedChanges ? " tracked changes" : ""}${path.submodule.untrackedChanges ? " untracked changes" : ""}`, + "muted", + ); + return { primary, secondary }; +} + +/** Format sibling availability without interpreting an unavailable count as clean. */ +export function formatStatusWorktree(row: ExtensionVcsStatusWorktreeSummary): StatusTextFacts { + const status = row.status; + const state = + status.state === "ready" + ? `${status.changedPathCount ? `${status.changedPathCount} changed paths` : "clean"}${status.conflictCount ? ` · ${status.conflictCount} conflicts` : ""}${status.operations.state === "ready" ? status.operations.value.map((value) => ` · ${value}`).join("") : " · operation state unavailable"}` + : `${status.state}: ${statusDisplayText(status.message)}`; + return { + primary: [ + { + text: statusDisplayText(row.branch ?? (row.bare ? "bare" : "detached")), + role: row.inspectable ? "accent" : "text", + }, + { text: ` ${statusDisplayText(row.worktree.path)}`, role: "muted" }, + ], + secondary: [ + { + text: `${state}${row.locked !== undefined ? ` · locked${row.locked ? `: ${statusDisplayText(row.locked)}` : ""}` : ""}${row.prunable !== undefined ? " · prunable" : ""}`, + role: row.status.state === "error" ? "conflict" : "muted", + }, + ], + }; +} + +/** Project the same snapshot used by JSON and the interactive surface, without an inspector. */ +export function projectStaticStatus( + snapshot: ExtensionVcsStatusSnapshot, + { + theme, + color = false, + }: { + theme?: AppTheme; + color?: boolean; + } = {}, +) { + const paint = (text: string, role: StatusTextRole) => + color && theme ? `${foreground(statusTextColor(theme, role))}${text}\x1b[0m` : text; + const paintSpans = (spans: StatusTextSpan[]) => + spans.map((span) => paint(span.text, span.role)).join(""); + const groups = groupStatusPaths(snapshot.paths); + const changedPathCount = groups.reduce((count, group) => count + group.paths.length, 0); + const head = snapshot.head; + const label = + head.kind === "detached" + ? `detached ${head.revisionId.slice(0, 12)}` + : `${head.name}${head.kind === "unborn" ? " (unborn)" : ""}`; + const lines = [ + paint(statusDisplayText(snapshot.worktree.path), "accent"), + statusDisplayText(label), + formatStatusUpstream(snapshot.upstream), + ]; + if (snapshot.operations.state === "ready") { + if (snapshot.operations.value.length) + lines.push(`Operation: ${snapshot.operations.value.join(" · ")}`); + } else + lines.push( + `Operation state ${snapshot.operations.state}: ${statusDisplayText(snapshot.operations.state === "error" ? snapshot.operations.message : snapshot.operations.reason)}`, + ); + const conflicts = snapshot.paths.filter((path) => path.conflict).length; + if (conflicts) lines.push(`${conflicts} conflicted path${conflicts === 1 ? "" : "s"}`); + lines.push( + "", + changedPathCount + ? `${changedPathCount} changed path${changedPathCount === 1 ? "" : "s"}` + : "Clean working tree", + ); + for (const group of groups) { + lines.push(paint(`${group.title} (${group.paths.length})`, "text")); + for (const path of group.paths) { + const { primary, secondary } = formatStatusPath(path); + lines.push(` ${paintSpans(primary)}${secondary.length ? ` ${paintSpans(secondary)}` : ""}`); + } + } + lines.push("", paint(STATUS_WORKTREES_HEADING, "accent")); + if (snapshot.siblings.state === "ready") { + if (!snapshot.siblings.value.worktrees.length) lines.push(" None"); + for (const row of snapshot.siblings.value.worktrees) { + const { primary, secondary } = formatStatusWorktree(row); + lines.push(` ${paintSpans(primary)} ${paintSpans(secondary)}`); + } + if (snapshot.siblings.value.truncated) + lines.push(" Additional worktrees omitted (scan limit)."); + } else if (snapshot.siblings.state === "loading") lines.push(" Loading"); + else + lines.push( + ` ${snapshot.siblings.state}: ${statusDisplayText(snapshot.siblings.state === "error" ? snapshot.siblings.message : snapshot.siblings.reason)}`, + ); + return `${lines.join("\n")}\n`; +} diff --git a/packages/hunk/src/ui/status/types.ts b/packages/hunk/src/ui/status/types.ts new file mode 100644 index 000000000..d9b1712ad --- /dev/null +++ b/packages/hunk/src/ui/status/types.ts @@ -0,0 +1,55 @@ +import type { AppBootstrap } from "../../core/bootstrap"; +import type { CliInput, CommonOptions, StatusCommandInput } from "../../core/run/commandInputs"; +import type { PersistedViewPreferences, UserKeyBinding } from "../../core/run/config"; +import type { InteractiveSessionInitialization } from "../../core/session/initialization"; +import type { + ExtensionVcsHistoryReviewAction, + ExtensionVcsStatusCapability, + ExtensionVcsStatusSnapshot, +} from "../../extension-api/types"; +import type { ExtensionSession } from "../../extensions/session"; +import type { ExtensionLoadResult } from "../../extensions/types"; +import type { InteractiveHistoryRuntime } from "../history/types"; + +/** Describe status resources without giving the presentation layer startup or trust authority. */ +export interface StatusRuntime { + input: StatusCommandInput; + snapshot: ExtensionVcsStatusSnapshot; + providerId: string; + providerName: string; + startupCwd: string; + launchOptions: CommonOptions; + extensionSession: ExtensionSession; + initialization: InteractiveSessionInitialization; + keybindings: Readonly>; + initialViewPreferences: PersistedViewPreferences; + viewPreferencesConfigPath?: string; + promptSaveViewPreferences: boolean; + notices: readonly string[]; + load(targetPath?: string, signal?: AbortSignal): Promise; + loadSiblings( + snapshot: ExtensionVcsStatusSnapshot, + signal?: AbortSignal, + ): Promise; + planReview( + snapshot: ExtensionVcsStatusSnapshot, + actionId: string, + signal?: AbortSignal, + ): ReturnType; + watchPlan( + snapshot: ExtensionVcsStatusSnapshot, + signal?: AbortSignal, + ): ReturnType>; + openHistory(targetPath: string, signal?: AbortSignal): Promise; + prepareReview( + input: CliInput, + cwd: string, + signal?: AbortSignal, + ): Promise>; + prepareHistoryReview( + action: ExtensionVcsHistoryReviewAction, + cwd: string, + signal?: AbortSignal, + ): Promise>; + close(): Promise; +} diff --git a/packages/hunk/src/ui/themes/types.ts b/packages/hunk/src/ui/themes/types.ts index 783733773..758e1bb74 100644 --- a/packages/hunk/src/ui/themes/types.ts +++ b/packages/hunk/src/ui/themes/types.ts @@ -20,7 +20,9 @@ export interface AppTheme { addedContentBg: string; removedContentBg: string; contextContentBg: string; + /** Positive foreground; status uses this for staged changes, independent of change type. */ addedSignColor: string; + /** Negative foreground; status uses this for unstaged/untracked changes, not just deletions. */ removedSignColor: string; lineNumberBg: string; lineNumberFg: string; diff --git a/scripts/generate/generate-docs.test.ts b/scripts/generate/generate-docs.test.ts index 7ccf053d2..aaea35823 100644 --- a/scripts/generate/generate-docs.test.ts +++ b/scripts/generate/generate-docs.test.ts @@ -37,7 +37,10 @@ describe("generated website references", () => { expect(reference).toContain("--no-transparent-bg"); expect(reference).toContain("hunk markup render"); expect(reference).not.toContain("## `hunk log`"); - expect(reference).not.toContain("`--vcs `"); + const statusSection = reference.split("## `hunk status`")[1]!.split("\n## ")[0]!; + expect(statusSection).toContain("`--vcs `"); + expect(statusSection).toContain("Q returns or quits"); + expect(reference.split("## `hunk status`")[0]).not.toContain("`--vcs `"); expect(reference).toMatch( new RegExp( `\\| \\x60${SESSION_BROKER_HOST_ENV}\\x60\\s+\\| Bind host; defaults to loopback \\x60${DEFAULT_SESSION_BROKER_HOST}\\x60\\.`, diff --git a/test/cli/status.test.ts b/test/cli/status.test.ts new file mode 100644 index 000000000..9554b43f0 --- /dev/null +++ b/test/cli/status.test.ts @@ -0,0 +1,176 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { prepareStartupPlan } from "../../packages/hunk/src/app/startup"; + +const dirs: string[] = []; +const main = resolve(import.meta.dir, "../../packages/hunk/src/main.tsx"); +/** Run the source CLI without a terminal or shell. */ +function runTestCommand(cwd: string, argv: string[]) { + const result = Bun.spawnSync(argv, { + cwd, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, XDG_CONFIG_HOME: join(cwd, "config-home") }, + }); + return { + code: result.exitCode, + stdout: result.stdout.toString(), + stderr: result.stderr.toString(), + }; +} +/** Create a clean Git workspace without inheriting developer status configuration. */ +function createTestRepo() { + const cwd = mkdtempSync(join(tmpdir(), "hunk-status-cli-")); + dirs.push(cwd); + for (const args of [ + ["init", "-qb", "main"], + ["config", "user.name", "Status Test"], + ["config", "user.email", "status@example.com"], + ]) { + expect(runTestCommand(cwd, ["git", ...args]).code).toBe(0); + } + writeFileSync(join(cwd, "file.txt"), "one\n"); + expect(runTestCommand(cwd, ["git", "add", "."]).code).toBe(0); + expect(runTestCommand(cwd, ["git", "commit", "-qm", "initial"]).code).toBe(0); + return cwd; +} +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("hunk status CLI contract", () => { + test("redirects one JSON/static snapshot without touching index or fetching", () => { + const cwd = createTestRepo(); + writeFileSync(join(cwd, "file.txt"), "two\n"); + const index = statSync(join(cwd, ".git", "index")); + expect( + runTestCommand(cwd, [ + "git", + "config", + "remote.origin.url", + "https://example.invalid/never-contact", + ]).code, + ).toBe(0); + const json = runTestCommand(cwd, [ + process.execPath, + "run", + main, + "status", + "--json", + "--no-extensions", + "--color", + "always", + ]); + expect(json.code).toBe(0); + expect(json.stderr).toBe(""); + expect(JSON.parse(json.stdout)).toMatchObject({ + schemaVersion: 1, + changedPathCount: 1, + paths: [{ path: "file.txt", index: "unchanged", worktree: "modified" }], + siblings: { state: "ready" }, + }); + expect(json.stdout).not.toContain("\x1b"); + expect(statSync(join(cwd, ".git", "index")).mtimeMs).toBe(index.mtimeMs); + const text = runTestCommand(cwd, [process.execPath, "run", main, "status", "--no-extensions"]); + expect(text.code).toBe(0); + expect(text.stdout).toContain("1 changed path"); + expect(text.stdout).toContain("Other worktrees"); + expect(text.stdout).not.toContain("\x1b"); + expect( + runTestCommand(cwd, [process.execPath, "run", main, "diff", "--no-extensions"]).code, + ).toBe(0); + expect( + runTestCommand(cwd, [process.execPath, "run", main, "show", "--no-extensions"]).code, + ).toBe(0); + expect( + runTestCommand(cwd, [process.execPath, "run", main, "log", "--static", "--no-extensions"]) + .code, + ).toBe(0); + }); + test("reports outside, bare and unsupported provider errors with nonzero exits", () => { + const cwd = mkdtempSync(join(tmpdir(), "hunk-status-errors-")); + dirs.push(cwd); + const outside = runTestCommand(cwd, [ + process.execPath, + "run", + main, + "status", + "--json", + "--no-extensions", + ]); + expect(outside.code).toBe(1); + expect(outside.stderr).toContain("not a git repository"); + expect(outside.stdout).toBe(""); + expect(runTestCommand(cwd, ["git", "init", "--bare", "-q"]).code).toBe(0); + const bare = runTestCommand(cwd, [process.execPath, "run", main, "status", "--no-extensions"]); + expect(bare.code).toBe(1); + expect(bare.stderr).toContain("bare"); + const unsupported = runTestCommand(cwd, [ + process.execPath, + "run", + main, + "status", + "--vcs", + "jj", + "--no-extensions", + ]); + expect(unsupported.code).toBe(1); + expect(unsupported.stderr).toContain("does not support workspace status"); + const help = runTestCommand(cwd, [process.execPath, "run", main, "status", "--help"]); + expect(help.code).toBe(0); + expect(help.stdout).toContain("--json"); + expect(help.stdout).toContain("--static"); + }); + test("chooses a typed interactive plan only for terminal ownership and honors shared config", async () => { + const cwd = createTestRepo(); + const configHome = join(cwd, "config-home"); + mkdirSync(join(configHome, "hunk"), { recursive: true }); + writeFileSync( + join(configHome, "hunk", "config.toml"), + 'theme = "nord"\nline_numbers = false\n', + ); + for (const [flags, stdinIsTTY, stdoutIsTTY, expected] of [ + [[], true, true, "status-interactive"], + [["--static"], true, true, "status-static"], + [["--json"], true, true, "status-static"], + [[], false, true, "status-static"], + [[], true, false, "status-static"], + ] as const) { + const plan = await prepareStartupPlan( + [process.execPath, main, "status", "--no-extensions", ...flags], + { + cwd, + env: { ...process.env, XDG_CONFIG_HOME: configHome }, + stdinIsTTY, + stdoutIsTTY, + }, + ); + expect(plan.kind).toBe(expected); + if (plan.kind !== "status-static" && plan.kind !== "status-interactive") + throw new Error("Unexpected startup route"); + try { + expect(plan.bootstrap.initialization.theme.initialTheme).toBe("nord"); + expect(plan.bootstrap.initialization.viewPreferences.showLineNumbers).toBe(false); + expect(plan.bootstrap.snapshot.siblings.state).toBe("loading"); + } finally { + await plan.bootstrap.close(); + await plan.bootstrap.extensionSession.shutdown(); + } + } + }); + test("loads the static entry without renderer dependencies", async () => { + const result = await Bun.build({ + entrypoints: [ + resolve(import.meta.dir, "../../packages/hunk/src/ui/status/runStaticStatus.ts"), + ], + target: "bun", + external: ["@opentui/*", "react", "@pierre/diffs"], + }); + expect(result.success).toBe(true); + const output = await result.outputs[0]!.text(); + expect(output).not.toMatch(/from ["'](?:@opentui|react|@pierre\/diffs)/); + }); +}); diff --git a/test/helpers/status-runtime.ts b/test/helpers/status-runtime.ts new file mode 100644 index 000000000..f2531be52 --- /dev/null +++ b/test/helpers/status-runtime.ts @@ -0,0 +1,169 @@ +import { join } from "node:path"; +import type { StatusRuntime } from "../../packages/hunk/src/ui/status/types"; +import { persistedViewPreferencesFromOptions } from "../../packages/hunk/src/core/run/config"; +import { createTestExtensionSession } from "./extension-session"; +import { createTestStatusSnapshot } from "./vcsStatus"; +import { createTestVcsAppBootstrap } from "./app-bootstrap"; +import { createTestDiffFile } from "./diff-helpers"; + +/** Create status resources with real borrowed extension ownership and no filesystem watchers. */ +export function createTestStatusRuntime(): StatusRuntime { + const snapshot = createTestStatusSnapshot(join(process.cwd(), "status-test-origin")); + snapshot.paths = [ + { path: "alpha.ts", index: "modified", worktree: "modified", conflict: false }, + { path: "beta.ts", index: "unchanged", worktree: "modified", conflict: false }, + ]; + snapshot.changedPathCount = 2; + snapshot.reviewActions = [ + { id: "staged", label: "Review staged changes" }, + { id: "unstaged", label: "Review unstaged changes" }, + ]; + snapshot.siblings = { + state: "ready", + value: { + truncated: false, + worktrees: [ + { + worktree: { + ...snapshot.worktree, + id: "sibling", + path: join(process.cwd(), "status-test-sibling"), + }, + branch: "sibling-branch", + detached: false, + bare: false, + inspectable: true, + status: { + state: "ready", + observedAt: snapshot.observedAt, + changedPathCount: 0, + conflictCount: 0, + operations: { state: "ready", value: [] }, + }, + }, + ], + }, + }; + const preferences = persistedViewPreferencesFromOptions({ theme: "nord", experimental: true }); + const runtime: StatusRuntime = { + input: { kind: "status", static: false, json: false, color: "always", options: {} }, + snapshot, + providerId: "test", + providerName: "Test", + startupCwd: process.cwd(), + launchOptions: { experimental: true }, + extensionSession: createTestExtensionSession(), + initialization: { + theme: { + initialTheme: "nord", + customThemes: [{ id: "status-custom", label: "Status custom", accent: "#123456" }], + }, + viewPreferences: preferences, + }, + keybindings: {}, + initialViewPreferences: preferences, + promptSaveViewPreferences: false, + notices: [], + async load(path) { + return path === snapshot.worktree.path || !path + ? structuredClone(snapshot) + : { + ...structuredClone(snapshot), + worktree: { ...snapshot.worktree, id: "sibling", path }, + head: { kind: "branch", name: "sibling-branch", revisionId: "b" }, + siblings: { state: "ready", value: { worktrees: [], truncated: false } }, + }; + }, + async loadSiblings(snapshot) { + return snapshot; + }, + async watchPlan() { + return { coverage: "poll-only", targets: [] }; + }, + async planReview(snapshot, id) { + return { + cwd: snapshot.worktree.path, + input: { kind: "vcs", staged: id === "staged", options: {} }, + }; + }, + async prepareReview(input, cwd) { + const bootstrap = createTestVcsAppBootstrap({ + files: [ + createTestDiffFile({ id: "alpha.ts", path: "alpha.ts" }), + createTestDiffFile({ id: "beta.ts", path: "beta.ts" }), + ], + }); + bootstrap.input = { ...input, options: { ...runtime.launchOptions, ...input.options } }; + bootstrap.reloadContext.cwd = cwd; + bootstrap.extensions = runtime.extensionSession.current; + bootstrap.customThemes = runtime.initialization.theme.customThemes; + return { ...bootstrap, extensions: runtime.extensionSession.current }; + }, + async prepareHistoryReview(action, cwd, signal) { + return runtime.prepareReview( + { + kind: "show", + ref: action.kind === "revision-show" ? action.revisionId : action.toRevisionId, + options: {}, + }, + cwd, + signal, + ); + }, + async openHistory(path) { + const source = { + async read() { + return { + commits: [ + { + revisionId: "status-commit", + displayId: "commit", + parentRevisionIds: [], + subject: "Status history commit", + authorName: "Ada", + authoredAt: "2026-01-01T00:00:00Z", + decorations: [], + }, + ], + done: true, + }; + }, + async close() {}, + }; + return { + input: { + kind: "history", + static: false, + color: "always", + format: "medium", + ascii: false, + extensionsEnabled: false, + extensionPaths: [], + }, + source, + providerId: "test", + providerName: "Test", + startupCwd: runtime.startupCwd, + repoRoot: path, + extensionSession: runtime.extensionSession, + notices: [], + customThemes: runtime.initialization.theme.customThemes, + initialization: runtime.initialization, + initialViewPreferences: preferences, + keybindings: {}, + promptSaveViewPreferences: false, + async planReview(commit) { + return { kind: "revision-show", revisionId: commit.revisionId }; + }, + async reopenSource() { + return source; + }, + async close() { + await source.close(); + }, + }; + }, + async close() {}, + }; + return runtime; +} diff --git a/test/helpers/vcsStatus.ts b/test/helpers/vcsStatus.ts new file mode 100644 index 000000000..52d53972c --- /dev/null +++ b/test/helpers/vcsStatus.ts @@ -0,0 +1,18 @@ +import type { ExtensionVcsStatusSnapshot } from "../../packages/hunk/src/extension-api/types"; + +/** Create a small JSON-safe status snapshot for boundary, bootstrap and projection tests. */ +export function createTestStatusSnapshot(path = "workspace"): ExtensionVcsStatusSnapshot { + return { + schemaVersion: 1, + observedAt: "2026-01-02T03:04:05.000Z", + worktree: { id: path, path, repositoryId: "test-repository" }, + token: "test-status-token", + head: { kind: "branch", name: "main", revisionId: "a".repeat(40) }, + upstream: { state: "ready", value: { kind: "none" } }, + operations: { state: "ready", value: [] }, + paths: [], + changedPathCount: 0, + reviewActions: [], + siblings: { state: "loading" }, + }; +} diff --git a/test/pty/status-integration.test.ts b/test/pty/status-integration.test.ts new file mode 100644 index 000000000..950b37555 --- /dev/null +++ b/test/pty/status-integration.test.ts @@ -0,0 +1,756 @@ +import { afterEach, expect, setDefaultTimeout, test } from "bun:test"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname, resolve } from "node:path"; +import { createServer } from "node:http"; +import { writeExtensionTrust, resolveRepoTrust } from "../../packages/hunk/src/extensions/trust"; +import { createTestStatusSnapshot } from "../helpers/vcsStatus"; +import { createPtyHarness } from "./harness"; + +const harness = createPtyHarness(); +const roots: string[] = []; +setDefaultTimeout(45_000); + +/** Run deterministic, shell-free Git fixture commands. */ +function runStatusPtyTestGit(cwd: string, args: string[]) { + const result = Bun.spawnSync(["git", ...args], { + cwd, + env: { + ...process.env, + GIT_AUTHOR_NAME: "Status", + GIT_AUTHOR_EMAIL: "status@example.com", + GIT_COMMITTER_NAME: "Status", + GIT_COMMITTER_EMAIL: "status@example.com", + }, + stdout: "pipe", + stderr: "pipe", + }); + if (result.exitCode) throw new Error(result.stderr.toString()); +} +/** Create mixed changes plus a linked sibling without modifying the developer checkout. */ +function createStatusPtyTestRepo() { + const root = mkdtempSync(join(tmpdir(), "hunk-status-pty-")); + roots.push(root); + const cwd = join(root, "origin"); + const sibling = join(root, "sibling"); + mkdirSync(cwd); + runStatusPtyTestGit(cwd, ["init", "-qb", "main"]); + writeFileSync(join(cwd, "alpha.ts"), "export const statusAlpha = 1;\n"); + writeFileSync(join(cwd, "beta.ts"), "export const statusBeta = 1;\n"); + runStatusPtyTestGit(cwd, ["add", "."]); + runStatusPtyTestGit(cwd, ["commit", "-qm", "Status initial commit"]); + runStatusPtyTestGit(cwd, ["worktree", "add", "-qb", "sibling-branch", sibling]); + writeFileSync(join(cwd, "alpha.ts"), "export const statusAlpha = 2;\n"); + runStatusPtyTestGit(cwd, ["add", "alpha.ts"]); + writeFileSync(join(cwd, "alpha.ts"), "export const statusAlpha = 3;\n"); + writeFileSync(join(cwd, "beta.ts"), "export const statusBeta = 2;\n"); + return { cwd, sibling }; +} +afterEach(() => { + harness.cleanup(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +test("status reuses real multi-file diff and log routes, with mouse actions and terminal cleanup", async () => { + const { cwd } = createStatusPtyTestRepo(); + const session = await harness.launchHunk({ + cwd, + args: ["status", "--no-extensions", "--color", "never"], + cols: 120, + rows: 24, + }); + try { + const status = await session.waitForText(/sibling-branch/, { timeout: 15_000 }); + expect(status).toContain("alpha.ts modified (staged) · modified (unstaged)"); + expect(status).toContain("beta.ts modified (unstaged)"); + expect(status).toContain("Tracked changes (2)"); + expect(status).toContain("Untracked files (0)"); + expect(status).not.toContain("Index / worktree"); + const strip = status.split("\n").find((line) => line.includes("Working tree changes"))!; + expect(strip).not.toMatch(/Log|Quit/); + const boundary = status.split("\n").findIndex((line) => line.includes("── Other worktrees ──")); + expect(boundary).toBeGreaterThan(0); + expect(status.split("\n")[boundary - 1]!.trim()).toBe(""); + expect(status).not.toContain("Selected file"); + // The ordinary action row exposes the same full-comparison action as U. + // Tuistory includes pre-alternate-screen scrollback; mouse coordinates are screen-relative. + const screenLines = status.split("\n").slice(-24); + const actionRow = screenLines.findIndex((line) => line.includes("Working tree changes")); + const actionColumn = screenLines[actionRow]!.indexOf("Working tree changes"); + session.writeRaw( + `\x1b[<0;${actionColumn + 2};${actionRow + 1}M\x1b[<0;${actionColumn + 2};${actionRow + 1}m`, + ); + const review = await session.waitForText(/statusBeta = 2/, { timeout: 15_000 }); + expect(review).toContain("statusAlpha = 3"); + await session.press("q"); + await session.waitForText(/Other worktrees/, { timeout: 15_000 }); + await session.press("l"); + await session.waitForText(/Status initial commit/, { timeout: 15_000 }); + await session.press("enter"); + await session.waitForText(/statusAlpha = 1/, { timeout: 15_000 }); + await session.press("q"); + await session.waitForText(/Status initial commit/, { timeout: 15_000 }); + await session.press("q"); + await session.waitForText(/Other worktrees/, { timeout: 15_000 }); + await session.press("q"); + await harness.waitForSnapshot( + session, + () => session.getRawOutput().includes("\x1b[?1049l"), + 5000, + ); + expect(session.getRawOutput()).toContain("\x1b[?1049l"); + } finally { + session.close(); + } +}); + +test("narrow/short resize, edit refresh and sibling inspect/back preserve the single status view", async () => { + const { cwd, sibling } = createStatusPtyTestRepo(); + const session = await harness.launchHunk({ + cwd, + args: ["status", "--no-extensions", "--color", "never"], + cols: 100, + rows: 24, + }); + try { + await session.waitForText(/sibling-branch/, { timeout: 15_000 }); + await session.press("down"); + writeFileSync(join(cwd, "aaa-new.ts"), "export const newStatusFile = true;\n"); + await session.waitForText(/3 changed paths/, { timeout: 15_000 }); + session.resize({ cols: 42, rows: 10 }); + const narrow = await session.waitForText(/beta.ts/, { timeout: 5000 }); + expect(narrow).toContain("Other worktrees:"); + expect(narrow).not.toContain("Selected file"); + session.resize({ cols: 120, rows: 24 }); + await session.waitForText(/sibling-branch/, { timeout: 5000 }); + // A new path appends after surviving paths, so beta remains focused after refresh. + await session.press("down"); + await session.press("down"); + await session.press("enter"); + await session.waitForText(/Clean working tree/, { timeout: 15_000 }); + const inspected = await session.text({ immediate: true }); + expect(inspected).toContain(sibling); + await session.press("q"); + await session.waitForText(/3 changed paths/, { timeout: 15_000 }); + session.writeRaw("\x03"); + await harness.waitForSnapshot( + session, + () => session.getRawOutput().includes("\x1b[?1049l"), + 5000, + ); + } finally { + session.close(); + } +}); + +test("launch custom themes survive sibling manual/watch reloads and reopening through log", async () => { + const { cwd, sibling } = createStatusPtyTestRepo(); + mkdirSync(join(cwd, ".hunk")); + mkdirSync(join(sibling, ".hunk")); + writeFileSync(join(cwd, ".git", "info", "exclude"), ".hunk/\n"); + writeFileSync( + join(cwd, ".hunk", "config.toml"), + 'theme = "launch-custom"\nwatch = true\nprompt_save_view_preferences = false\n\n[themes.launch-custom]\nlabel = "Launch custom"\naccent = "#123456"\n', + ); + writeFileSync(join(sibling, ".hunk", "config.toml"), 'theme = "nord"\n'); + writeFileSync(join(sibling, "alpha.ts"), "export const statusAlpha = 8;\n"); + const session = await harness.launchHunk({ + cwd, + args: ["status", "--no-extensions"], + cols: 120, + rows: 24, + }); + try { + await session.waitForText(/sibling-branch/, { timeout: 15_000 }); + await session.press("down"); + await session.press("down"); + await session.press("enter"); + await session.waitForText(/1 changed path/, { timeout: 15_000 }); + await session.press("u"); + await session.waitForText(/statusAlpha = 8/, { timeout: 15_000 }); + await session.press("r"); + await session.waitIdle(); + writeFileSync(join(sibling, "alpha.ts"), "export const statusAlpha = 9;\n"); + await session.waitForText(/statusAlpha = 9/, { timeout: 15_000 }); + await session.press("t"); + await session.waitForText(/›\s+Launch custom/, { timeout: 5000 }); + await session.press("escape"); + await session.press("q"); + await session.waitForText(/Other worktrees/, { timeout: 15_000 }); + await session.press("t"); + await session.waitForText(/›\s+Launch custom/, { timeout: 5000 }); + await session.press("escape"); + await session.press("l"); + await session.waitForText(/Status initial commit/, { timeout: 15_000 }); + await session.press("enter"); + await session.waitForText(/statusAlpha = 1/, { timeout: 15_000 }); + await session.press("t"); + await session.waitForText(/›\s+Launch custom/, { timeout: 5000 }); + await session.press("down"); + await session.press("enter"); + await session.press("q"); + await session.waitForText(/Status initial commit/, { timeout: 15_000 }); + await session.press("q"); + await session.waitForText(/Other worktrees/, { timeout: 15_000 }); + await session.press("t"); + await session.waitForText(/›\s+andromeeda/, { timeout: 5000 }); + session.writeRaw("\x03"); + await harness.waitForSnapshot( + session, + () => session.getRawOutput().includes("\x1b[?1049l"), + 5000, + ); + } finally { + session.close(); + } +}); + +test("explicit untracked rows remain in the full comparison after refresh without changing aggregate exclusion", async () => { + const { cwd } = createStatusPtyTestRepo(); + mkdirSync(join(cwd, ".hunk")); + writeFileSync(join(cwd, ".git", "info", "exclude"), ".hunk/\n"); + writeFileSync(join(cwd, ".hunk", "config.toml"), "exclude_untracked = true\n"); + writeFileSync(join(cwd, "zzz-status.ts"), "export const explicitUntracked = true;\n"); + const session = await harness.launchHunk({ + cwd, + args: ["status", "--no-extensions", "--color", "never"], + cols: 120, + rows: 24, + }); + try { + await session.waitForText(/zzz-status.ts/, { timeout: 15_000 }); + await session.press("down"); + await session.press("down"); + await session.press("enter"); + await session.waitForText(/explicitUntracked = true/, { timeout: 15_000 }); + await session.press("r"); + await session.waitIdle(); + expect(await session.text({ immediate: true })).toContain("explicitUntracked = true"); + await session.press("q"); + await session.waitForText(/Other worktrees/, { timeout: 15_000 }); + await session.press("u"); + await session.waitForText(/statusAlpha = 3/, { timeout: 15_000 }); + expect(await session.text({ immediate: true })).not.toContain("zzz-status.ts"); + session.writeRaw("\x03"); + await harness.waitForSnapshot( + session, + () => session.getRawOutput().includes("\x1b[?1049l"), + 5000, + ); + } finally { + session.close(); + } +}); + +test("oversized wrapped paths remain keyboard/mouse reachable after narrow and short resize", async () => { + const { cwd } = createStatusPtyTestRepo(); + const path = join("z".repeat(85), "y".repeat(85), "x".repeat(85), "status-tail.ts"); + mkdirSync(dirname(join(cwd, path)), { recursive: true }); + writeFileSync(join(cwd, path), "export const tallStatusTarget = true;\n"); + const session = await harness.launchHunk({ + cwd, + args: ["status", "--no-extensions"], + cols: 30, + rows: 10, + }); + try { + await session.waitForText(/alpha.ts/, { timeout: 15_000 }); + await session.press("down"); + await session.press("down"); + for (let i = 0; i < 12; i++) { + if ( + (await session.text({ immediate: true })) + .split("\n") + .slice(-10) + .map((line) => line.trim()) + .join("") + .includes("status-tail.ts") + ) + break; + await session.press("down"); + } + expect( + (await session.text({ immediate: true })) + .split("\n") + .slice(-10) + .map((line) => line.trim()) + .join(""), + ).toContain("status-tail.ts"); + session.resize({ cols: 34, rows: 11 }); + await session.waitIdle(); + for (let i = 0; i < 4; i++) { + if ( + (await session.text({ immediate: true })) + .split("\n") + .slice(-11) + .map((line) => line.trim()) + .join("") + .includes("zzzzzz") + ) + break; + session.writeRaw("\x1b[<64;5;6M"); + await session.waitIdle(); + } + expect( + (await session.text({ immediate: true })) + .split("\n") + .slice(-11) + .map((line) => line.trim()) + .join(""), + ).toContain("zzzzzz"); + for (let i = 0; i < 4; i++) { + if ( + (await session.text({ immediate: true })) + .split("\n") + .slice(-11) + .map((line) => line.trim()) + .join("") + .includes("status-tail.ts") + ) + break; + session.writeRaw("\x1b[<65;5;6M"); + await session.waitIdle(); + } + expect( + (await session.text({ immediate: true })) + .split("\n") + .slice(-11) + .map((line) => line.trim()) + .join(""), + ).toContain("status-tail.ts"); + // Enter still opens the long path's full comparison, not the next row below it. + session.resize({ cols: 120, rows: 24 }); + await session.waitIdle(); + await session.press("enter"); + await session.waitForText(/tallStatusTarget = true/, { timeout: 15_000 }); + } finally { + session.close(); + } +}); + +/** Poll externally observable lifecycle facts without relying on fixed startup sleeps. */ +async function waitStatusPtyTestFact(read: () => T | undefined | Promise) { + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + const result = await read(); + if (result !== undefined) return result; + await Bun.sleep(50); + } + throw new Error("Timed out waiting for status lifecycle fact"); +} + +/** Allocate a private loopback broker endpoint for this terminal integration. */ +async function reserveStatusPtyTestPort() { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const port = (server.address() as import("node:net").AddressInfo).port; + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + return port; +} + +test("enabled trusted launch extensions span real broker-mounted sibling reviews and shut down once", async () => { + const { cwd, sibling } = createStatusPtyTestRepo(); + const configHome = harness.createIsolatedConfigHome(); + const events = join(dirname(cwd), "extension-events.log"); + const forbidden = join(dirname(cwd), "sibling-extension-ran"); + mkdirSync(join(cwd, ".hunk", "extensions"), { recursive: true }); + mkdirSync(join(sibling, ".hunk", "extensions"), { recursive: true }); + writeFileSync(join(cwd, ".git", "info", "exclude"), ".hunk/\n"); + writeFileSync( + join(cwd, ".hunk", "extensions", "owned.ts"), + ` + import { appendFileSync } from "node:fs"; + export default function(hunk) { + appendFileSync(${JSON.stringify(events)}, "factory\\n"); + hunk.on("startup", () => appendFileSync(${JSON.stringify(events)}, "startup\\n")); + hunk.on("shutdown", () => appendFileSync(${JSON.stringify(events)}, "shutdown\\n")); + } + `, + ); + writeFileSync( + join(sibling, ".hunk", "extensions", "forbidden.ts"), + `import { writeFileSync } from "node:fs"; export default () => writeFileSync(${JSON.stringify(forbidden)}, "ran");`, + ); + const env = { ...process.env, XDG_CONFIG_HOME: configHome, XDG_RUNTIME_DIR: configHome }; + writeExtensionTrust(cwd, "trusted", { env }); + expect(resolveRepoTrust(sibling, { env })).toBe("unknown"); + writeFileSync(join(sibling, "alpha.ts"), "export const siblingBrokerTarget = 42;\n"); + const port = await reserveStatusPtyTestPort(); + const session = await harness.launchHunk({ + cwd, + args: ["status", "--vcs", "git"], + cols: 120, + rows: 24, + env: { + XDG_CONFIG_HOME: configHome, + XDG_RUNTIME_DIR: configHome, + HUNK_MCP_DISABLE: "0", + HUNK_MCP_PORT: String(port), + }, + }); + let daemonPid: number | undefined; + const cli = (args: string[]) => { + const result = Bun.spawnSync( + [ + process.execPath, + "run", + resolve(import.meta.dir, "../../packages/hunk/src/main.tsx"), + "session", + ...args, + ], + { + env: { ...env, HUNK_MCP_PORT: String(port) }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }, + ); + if (result.exitCode !== 0) throw new Error(result.stderr.toString()); + return JSON.parse(result.stdout.toString()); + }; + try { + await session.waitForText(/sibling-branch/, { timeout: 15_000 }); + expect(readFileSync(events, "utf8")).toBe("factory\nstartup\n"); + await session.press("down"); + await session.press("down"); + await session.press("enter"); + await session.waitForText(/1 changed path/, { timeout: 15_000 }); + await session.press("u"); + await session.waitForText(/siblingBrokerTarget = 42/, { timeout: 15_000 }); + await waitStatusPtyTestFact(async () => { + try { + const response = await fetch(`http://127.0.0.1:${port}/health`); + return response.ok ? true : undefined; + } catch { + return undefined; + } + }); + // Public health deliberately contains no PID; only this isolated launch record owns teardown. + daemonPid = JSON.parse( + readFileSync(join(configHome, "hunk-mcp", `daemon-127-0-0-1-${port}.json`), "utf8"), + ).pid; + const mounted = await waitStatusPtyTestFact(() => cli(["list", "--json"]).sessions[0]); + expect(mounted.cwd).toBe(sibling); + expect(mounted.repoRoot).toBe(sibling); + const navigated = cli([ + "navigate", + mounted.sessionId, + "--file", + "alpha.ts", + "--new-line", + "1", + "--json", + ]); + expect(navigated.result).toMatchObject({ + filePath: "alpha.ts", + revealed: "line", + side: "new", + line: 1, + }); + await session.press("q"); + await session.waitForText(/Other worktrees/, { timeout: 15_000 }); + await waitStatusPtyTestFact(() => + cli(["list", "--json"]).sessions.length === 0 ? true : undefined, + ); + await session.press("l"); + await session.waitForText(/Status initial commit/, { timeout: 15_000 }); + expect(cli(["list", "--json"]).sessions).toHaveLength(0); + await session.press("enter"); + await session.waitForText(/statusAlpha = 1/, { timeout: 15_000 }); + const reopened = await waitStatusPtyTestFact(() => cli(["list", "--json"]).sessions[0]); + expect(reopened.cwd).toBe(sibling); + expect(reopened.sessionId).not.toBe(mounted.sessionId); + expect(readFileSync(events, "utf8")).toBe("factory\nstartup\n"); + expect(existsSync(forbidden)).toBe(false); + expect(resolveRepoTrust(sibling, { env })).toBe("unknown"); + session.writeRaw("\x03"); + await waitStatusPtyTestFact(() => + readFileSync(events, "utf8").includes("shutdown") ? true : undefined, + ); + await harness.waitForSnapshot( + session, + () => session.getRawOutput().includes("\x1b[?1049l"), + 5000, + ); + expect(readFileSync(events, "utf8")).toBe("factory\nstartup\nshutdown\n"); + await waitStatusPtyTestFact(() => + cli(["list", "--json"]).sessions.length === 0 ? true : undefined, + ); + } finally { + session.close(); + if (daemonPid) { + try { + process.kill(daemonPid, "SIGTERM"); + } catch {} + } + } +}); + +test("unavailable/error sibling rows stay disabled and pending Log preparation cancels locally and globally", async () => { + const { cwd } = createStatusPtyTestRepo(); + const extension = join(dirname(cwd), "status-provider.ts"); + const events = join(dirname(cwd), "pending-events.log"); + const snapshot = createTestStatusSnapshot(cwd); + const siblings = { + truncated: false, + worktrees: ["unavailable", "error"].map((state, index) => ({ + worktree: { + ...snapshot.worktree, + id: `failed-${index}`, + path: join(dirname(cwd), `failed-${index}`), + }, + branch: `failed-${index}`, + detached: false, + bare: false, + inspectable: false, + status: { + state, + message: state === "unavailable" ? "Missing worktree" : "Cannot read worktree", + }, + })), + }; + writeFileSync( + extension, + ` + import { appendFileSync } from "node:fs"; + export default function(hunk) { + const record = (text) => appendFileSync(${JSON.stringify(events)}, text + "\\n"); + hunk.registerVcsAdapter({ id: "status-test", name: "Status test", detect: () => null, + status: { + read: async () => (${JSON.stringify(snapshot)}), + readSiblings: async () => (${JSON.stringify(siblings)}), + planReview: async () => { throw new Error("No review"); }, + }, + history: { + open: async () => ({ + read: ({signal}) => { record("read"); return new Promise(resolve => signal.addEventListener("abort", () => { record("aborted"); resolve({commits: [], done: true}); }, {once: true})); }, + close: async () => { record("close"); throw new Error("Fixture cleanup rejected"); }, + }), + planReview: () => ({kind: "revision-show", revisionId: "unused"}), + }, + }); + } + `, + ); + const session = await harness.launchHunk({ + cwd, + args: ["status", "--vcs", "status-test", "--extension", extension], + cols: 120, + rows: 24, + }); + try { + const ready = await session.waitForText(/Cannot read worktree/, { timeout: 15_000 }); + expect(ready).toContain("unavailable: Missing worktree"); + expect(ready).toContain("error: Cannot read worktree"); + await session.press("down"); + await session.press("enter"); + await session.waitIdle(); + expect(await session.text({ immediate: true })).toContain("Cannot read worktree"); + await session.press("l"); + await session.waitForText(/Preparing…/, { timeout: 5000 }); + await waitStatusPtyTestFact(() => (existsSync(events) ? true : undefined)); + await session.press("q"); + await session.waitForText(/Fixture cleanup rejected/, { timeout: 5000 }); + expect(readFileSync(events, "utf8")).toBe("read\naborted\nclose\n"); + await session.press("l"); + await session.waitForText(/Preparing…/, { timeout: 5000 }); + await waitStatusPtyTestFact(() => + readFileSync(events, "utf8").split("read\n").length === 3 ? true : undefined, + ); + session.writeRaw("\x03"); + await harness.waitForSnapshot( + session, + () => session.getRawOutput().includes("\x1b[?1049l"), + 5000, + ); + expect(readFileSync(events, "utf8")).toBe("read\naborted\nclose\nread\naborted\nclose\n"); + } finally { + session.close(); + } +}); + +for (const color of ["always", "never"] as const) { + test(`live status spans honor custom staging colors and remain legible with color ${color}`, async () => { + const { cwd } = createStatusPtyTestRepo(); + mkdirSync(join(cwd, ".hunk")); + writeFileSync(join(cwd, ".git", "info", "exclude"), ".hunk/\n"); + writeFileSync( + join(cwd, ".hunk", "config.toml"), + `theme = "status-signs" +prompt_save_view_preferences = false +[themes.status-signs] +base = "nord" +addedSignColor = "#123456" +removedSignColor = "#654321" +accent = "#abcdef" +text = "#eeeeee" +muted = "#aaaaaa" +`, + ); + writeFileSync(join(cwd, "staged.ts"), "export const staged = true;\n"); + runStatusPtyTestGit(cwd, ["add", "staged.ts"]); + writeFileSync(join(cwd, "untracked.ts"), "export const untracked = true;\n"); + const hash = Bun.spawnSync(["git", "rev-parse", "HEAD:alpha.ts"], { cwd, stdout: "pipe" }) + .stdout.toString() + .trim(); + const unmerged = Bun.spawnSync(["git", "update-index", "--index-info"], { + cwd, + stdin: Buffer.from( + [1, 2, 3].map((stage) => `100644 ${hash} ${stage}\tconflict.ts\n`).join(""), + ), + stdout: "pipe", + stderr: "pipe", + }); + expect(unmerged.exitCode).toBe(0); + const session = await harness.launchHunk({ + cwd, + args: ["status", "--no-extensions", "--color", color], + cols: 120, + rows: 24, + }); + try { + const ready = await session.waitForText(/sibling-branch/, { timeout: 15_000 }); + for (const line of [ + "alpha.ts modified (staged) · modified (unstaged)", + "beta.ts modified (unstaged)", + "staged.ts new file (staged)", + "untracked.ts", + "conflict.ts unmerged (staged) · unmerged (unstaged) · conflict", + ]) + expect(ready).toContain(line); + /** Expand ASCII fixture rows to verify each label and filename cell's actual paint. */ + const cellsFor = (name: string) => { + const row = session.getTerminalData().lines.find((line) => + line.spans + .map((span) => span.text) + .join("") + .includes(name), + ); + expect(row).toBeDefined(); + return row!.spans.flatMap((span) => [...span.text].map((text) => ({ text, fg: span.fg }))); + }; + const expected = (value: string) => (color === "always" ? value : "#ffffff"); + const mixed = cellsFor("alpha.ts"); + const mixedText = mixed.map((cell) => cell.text).join(""); + expect(mixed[mixedText.indexOf("modified (staged)")]!.fg).toBe(expected("#123456")); + expect(mixed[mixedText.indexOf("modified (unstaged)")]!.fg).toBe(expected("#654321")); + expect(mixed[mixedText.indexOf("alpha.ts")]!.fg).toBe(expected("#eeeeee")); + for (const [name, label, foreground] of [ + ["beta.ts", "modified (unstaged)", "#654321"], + ["staged.ts", "new file (staged)", "#123456"], + ["untracked.ts", "untracked.ts", "#654321"], + ["conflict.ts", "unmerged (staged)", "#abcdef"], + ]) { + const cells = cellsFor(name!); + const text = cells.map((cell) => cell.text).join(""); + expect(cells[text.indexOf(name!)]!.fg).toBe(expected(foreground!)); + expect(cells[text.indexOf(label!)]!.fg).toBe(expected(foreground!)); + } + const sibling = cellsFor("sibling-branch"); + const siblingText = sibling.map((cell) => cell.text).join(""); + expect(sibling[siblingText.indexOf("sibling-branch")]!.fg).toBe(expected("#abcdef")); + expect(sibling[siblingText.indexOf(dirname(cwd))]!.fg).toBe(expected("#aaaaaa")); + session.resize({ cols: 42, rows: 12 }); + await session.waitIdle(); + // Scroll to the last path; the section boundary is in-flow, not a fixed header/inspector. + for (let i = 0; i < 4; i++) await session.press("down"); + const narrow = (await session.text({ immediate: true })).split("\n").slice(-12).join("\n"); + expect(narrow).toContain("untracked.ts"); + await session.press("down"); + const siblingView = (await session.text({ immediate: true })) + .split("\n") + .slice(-12) + .join("\n"); + expect(siblingView).toContain("── Other worktrees ──"); + expect(siblingView).toContain("sibling-branch"); + expect(siblingView).not.toContain("Selected file"); + } finally { + session.close(); + } + }); +} + +test("bounded file groups expand independently by keyboard and mouse, retain routes, and keep worktrees reachable", async () => { + const { cwd } = createStatusPtyTestRepo(); + for (let i = 0; i < 9; i++) + writeFileSync(join(cwd, `tracked-${i}.ts`), `export const added${i} = true;\n`); + runStatusPtyTestGit(cwd, ["add", ...Array.from({ length: 9 }, (_, i) => `tracked-${i}.ts`)]); + for (let i = 0; i < 11; i++) + writeFileSync( + join(cwd, `new-${String(i).padStart(2, "0")}.ts`), + "export const newFile = true;\n", + ); + const session = await harness.launchHunk({ + cwd, + args: ["status", "--no-extensions", "--color", "never"], + cols: 42, + rows: 40, + }); + const screen = async () => + (await session.text({ immediate: true })).split("\n").slice(-40).join("\n"); + const click = async (needle: string, occurrence = 0) => { + const lines = (await screen()).split("\n"); + const y = lines.map((line, i) => (line.includes(needle) ? i : -1)).filter((i) => i >= 0)[ + occurrence + ]!; + expect(y).toBeDefined(); + const x = lines[y]!.indexOf(needle); + session.writeRaw(`\x1b[<0;${x + 2};${y + 1}M\x1b[<0;${x + 2};${y + 1}m`); + await session.waitIdle(); + }; + try { + await session.waitForText(/sibling-branch/, { timeout: 15_000 }); + const collapsed = await screen(); + expect(collapsed).toContain("Tracked changes (11)"); + expect(collapsed).toContain("Untracked files (11)"); + expect(collapsed.match(/and 1 more file/g)).toHaveLength(2); + expect(collapsed).not.toContain("tracked-8.ts"); + expect(collapsed).not.toContain("new-10.ts"); + expect(collapsed).toContain("── Other worktrees ──"); + expect(collapsed).not.toMatch(/Log|Quit|Index \/ worktree|MM |\.M /); + for (let i = 0; i < 10; i++) await session.press("down"); + await session.press("enter"); + expect(await screen()).toContain("tracked-8.ts"); + expect(await screen()).toContain("Show fewer"); + expect(await screen()).not.toContain("new-10.ts"); + await click("and 1 more file"); + expect(await screen()).toContain("new-10.ts"); + // Mouse expansion must leave keyboard focus on the tracked group's action. + await session.press("space"); + expect(await screen()).not.toContain("tracked-8.ts"); + expect(await screen()).toContain("new-10.ts"); + await session.press("r"); + await session.waitIdle(); + await session.press("l"); + await session.waitForText(/Status initial commit/, { timeout: 15_000 }); + await session.press("q"); + await session.waitForText(/sibling-branch/, { timeout: 15_000 }); + expect(await screen()).not.toContain("tracked-8.ts"); + expect(await screen()).toContain("new-10.ts"); + await click("sibling-branch"); + await click("sibling-branch"); + await session.waitForText(/Clean working tree/, { timeout: 15_000 }); + expect(await screen()).toContain("Back"); + await session.press("q"); + await session.waitForText(/new-10.ts/, { timeout: 15_000 }); + expect(await screen()).not.toContain("tracked-8.ts"); + session.resize({ cols: 32, rows: 14 }); + await session.waitIdle(); + await session.press("up"); + await session.press("space"); + const narrow = (await session.text({ immediate: true })).split("\n").slice(-14).join("\n"); + expect(narrow).toContain("and 1 more file"); + expect(narrow).not.toContain("new-10.ts"); + await session.press("down"); + expect((await session.text({ immediate: true })).split("\n").slice(-14).join("\n")).toContain( + "sibling-branch", + ); + } finally { + session.close(); + } +}); diff --git a/test/smoke/tty.test.ts b/test/smoke/tty.test.ts index df4a3eae9..8a0f4784c 100644 --- a/test/smoke/tty.test.ts +++ b/test/smoke/tty.test.ts @@ -1,5 +1,5 @@ import { afterAll, afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { cleanupTestConfigHomes, createTestConfigHome } from "../helpers/config-home"; @@ -585,4 +585,70 @@ describe("TTY render smoke", () => { expect(output).toContain("@@ -1 +1,2 @@"); expect(output).toContain("export const answer = 42;"); }); + + ttyTest( + "status enters a real multi-file diff and returns before restoring the terminal", + async () => { + const root = mkdtempSync(join(tmpdir(), "hunk-status-tty-")); + tempDirs.push(root); + const cwd = join(root, "repo"); + mkdirSync(cwd); + const git = (args: string[]) => { + const result = Bun.spawnSync(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + GIT_AUTHOR_NAME: "Status", + GIT_AUTHOR_EMAIL: "status@example.com", + GIT_COMMITTER_NAME: "Status", + GIT_COMMITTER_EMAIL: "status@example.com", + }, + }); + if (result.exitCode) throw new Error(result.stderr.toString()); + }; + git(["init", "-qb", "main"]); + writeFileSync(join(cwd, "one.ts"), "export const statusOne = 1;\n"); + writeFileSync(join(cwd, "two.ts"), "export const statusTwo = 1;\n"); + git(["add", "."]); + git(["commit", "-qm", "Status smoke"]); + writeFileSync(join(cwd, "one.ts"), "export const statusOne = 2;\n"); + writeFileSync(join(cwd, "two.ts"), "export const statusTwo = 2;\n"); + const transcript = join(root, "status.typescript"); + const proc = spawnTtySmokeProcess( + `${shellQuote(process.execPath)} run ${shellQuote(sourceEntrypoint)} status --vcs git --no-extensions`, + cwd, + transcript, + ); + try { + const status = await waitForTranscript(proc, transcript, "status ready", (output) => + output.includes("Other worktrees"), + ); + const review = await writeTtyInputUntil( + proc, + transcript, + status.length, + "u", + "full comparison", + (output) => output.includes("statusTwo = 2"), + ); + expect(stripTerminalControl(review)).toContain("statusOne = 2"); + await writeTtyInput(proc, "q"); + await waitForTranscriptUpdate( + proc, + transcript, + review.length, + "return to status", + (output) => output.includes("Other worktrees"), + ); + await writeTtyInput(proc, "q"); + await waitForTtyExit(proc); + expect(await readTranscript(transcript)).toContain("\x1b[?1049l"); + } finally { + proc.kill(); + await proc.exited; + } + }, + ); }); diff --git a/website/src/content/docs/docs/reference/cli.md b/website/src/content/docs/docs/reference/cli.md index 87dd1bd63..ab6c73733 100644 --- a/website/src/content/docs/docs/reference/cli.md +++ b/website/src/content/docs/docs/reference/cli.md @@ -90,6 +90,57 @@ Also accepts `--watch`: auto-reload when the current diff input changes. Also accepts every [common review option](#common-review-options). +## `hunk status` + +inspect the current workspace and sibling worktrees + +### Usage + +```bash +hunk status [--static | --json] +``` + +Terminals open live workspace status; redirects receive one static snapshot. + +Enter inspects a row; S/U review staged/working-tree changes; L opens Log; Q returns or quits. + +F10 opens menus, T chooses a theme, R refreshes, and W expands other worktrees. + +--json emits the versioned status snapshot without paging or terminal color. + +Status never fetches or modifies the repository; providers without status report unsupported. + +### Command-specific options + +| Option | Description | +| --------------------------- | --------------------------------------------------------------- | +| `--mode ` | layout mode: auto, split, unified | +| `--cursor-line