diff --git a/.changeset/README.md b/.changeset/README.md index 8d816bd88..91da40838 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -14,7 +14,9 @@ Select `hunkdiff` and choose the semver bump that matches the shipped CLI/packag - `minor` for new user-facing features - `major` for breaking changes -`package.json` intentionally lists `"."` in `workspaces` so Changesets can discover the root `hunkdiff` package. Keep that entry unless Hunk moves the publishable package out of the repository root. +The private root workspace lists `packages/*`, and Changesets discovers the published `hunkdiff` +package at `packages/hunk/package.json`. Private implementation workspaces remain ignored and are +never published. For maintenance-only PRs that should not appear in release notes, create an empty changeset: @@ -28,6 +30,9 @@ Release prep runs: bun run release:version ``` -That consumes the pending `.changeset/*.md` files, updates `CHANGELOG.md`, and bumps package versions for the release commit. +`CHANGELOG.md` at the repository root is the canonical changelog consumed by the website and +release tooling. The version wrapper temporarily stages that history beside the package for +Changesets, then copies the generated release entry back to the root and removes the temporary +package changelog. It also bumps `packages/hunk/package.json` for the release commit. After the tag release publishes npm packages and GitHub release assets, verify Homebrew through `Homebrew/homebrew-core`. Hunk is on Homebrew's Autobump list, so do not open manual simple version-bump PRs. Wait for the automated `hunk ` PR, confirm it merges, then verify `brew install hunk` resolves to the released version. Use `brew bump-formula-pr hunk --version ` only if Homebrew maintainers ask for a manual bump or Autobump stalls unexpectedly. diff --git a/.changeset/brave-rows-narrow.md b/.changeset/brave-rows-narrow.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/brave-rows-narrow.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/bright-pens-reply.md b/.changeset/bright-pens-reply.md new file mode 100644 index 000000000..12206c849 --- /dev/null +++ b/.changeset/bright-pens-reply.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Add session CLI support for replying to existing inline review notes with inherited code anchors. diff --git a/.changeset/bun-1-4-2-runtime.md b/.changeset/bun-1-4-2-runtime.md new file mode 100644 index 000000000..3b3bbf48d --- /dev/null +++ b/.changeset/bun-1-4-2-runtime.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Upgrade the bundled Bun runtime to 1.4.2 for lower memory use and runtime fixes. diff --git a/.changeset/calm-rows-compare.md b/.changeset/calm-rows-compare.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/calm-rows-compare.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/calm-vcs-foundation.md b/.changeset/calm-vcs-foundation.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/calm-vcs-foundation.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/cancellable-shared-history-extensions.md b/.changeset/cancellable-shared-history-extensions.md new file mode 100644 index 000000000..7806a652b --- /dev/null +++ b/.changeset/cancellable-shared-history-extensions.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Keep commit reviews responsive while bundled VCS commands run, and share one correctly owned extension lifecycle across retained history and embedded reviews. diff --git a/.changeset/clean-rows-plan.md b/.changeset/clean-rows-plan.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/clean-rows-plan.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/collapsible-file-tree.md b/.changeset/collapsible-file-tree.md new file mode 100644 index 000000000..4ef7c1a4f --- /dev/null +++ b/.changeset/collapsible-file-tree.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Allow folders in the wide sidebar tree to be collapsed and expanded with the mouse while file navigation reveals destinations hidden inside collapsed folders. diff --git a/.changeset/commit-review-info.md b/.changeset/commit-review-info.md new file mode 100644 index 000000000..35460fc44 --- /dev/null +++ b/.changeset/commit-review-info.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Show history-style commit metadata in the review-info panel when opening a commit from interactive `hunk log`, with a copyable right-aligned revision and public pane clipboard/theme support. diff --git a/.changeset/cool-rows-target.md b/.changeset/cool-rows-target.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/cool-rows-target.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/explicit-extension-session-owner.md b/.changeset/explicit-extension-session-owner.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/explicit-extension-session-owner.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/flicker-free-history-review.md b/.changeset/flicker-free-history-review.md new file mode 100644 index 000000000..abb8a3bc2 --- /dev/null +++ b/.changeset/flicker-free-history-review.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Keep the themed history loading screen visible until a selected commit review is ready to claim the terminal. diff --git a/.changeset/funky-candles-drive.md b/.changeset/funky-candles-drive.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/funky-candles-drive.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/fuzzy-keys-match.md b/.changeset/fuzzy-keys-match.md new file mode 100644 index 000000000..c9f4bfeb1 --- /dev/null +++ b/.changeset/fuzzy-keys-match.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Make `alt` and `option` keybindings work with both legacy terminal and Kitty keyboard encodings while preserving distinct Meta bindings when the terminal reports them. diff --git a/.changeset/github-style-history-groups.md b/.changeset/github-style-history-groups.md new file mode 100644 index 000000000..ba551d6e5 --- /dev/null +++ b/.changeset/github-style-history-groups.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Redesign interactive `hunk log` with themed GitHub-style day groups, account-like author handles, relative timestamps, and an optional commit graph view. diff --git a/.changeset/large-showers-heal.md b/.changeset/large-showers-heal.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/large-showers-heal.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/list-hunk-history.md b/.changeset/list-hunk-history.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/list-hunk-history.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/neat-raw-rows.md b/.changeset/neat-raw-rows.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/neat-raw-rows.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/neat-rows-dispatch.md b/.changeset/neat-rows-dispatch.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/neat-rows-dispatch.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/organize-repository-scripts.md b/.changeset/organize-repository-scripts.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/organize-repository-scripts.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/package-first-phase-one.md b/.changeset/package-first-phase-one.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/package-first-phase-one.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/persistent-history-review.md b/.changeset/persistent-history-review.md new file mode 100644 index 000000000..8eff3a6d3 --- /dev/null +++ b/.changeset/persistent-history-review.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Keep `hunk log` and opened commit reviews in one terminal renderer so returning never exposes previous terminal output. diff --git a/.changeset/pretty-git-history.md b/.changeset/pretty-git-history.md new file mode 100644 index 000000000..49cef1d39 --- /dev/null +++ b/.changeset/pretty-git-history.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Add themed, static-first Git and Jujutsu history with compact output and an interactive browser that opens selected commits in Hunk. diff --git a/.changeset/quiet-logs-save.md b/.changeset/quiet-logs-save.md new file mode 100644 index 000000000..4d03b727e --- /dev/null +++ b/.changeset/quiet-logs-save.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Prompt to save theme changes when quitting an interactive `hunk log` session, matching other review commands. diff --git a/.changeset/responsive-log.md b/.changeset/responsive-log.md new file mode 100644 index 000000000..d9e685794 --- /dev/null +++ b/.changeset/responsive-log.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Open `hunk log` as an auto-responsive terminal browser with GitHub-inspired rows, right-aligned commit links and copy actions, while preserving static output for pipes and `--static`. diff --git a/.changeset/shared-hunk-session-router.md b/.changeset/shared-hunk-session-router.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/shared-hunk-session-router.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/shared-log-chrome.md b/.changeset/shared-log-chrome.md new file mode 100644 index 000000000..5bd4ad805 --- /dev/null +++ b/.changeset/shared-log-chrome.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Add Hunk's desktop menu chrome, live theme picker, and provider-owned merge-parent selection to the interactive repository history browser. diff --git a/.changeset/short-git-sha-titles.md b/.changeset/short-git-sha-titles.md new file mode 100644 index 000000000..18131575f --- /dev/null +++ b/.changeset/short-git-sha-titles.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Shorten full Git commit IDs to seven characters in comparison titles so the title bar stays compact. diff --git a/.changeset/static-output-without-tty.md b/.changeset/static-output-without-tty.md new file mode 100644 index 000000000..42550ca20 --- /dev/null +++ b/.changeset/static-output-without-tty.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Print reviews as static plain text instead of launching the interactive TUI when stdout is not a terminal. diff --git a/.changeset/steady-job-control-tests.md b/.changeset/steady-job-control-tests.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/steady-job-control-tests.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/steady-jobs-resume.md b/.changeset/steady-jobs-resume.md new file mode 100644 index 000000000..23efd2b4a --- /dev/null +++ b/.changeset/steady-jobs-resume.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Keep suspended Hunk jobs alive so `fg` restores the TUI and its in-progress state. diff --git a/.changeset/strong-rats-listen.md b/.changeset/strong-rats-listen.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/strong-rats-listen.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/tall-videos-read.md b/.changeset/tall-videos-read.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/tall-videos-read.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/tiny-vms-shell.md b/.changeset/tiny-vms-shell.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/tiny-vms-shell.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/wide-snails-feel.md b/.changeset/wide-snails-feel.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/wide-snails-feel.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index c20aae747..b1e294a8b 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -1,5 +1,5 @@ /** - * Enforces module boundaries on the production import graph (`src/` plus `packages/`). + * Enforces module boundaries on the production import graph (`packages/hunk/src/` plus `packages/`). * * Each rule names one boundary of the target architecture described in * docs/module-boundaries.md. Pre-existing violations live in @@ -8,27 +8,28 @@ * `bun run deps:check` fails on any violation not in the baseline. */ -// UI files allowed to couple to src/app and src/session: the composition shell, the two -// named session adapter hooks, and the session-navigation resolution helper those hooks -// share. Everything else in src/ui stays presentation-only. +// UI files allowed to couple to packages/hunk/src/app and packages/hunk/src/session: App/AppHost, +// HunkSessionHost, runInteractiveApp, the named session adapter hooks, and their shared navigation +// helper. Everything else in packages/hunk/src/ui stays presentation-only. const UI_SESSION_ADAPTERS = [ - "^src/ui/App\\.tsx$", - "^src/ui/AppHost\\.tsx$", - "^src/ui/runInteractiveApp\\.tsx$", - "^src/ui/hooks/useHunkSessionBridge\\.ts$", - "^src/ui/hooks/useTerminalReview\\.ts$", - "^src/ui/lib/reviewState\\.ts$", + "^packages/hunk/src/ui/App\\.tsx$", + "^packages/hunk/src/ui/AppHost\\.tsx$", + "^packages/hunk/src/ui/runInteractiveApp\\.tsx$", + "^packages/hunk/src/ui/session/HunkSessionHost\\.tsx$", + "^packages/hunk/src/ui/hooks/useHunkSessionBridge\\.ts$", + "^packages/hunk/src/ui/hooks/useTerminalReview\\.ts$", + "^packages/hunk/src/ui/lib/reviewState\\.ts$", ]; // Every way the shipped product is entered: the CLI, the highlight worker thread, the two -// published facades, and the skill generator. A module under src/ that no entry reaches, +// published facades, and the skill generator. A module under packages/hunk/src/ that no entry reaches, // directly or transitively, is not in the product. const PRODUCTION_ENTRY_POINTS = [ - "^src/main\\.tsx$", - "^src/highlightWorkerEntry\\.ts$", - "^src/opentui/index\\.ts$", - "^src/extension-api/index\\.ts$", - "^src/hunk-review/skillDocument\\.ts$", + "^packages/hunk/src/main\\.tsx$", + "^packages/hunk/src/highlightWorkerEntry\\.ts$", + "^packages/hunk/src/opentui/index\\.ts$", + "^packages/hunk/src/extension-api/index\\.ts$", + "^packages/hunk/src/hunk-review/skillDocument\\.ts$", ]; // Modules kept alive by tests alone. The cruise excludes tests, so these look unreachable @@ -37,12 +38,12 @@ const PRODUCTION_ENTRY_POINTS = [ // without the coverage to justify it. const TEST_ONLY_MODULES = [ // Note-height measurement exercised by the review-conformance corpus. - "^src/core/review/noteSize\\.ts$", + "^packages/hunk/src/core/review/noteSize\\.ts$", // The floating agent-note popover and its measurement helper. Nothing renders them since // notes moved into the diff flow as STML cards; their unit tests are the only consumers // left, so they are quarantined here until that call is made rather than deleted blind. - "^src/ui/components/panes/AgentCard\\.tsx$", - "^src/ui/lib/agentPopover\\.ts$", + "^packages/hunk/src/ui/components/panes/AgentCard\\.tsx$", + "^packages/hunk/src/ui/lib/agentPopover\\.ts$", ]; module.exports = { @@ -58,75 +59,122 @@ module.exports = { { name: "extension-api-is-import-free", comment: - "src/extension-api is the published contract; declaration emission publishes whatever it reaches (scripts/check-pack.ts gates the pack, this gates the graph).", + "packages/hunk/src/extension-api is the published contract; declaration emission publishes whatever it reaches (scripts/packaging/check-pack.ts gates the pack, this gates the graph).", severity: "error", - from: { path: "^src/extension-api/" }, - to: { path: "^(src|packages)/", pathNot: "^src/extension-api/" }, + from: { path: "^packages/hunk/src/extension-api/" }, + to: { path: "^packages/", pathNot: "^packages/hunk/src/extension-api/" }, + }, + { + name: "hunk-vcs-stays-provider-neutral", + comment: + "@hunk/vcs owns dependency-bottom implementation helpers; it never imports Hunk, public contracts, or a provider implementation.", + severity: "error", + from: { path: "^packages/hunk-vcs/src/" }, + to: { path: "^packages/", pathNot: "^packages/hunk-vcs/src/" }, + }, + { + name: "hunk-git-stays-on-vcs-contract", + comment: + "@hunk/git owns the Git provider and may reach only its local modules, the public extension contract, and explicit dependency-bottom @hunk/vcs leaves.", + severity: "error", + from: { path: "^packages/hunk-git/src/" }, + to: { + path: "^packages/", + pathNot: "^packages/(hunk-git|hunk-vcs)/src/|^packages/hunk/src/extension-api/", + }, + }, + { + name: "hunk-jj-stays-on-vcs-contract", + comment: + "@hunk/jj owns the Jujutsu provider and may reach only its local modules, the public extension contract, and explicit dependency-bottom @hunk/vcs leaves.", + severity: "error", + from: { path: "^packages/hunk-jj/src/" }, + to: { + path: "^packages/", + pathNot: "^packages/(hunk-jj|hunk-vcs)/src/|^packages/hunk/src/extension-api/", + }, + }, + { + name: "hunk-sapling-stays-on-vcs-contract", + comment: + "@hunk/sapling owns the Sapling provider and may reach only its local modules, the public extension contract, and explicit dependency-bottom @hunk/vcs leaves.", + severity: "error", + from: { path: "^packages/hunk-sapling/src/" }, + to: { + path: "^packages/", + pathNot: "^packages/(hunk-sapling|hunk-vcs)/src/|^packages/hunk/src/extension-api/", + }, }, { name: "lib-is-a-leaf", comment: - "src/lib holds dependency-free helpers usable from any tier; it may reach the import-free extension API contract and nothing else.", + "packages/hunk/src/lib holds leaf compatibility exports; it may reach the extension contract and dependency-bottom @hunk/vcs helpers only.", severity: "error", - from: { path: "^src/lib/" }, - to: { path: "^(src|packages)/", pathNot: "^src/(lib|extension-api)/" }, + from: { path: "^packages/hunk/src/lib/" }, + to: { + path: "^packages/", + pathNot: "^packages/(hunk/src/(lib|extension-api)/|hunk-vcs/src/)", + }, }, { name: "core-stays-domain", comment: - "src/core is the domain model. It may use src/lib and the extension-api contract, but never the UI, app composition, session brokering, extension host, or opentui facade above it.", + "packages/hunk/src/core is the domain model. It may use packages/hunk/src/lib and the extension-api contract, but never the UI, app composition, session brokering, extension host, or opentui facade above it.", severity: "error", - from: { path: "^src/core/" }, - to: { path: "^src/(ui|app|session|extensions|opentui)/" }, + from: { path: "^packages/hunk/src/core/" }, + to: { path: "^packages/hunk/src/(ui|app|session|extensions|opentui)/" }, }, { name: "extensions-host-stays-below-surfaces", comment: - "The extension host and bundled extensions sit below the surfaces that load them. The bundled UI tier (src/extensions/default/ui/) is exempt from the src/ui half by documented design: its dogfooding boundary is the published props contract — data, actions, theme — while rendering helpers are host code (see the sidebar module header).", + "The extension host and bundled extensions sit below the surfaces that load them. The bundled UI tier (packages/hunk/src/extensions/default/ui/) is exempt from the packages/hunk/src/ui half by documented design: its dogfooding boundary is the published props contract — data, actions, theme — while rendering helpers are host code (see the sidebar module header).", severity: "error", - from: { path: "^src/extensions/", pathNot: "^src/extensions/default/ui/" }, - to: { path: "^src/(ui|app|session|opentui)/" }, + from: { + path: "^packages/hunk/src/extensions/", + pathNot: "^packages/hunk/src/extensions/default/ui/", + }, + to: { path: "^packages/hunk/src/(ui|app|session|opentui)/" }, }, { name: "bundled-ui-extensions-render-only", comment: - "The bundled UI tier may consume src/ui rendering helpers as host code, but composition and session brokering stay out of reach — a pane gets its data and actions through the published props.", + "The bundled UI tier may consume packages/hunk/src/ui rendering helpers as host code, but composition and session brokering stay out of reach — a pane gets its data and actions through the published props.", severity: "error", - from: { path: "^src/extensions/default/ui/" }, - to: { path: "^src/(app|session|opentui)/" }, + from: { path: "^packages/hunk/src/extensions/default/ui/" }, + to: { path: "^packages/hunk/src/(app|session|opentui)/" }, }, { name: "session-stays-below-app-and-ui", comment: - "src/session brokers transport and protocol. It consumes core and packages; the app tier registers into it, not the other way round.", + "packages/hunk/src/session brokers transport and protocol. It consumes core and packages; the app tier registers into it, not the other way round.", severity: "error", - from: { path: "^src/session/" }, - to: { path: "^src/(ui|app|extensions|opentui)/" }, + from: { path: "^packages/hunk/src/session/" }, + to: { path: "^packages/hunk/src/(ui|app|extensions|opentui)/" }, }, { name: "app-composes-without-ui", comment: - "src/app wires core, extensions, and session together for startup; rendering stays in src/ui, which imports app — never the reverse.", + "packages/hunk/src/app wires core, extensions, and session together for startup; rendering stays in packages/hunk/src/ui, which imports app — never the reverse.", severity: "error", - from: { path: "^src/app/" }, - to: { path: "^src/(ui|opentui)/" }, + from: { path: "^packages/hunk/src/app/" }, + to: { path: "^packages/hunk/src/(ui|opentui)/" }, }, { name: "ui-couples-to-session-via-adapters", comment: - "Only the composition shell and the named session adapter hooks may import src/app or src/session; ordinary UI components and helpers stay presentation-only so the review surface can move to other hosts.", + "Only the composition shell and the named session adapter hooks may import packages/hunk/src/app or packages/hunk/src/session; ordinary UI components and helpers stay presentation-only so the review surface can move to other hosts.", severity: "error", - from: { path: "^src/ui/", pathNot: UI_SESSION_ADAPTERS }, - to: { path: "^src/(app|session)/" }, + from: { path: "^packages/hunk/src/ui/", pathNot: UI_SESSION_ADAPTERS }, + to: { path: "^packages/hunk/src/(app|session)/" }, }, { name: "no-dead-modules", comment: - "Every module under src/ earns its place by being reachable from an entry point. Dead files are worse than clutter: they still import, so they hold boundaries hostage and answer questions nobody asks. `orphan` only catches fully disconnected files, which misses dead code that still has dependencies — reachability catches both. A flagged module is either deleted or, if tests are its only real consumer, listed in TEST_ONLY_MODULES with a reason.", + "Every module under packages/hunk/src/ earns its place by being reachable from an entry point. Dead files are worse than clutter: they still import, so they hold boundaries hostage and answer questions nobody asks. `orphan` only catches fully disconnected files, which misses dead code that still has dependencies — reachability catches both. A flagged module is either deleted or, if tests are its only real consumer, listed in TEST_ONLY_MODULES with a reason.", severity: "error", from: { path: PRODUCTION_ENTRY_POINTS }, to: { - path: "^src/", + path: "^packages/hunk/src/", pathNot: [...PRODUCTION_ENTRY_POINTS, ...TEST_ONLY_MODULES], reachable: false, }, @@ -137,36 +185,39 @@ module.exports = { "core/bootstrap.ts composes the leaves: it names the changeset, the parsed input, the resolved preferences, and the detected theme mode to describe one launch. A module directory importing it back would invert that layering and rebuild the grab-bag cycle the 2026-08 phases dismantled. core/changeset/loaders.ts is the single exception — loadAppBootstrap assembles the value, so it names the shape it returns; its natural home is the app tier, and moving it there retires this exception.", severity: "error", from: { - path: "^src/core/(changeset|run|process|install|review|vcs|watch|patch|theme)/", - pathNot: "^src/core/changeset/loaders\\.ts$", + path: "^packages/hunk/src/core/(changeset|run|process|install|review|vcs|watch|patch|theme)/", + pathNot: "^packages/hunk/src/core/changeset/loaders\\.ts$", }, - to: { path: "^src/core/bootstrap\\.ts$" }, + to: { path: "^packages/hunk/src/core/bootstrap\\.ts$" }, }, { name: "review-reducer-is-module-internal", comment: - "The review reducer applies actions; callers state intent instead, so surfaces cannot reach past planReviewIntent into the transition table. First of the per-module interior rules — this establishes the mechanism later phases extend to the rest of src/core (identity.ts and the other named model modules stay public by design).", + "The review reducer applies actions; callers state intent instead, so surfaces cannot reach past planReviewIntent into the transition table. First of the per-module interior rules — this establishes the mechanism later phases extend to the rest of packages/hunk/src/core (identity.ts and the other named model modules stay public by design).", severity: "error", - from: { path: "^src/", pathNot: "^src/core/review/" }, - to: { path: "^src/core/review/reducer\\.ts$" }, + from: { path: "^packages/hunk/src/", pathNot: "^packages/hunk/src/core/review/" }, + to: { path: "^packages/hunk/src/core/review/reducer\\.ts$" }, }, { name: "changeset-internals-stay-in-module", comment: "core/changeset owns the changeset model and the pipeline that acquires one. Outsiders name the model, the loaders, and the per-file helpers they build on (model, loaders, diffFile, fileSource, fileLanguage, binary, diffPaths, hunkHeader, hunkSummary); the patch-to-model parse, the Pierre extension-table lookup, and the sidecar reader are steps inside that pipeline, reached through the loaders instead.", severity: "error", - from: { path: "^src/", pathNot: "^src/core/changeset/" }, + from: { path: "^packages/hunk/src/", pathNot: "^packages/hunk/src/core/changeset/" }, to: { - path: "^src/core/changeset/(fromPatch|fileLanguageLookup|sidecar)\\.ts$", + path: "^packages/hunk/src/core/changeset/(fromPatch|fileLanguageLookup|sidecar)\\.ts$", }, }, { name: "packages-stay-standalone", comment: - "Workspace packages are standalone publishable units; they never import the app source tree.", + "Workspace packages are standalone units; bundled providers are governed by their narrower public-contract rules above, while other packages never import the app source tree.", severity: "error", - from: { path: "^packages/" }, - to: { path: "^src/" }, + from: { + path: "^packages/(?!hunk/)", + pathNot: "^packages/hunk-(git|jj|sapling)/", + }, + to: { path: "^packages/hunk/src/" }, }, ], options: { diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 0b2ddb129..8f3f3f5e6 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -29,7 +29,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eabc5cf5d..3132e69af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -49,7 +49,7 @@ jobs: node-version: 22 - name: Install Jujutsu - uses: taiki-e/install-action@07b4745e0c39a41822af610387492e3e53aa222b # v2.83.4 + uses: taiki-e/install-action@5b4d68e2e660441203ab128a23676f1e4faf1532 # v2.86.3 with: tool: jj-cli @@ -104,7 +104,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - name: Install dependencies run: bun install --frozen-lockfile @@ -129,7 +129,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -150,7 +150,7 @@ jobs: pkg_dir="$(mktemp -d)" install_dir="$(mktemp -d)" node_dir="$(dirname "$(command -v node)")" - npm pack --pack-destination "$pkg_dir" >/dev/null + npm pack ./packages/hunk --ignore-scripts --pack-destination "$pkg_dir" >/dev/null pkg="$(find "$pkg_dir" -maxdepth 1 -name 'hunkdiff-*.tgz' | head -n1)" npm install -g --prefix "$install_dir" "$pkg" PATH="$install_dir/bin:$node_dir:/usr/bin:/bin" @@ -182,7 +182,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -218,7 +218,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/install-sh-e2e.yml b/.github/workflows/install-sh-e2e.yml index 52074ef5f..dd5eeb6a3 100644 --- a/.github/workflows/install-sh-e2e.yml +++ b/.github/workflows/install-sh-e2e.yml @@ -1,6 +1,6 @@ # Runs the hunk.dev install script end to end against the newest published release. # -# The unit suite (scripts/install-sh.test.ts) checks the script as text and exercises its +# The unit suite (scripts/packaging/install-sh.test.ts) checks the script as text and exercises its # functions in isolation; this workflow is the only place the real download, checksum, extract, # and PATH flow runs against real release assets. It triggers on changes to the script itself, # on a weekly schedule to catch release-asset drift the script did not cause, and on demand. diff --git a/.github/workflows/install-vm.yml b/.github/workflows/install-vm.yml index 5625ec850..5b2ff8e42 100644 --- a/.github/workflows/install-vm.yml +++ b/.github/workflows/install-vm.yml @@ -31,7 +31,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - name: Install ordinary development dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 7edc6626c..1df76dd82 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -37,7 +37,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install Nix - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: extra_nix_config: | extra-substituters = https://nix-community.cachix.org diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 86b125a71..9954885b7 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -41,13 +41,13 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - name: Verify release-note changeset env: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.sha }} - run: bun run ./scripts/verify-pr-release-notes.ts "$BASE_SHA" "$HEAD_SHA" + run: bun run ./scripts/release/verify-pr-release-notes.ts "$BASE_SHA" "$HEAD_SHA" windows-compat: name: Windows compatibility @@ -64,7 +64,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -72,7 +72,7 @@ jobs: node-version: 22 - name: Install Jujutsu - uses: taiki-e/install-action@07b4745e0c39a41822af610387492e3e53aa222b # v2.83.4 + uses: taiki-e/install-action@5b4d68e2e660441203ab128a23676f1e4faf1532 # v2.86.3 with: tool: jj-cli @@ -117,7 +117,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - name: Install dependencies run: bun install --frozen-lockfile @@ -142,7 +142,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -150,7 +150,7 @@ jobs: node-version: 22 - name: Install Jujutsu - uses: taiki-e/install-action@07b4745e0c39a41822af610387492e3e53aa222b # v2.83.4 + uses: taiki-e/install-action@5b4d68e2e660441203ab128a23676f1e4faf1532 # v2.86.3 with: tool: jj-cli @@ -212,7 +212,7 @@ jobs: pkg_dir="$(mktemp -d)" install_dir="$(mktemp -d)" node_dir="$(dirname "$(command -v node)")" - npm pack --pack-destination "$pkg_dir" >/dev/null + npm pack ./packages/hunk --ignore-scripts --pack-destination "$pkg_dir" >/dev/null pkg="$(find "$pkg_dir" -maxdepth 1 -name 'hunkdiff-*.tgz' | head -n1)" npm install -g --prefix "$install_dir" "$pkg" PATH="$install_dir/bin:$node_dir:/usr/bin:/bin" diff --git a/.github/workflows/release-prebuilt-npm.yml b/.github/workflows/release-prebuilt-npm.yml index 0dc76a89c..3537b0787 100644 --- a/.github/workflows/release-prebuilt-npm.yml +++ b/.github/workflows/release-prebuilt-npm.yml @@ -48,7 +48,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - name: Resolve npm dist-tag and GitHub latest status id: resolve @@ -59,7 +59,7 @@ jobs: if [ "$GITHUB_EVENT_NAME" = "push" ]; then current_latest="$(npm view hunkdiff dist-tags.latest)" fi - resolution="$(bun ./scripts/resolve-release-channel.ts \ + resolution="$(bun ./scripts/release/resolve-release-channel.ts \ --event "$GITHUB_EVENT_NAME" \ --ref "$GITHUB_REF_NAME" \ --requested-tag "$REQUESTED_NPM_TAG" \ @@ -87,14 +87,14 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - name: Install dependencies run: bun install --frozen-lockfile - name: Verify tag matches package version if: github.event_name == 'push' - run: bun run ./scripts/check-release-version.ts "${{ github.ref_name }}" + run: bun run ./scripts/release/check-release-version.ts "${{ github.ref_name }}" - name: Compare release benchmark snapshot id: benchmark-gate @@ -155,7 +155,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - name: Install dependencies run: bun install --frozen-lockfile @@ -163,7 +163,7 @@ jobs: - name: Build host artifact run: | bun run build:bin - bun run ./scripts/build-prebuilt-artifact.ts --expect-package "${{ matrix.package_name }}" + bun run ./scripts/packaging/build-prebuilt-artifact.ts --expect-package "${{ matrix.package_name }}" # Bun's default x64 runtime is compiled for Haswell and raises SIGILL on older CPUs. # Run the real artifact under an emulated pre-AVX CPU so a dropped baseline target @@ -195,7 +195,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -207,7 +207,7 @@ jobs: - name: Verify tag matches package version if: github.event_name == 'push' - run: bun run ./scripts/check-release-version.ts "${{ github.ref_name }}" + run: bun run ./scripts/release/check-release-version.ts "${{ github.ref_name }}" - name: Download platform artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -255,7 +255,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -302,7 +302,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - name: Download platform artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index 139ee2836..7e00075ae 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -24,7 +24,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - name: Install repository dependencies run: bun install --frozen-lockfile @@ -33,7 +33,7 @@ jobs: run: bun install --cwd website --frozen-lockfile - name: Test website tooling - run: bun test scripts/generate-docs.test.ts scripts/check-website-links.test.ts scripts/check-extension-catalog.test.ts + run: bun test scripts/generate/generate-docs.test.ts scripts/website/check-website-links.test.ts scripts/website/check-extension-catalog.test.ts - name: Check generated reference docs run: bun run check:docs diff --git a/AGENTS.md b/AGENTS.md index 1ffb65328..9c6dda1e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,26 +7,40 @@ ## architecture +### workspace map + +```text +packages/hunk/ published CLI, application, and public extension/OpenTUI facades +packages/hunk-vcs/ private dependency-bottom VCS helpers +packages/hunk-{git,jj,sapling}/ private bundled VCS providers +packages/session-broker-core/ low-level broker protocol and state +packages/session-broker/ runtime-neutral broker, daemon, auth, and connection lifecycle +packages/session-broker-{bun,node}/ runtime listener adapters +packages/term-video/ private terminal capture tooling +website/ product/documentation site, not the browser review client +``` + +### application flow + ```text -CLI input - -> parse runtime + config-backed view options - -> normalize into one Changeset / DiffFile model - -> App shell coordinates state, layout, and review navigation - -> pane components render review UI - -> Pierre-backed terminal renderer draws diff rows +main.tsx -> app/startup.ts -> app/cli.ts + -> headless command/history plan returned to main.tsx, or + -> app plan: extensionBootstrap.ts -> sessionBootstrap.ts -> normalized Changeset / DiffFile[] + -> main.tsx lazy-loads runInteractiveApp.tsx -> HunkSessionHost -> AppHost -> App + -> pane/diff planning -> Pierre-backed terminal rows ``` ### shared review seam -Review core serves multiple surfaces: TUI today; web, API, and agent/runtime consumers later. Do -not recreate semantic review behavior in a surface: +Terminal, agent/session, broker/protocol, extension, and HTTP/SSE consumers share review semantics. +The browser client and UI remain planned. Do not recreate semantic review behavior in a surface: ```text DiffFile[] -> projectReviewDocument -> ReviewDocumentV1 -> ReviewStore ReviewIntent + caller facts -> planReviewIntent -> ReviewAction[] -> reducer -> surface projection ``` -- **Model:** `src/core/review/{types,document,identity}.ts` owns the ordered, JSON-safe document. +- **Model:** `packages/hunk/src/core/review/{types,document,identity}.ts` owns the ordered, JSON-safe document. File order is review/sidebar order; use `key` (referenced as `fileKey` elsewhere), `contentIdentity`, and `sourceIdentity` (cached source text additionally requires `sourceAttested`) — not runtime IDs or indexes — across reloads/surfaces. @@ -48,35 +62,39 @@ ReviewIntent + caller facts -> planReviewIntent -> ReviewAction[] -> reducer -> - **Conformance:** `test/review-conformance/` has hand-authored semantic fixtures covering every registered core, terminal, producer, broker, protocol, and extension projection. Every new semantic consumer registers its real projection and runs the whole corpus. - `scripts/source-boundaries.test.ts` keeps the seam + `scripts/quality/source-boundaries.test.ts` keeps the seam renderer/platform-free; its Node-debt list is shrink-only and tombstone lists append-only. A repaid seam finding deletes copies, adds a file or banned-symbol tombstone and adversarial fixture, registers consumers, and updates `docs/browser-review-seam-audit.md`. -- Bundled VCS implementations live under `src/extensions/default/vcs//` and consume the - public extension contract; `src/app` composes their registrations into the provider-neutral - core VCS catalog. Do not add provider commands, spawning, or source readers under `src/core`. +- Bundled VCS implementations live in the private `packages/hunk-{git,jj,sapling}` workspaces and consume + the public `hunkdiff/extension` contract plus explicit `@hunk/vcs/*` implementation leaves; + `packages/hunk/src/app` composes their registrations into the + provider-neutral core VCS catalog. Do not add provider commands, spawning, or source readers under `packages/hunk/src/core`. - `hunk daemon serve` is the one loopback daemon for all live sessions; sessions auto-start and register with it rather than opening per-TUI ports. Reuse `classifyReviewPublication` and `ReviewChunkAssembler` for publication ordering and bounded, digest-verified resources. Browser review stays same-origin with no CORS; each session mints its capability and gives the daemon only its digest. Transport semantics come from the browser-safe review protocol modules and the existing intent path. See `docs/browser-review-rebuild.md` and the relevant module headers. -- User and bundled extensions share one API and registry. Shipped VCS backends and the built-in - sidebar register through the public contract. Keep `src/extension-api/types.ts` import-free, +- User extensions, bundled VCS providers, and bundled UI share one public registration API and + registry model, but use separate registry instances and lifecycles. `ExtensionSession` owns the + user registry across routed surfaces. Keep `packages/hunk/src/extension-api/types.ts` import-free, bundled VCS renderer-free, repo-local extensions trust-gated, and bundled extensions active under `--no-extensions`. See `docs/extension-architecture.md`, `docs/extensions.md`, and - `skills/hunk-extensions/SKILL.md`. + `packages/hunk/skills/hunk-extensions/SKILL.md`. - Sidecar file order is intentional sidebar and review-stream order. - Derive shared rendering, navigation, scrolling, and note behavior from one planning layer. Make shared geometry explicit, and remove obsolete paths instead of retaining parallel implementations. ## architectural rules -- Import boundaries between `src/` top-level trees are enforced by `bun run deps:check` - (dependency-cruiser; rules in `.dependency-cruiser.cjs`, target tiers in - `docs/module-boundaries.md`). The known-violations baseline is shrink-only: fix an edge, rerun - `bun run deps:baseline`, never add to it. +- `bun run deps:check` enforces both the `packages/*` workspace graph and + `packages/hunk/src/*` tiers; `.dependency-cruiser.cjs` is authoritative and + `docs/module-boundaries.md` explains the model. Standalone packages do not import Hunk internals. + Bundled providers use only provider-local modules, platform built-ins, `hunkdiff/extension`, and + explicit `@hunk/vcs/*` leaves. The known-violations baseline is shrink-only: fix an edge, rerun + `bun run deps:baseline`, and never add to it. - Keep the app review-first: the main pane is a single top-to-bottom stream of all visible file diffs. - The sidebar is for navigation. Selecting a file jumps to that file in the main review stream; it should not collapse the main pane to one file. - Keep Pierre as the diff engine and renderer foundation. Do not switch the main renderer back to OpenTUI's built-in `` widget. @@ -86,8 +104,10 @@ ReviewIntent + caller facts -> planReviewIntent -> ReviewAction[] -> reducer -> ## component guidance -- Keep `App` as the orchestration shell for state, navigation, layout, theme, filtering, and pane - coordination; pane rendering belongs in dedicated components. +- `HunkSessionHost` owns history/review routing in one React root. `AppHost` owns reload + serialization, extension adoption, and broker/content/React commit ordering. `App` owns review + interaction, navigation, layout, theme, filtering, and pane coordination. Pane and diff modules + own rendering and geometry. - Confirmation prompts with a small set of choices should reuse `ConfirmDialog` (body rows plus a clickable key-legend action row) instead of composing `ModalFrame` with a hand-rolled footer; keyboard handling for its actions stays in `useAppKeyboardShortcuts`. - Extend existing components or add focused components rather than growing `App` into a monolith. - Shared formatting, ids, and small derivations belong in helpers, not repeated inline. @@ -95,16 +115,16 @@ ReviewIntent + caller facts -> planReviewIntent -> ReviewAction[] -> reducer -> ## theme guidance -- Built-in theme ids and source metadata live in `src/core/theme/catalog.ts`; `src/ui/themes.ts` +- Built-in theme ids and source metadata live in `packages/hunk/src/core/theme/catalog.ts`; `packages/hunk/src/ui/themes.ts` derives Hunk's semantic `AppTheme` values. - When adding or renaming a built-in theme, update validation, public exports, docs/examples, the appropriate Changeset, and tests. Keep source palette tokens separate from semantic mappings and cover non-trivial derived colors. -- `BUNDLED_SHIKI_THEME_DIFF_COLORS` in `src/core/theme/catalog.ts` is generated. Edit the sourcing policy in `scripts/generate-theme-diff-colors.ts`, then run `bun run generate:theme-colors`. +- `BUNDLED_SHIKI_THEME_DIFF_COLORS` in `packages/hunk/src/core/theme/catalog.ts` is generated. Edit the sourcing policy in `scripts/generate/generate-theme-diff-colors.ts`, then run `bun run generate:theme-colors`. ## testing -- Colocate unit tests with the code they cover (`src/core/foo.ts` + `src/core/foo.test.ts`, `src/ui/AppHost.*.test.tsx`, `src/ui/lib/*.test.ts`). +- Colocate unit tests with the code they cover (`packages/hunk/src/core/foo.ts` + `packages/hunk/src/core/foo.test.ts`, `packages/hunk/src/ui/AppHost.*.test.tsx`, `packages/hunk/src/ui/lib/*.test.ts`). - Put shared unit-test helpers in `test/helpers/`. - Name test helpers so they explicitly include `Test` and are clearly test-only (`createTestDiffFile`). - Use repo-level `test/` directories by intent: @@ -112,7 +132,12 @@ ReviewIntent + caller facts -> planReviewIntent -> ReviewAction[] -> reducer -> - `test/session/` for daemon/session integration and end-to-end flows. - `test/pty/` for PTY-backed live UI integration tests. - `test/review-conformance/` for the shared review model's golden fixtures and per-consumer conformance suites. + - `test/session-broker-node/` for real Node listener/adapter conformance. + - `test/session-broker-runtime/` for shared Bun/Node broker fixtures. - `test/smoke/` for opt-in terminal transcript smoke coverage. +- `bun run test` does not include review conformance, PTY, TTY smoke, or real-Node adapter + conformance under `test/session-broker-node/`. Run the dedicated command documented in + `test/README.md` when changing those areas. ## code comments @@ -126,8 +151,9 @@ ReviewIntent + caller facts -> planReviewIntent -> ReviewAction[] -> reducer -> neighboring authority it deliberately leaves elsewhere, and call out preservation or non-reloadable invariants. Prefer concrete flows ("watch changes and manual refresh both rebuild the mounted review") over abstract labels ("handles refresh"). -- Add inline comments for intent, invariants, or tricky behavior that would not be obvious to a fresh reader. -- Skip comments that only narrate what the code already says. +- Add inline comments for intent, invariants, ownership, or non-obvious tradeoffs. Do not narrate + syntax, praise the implementation, address the reader conversationally, or record temporary + implementation history that belongs in Git or maintainer docs. ## naming @@ -145,11 +171,11 @@ ReviewIntent + caller facts -> planReviewIntent -> ReviewAction[] -> reducer -> - Agent context belongs beside the code, not hidden in a separate mode or workflow. - Agent notes are hunk-specific: show notes for the selected hunk, render them in the diff flow near the annotated row, and keep a clear spatial relationship to the code they explain. - Keep note behavior explicit. If the UI intentionally prioritizes one note, one selection, or one active target, encode that as a named policy rather than scattering array-index assumptions through the codebase. -- STML markup notes (experimental) live in `src/ui/lib/stml/`. The layout engine is deliberately a deterministic line layout, not OpenTUI flexbox: the row-windowed review stream needs exact note heights before mount, so `(markup, width)` must always produce the same lines. Colors stay symbolic until render time so measurement never needs a theme. Do not "simplify" this into flexbox renderables, and keep note-card geometry in `agentNoteGeometry` as the single source for rendering, measurement, and agent-facing width reporting. +- STML markup notes (experimental) live in `packages/hunk/src/ui/lib/stml/`. The layout engine is deliberately a deterministic line layout, not OpenTUI flexbox: the row-windowed review stream needs exact note heights before mount, so `(markup, width)` must always produce the same lines. Colors stay symbolic until render time so measurement never needs a theme. Do not "simplify" this into flexbox renderables, and keep note-card geometry in `agentNoteGeometry` as the single source for rendering, measurement, and agent-facing width reporting. - Keep temporary sidecars concise and review-oriented. Their file order is intentional, while the visible note UI remains hunk-note driven rather than showing generic explainer cards. -- Agents review via `skills/hunk-review/SKILL.md` using `hunk session *` commands; do not run interactive TUI commands directly. -- `skills/hunk-review/SKILL.md` is generated. Edit `src/hunk-review/skillDocument.ts`, `src/session/agent/surface.ts`, or `src/session/agent/errors.ts`, then run `bun run generate:skill`; never hand-edit the skill file. +- Agents review via `packages/hunk/skills/hunk-review/SKILL.md` using `hunk session *` commands; do not run interactive TUI commands directly. +- `packages/hunk/skills/hunk-review/SKILL.md` is generated. Edit `packages/hunk/src/hunk-review/skillDocument.ts`, `packages/hunk/src/session/agent/surface.ts`, or `packages/hunk/src/session/agent/errors.ts`, then run `bun run generate:skill`; never hand-edit the skill file. ## binary notes @@ -162,6 +188,8 @@ ReviewIntent + caller facts -> planReviewIntent -> ReviewAction[] -> reducer -> - For rendering changes: run `bun run typecheck`, `bun run test`, `bun run test:integration`, `bun run test:tty-smoke`, and do one real TTY smoke run on an actual diff. - For interaction, layout, scrolling, navigation, windowing, or other terminal-native behavior: add or update PTY integration coverage in `test/pty/*-integration.test.ts` and run it with `bun run test:integration`. +- For broker/runtime changes, run `bun run test:session-broker-node` in addition to the relevant + Bun tests. - For CLI, config, or pager work: make sure the relevant source invocation still works (`diff`, `show`, `patch`, or `pager`). - Preserve current interaction model unless the user asks to change it explicitly. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 84b7d8c4f..c943c3794 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,7 +35,7 @@ Please discuss a change before implementation when it introduces or substantiall Before adding a built-in workflow, integration, or alternate presentation, check whether it can be implemented as an extension. Extensions are usually the better home for opt-in behavior such as VCS integrations, sidebars, file views, commands, keyboard modes, line highlighters, and repository-specific review workflows. -Start with [`docs/extensions.md`](docs/extensions.md) and the checked-in [`examples/extensions/`](examples/extensions/). If you use a coding agent, [`skills/hunk-extensions/SKILL.md`](skills/hunk-extensions/SKILL.md) maps the public API and its implementation. A small prototype is often the fastest way to learn whether the current API is enough. +Start with [`docs/extensions.md`](docs/extensions.md) and the checked-in [`examples/extensions/`](examples/extensions/). If you use a coding agent, [`packages/hunk/skills/hunk-extensions/SKILL.md`](packages/hunk/skills/hunk-extensions/SKILL.md) maps the public API and its implementation. A small prototype is often the fastest way to learn whether the current API is enough. If the extension API cannot express the idea, do not immediately bypass it with feature-specific core code. Explain: @@ -71,7 +71,7 @@ Keep the change focused on one user problem. Before adding another helper, state Requirements: -- Bun 1.3.14+ +- Bun 1.4.2+ - Node.js 22+ for npm package verification and release tasks - Git - macOS, Linux, or Windows @@ -80,11 +80,25 @@ Install dependencies and run Hunk from source: ```bash bun install -bun run src/main.tsx -- diff +bun run packages/hunk/src/main.tsx -- diff ``` Nix users can run `nix develop` or use [direnv](https://direnv.net/) to enter the development shell. +## Repository architecture + +`packages/hunk/` is the shipped application. Private VCS provider, VCS helper, session-broker, and +terminal-video workspaces keep their implementation dependencies separate from Hunk internals. +Start with: + +- [`docs/source-architecture.md`](docs/source-architecture.md) for package and source ownership; +- [`docs/module-boundaries.md`](docs/module-boundaries.md) for legal import direction; +- [`docs/extension-architecture.md`](docs/extension-architecture.md) for extension registries and lifecycle; and +- [`test/README.md`](test/README.md) for test placement and command coverage. + +Run the checks for the area you changed; dedicated review-conformance, broker, PTY, and TTY suites +are not all part of `bun run test`. The test layout lists their commands. + ## Show UI changes For user-visible terminal changes, include visual evidence in the pull request. @@ -95,7 +109,7 @@ For user-visible terminal changes, include visual evidence in the pull request. - Demonstrate keyboard and mouse behavior when the change affects an action that supports both. - Use the real Hunk TUI rather than a mockup or redirected stdout capture. -The source checkout includes [`skills/launch-video/SKILL.md`](skills/launch-video/SKILL.md), which generates polished videos from real PTY-driven Hunk frames. If you use a coding agent, ask it to follow the skill's **single-feature recipe**. The pipeline is Unix-only and requires Chromium and ffmpeg; screenshots are fine when it is not practical to run. +The source checkout includes [`skills/hunk-launch-video/SKILL.md`](skills/hunk-launch-video/SKILL.md), which generates polished videos from real PTY-driven Hunk frames. If you use a coding agent, ask it to follow the skill's **single-feature recipe**. The pipeline is Unix-only and requires Chromium and ffmpeg; screenshots are fine when it is not practical to run. Upload media to the pull request. Do not commit `.video-work/`, captured frames, or encoded videos. diff --git a/README.md b/README.md index d327d7a3f..151610466 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ mise use -g hunk # macOS, Linux, or Windows > [!NOTE] > If you previously installed hunk via `modem-dev/tap`, be sure to uninstall it first with `brew uninstall modem-dev/tap/hunk`. -Windows requires mise 2026.8.6 or newer. Nix users can use the `default` package exported in `flake.nix`; see [nix/README.md](./nix/README.md) for details. Hunk also ships as a default tool in [Omarchy](https://omarchy.org), installed through mise. +Windows requires mise 2026.8.6 or newer. Nix users can use the `default` package exported in `flake.nix`; see [the Nix guide](https://github.com/modem-dev/hunk/blob/main/nix/README.md) for details. Hunk also ships as a default tool in [Omarchy](https://omarchy.org), installed through mise. Requirements: @@ -87,11 +87,24 @@ hunk --fast # experimentally offload eligible syntax highligh hunk diff --watch # auto-reload as the working tree changes 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 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 +owns traversal, filtering, refs, and how a history item opens for review; the bundled Git and +Jujutsu adapters both implement that public capability. Static output keeps full commit, author, +date, message, branch/bookmark, remote, and tag details; `--oneline` provides compact records, and +`--theme` uses the same palette as Hunk review. Interactive history groups commits by local-calendar +day with account-like author handles and relative times, while keeping commit ids right-aligned and +clickable. Enable **Graph view** from the View menu to replace day groups with commit-topology lanes. +After opening a commit, quit its normal Hunk review to return to the same selection. + ### Working with Jujutsu and Sapling -Hunk auto-detects Jujutsu and Sapling checkouts, so `hunk diff [revset]` and `hunk show [revset]` use native revsets inside jj or Sapling workspaces. To override VCS detection, set `vcs = "git"` or `vcs = "jj"` or `vcs = "sl"` in [config](#config). +Hunk auto-detects Jujutsu and Sapling checkouts, so `hunk diff [revset]` and `hunk show [revset]` use native revsets inside jj or Sapling workspaces. `hunk log --vcs jj` also reads JJ history directly, including in a non-colocated workspace. To override VCS detection, set `vcs = "git"` or `vcs = "jj"` or `vcs = "sl"` in [config](#config). ### Working with raw files and patches @@ -115,7 +128,7 @@ A good generic prompt is: Load the Hunk skill and use it for this review. Run `hunk skill path` to get the skill path. ``` -For the full live-session and `--agent-context` workflow guide, see [docs/agent-workflows.md](docs/agent-workflows.md). Experimental rich STML note bodies require starting the review with `--experimental`; plain agent notes remain the default. +For the full live-session and `--agent-context` workflow guide, see [the agent workflow guide](https://github.com/modem-dev/hunk/blob/main/docs/agent-workflows.md). Experimental rich STML note bodies require starting the review with `--experimental`; plain agent notes remain the default. ## Feature comparison @@ -163,7 +176,7 @@ transparent_background = false ``` Choose a built-in theme, `auto`, or a custom theme with `theme`. See -[docs/themes.md](docs/themes.md) for automatic selection, custom theme tables, +[the theme guide](https://hunk.dev/docs/configure/themes/) for automatic selection, custom theme tables, syntax scopes, and legacy syntax-table migration. `exclude_untracked` affects Git/Sapling working-tree `hunk diff` sessions only. @@ -177,7 +190,7 @@ syntax scopes, and legacy syntax-table migration. Every keyboard shortcut is a named command, and a `[keybindings]` table in your user config remaps command ids to the keys you want them on — several keys per command, exclusive claims over defaults, and `false` to unbind. See -[docs/keybindings.md](docs/keybindings.md) for the rules, the chord grammar, +[the keybinding guide](https://hunk.dev/docs/configure/keybindings/) for the rules, the chord grammar, and the full table of built-in commands and their default keys. ### Git integration @@ -267,24 +280,24 @@ Browse community extensions at publish yours by pushing the extension to a repository root and adding that topic. -See [docs/extensions.md](docs/extensions.md) for the full API, the trust model, +See [the extension guide](https://hunk.dev/docs/extend/extensions/) for the full API, the trust model, publishing guidance, and the `[extensions]` / `[extension.]` config reference. Installable examples include a dependency-free -[`hunk gh 123` GitHub PR workflow](examples/extensions/github-pr/), -[review triage](examples/extensions/review-triage/), -[authoritative review snapshot export](examples/extensions/review-snapshot-export/), an optional -[rendered Markdown file view](examples/extensions/rendered-markdown/), and a -[Vim navigation mode](examples/extensions/vim-navigation/) built from public semantic commands. +[`hunk gh 123` GitHub PR workflow](https://github.com/modem-dev/hunk/tree/main/examples/extensions/github-pr), +[review triage](https://github.com/modem-dev/hunk/tree/main/examples/extensions/review-triage), +[authoritative review snapshot export](https://github.com/modem-dev/hunk/tree/main/examples/extensions/review-snapshot-export), an optional +[rendered Markdown file view](https://github.com/modem-dev/hunk/tree/main/examples/extensions/rendered-markdown), and a +[Vim navigation mode](https://github.com/modem-dev/hunk/tree/main/examples/extensions/vim-navigation) built from public semantic commands. ### OpenTUI component Hunk also publishes `HunkDiffView` and lower-level primitives from `hunkdiff/opentui` for embedding the same diff renderer in your own OpenTUI app. -See [docs/opentui-component.md](docs/opentui-component.md) for install, API, and runnable examples. +See [the OpenTUI component guide](https://hunk.dev/docs/reference/opentui-components/) for install, API, and runnable examples. ## Examples -Ready-to-run demo diffs live in [`examples/`](examples/README.md). +Ready-to-run demo diffs live in [the examples directory](https://github.com/modem-dev/hunk/tree/main/examples). Each example includes the exact command to run from the repository root. @@ -292,7 +305,7 @@ Each example includes the exact command to run from the repository root. 💬 _Chat with users/contributors on the [Modem Discord server](https://discord.gg/WZFjaP6Gt8)_ -For source setup, tests, packaging checks, and repo architecture, see [CONTRIBUTING.md](CONTRIBUTING.md). +For source setup, tests, packaging checks, and repo architecture, see [the contribution guide](https://github.com/modem-dev/hunk/blob/main/CONTRIBUTING.md). ## Sponsor @@ -308,4 +321,4 @@ Sponsored by [Modem](https://modem.dev?utm_source=github&utm_medium=oss&utm_camp ## License -[MIT](LICENSE) +[MIT](https://github.com/modem-dev/hunk/blob/main/LICENSE) diff --git a/benchmarks/bootstrap-load.ts b/benchmarks/bootstrap-load.ts index 95c709318..6da0bad5d 100644 --- a/benchmarks/bootstrap-load.ts +++ b/benchmarks/bootstrap-load.ts @@ -5,8 +5,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { performance } from "perf_hooks"; import { parsePatchFiles } from "@pierre/diffs"; -import { getBundledVcsCatalog } from "../src/app/vcsCatalog"; -import { loadAppBootstrap } from "../src/core/changeset/loaders"; +import { getBundledVcsCatalog } from "../packages/hunk/src/app/vcsCatalog"; +import { loadAppBootstrap } from "../packages/hunk/src/core/changeset/loaders"; const FILE_COUNT = 64; const LINES_PER_FILE = 420; diff --git a/benchmarks/changeset-parse.ts b/benchmarks/changeset-parse.ts index a090f999b..fdd642521 100644 --- a/benchmarks/changeset-parse.ts +++ b/benchmarks/changeset-parse.ts @@ -1,9 +1,9 @@ // Benchmark raw patch parsing and sanitized DiffFile construction for several diff shapes. import { performance } from "perf_hooks"; import { parsePatchFiles } from "@pierre/diffs"; -import { buildDiffFile } from "../src/core/changeset/diffFile"; -import { findPatchChunk, splitPatchIntoFileChunks } from "../src/core/patch/chunks"; -import { sanitizePatchText } from "../src/core/patch/sanitize"; +import { buildDiffFile } from "../packages/hunk/src/core/changeset/diffFile"; +import { findPatchChunk, splitPatchIntoFileChunks } from "../packages/hunk/src/core/patch/chunks"; +import { sanitizePatchText } from "../packages/hunk/src/core/patch/sanitize"; import { createSyntheticPatch } from "./lib/fixtures"; interface Scenario { diff --git a/benchmarks/compact-highlight-payload.ts b/benchmarks/compact-highlight-payload.ts index f09189b59..674c611c1 100644 --- a/benchmarks/compact-highlight-payload.ts +++ b/benchmarks/compact-highlight-payload.ts @@ -3,20 +3,20 @@ // normalizes it, and the terminal paint seam remains a later milestone. import { performance } from "node:perf_hooks"; import { cleanLastNewline, parseDiffFromFile } from "@pierre/diffs"; -import type { DiffFile } from "../src/core/changeset/model"; +import type { DiffFile } from "../packages/hunk/src/core/changeset/model"; import { buildSplitRows, loadHighlightedDiff, type HighlightedDiffCode, -} from "../src/ui/diff/diffRows"; +} from "../packages/hunk/src/ui/diff/diffRows"; import { compactHighlightRunsForLine, compactHighlightTransferList, compactHighlightedDiffByteLength, encodeCompactHighlightedDiff, validateCompactHighlightedDiff, -} from "../src/ui/diff/worker"; -import { resolveTheme } from "../src/ui/themes"; +} from "../packages/hunk/src/ui/diff/worker"; +import { resolveTheme } from "../packages/hunk/src/ui/themes"; const LINE_COUNT = Number(process.env.HUNK_COMPACT_HIGHLIGHT_LINES ?? 8_000); const SAMPLES = Number(process.env.HUNK_COMPACT_HIGHLIGHT_SAMPLES ?? 7); diff --git a/benchmarks/geometry-memory.ts b/benchmarks/geometry-memory.ts index 04061d66a..53f402157 100644 --- a/benchmarks/geometry-memory.ts +++ b/benchmarks/geometry-memory.ts @@ -1,8 +1,8 @@ // Track retained memory for the all-files geometry cache used by review scrolling/navigation. import { heapStats } from "bun:jsc"; import { performance } from "node:perf_hooks"; -import { measureDiffSectionGeometry } from "../src/ui/diff/diffSectionGeometry"; -import { resolveTheme } from "../src/ui/themes"; +import { measureDiffSectionGeometry } from "../packages/hunk/src/ui/diff/diffSectionGeometry"; +import { resolveTheme } from "../packages/hunk/src/ui/themes"; import { createGiantSingleDiffFile, createLargeSplitStreamBootstrap, diff --git a/benchmarks/highlight-cache-layers.ts b/benchmarks/highlight-cache-layers.ts index f77ad098b..4b165e2ad 100644 --- a/benchmarks/highlight-cache-layers.ts +++ b/benchmarks/highlight-cache-layers.ts @@ -1,10 +1,10 @@ // Compare a resident main-process cache hit with a worker-LRU revisit after main-cache eviction. import { performance } from "node:perf_hooks"; import { parseDiffFromFile } from "@pierre/diffs"; -import type { DiffFile } from "../src/core/changeset/model"; -import { resolveTheme } from "../src/ui/themes"; -import { disposeHighlightWorker } from "../src/ui/diff/worker/highlightWorkerClient"; -import { prefetchHighlightedDiff } from "../src/ui/diff/useHighlightedDiff"; +import type { DiffFile } from "../packages/hunk/src/core/changeset/model"; +import { resolveTheme } from "../packages/hunk/src/ui/themes"; +import { disposeHighlightWorker } from "../packages/hunk/src/ui/diff/worker/highlightWorkerClient"; +import { prefetchHighlightedDiff } from "../packages/hunk/src/ui/diff/useHighlightedDiff"; const lineCount = 8_000; const theme = resolveTheme("github-dark-default", null); diff --git a/benchmarks/highlight-prefetch.ts b/benchmarks/highlight-prefetch.ts index 1ccd7946c..082a411aa 100644 --- a/benchmarks/highlight-prefetch.ts +++ b/benchmarks/highlight-prefetch.ts @@ -5,9 +5,9 @@ import React from "react"; import { testRender } from "@opentui/react/test-utils"; import { parseDiffFromFile } from "@pierre/diffs"; import { act } from "react"; -import { AppHost } from "../src/ui/AppHost"; -import type { AppBootstrap } from "../src/core/bootstrap"; -import type { DiffFile } from "../src/core/changeset/model"; +import { BenchmarkAppHost as AppHost } from "./lib/appHost"; +import type { AppBootstrap } from "../packages/hunk/src/core/bootstrap"; +import type { DiffFile } from "../packages/hunk/src/core/changeset/model"; function createDiffFile(index: number, marker: string): DiffFile { const path = `src/example${index}.ts`; diff --git a/benchmarks/huge-stream.ts b/benchmarks/huge-stream.ts index c4c006ac2..f525336f2 100644 --- a/benchmarks/huge-stream.ts +++ b/benchmarks/huge-stream.ts @@ -7,7 +7,7 @@ import { performance } from "node:perf_hooks"; import { testRender } from "@opentui/react/test-utils"; import React from "react"; -import { AppHost } from "../src/ui/AppHost"; +import { BenchmarkAppHost as AppHost } from "./lib/appHost"; import { createHugeStreamBootstrap, GIANT_SINGLE_FILE_LINES, diff --git a/benchmarks/interaction-latency.ts b/benchmarks/interaction-latency.ts index d1c3ee983..cdc782db4 100644 --- a/benchmarks/interaction-latency.ts +++ b/benchmarks/interaction-latency.ts @@ -4,7 +4,7 @@ import { performance } from "node:perf_hooks"; import { testRender } from "@opentui/react/test-utils"; import React from "react"; -import { AppHost } from "../src/ui/AppHost"; +import { BenchmarkAppHost as AppHost } from "./lib/appHost"; import { createLargeSplitStreamBootstrap, DEFAULT_FILE_COUNT, diff --git a/benchmarks/large-stream-fixture.ts b/benchmarks/large-stream-fixture.ts index c55366de3..c6dd56af9 100644 --- a/benchmarks/large-stream-fixture.ts +++ b/benchmarks/large-stream-fixture.ts @@ -1,6 +1,6 @@ import { parseDiffFromFile, parsePatchFiles } from "@pierre/diffs"; -import type { AppBootstrap } from "../src/core/bootstrap"; -import type { DiffFile } from "../src/core/changeset/model"; +import type { AppBootstrap } from "../packages/hunk/src/core/bootstrap"; +import type { DiffFile } from "../packages/hunk/src/core/changeset/model"; export const DEFAULT_FILE_COUNT = 180; export const DEFAULT_LINES_PER_FILE = 120; diff --git a/benchmarks/large-stream-profile.ts b/benchmarks/large-stream-profile.ts index c5d1b8d97..eeaa64fc0 100644 --- a/benchmarks/large-stream-profile.ts +++ b/benchmarks/large-stream-profile.ts @@ -1,10 +1,10 @@ // Profile large split-mode review streams by timing the main pure planning stages // before the React tree and renderer get involved. import { performance } from "perf_hooks"; -import { buildSplitRows } from "../src/ui/diff/diffRows"; -import { buildReviewRenderPlan } from "../src/ui/diff/reviewRenderPlan"; -import { measureDiffSectionGeometry } from "../src/ui/diff/diffSectionGeometry"; -import { resolveTheme } from "../src/ui/themes"; +import { buildSplitRows } from "../packages/hunk/src/ui/diff/diffRows"; +import { buildReviewRenderPlan } from "../packages/hunk/src/ui/diff/reviewRenderPlan"; +import { measureDiffSectionGeometry } from "../packages/hunk/src/ui/diff/diffSectionGeometry"; +import { resolveTheme } from "../packages/hunk/src/ui/themes"; import { createLargeSplitStreamFiles, DEFAULT_FILE_COUNT, diff --git a/benchmarks/large-stream.ts b/benchmarks/large-stream.ts index 9a82c730b..1d5bb0685 100644 --- a/benchmarks/large-stream.ts +++ b/benchmarks/large-stream.ts @@ -2,8 +2,8 @@ import { performance } from "perf_hooks"; import React from "react"; import { testRender } from "@opentui/react/test-utils"; -import { AppHost } from "../src/ui/AppHost"; -import { VIEWPORT_READ_COALESCE_MS } from "../src/ui/lib/viewportTiming"; +import { BenchmarkAppHost as AppHost } from "./lib/appHost"; +import { VIEWPORT_READ_COALESCE_MS } from "../packages/hunk/src/ui/lib/viewportTiming"; import { createLargeSplitStreamBootstrap, DEFAULT_FILE_COUNT, diff --git a/benchmarks/lib/appHost.tsx b/benchmarks/lib/appHost.tsx new file mode 100644 index 000000000..7e3c3f3e8 --- /dev/null +++ b/benchmarks/lib/appHost.tsx @@ -0,0 +1,29 @@ +import { useEffect, useState, type ComponentProps } from "react"; +import { createExtensionSession } from "../../packages/hunk/src/extensions/session"; +import { createEmptyExtensionLoadResult } from "../../packages/hunk/src/extensions/types"; +import { AppHost } from "../../packages/hunk/src/ui/AppHost"; + +/** Supply explicit session ownership to an isolated benchmark AppHost mount. */ +export function BenchmarkAppHost({ bootstrap }: Pick, "bootstrap">) { + const [extensionSession] = useState(() => + createExtensionSession( + (bootstrap.extensions as ReturnType | undefined) ?? + createEmptyExtensionLoadResult(bootstrap.reloadContext.cwd), + bootstrap.reloadContext.cwd, + ), + ); + useEffect( + () => () => { + void extensionSession.shutdown(); + }, + [extensionSession], + ); + return ( + extensionSession.shutdown()} + /> + ); +} diff --git a/benchmarks/memory.ts b/benchmarks/memory.ts index cac06e3b3..d341c618f 100644 --- a/benchmarks/memory.ts +++ b/benchmarks/memory.ts @@ -3,10 +3,10 @@ import { performance } from "perf_hooks"; import React from "react"; import { testRender } from "@opentui/react/test-utils"; import { act } from "react"; -import { buildSplitRows } from "../src/ui/diff/diffRows"; -import { buildReviewRenderPlan } from "../src/ui/diff/reviewRenderPlan"; -import { resolveTheme } from "../src/ui/themes"; -import { AppHost } from "../src/ui/AppHost"; +import { buildSplitRows } from "../packages/hunk/src/ui/diff/diffRows"; +import { buildReviewRenderPlan } from "../packages/hunk/src/ui/diff/reviewRenderPlan"; +import { resolveTheme } from "../packages/hunk/src/ui/themes"; +import { BenchmarkAppHost as AppHost } from "./lib/appHost"; import { createLargeSplitStreamBootstrap } from "./large-stream-fixture"; const viewport = { width: 240, height: 28 } as const; diff --git a/benchmarks/navigation-memory.ts b/benchmarks/navigation-memory.ts index 18dcca633..a1fe71d94 100644 --- a/benchmarks/navigation-memory.ts +++ b/benchmarks/navigation-memory.ts @@ -3,7 +3,7 @@ import { testRender } from "@opentui/react/test-utils"; import { performance } from "node:perf_hooks"; import React from "react"; import { act } from "react"; -import { AppHost } from "../src/ui/AppHost"; +import { BenchmarkAppHost as AppHost } from "./lib/appHost"; import { createLargeSplitStreamBootstrap } from "./large-stream-fixture"; type MemorySample = { diff --git a/benchmarks/non-ascii-stream.ts b/benchmarks/non-ascii-stream.ts index 9ce73db95..578d021ea 100644 --- a/benchmarks/non-ascii-stream.ts +++ b/benchmarks/non-ascii-stream.ts @@ -5,7 +5,7 @@ import { performance } from "node:perf_hooks"; import { testRender } from "@opentui/react/test-utils"; import React from "react"; -import { AppHost } from "../src/ui/AppHost"; +import { BenchmarkAppHost as AppHost } from "./lib/appHost"; import { createLargeSplitStreamBootstrap } from "./large-stream-fixture"; import { destroyRenderer, diff --git a/benchmarks/render-layout.ts b/benchmarks/render-layout.ts index d8c974151..6d5effc07 100644 --- a/benchmarks/render-layout.ts +++ b/benchmarks/render-layout.ts @@ -1,9 +1,9 @@ // Benchmark pure diff row/layout planning across split, stack, and size-shape cases. import { performance } from "perf_hooks"; -import { buildSplitRows, buildStackRows } from "../src/ui/diff/diffRows"; -import { buildReviewRenderPlan } from "../src/ui/diff/reviewRenderPlan"; -import { measureDiffSectionGeometry } from "../src/ui/diff/diffSectionGeometry"; -import { resolveTheme } from "../src/ui/themes"; +import { buildSplitRows, buildStackRows } from "../packages/hunk/src/ui/diff/diffRows"; +import { buildReviewRenderPlan } from "../packages/hunk/src/ui/diff/reviewRenderPlan"; +import { measureDiffSectionGeometry } from "../packages/hunk/src/ui/diff/diffSectionGeometry"; +import { resolveTheme } from "../packages/hunk/src/ui/themes"; import { createLargeSplitStreamFiles } from "./large-stream-fixture"; const theme = resolveTheme("midnight", null); diff --git a/benchmarks/resize-memory.ts b/benchmarks/resize-memory.ts index ca3b59e74..6bf8731a1 100644 --- a/benchmarks/resize-memory.ts +++ b/benchmarks/resize-memory.ts @@ -4,7 +4,7 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { performance } from "node:perf_hooks"; import React from "react"; -import { AppHost } from "../src/ui/AppHost"; +import { BenchmarkAppHost as AppHost } from "./lib/appHost"; import { createLargeSplitStreamBootstrap } from "./large-stream-fixture"; type MemorySample = { diff --git a/benchmarks/run.ts b/benchmarks/run.ts index d62688a25..9dd4e74cd 100644 --- a/benchmarks/run.ts +++ b/benchmarks/run.ts @@ -99,7 +99,9 @@ function gitSha() { async function packageVersion() { try { - const packageJson = JSON.parse(await Bun.file("package.json").text()) as { version?: string }; + const packageJson = JSON.parse(await Bun.file("packages/hunk/package.json").text()) as { + version?: string; + }; return packageJson.version; } catch { return undefined; diff --git a/benchmarks/terminal-width.ts b/benchmarks/terminal-width.ts index 012a323d5..53a32c565 100644 --- a/benchmarks/terminal-width.ts +++ b/benchmarks/terminal-width.ts @@ -1,7 +1,7 @@ // Benchmark Hunk's scalar fast path and cached complex-cluster path against string-width. import { performance } from "node:perf_hooks"; import stringWidth from "string-width"; -import { measureTextWidth } from "../src/ui/lib/text"; +import { measureTextWidth } from "../packages/hunk/src/ui/lib/text"; const ITERATIONS = 2_000; const WARMUP_ITERATIONS = 50; diff --git a/benchmarks/worker-highlight-cache.ts b/benchmarks/worker-highlight-cache.ts index 2e344423c..70a7d692b 100644 --- a/benchmarks/worker-highlight-cache.ts +++ b/benchmarks/worker-highlight-cache.ts @@ -4,8 +4,8 @@ import { parseDiffFromFile } from "@pierre/diffs"; import { disposeHighlightWorker, highlightDiffInWorker, -} from "../src/ui/diff/worker/highlightWorkerClient"; -import { compactHighlightedDiffByteLength } from "../src/ui/diff/worker/highlightCompact"; +} from "../packages/hunk/src/ui/diff/worker/highlightWorkerClient"; +import { compactHighlightedDiffByteLength } from "../packages/hunk/src/ui/diff/worker/highlightCompact"; const lineCount = 8_000; const additions = Array.from( diff --git a/benchmarks/working-tree-load.ts b/benchmarks/working-tree-load.ts index d8156cdef..285990174 100644 --- a/benchmarks/working-tree-load.ts +++ b/benchmarks/working-tree-load.ts @@ -1,7 +1,7 @@ // Benchmark git-backed working-tree loading, including untracked file handling. import { performance } from "perf_hooks"; -import { getBundledVcsCatalog } from "../src/app/vcsCatalog"; -import { loadAppBootstrap } from "../src/core/changeset/loaders"; +import { getBundledVcsCatalog } from "../packages/hunk/src/app/vcsCatalog"; +import { loadAppBootstrap } from "../packages/hunk/src/core/changeset/loaders"; import { addUntrackedFiles, createChangedRepo } from "./lib/fixtures"; interface Scenario { diff --git a/benchmarks/wrapped-cjk.ts b/benchmarks/wrapped-cjk.ts index 760e441f5..02ef298ba 100644 --- a/benchmarks/wrapped-cjk.ts +++ b/benchmarks/wrapped-cjk.ts @@ -4,12 +4,12 @@ import { performance } from "node:perf_hooks"; import { parsePatchFiles } from "@pierre/diffs"; import { testRender } from "@opentui/react/test-utils"; import React, { act } from "react"; -import type { AppBootstrap } from "../src/core/bootstrap"; -import type { DiffFile } from "../src/core/changeset/model"; -import { AppHost } from "../src/ui/AppHost"; -import { prefetchHighlightedDiff } from "../src/ui/diff/useHighlightedDiff"; -import { VIEWPORT_READ_COALESCE_MS } from "../src/ui/lib/viewportTiming"; -import { resolveTheme } from "../src/ui/themes"; +import type { AppBootstrap } from "../packages/hunk/src/core/bootstrap"; +import type { DiffFile } from "../packages/hunk/src/core/changeset/model"; +import { BenchmarkAppHost as AppHost } from "./lib/appHost"; +import { prefetchHighlightedDiff } from "../packages/hunk/src/ui/diff/useHighlightedDiff"; +import { VIEWPORT_READ_COALESCE_MS } from "../packages/hunk/src/ui/lib/viewportTiming"; +import { resolveTheme } from "../packages/hunk/src/ui/themes"; import { destroyRenderer, renderPass, diff --git a/bun.lock b/bun.lock index 333ce24c8..4a43c3db7 100644 --- a/bun.lock +++ b/bun.lock @@ -4,40 +4,65 @@ "workspaces": { "": { "name": "hunk", - "dependencies": { - "bun": "^1.3.14", - "chokidar": "^4.0.3", - "commander": "^14.0.3", - "diff": "^8.0.3", - "get-east-asian-width": "^1.5.0", - "shell-quote": "1.9.0", - "string-width": "^8.2.1", - "zod": "~4.4.3", - }, "devDependencies": { "@changesets/changelog-github": "^0.7.0", + "@hunk/git": "workspace:*", + "@hunk/jj": "workspace:*", + "@hunk/sapling": "workspace:*", "@hunk/session-broker": "workspace:*", "@hunk/session-broker-bun": "workspace:*", "@hunk/session-broker-core": "workspace:*", "@hunk/term-video": "workspace:*", + "@hunk/vcs": "workspace:*", "@opentui/core": "^0.5.6", "@opentui/react": "^0.5.6", "@pierre/diffs": "1.3.5", "@shikijs/themes": "3.23.0", - "@types/bun": "1.3.14", + "@types/bun": "1.4.1", "@types/react": "^19.2.14", "@types/ws": "^8.18.1", + "bun": "^1.4.2", "dependency-cruiser": "18.2.0", + "diff": "^8.0.3", "knip": "^6.32.0", "lint-staged": "^16.4.0", "marked": "17.0.1", "oxfmt": "^0.41.0", "oxlint": "^1.56.0", "react": "^19.2.4", - "simple-git-hooks": "^2.13.1", + "simple-git-hooks": "^2.14.0", + "string-width": "^8.2.1", "tuistory": "^0.11.0", "typescript": "^5.9.3", }, + }, + "packages/hunk": { + "name": "hunkdiff", + "version": "0.21.1", + "bin": { + "hunk": "./bin/hunk.cjs", + "hunkdiff": "./bin/hunk.cjs", + }, + "dependencies": { + "bun": "^1.4.2", + "chokidar": "^4.0.3", + "commander": "^14.0.3", + "diff": "^8.0.3", + "get-east-asian-width": "^1.5.0", + "shell-quote": "1.9.0", + "string-width": "^8.2.1", + "zod": "~4.4.3", + }, + "devDependencies": { + "@opentui/core": "^0.5.6", + "@opentui/react": "^0.5.6", + "@pierre/diffs": "1.3.5", + "@shikijs/themes": "3.23.0", + "@types/bun": "1.4.1", + "@types/react": "^19.2.14", + "react": "^19.2.4", + "typescript": "^5.9.3", + }, "peerDependencies": { "@opentui/core": "^0.5.6", "@opentui/react": "^0.5.6", @@ -48,6 +73,34 @@ "@pierre/diffs", ], }, + "packages/hunk-git": { + "name": "@hunk/git", + "version": "0.0.0", + "dependencies": { + "@hunk/vcs": "workspace:*", + "hunkdiff": "workspace:*", + }, + }, + "packages/hunk-jj": { + "name": "@hunk/jj", + "version": "0.0.0", + "dependencies": { + "@hunk/vcs": "workspace:*", + "hunkdiff": "workspace:*", + }, + }, + "packages/hunk-sapling": { + "name": "@hunk/sapling", + "version": "0.0.0", + "dependencies": { + "@hunk/vcs": "workspace:*", + "hunkdiff": "workspace:*", + }, + }, + "packages/hunk-vcs": { + "name": "@hunk/vcs", + "version": "0.0.0", + }, "packages/session-broker": { "name": "@hunk/session-broker", "version": "0.0.0", @@ -103,6 +156,12 @@ "@hono/node-ws": ["@hono/node-ws@1.3.1", "", { "dependencies": { "ws": "^8.17.0" }, "peerDependencies": { "@hono/node-server": "^1.19.11", "hono": "^4.6.0" } }, "sha512-vo/MwCnpJAVHBkGzWjCJ28wF45fYHAfbPZcH2rodZODHtch2GHA94KtMfusmVycTUtsLAsaNsHhtY6P8X3RQsA=="], + "@hunk/git": ["@hunk/git@workspace:packages/hunk-git"], + + "@hunk/jj": ["@hunk/jj@workspace:packages/hunk-jj"], + + "@hunk/sapling": ["@hunk/sapling@workspace:packages/hunk-sapling"], + "@hunk/session-broker": ["@hunk/session-broker@workspace:packages/session-broker"], "@hunk/session-broker-bun": ["@hunk/session-broker-bun@workspace:packages/session-broker-bun"], @@ -113,6 +172,8 @@ "@hunk/term-video": ["@hunk/term-video@workspace:packages/term-video"], + "@hunk/vcs": ["@hunk/vcs@workspace:packages/hunk-vcs"], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" } }, "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw=="], "@opentui/core": ["@opentui/core@0.5.6", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.6", "@opentui/core-darwin-x64": "0.5.6", "@opentui/core-linux-arm64": "0.5.6", "@opentui/core-linux-arm64-musl": "0.5.6", "@opentui/core-linux-x64": "0.5.6", "@opentui/core-linux-x64-musl": "0.5.6", "@opentui/core-win32-arm64": "0.5.6", "@opentui/core-win32-x64": "0.5.6" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-e38H1VceXoSSOEYmhUeHDE3F3LHXn/sFEZ6NhbXkbYdrkdV+1MMTgEkGZJ9I5fHhBlJdGJ1LDTPw8V0ZSUXSyQ=="], @@ -135,37 +196,29 @@ "@opentui/react": ["@opentui/react@0.5.6", "", { "dependencies": { "@opentui/core": "0.5.6", "react-reconciler": "^0.33.0" }, "peerDependencies": { "react": ">=19.2.0", "react-devtools-core": "^7.0.1", "ws": "^8.18.0" } }, "sha512-dMQTlcD1zlDTQC8r9rZKlHD7CORkY2M5D7tqhgoccoOK9p6q05FqrdRCTYHu1+VZYafqIV0k2IpMY2e34sngiQ=="], - "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Omj20SuiHBOUjUBIyqtkNjSUIjOtEOJwmbix/ZyFH4BaQ6OZTaaRWIR4TjHVz0yadHgli6lLTiAh1uarnvD49A=="], - - "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-FFj3QdU/OhlDyZOJ8CWfN5eWLpRlT4qjZg7lMQi7jA6GuoY5ajlO1zWLP/MuHYRSbXQUvV52RejNi8DVnAp13w=="], - - "@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-OSfsTZstc898HHElhU4NccaBGOSSDn5VfahiVTnidZ9B/+wb7WTyfZJaBeJcfjwJ9H2W9uTh2TGtl3UfcXgV9g=="], + "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.4.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MXdZkP1featqxZ+/VTXWG1BVjM4OGBehVY2Q88EeUj/7L0UMeCGItmyPYTN+wxvlGJ6F66JEtzsw+GvQWewnag=="], - "@oven/bun-freebsd-aarch64": ["@oven/bun-freebsd-aarch64@1.3.14", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-LIKrXaFxAHybVO5Pf+9XP2FHUj/5APvXTUKk9dqHm5iFz4oH+W24cmhjkJirNujh9hKeTyrpWSe3no9JZKowIw=="], + "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.4.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-gZTxZuLjkUhAWjTETu3tw0WhsEdNkJ64daj60ybhPf835a2yollV3yTkK9JozvzKPx4TRFzLSl8C+U525pxVbw=="], - "@oven/bun-freebsd-x64": ["@oven/bun-freebsd-x64@1.3.14", "", { "os": "freebsd", "cpu": "x64" }, "sha512-uwD+fGUH1ADpIF3B1U2jWzzb20QwRLZfj5QZ28GUCGrAJ/nTmWrD6YYGsblCY1wuhldRez3lU40AyuvSCyLYmw=="], + "@oven/bun-freebsd-aarch64": ["@oven/bun-freebsd-aarch64@1.4.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-SMNItMw1Z8QeeQVKnw8jA7xQNkeXdP+OPgin4Wi/QTx/B8RHHLnuZfqmFy7NtVeT2NF0kKYppW4WWd2CCYZjhQ=="], - "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-X5SsPZHs+iYO8R/efIcRtc7gT2Q2DgPfliCxEkx4cXBumwkw0c/EsHMNwH3EgGpCDaZ7IYVPhpCG/xBOQHEwZw=="], + "@oven/bun-freebsd-x64": ["@oven/bun-freebsd-x64@1.4.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-THbPKXhO54N0DpFRKZNDZpQ7dpbX0bWASuARckAUS9wRtFIHsiY+uULXJvxJGo2YD1YewvXQ4G8Fj7XT5oBCiw=="], - "@oven/bun-linux-aarch64-android": ["@oven/bun-linux-aarch64-android@1.3.14", "", { "os": "android", "cpu": "arm64" }, "sha512-y4kq5b85lsrmFb9Xvi4w9mA5IEFJkLMrSmYn06q24KjL9rUWDWO3VFZEtteZxUN5+ec3Zm5S8OnJw1umaCbVjA=="], + "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-3BBP9ovJ2RGHFH6Ae1CAtxNtG1+YY6GD6rmYbsUosoAk9+OEl6zeDQ/k4fBkc6dYOJCtWnx8hUxzNzQATSmvYQ=="], - "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmqOA92Cd1NL/1XBd4bFkJLxQ86K0RW7ohxS2qzzAvuitO4JiIxjjTeCspoU44zCozH72HpfZfUE2On31OjnWA=="], + "@oven/bun-linux-aarch64-android": ["@oven/bun-linux-aarch64-android@1.4.2", "", { "os": "android", "cpu": "arm64" }, "sha512-3mZKO2rhsNgbAUtAHC1UKUlF2zTxFraDZT/Elv8wzyH0fJL9h+Iv3TgB9lO63w89PRn3eFe+NRA1bhVgikKNPQ=="], - "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-7OVTAKvwfPmSbIV1HpdOoVVx5VRc427GuPPne93N6vk4eQBPId9nXmZDh9/zGaKPdbVjVtQSZafWQoUjx38Utw=="], + "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-+Sm6y+lSiSFBOtXmnekp5Q6n1tUKlyv71FCPWBc61Cgb14T5eBs8SN/nh4MUCOKzONkI3O+as3MGUgikS4aCBQ=="], - "@oven/bun-linux-x64-android": ["@oven/bun-linux-x64-android@1.3.14", "", { "os": "android", "cpu": "x64" }, "sha512-qe9e1d+3VAEU7nAA2ol9Jvmy/o99PVMSgZhHn7Q/9O3YcDrfEqyQ8zm4zoe5qTEo8HZH0dN03Le0Ys2eQPs7eg=="], + "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-9/E/UXOTpSo3YsV5g+FhtTd/qTpiWoKuxS12cqtuYA1ssu9fRAoPQnipFgGyck3tWO63iUdxBiygq+kELFawng=="], - "@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-q/8EdOC0yUE8FPeoOVq8/Pw5I9/tJaYmUfO/uDUAREx8IUnOJH1RJ5A3BjFqre8pvJoiZA9AovPJq5FnNNjSxA=="], + "@oven/bun-linux-x64-android": ["@oven/bun-linux-x64-android@1.4.2", "", { "os": "android", "cpu": "x64" }, "sha512-6HC5tzcC79113n2IHCTJMWv+HsQImv4ZFEK2XpYLxY6HbT8tM4cUM2Zv1bHZBQsS3jv/zYBamDJ1UX7If0d5tw=="], - "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-GBCB/k/sIqcr06eTNgg7g46qiUv35Jasx4XiccJ/n7RGqrE4RWUD/XJBbWFprVPjvqd59+QtSnS99XGqvftHfg=="], + "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-vVTKUg1bnPhRP/Hp73jIVoFh2vPFNYEqYX0ERKfZBOQEEHitNAeukZzzuUDZS0SoDCIpuWUGSpd/CDMbjdR+Uw=="], - "@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-n6iE71G4lQE4XkrZhQQcL5YUlxDbnq6nqV7zeQi33PMsLT/0kYE+RvHOtBWZ3w0wMdXZfINmp63hIb9ijUBGtw=="], + "@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.4.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-8EJ1ST7339WJE3poPW5nBgVW/lWf9HBz4W27ZUNhburKmcBLOByPyE6DP9fHD8FQGm5c+ilUN2hX1mrW0jxq9Q=="], - "@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.3.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-T7s3x/BsVKQObGU6QDkZeI6wKynzqGbBH1yI77jrrj5siElclxr3DQrDIk8CV4G5/SJq2HHq4kpLyYY2DKCSmA=="], - - "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.14", "", { "os": "win32", "cpu": "x64" }, "sha512-mUFWL3BoYkNpjd8e9PqROiFF/1Xeotq20mABJsiQH62jM1g5zqWh4khw1RZ6bX8Q8fWvlPaxG1PjofkmjUi3vg=="], - - "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.14", "", { "os": "win32", "cpu": "x64" }, "sha512-uIjLUC1S9DWgICzuoMba7vurBJnBruE4S5CxnvmZkdqWVXRzx1Rgu636HoH+k0qeaQCFh3jeG3JQ1y6fRHv0sw=="], + "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.4.2", "", { "os": "win32", "cpu": "x64" }, "sha512-+bN6OuVld/9diT/RLSXSW7JE6CvNE3gL9XsAEjULi1nUsXd6DNO6GuA9jNdNb3r8PdJFnYHr5aypNV1Oj3Rd9g=="], "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.142.0", "", { "os": "android", "cpu": "arm" }, "sha512-ZiRGDutGsv1G6bL/ozy/koC0Sv39T1DqyoC4KD1DOy9ZoACm1O5UWhEK2c02Qdk+4lfLVkvFa/mQ0fm/4h1BtQ=="], @@ -353,7 +406,7 @@ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], - "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/bun": ["@types/bun@1.4.1", "", { "dependencies": { "bun-types": "1.4.1" } }, "sha512-0AVGiTXGajf1rgKom3N+c5L7CBxuoyyv1i44M0nX4UDK0G/fnRAMiri93nHuVPIb429KKtAgj7HatVmmOjeQLA=="], "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], @@ -385,11 +438,11 @@ "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "bun": ["bun@1.3.14", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.14", "@oven/bun-darwin-x64": "1.3.14", "@oven/bun-darwin-x64-baseline": "1.3.14", "@oven/bun-freebsd-aarch64": "1.3.14", "@oven/bun-freebsd-x64": "1.3.14", "@oven/bun-linux-aarch64": "1.3.14", "@oven/bun-linux-aarch64-android": "1.3.14", "@oven/bun-linux-aarch64-musl": "1.3.14", "@oven/bun-linux-x64": "1.3.14", "@oven/bun-linux-x64-android": "1.3.14", "@oven/bun-linux-x64-baseline": "1.3.14", "@oven/bun-linux-x64-musl": "1.3.14", "@oven/bun-linux-x64-musl-baseline": "1.3.14", "@oven/bun-windows-aarch64": "1.3.14", "@oven/bun-windows-x64": "1.3.14", "@oven/bun-windows-x64-baseline": "1.3.14" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-aB6GVd42x1Y5ie1K16SF+oLGtgSkwX9hgoDdIW88pjvfTccU8F1vfpoOt34QLv0dZ1v3XimtaxPlZUG81Gx9Zg=="], + "bun": ["bun@1.4.2", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.4.2", "@oven/bun-darwin-x64": "1.4.2", "@oven/bun-freebsd-aarch64": "1.4.2", "@oven/bun-freebsd-x64": "1.4.2", "@oven/bun-linux-aarch64": "1.4.2", "@oven/bun-linux-aarch64-android": "1.4.2", "@oven/bun-linux-aarch64-musl": "1.4.2", "@oven/bun-linux-x64": "1.4.2", "@oven/bun-linux-x64-android": "1.4.2", "@oven/bun-linux-x64-musl": "1.4.2", "@oven/bun-windows-aarch64": "1.4.2", "@oven/bun-windows-x64": "1.4.2" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-TrSXo6HJfIEaczpb3kjX82I2pL47vK1QUNmHRCUdz9IzaOwa9lzOXSWwu2l18YHE3sNfGRapVLd4nNm+22vVVA=="], "bun-ffi-structs": ["bun-ffi-structs@0.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="], - "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "bun-types": ["bun-types@1.4.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-loKuVrAFZKfEv+JvWkHRS9GW5IqLuLRjVXN9p+vZvBN86O5hf/pBZQ5hSoyipsrMmWObZBDvWnlmKvjKTM0PdA=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], @@ -495,6 +548,8 @@ "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], + "hunkdiff": ["hunkdiff@workspace:packages/hunk"], + "ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], "ini": ["ini@4.1.1", "", {}, "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g=="], @@ -633,7 +688,7 @@ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], - "simple-git-hooks": ["simple-git-hooks@2.13.1", "", { "bin": { "simple-git-hooks": "cli.js" } }, "sha512-WszCLXwT4h2k1ufIXAgsbiTOazqqevFCIncOuUBZJ91DdvWcC5+OFkluWRQPrcuSYd8fjq+o2y1QfWqYMoAToQ=="], + "simple-git-hooks": ["simple-git-hooks@2.14.0", "", { "bin": { "simple-git-hooks": "cli.js" } }, "sha512-kkTORPAuxQz2g1QUuN0J8yGWT28tjGBfPOdJ4xT7Vbeu30veKUZQIoID2qGgCDAakaQn59UeZJJ4cFGB8PVP2A=="], "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], diff --git a/bunfig.toml b/bunfig.toml index 36eede24a..02b8e6eab 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,3 +1,8 @@ [install] # Only install packages published at least 7 days ago. minimumReleaseAge = 604800 + +# Bun 1.4 defaults workspaces to the isolated linker, which nests packages under +# node_modules/.bun/@/node_modules/. simple-git-hooks resolves its +# own package.json one directory too high in that layout, so preserve Hunk's hoisted layout. +linker = "hoisted" diff --git a/docs/agent-workflows.md b/docs/agent-workflows.md index 5020d45bc..a6bdd9cd8 100644 --- a/docs/agent-workflows.md +++ b/docs/agent-workflows.md @@ -8,7 +8,7 @@ Use Hunk with agents in two ways: ## Recommended workflow: steer a live Hunk window 1. Open Hunk in one terminal with a normal review command such as `hunk diff` or `hunk show`. -2. Load the Hunk review skill: [`skills/hunk-review/SKILL.md`](../skills/hunk-review/SKILL.md). +2. Load the Hunk review skill: [`packages/hunk/skills/hunk-review/SKILL.md`](../packages/hunk/skills/hunk-review/SKILL.md). 3. Ask the agent to use the skill and review the current session. A good generic prompt is: @@ -90,18 +90,23 @@ For one note, use `comment add`: hunk session comment add --repo . --file README.md --new-line 103 --summary "Tighten this wording" ``` -For multiple notes, use one stdin batch with `comment apply`: +Reply to an existing note by id; the reply inherits the parent's file and code anchor: ```bash -printf '%s\n' '{"comments":[{"filePath":"README.md","newLine":103,"summary":"Tighten this wording"}]}' \ +hunk session comment add --repo . --reply-to user:123 --summary "Addressed in the latest revision" +``` + +For multiple notes or replies, use one stdin batch with `comment apply`: + +```bash +printf '%s\n' '{"comments":[{"filePath":"README.md","newLine":103,"summary":"Tighten this wording"},{"replyTo":"user:123","summary":"Addressed"}]}' \ | hunk session comment apply --repo . --stdin ``` -`comment apply` payload items need: +Each `comment apply` item requires `summary` and either: -- `filePath` -- `summary` -- exactly one target such as `hunk`, `hunkNumber`, `oldLine`, or `newLine` +- `replyTo` by itself to inherit an existing note's anchor, or +- `filePath` with exactly one target such as `hunk`, `hunkNumber`, `oldLine`, or `newLine` If you want the UI to jump to the new note, add `--focus` to `comment add` or `comment apply`. diff --git a/docs/browser-review-rebuild.md b/docs/browser-review-rebuild.md index 7d8d9ecce..e5cebe3fc 100644 --- a/docs/browser-review-rebuild.md +++ b/docs/browser-review-rebuild.md @@ -1,11 +1,16 @@ # Browser review rebuild plan +> **Current status (2026-09-06; after Phase 4).** Shared review semantics, producer publication, the broker +> mirror, bounded resources, capability-authenticated HTTP actions, and SSE have landed. No browser +> client, served review page, browser assets, or browser CLI entrypoint exists yet. Phases 5 and 6 +> remain planned. Semantic deep-link grammar is deferred until its first browser/opener consumer. + The synchronized browser-review feature (originally prototyped in one large branch) lands as a stack of small, independently reviewable PRs. Its Hunk-owned capability, resource, and semantic protocol remains separate from the generic per-application daemon contract in [`session-broker-sdk.md`](session-broker-sdk.md). Each phase has a hard gate and stands on the previous one. The seam contract — shared primitives stay renderer-free and platform-neutral — -is enforced by `scripts/source-boundaries.test.ts`, whose debt lists may only shrink. +is enforced by `scripts/quality/source-boundaries.test.ts`, whose debt lists may only shrink. Each phase lists the audit findings it repays (`browser-review-seam-audit.md`, ids A1–G5). A finding whose duplicate sites span phases is checked off when its **last** site converts; until @@ -15,20 +20,20 @@ Phase 6 carries the `minor` changeset announcing the feature. ## Phase 0 — seam contract and guardrails (this doc) -- Boundary gates for `src/core/review/` (the shared review model), `src/session/reviewProtocol.ts` - (the wire schema), and `src/web/` (the browser client). The gates tolerate absent trees, so +- Boundary gates for `packages/hunk/src/core/review/` (the shared review model), `packages/hunk/src/session/reviewProtocol.ts` + (the wire schema), and `packages/hunk/src/web/` (the browser client). The gates tolerate absent trees, so they land ahead of the code they constrain. - A shrink-only debt map for the Node-only primitives the prototype's model files still carry; each entry must be repaid with a platform-neutral implementation before a browser bundle may import that file. - The existing architecture boundaries stay at full strength. The prototype relocated bundled - VCS providers into `src/core/vcs/` and weakened this suite to compensate; that relocation must + VCS providers into `packages/hunk/src/core/vcs/` and weakened this suite to compensate; that relocation must not ride along with any rebuild phase — extraction PRs land against the restored gates. ## Phase 1 — review model + terminal adoption (three PRs) 1. **Review store**: `state / actions / reducer / store / intents / selectors` in - `src/core/review/`, with `useReviewController` / `App` / `AppHost` refactored onto it in the + `packages/hunk/src/core/review/`, with `useReviewController` / `App` / `AppHost` refactored onto it in the same PR. Behavior-neutral; existing PTY integration tests must pass untouched. 2. **Review document projection + diff geometry**: `document / identity / sourceIdentity / anchors / contentManifest / notes / expansion / reconcile / jsonStream` plus the geometry @@ -39,9 +44,10 @@ anchors / contentManifest / notes / expansion / reconcile / jsonStream` plus the 3. **Navigation intents + command catalog**: `selection/move` / `selection/select-file` and the shared navigation/reveal/note selectors; the agent runtime's `navigateSession` deleted in favor of the shared walk; the command catalog split with semantic commands lowered to - intents; the semantic address grammar. + intents. A semantic address grammar landed here and was later removed because it had no + consumer; recreate it beside the first Phase 5 or 6 consumer. -Repays: A1–A10 (PR 2); B1–B9, B11, F1–F3, G3 core grammar (PR 3); D2 core and terminal sites. +Repays: A1–A10 (PR 2); B1–B9, B11, F1–F3 (PR 3); D2 core and terminal sites. G3's first grammar was later removed as unused. B-findings with browser sites stay open until Phase 5 consumes the selectors. Gate: ladder rungs 1–4 — tombstones appended for every deleted copy, terminal planner registered in the conformance harness, adversarial fixtures landed per repaid finding, and the @@ -49,9 +55,11 @@ existing PTY suite passing untouched. ## Phase 2 — producer runtime -`src/app/reviewSessionRuntime.ts`: generations, snapshot serving, resource materialization, -serving the existing `hunk session` surface only. Resource read failures map to distinct error -codes (integrity failures are never collapsed into `unknown-resource`). +`packages/hunk/src/app/review/producer.ts` owns generations, snapshot serving, and resource +materialization. `packages/hunk/src/app/session/reviewRuntime.ts` mounts the producer and broker +client for an interactive session. This phase serves the existing `hunk session` surface only. +Resource read failures map to distinct error codes; integrity failures are never collapsed into +`unknown-resource`. Repays: D1 and D4 producer/snapshot sites (helpers land in core beside the model; remaining sites convert in Phase 3); D5 producer sites. @@ -176,7 +184,7 @@ browser mirrors the terminal theme. ## Commands and keyboard shortcuts in the browser -The terminal command system (`src/ui/lib/appCommands.ts`) fuses three separable things per +The terminal command system (`packages/hunk/src/ui/lib/appCommands.ts`) fuses three separable things per command: identity (id, title, chords), binding (terminal `KeyEvent` matching), and effect (closures over live App state). Making commands work in the browser means splitting them, not transporting them: @@ -214,7 +222,7 @@ allowlist design exists. Import gates prove code _may_ use a primitive, not that it _does_ — a consumer can silently re-derive. Every phase therefore passes the same five-rung ladder, and each rung is mechanical: -1. **Boundary gates** (every phase, exists today): `scripts/source-boundaries.test.ts` — +1. **Boundary gates** (every phase, exists today): `scripts/quality/source-boundaries.test.ts` — import containment, shrink-only debt lists, and the extracted-duplicate tombstone list. Repaying an audit finding means deleting the duplicate copies **and appending their paths to the tombstone list in the same PR**; a resurrected path fails CI forever after. diff --git a/docs/browser-review-seam-audit.md b/docs/browser-review-seam-audit.md index 485e4eb1d..a909b1cbd 100644 --- a/docs/browser-review-seam-audit.md +++ b/docs/browser-review-seam-audit.md @@ -1,5 +1,10 @@ # Browser review seam audit — prototype duplication findings +> **Current status (2026-09-06; after Phase 4).** Core review semantics, terminal adoption, producer and broker +> projections, wire protocol, bounded resources, HTTP actions, and SSE have landed. There is no +> browser client or served review page. Historical prototype locators below may name deleted files; +> each finding's latest status annotation is authoritative. + Companion to `browser-review-rebuild.md`. This is the per-finding work-list behind the plan's seam inventory: every duplicated derivation found in the prototype, the sites on each side, the observed or reachable divergence, and the shared primitive that replaces it. File/line @@ -9,11 +14,7 @@ should delete the copies its primitive replaces and check off the finding here. ## Run boundary — after Phase 4 -Phases 0–4 have landed: the seam contract and its gates, the shared review model with the -terminal on it, the producer runtime, the wire protocol with the daemon's review mirror and -resource path, and now the HTTP surface — capability authorization, publication and resource -reads, the SSE event stream, and action submission — with no browser client. What remains -open, and where the plan puts it: +Open work and its planned phase: - **C3, C5** — the epoch/supersede queue and one reconnect scheduler, both browser-client work (Phase 5). C4 is repaid on the server side; its client half closes with the reader @@ -26,10 +27,11 @@ open, and where the plan puts it: rather than retained frames. The stream carries no deltas, so a publication _is_ a complete resynchronization and a history would be an optimization; if Phase 5 measures a need for one, it is additive. -- **Browser sites of A, B, C, D** — every finding whose fix landed in core with only the - terminal, producer, broker, and wire converted keeps its browser half open: A6, A7, A11, - B3–B8, B10's client, C1 and C2's client sites, D1's composer sites, D3's - `pierreNoteAnchor`, D4's `parseCanonicalReviewFile`. +- **Browser sites of A, B, C, D** — every finding whose latest annotation still requires a + browser consumer remains open: A3–A11; B3–B8 and B10's client; C1 and C2's client; D1's + composer, D2 and D5's browser sites, D3's `pierreNoteAnchor`, and D4's + `parseCanonicalReviewFile`. B5 still needs its browser source policy. B7 and B8 also retain + terminal adoption/behavior work. - **E1, E2** — the shared stat-badge formatter, and the product decision about whether the browser mirrors the terminal theme. Both Phase 5; E2 must be decided before its second PR. - **F browser bindings** — F1–F3's browser halves (palette, keymap resolution onto DOM @@ -37,21 +39,21 @@ open, and where the plan puts it: - **G1, G2 policy** — view-option classification and persistence, and the multi-client selection and authorship policy. G2's _wire fields_ are done (see G2); what a receiver should do with an actor tag is the decision, due before Phase 5 PR 2. -- **G3 adoption** — the address grammar exists with no consumers; browser deep links - (Phase 5) and opener fragments (Phase 6) close it. +- **G3 adoption** — no address grammar exists on current main. It was removed as dead code and + returns beside the first browser deep-link or opener-fragment consumer (Phases 5–6). - **G5** — a placement rule for undo, if undo is ever built. Not work. -Two residuals earlier runs created rather than inherited: remote note _composition_ has no -draft-body intent yet (recorded under B12), and the publication a client reads over HTTP is -a position plus a resource catalog rather than a serialized `ReviewState` — selection, -filter, and notes reach a client through the resources and actions it already has, and -whether a client needs more than that is Phase 5's first question. +Remote draft composition now reaches `notes/update-draft` through core and the wire protocol, but +has no browser caller until Phase 5. HTTP publications contain only a position and resource catalog; +resource reads provide canonical files, patches, and sources, while successful actions acknowledge a +new position. Phase 5 must define bounded snapshot or delta transport for live selection, filter, +and note state before a browser can render those changes. ## A. Diff geometry - **A1. Collapsed-gap line ranges — 3 implementations, terminal off by one.** Terminal - `src/ui/diff/pierre.ts` (`leadingCollapsedRanges`/`trailingCollapsedRanges`, ~:456-480) vs - core `src/core/review/expansion.ts` (`reviewGapAddress`); web already consumes core's gap + `packages/hunk/src/ui/diff/pierre.ts` (`leadingCollapsedRanges`/`trailingCollapsedRanges`, ~:456-480) vs + core `packages/hunk/src/core/review/expansion.ts` (`reviewGapAddress`); web already consumes core's gap objects. For a pure-insertion hunk the terminal's leading range is off by one against core's, and the trailing math diverges for zero-count sides; a note created on a terminal-expanded line can be rejected on reload because `intents.ts` validates against `reviewGapAddress`. @@ -63,7 +65,7 @@ whether a client needs more than that is Phase 5's first question. anchor side has zero rows, so `pure-deletion-hunk` pins lines 1–5 instead of preserving the former one-line undercount. A2's separate trailing-gap residual remains. - **A2. Trailing-context existence — 3 formulations.** `pierre.ts` `trailingCollapsedLines`, - producer `src/app/session/registration.ts` (~:131-139, boolean `hasTrailingContext`), core + producer `packages/hunk/src/app/session/registration.ts` (~:131-139, boolean `hasTrailingContext`), core `expansion.ts`. The browser can offer a "Trailing context" button whose expansion core then rejects (`gap-not-found`). Fix: one `reviewTrailingGap(file)` in core. _Repaid (Phase 1 PR 2)_: `reviewTrailingGap` in `core/review/expansion.ts`; @@ -137,7 +139,7 @@ whether a client needs more than that is Phase 5's first question. - **A11. Language registration side effect missing in browser.** `core/changeset/fileLanguage.ts` registers `.mts`/`.cts`; the web bundle never imports it, so Pierre's own inference runs unregistered for files without an explicit `language`. Fix: side-effect import in - `src/web/main.tsx` (or fold registration into the shared model). + `packages/hunk/src/web/main.tsx` (or fold registration into the shared model). Renderer-specific, do not unify: terminal row construction and measured-cell windowing (`rowWindowing.ts`, `diffSectionGeometry.ts`) vs browser IntersectionObserver windowing and @@ -251,7 +253,7 @@ duplication); hunk header text (browser delegates to Pierre separators); platfor decides whether the claim holds. The wire carries it as `expandedLineProof` on the two actions that can name a line (`notes/start-draft`'s target, and `notes/create-user`'s precondition on the draft it is saving), refusing evidence that accompanies no line, and - `src/app/session/reviewCommands.ts` checks it before planning. Where the resulting note hangs + `packages/hunk/src/app/session/reviewCommands.ts` checks it before planning. Where the resulting note hangs is deliberately _not_ decided there: it goes through `reviewLineAnchor`'s fallback owner exactly as a terminal note does, which `reviewCommands.test.ts` pins by asserting an empty intersection set and the declared owner. Fixtures `start-draft-on-an-expanded-line`, @@ -284,7 +286,7 @@ duplication); hunk header text (browser delegates to Pierre separators); platfor the members that need them — and `toReviewIntent` strips them again, so an action is validated and narrowed rather than restated. The action-type-to-parser table is keyed by the vocabulary, so a wire-reachable intent without a parser does not compile. - `scripts/review-vocabulary.test.ts` is the ladder's rung 5: it asserts the equality, that + `scripts/quality/review-vocabulary.test.ts` is the ladder's rung 5: it asserts the equality, that every exclusion names a real intent once, and that every type in the vocabulary really reaches a parser. Round-trip fixtures for every action live in `test/review-conformance/wireFixtures.ts`, registered as the `review wire protocol` consumer. @@ -320,7 +322,7 @@ path suffixes, expansion retention, git-status badges). `test/review-conformance/orderingFixtures.ts` and cover both the classification and the transitions a real producer emits. Broker and browser sites close in Phases 3 and 5. _Repaid (Phase 3, broker site)_: the daemon's `ReviewMirror` - (`src/session/broker/reviewMirror.ts`) holds one publication per session and orders every + (`packages/hunk/src/session/broker/reviewMirror.ts`) holds one publication per session and orders every arriving one with a single `classifyReviewPublication` call — `accepted` advances the revision, `gap` replaces the generation and retires everything derived from the old one, `stale` is ignored. It has no comparison of its own, so the prototype's "accept equal @@ -328,7 +330,7 @@ path suffixes, expansion retention, git-status badges). new position is a replay, exactly as the contract says. The one non-ordering rule it does apply is stated as such — a later generation is adoptable only together with the catalog describing it, because a mirror holding a position whose resources it cannot name would - advertise reads nobody can serve. `src/app/session/reviewCommands.ts` makes the same one + advertise reads nobody can serve. `packages/hunk/src/app/session/reviewCommands.ts` makes the same one call for an action's `expectedStateRevision`, so "has the review moved past what this caller decided from" is the same question as "is this publication ahead". The mirror is registered against the Phase 2 fixtures as the `broker review mirror` ordering consumer, @@ -342,7 +344,7 @@ path suffixes, expansion retention, git-status badges). reservations, and concurrency tuning stay at the edges. _Repaid (Phase 2, producer site)_: `core/review/resources.ts` owns resource addressing, the chunk bound both ends validate against, and the failure vocabulary; - `src/app/review/resourceStore.ts` produces and serves the bytes. Single flight is + `packages/hunk/src/app/review/resourceStore.ts` produces and serves the bytes. Single flight is structural rather than a cache bolted on — a read reaches the underlying reader only through the in-flight map — and bulk loads run under an explicit concurrency limit instead of an unbounded `Promise.all`, which is the pair of defects the original review found. @@ -354,7 +356,7 @@ path suffixes, expansion retention, git-status badges). each chunk to start where the last ended, treats a chunk that neither advances nor ends as a failure, accepts a zero-length resource as one empty end-of-stream chunk, and verifies the assembled bytes against the digest with a `ReviewDigestFn` the caller injects. The - broker's load loop (`src/session/broker/state.ts`) does nothing but ask for the next window + broker's load loop (`packages/hunk/src/session/broker/state.ts`) does nothing but ask for the next window and decode it; there is one loop, where the prototype had two near-verbatim copies inside this file that already disagreed about progress and end-of-stream. Bounding is `reviewResourceCache.ts`: an LRU with a daemon-wide byte budget plus a reservation taken @@ -362,7 +364,7 @@ path suffixes, expansion retention, git-status badges). resource reserves one chunk and is resized to what the writer declares rather than reserving its kind's ceiling, which is what let a handful of ordinary patches serialize the parallel loads. Single flight is one map keyed by session, generation, and resource id; - concurrent callers await the same assembly. `src/session/broker/reviewResources.integration.test.ts` + concurrent callers await the same assembly. `packages/hunk/src/session/broker/reviewResources.integration.test.ts` drives the whole path with only the socket replaced. The browser's `apiClient` range loop closes in Phase 5. - **C3. Epoch/supersede/trailing-retry — 2 parallel machines.** Runtime reload queue @@ -373,9 +375,9 @@ path suffixes, expansion retention, git-status badges). begin/end envelopes, and the event-id grammar are built in `browserReviewServer.ts` and re-declared/regex-parsed in `mirror.ts`/`apiClient.ts`; client bounds (12 MiB / 1024 chunks) are unlinked from server bounds and only coincidentally compatible. Fix: - `src/session/reviewEventProtocol.ts` owning names, envelopes, id grammar, and bounds derived + `packages/hunk/src/session/reviewEventProtocol.ts` owning names, envelopes, id grammar, and bounds derived from `MAX_BROWSER_REVIEW_SNAPSHOT_BYTES`. - _Repaid (Phase 4, server side)_: `src/session/reviewEventProtocol.ts` owns the event + _Repaid (Phase 4, server side)_: `packages/hunk/src/session/reviewEventProtocol.ts` owns the event vocabulary, the frame names and their phases, the begin/chunk/end envelopes and their parsers, the event-id grammar, and every bound — `MAX_REVIEW_EVENT_PAYLOAD_BYTES` is the protocol's envelope bound, `REVIEW_EVENT_CHUNK_BYTES` is the shared resource chunk size, @@ -383,7 +385,7 @@ path suffixes, expansion retention, git-status badges). clamped to the ceiling a reader is allowed to hold rather than emitting frames the reader will refuse. `browserReviewServer.ts` imports all of it and declares none of it; the browser client imports the same module unchanged in Phase 5, which - `scripts/source-boundaries.test.ts` keeps possible by gating the module's transitive + `scripts/quality/source-boundaries.test.ts` keeps possible by gating the module's transitive closure platform-free. Two decisions differ from the prototype deliberately: a chunked payload is framed and verified as the byte stream it is, so reading it is the shared `ReviewChunkAssembler` rather than a fourth reassembly loop (C2's rule applied here); and @@ -413,7 +415,7 @@ path suffixes, expansion retention, git-status badges). `test/review-conformance/noteSize.ts` pin the boundary the two prototype rules disagreed at, including a note whose summary, rationale, and markup each fit while the note itself is three times the bound. Wire and composer sites adopt it in Phases 3 and 5. - _Repaid (Phase 3, wire site)_: `isTransportableReviewNote` in `src/session/reviewProtocol.ts` + _Repaid (Phase 3, wire site)_: `isTransportableReviewNote` in `packages/hunk/src/session/reviewProtocol.ts` is `reviewNoteWithinSizeLimit` and nothing else — the wire has no per-field check any more, and declares no second bound. The protocol module is registered as a consumer of the note-size corpus, so `every-field-fits-but-the-note-does-not` — the note whose summary, rationale, and @@ -437,7 +439,7 @@ path suffixes, expansion retention, git-status badges). the prototype's failure impossible rather than merely fixed: its broker copy re-derived intersections, omitted the fallback branch, and rejected a legal expanded-gap note — and with it the whole registration. The case is pinned from the wire end in - `src/app/session/reviewCommands.test.ts`: a note created remotely on an expanded-gap line + `packages/hunk/src/app/session/reviewCommands.test.ts`: a note created remotely on an expanded-gap line ends up with an empty intersection set and the fallback owner the caller declared, which is exactly the shape the dropped branch produced. Web `pierreNoteAnchor` closes in Phase 5. - **D4. Canonical-file ↔ manifest consistency — 3 checks, 3 field lists.** Producer @@ -471,7 +473,7 @@ path suffixes, expansion retention, git-status badges). variant is what let a writer and a reader disagree — with `normalizeReviewDigest` for values arriving from outside and `reviewDigestsEqual` normalizing _both_ operands. Hashing itself is an injected `ReviewDigestFn` rather than inline `createHash` calls; the producer supplies - Node's at the edge (`src/core/reviewDigest.ts`), which is also what repaid the shared model's + Node's at the edge (`packages/hunk/src/core/reviewDigest.ts`), which is also what repaid the shared model's last node-debt entry. Resource bounds are constants in `core/review/resources.ts` that the producer imports rather than restates. Wire constants, the action-envelope parser, and the two note-filter namings are Phase 3. @@ -486,7 +488,7 @@ path suffixes, expansion retention, git-status badges). registration's file limit, and every digest check is `isReviewSha256Digest`. The one coupling the protocol cannot express as an import is the transport frame size — importing the broker package would cost the module its browser safety — so - `scripts/review-vocabulary.test.ts` asserts it instead, alongside a check that no session + `scripts/quality/review-vocabulary.test.ts` asserts it instead, alongside a check that no session module re-declares a name the review model exports and that no module writes its own 64-character digest pattern. Naming `isActiveStoredReviewNote` beside `isRenderableStoredReviewNote` and commenting `parseReviewState` are browser-tier work and @@ -497,7 +499,7 @@ path suffixes, expansion retention, git-status badges). - **E1. File stat badges.** Terminal `ui/lib/files.ts` `formatSidebarStat` (zero-hiding, truncation marker) vs web inline `+${additions} −${deletions}` in `treeSource.ts`. One shared formatter. -- **E2. Theme.** Web hardcodes two standalone palettes disconnected from `src/ui/themes` and +- **E2. Theme.** Web hardcodes two standalone palettes disconnected from `packages/hunk/src/ui/themes` and the `AppTheme` mapping; whether the browser mirrors the terminal theme is an open product decision — decide before Phase 5, don't unify by default. @@ -508,14 +510,14 @@ like A–E; they are the copies the browser _would_ grow the moment shortcuts ar here so the extraction happens before the duplication exists. Design detail in `browser-review-rebuild.md` § "Commands and keyboard shortcuts in the browser". -- **F1. Command catalog fused with terminal binding and effects.** `src/ui/lib/appCommands.ts` +- **F1. Command catalog fused with terminal binding and effects.** `packages/hunk/src/ui/lib/appCommands.ts` couples identity (id, title, chords), binding (terminal `KeyEvent` matchers), and effect (closures over live App state) in one table; menus (`ui/lib/appMenus.ts`) and the help dialog render from it, so a browser palette or help screen would have to restate the list. Fix: extract a renderer-neutral catalog (id, title, category, default chords, resolution locus — semantic / client-local / host-only); terminal keeps matchers and handlers, browser adds its own, both render menus/help/palette from the catalog. - _Repaid (Phase 1 PR 3)_: `src/core/run/commandCatalog.ts` carries id, title, category, default + _Repaid (Phase 1 PR 3)_: `packages/hunk/src/core/run/commandCatalog.ts` carries id, title, category, default chords, resolution locus, extension visibility, and menu-closing behavior for all 44 built-ins. `ui/lib/appCommands.ts` builds its dispatch table from it — the handler map is keyed by `AppCommandId`, so a catalogued command with no terminal handler fails to typecheck — and @@ -545,12 +547,10 @@ here so the extraction happens before the duplication exists. Design detail in `selectReviewGapForSelection` in core, replacing the terminal's `selectGapForKeyboardToggle`. `SEMANTIC_COMMANDS_WITHOUT_REVIEW_EFFECT` is now empty, and every semantic command lowers to an intent a remote client could fire. - Residual (found in review): `lowerAppCommandToReviewIntent` still has no production caller — - the terminal's handlers read the catalog's declared scope/direction but build their intents - inline, so the lowering and the terminal closures can diverge with only - `commandCatalog.test.ts` noticing half the drift. Closes when the lowering gains its second - consumer (the Phase 5 palette / wire command path); until then any change to a declared - review effect must update both sites, and a review-effect parity check is the missing test. + _Repaid (terminal adoption)_: `useTerminalReview.ts` calls + `lowerAppCommandToReviewIntent`, so the terminal no longer rebuilds semantic command intents + inline. The future browser palette remains a second surface, not a prerequisite for terminal + parity. - **F3. Keymap resolution is terminal-owned.** Chords are shared config strings (`keymap.ts`, `[keybindings]`), but resolution against defaults and conflict handling lives with the terminal table; a browser keymap would duplicate it and drift on user rebinds. Fix: resolve @@ -595,10 +595,10 @@ implementation does. four parts split cleanly, and only the first is done: (1) actions carry an actor tag, here; (2) whether selection is shared-with-follow or per-client-with-follow is a product decision, due before Phase 5 PR 2; (3) note authorship defaulting from the actor lands with remote - note composition in Phase 5; (4) how a client obtains its identity lands with the capability - the HTTP surface issues in Phase 4. The producer records the tag and applies no policy to - it, and a client cannot widen what it may do by claiming a kind — so adding a policy later - changes behavior rather than the schema, which is the whole point of carrying the field now. + note composition in Phase 5; (4) how a client obtains its identity was originally assigned to + Phase 4. The wire parser validates the tag, the broker forwards it to the owning session, and + the producer-side action handler drops it before semantic planning. A client cannot widen what + it may do by claiming a kind; adding policy later changes behavior rather than the schema. _Amended (Phase 4)_: part (4) does not land with the HTTP surface after all, and the reason is worth stating. The capability authorizes _a review_, not _a client_: one link may be opened in several tabs, and the surface deliberately cannot tell them apart, @@ -624,13 +624,13 @@ implementation does. `no-dead-modules` rule now flags exactly that shape. The grammar above still stands as the design; write it again beside the first consumer that addresses a review across a boundary. - **G4. User-facing error catalog.** The repo already solves this once for agents: - `src/session/agent/errors.ts` single-sources every message the generated skill quotes, with + `packages/hunk/src/session/agent/errors.ts` single-sources every message the generated skill quotes, with contract tests. The browser has no equivalent — action rejections (`invalid-action`, `stale-generation`, resource integrity failures) would surface as ad-hoc strings invented in - `src/web`, drifting from what the terminal shows for the same failure. One error-code → + `packages/hunk/src/web`, drifting from what the terminal shows for the same failure. One error-code → user-message catalog beside the wire protocol, consumed by both clients (and reused by the agent surface where codes overlap). Phase 4 (codes stabilize) / Phase 5 (browser consumes). - _Repaid (Phase 4, catalog creation)_: `src/session/reviewErrorCatalog.ts` gives every code a + _Repaid (Phase 4, catalog creation)_: `packages/hunk/src/session/reviewErrorCatalog.ts` gives every code a statement and a remedy, in the agent surface's own pattern. Totality is mechanical rather than reviewed: the catalog is a `Record` over `HunkReviewClientErrorCodeV1`, itself _composed_ — resource plus request plus intent-planning plus the transport's own codes — @@ -653,11 +653,11 @@ implementation does. The per-phase verification ladder lives in `browser-review-rebuild.md` § "Per-phase seam verification". A finding here counts as repaid only when all four hold: duplicate copies deleted, their paths (for whole files) or banned-symbol entries (for function-level -deletions) appended to the tombstone lists in `scripts/source-boundaries.test.ts`, +deletions) appended to the tombstone lists in `scripts/quality/source-boundaries.test.ts`, the finding's adversarial fixture landed in the conformance harness, and the consumer registered against that harness. -- The seam boundary tests (`scripts/source-boundaries.test.ts`) keep deleted copies deleted. +- The seam boundary tests (`scripts/quality/source-boundaries.test.ts`) keep deleted copies deleted. - Renderer parity tests (Phase 5 gate) drive shared fixtures through the terminal planner and browser projection and assert identical note placement, gap addressing, reveal targets, and default note targets — the drift class import gates cannot catch. diff --git a/docs/changelog-on-hunk-dev.md b/docs/changelog-on-hunk-dev.md index 73f663f92..29cfcd3c5 100644 --- a/docs/changelog-on-hunk-dev.md +++ b/docs/changelog-on-hunk-dev.md @@ -1,7 +1,7 @@ # Release notes on hunk.dev -`CHANGELOG.md` is the source of truth for what shipped. `scripts/generate-changelog.ts` projects it -into the pages published at `hunk.dev/changelog`, the same way `scripts/generate-docs.ts` projects +`CHANGELOG.md` is the source of truth for what shipped. `scripts/generate/generate-changelog.ts` projects it +into the pages published at `hunk.dev/changelog`, the same way `scripts/generate/generate-docs.ts` projects runtime metadata into the CLI and config references. Nothing under the generated paths is hand-edited; `bun run check:changelog` fails the build when they drift. @@ -103,7 +103,7 @@ Presentation lives in the site: - The landing page imports `website/releases/latest.json` for its release ribbon, so the current version is a static import rather than a build-time parse of generated Markdown. -`vercel.json` must keep `CHANGELOG.md` and `scripts/generate-changelog.ts` in its `ignoreCommand` +`vercel.json` must keep `CHANGELOG.md` and `scripts/generate/generate-changelog.ts` in its `ignoreCommand` path list, or release commits will not trigger a deploy. ## Social cards @@ -134,6 +134,6 @@ none), and patch chips appear only when a series has more than one release. launch-video pipeline keeps generated media out of Git. - **Contributor lists.** The GitHub release bodies name first-time contributors, which is community goodwill and organic links. `CHANGELOG.md` does not carry authors, so this needs a second input. -- **The in-app update notice.** `src/core/process/updateNotice.ts` tells users a new version exists without +- **The in-app update notice.** `packages/hunk/src/core/process/updateNotice.ts` tells users a new version exists without linking what changed. Appending `hunk.dev/changelog/` is the highest-intent entry point available and is tracked separately. diff --git a/docs/extension-api-evaluation.md b/docs/extension-api-evaluation.md index a6ce6ffaf..21fdf9b4b 100644 --- a/docs/extension-api-evaluation.md +++ b/docs/extension-api-evaluation.md @@ -1,14 +1,24 @@ # Extension API field notes: Review triage +> **Historical field report (July 2026).** This evaluation describes the API when the review-triage +> example was introduced. Use the [extension guide](extensions.md) and +> [extension architecture](extension-architecture.md) for current behavior. Since this report, +> command handlers gained review navigation and pane components gained exact host-owned width and +> height. Host-managed persistence, richer dialogs/menu layout, and a public pane viewport-scroll +> contract remain open design areas. + `examples/extensions/review-triage/` is a deliberately ordinary, user-installable extension built only against `hunkdiff/extension`. It provides a session-local hunk triage board: a reviewer can open a right sidebar, navigate through public hunk summaries, mark the current hunk approved/investigate/blocked with an optional rationale, and clear decisions. Its commands are ordinary **Extensions** menu entries, while lifecycle and bus events keep the board current. Building it validated the API's central path: a third-party extension can compose a React sidebar, menu-reachable commands, host-owned modal dialogs, selection snapshots, lifecycle subscriptions, notifications, and a small inter-extension bus without imports from Hunk internals. The PTY integration test loads this exact directory rather than a string fixture. ## Findings -### Sidebar geometry and selection following are missing +### Sidebar geometry and selection following were missing + +**Current status:** partially resolved. Pane props now expose exact host-owned width and height; +viewport bounds and a host scroll-item capability are still not public. -The public sidebar props expose width but not pane height, viewport bounds, scroll position, or a way to scroll an item into view. The bundled file sidebar uses host-internal `ScrollBoxRenderable` viewport events and `scrollChildIntoView`; a third-party sidebar cannot reproduce its windowing or follow-selection behavior. Review triage therefore uses a simple scrollbox and compact rows, but selected hunks can fall out of view for a large review. +At the time of this report, the public sidebar props exposed width but not pane height, viewport bounds, scroll position, or a way to scroll an item into view. The bundled file sidebar uses host-internal `ScrollBoxRenderable` viewport events and `scrollChildIntoView`; a third-party sidebar cannot reproduce its windowing or follow-selection behavior. Review triage therefore uses a simple scrollbox and compact rows, but selected hunks can fall out of view for a large review. **Suggested addition:** expose read-only pane viewport geometry plus a narrow `actions.scrollItemIntoView(id)` capability (or a supported scrollbox ref contract). This would let extensions virtualize and retain selection visibility without exposing OpenTUI internals. @@ -18,11 +28,14 @@ The triage board can only be session-local. Extension config is repository-overr **Suggested addition:** a namespaced, user-owned storage API with explicit scopes such as session and local-user/repository, plus a changeset identity available for reconciliation. Hunk should own the file location and trust semantics. -### Command handlers cannot navigate the review stream +### Command handlers could not navigate the review stream + +**Current status:** resolved. `ExtensionCommandContext.navigation` now exposes guarded +`selectFile`, `selectHunk`, and `revealLine` actions. -Commands receive a selection snapshot, dialogs, and sidebar open/close controls, but no `selectFile` or `selectHunk`. A "next blocked hunk" command therefore cannot navigate directly; it would need to rely on a mounted sidebar to perform navigation, which is both indirect and unreliable on a narrow terminal. Review triage avoids shipping that misleading command and makes hunk rows clickable instead. +At the time of this report, commands received a selection snapshot, dialogs, and sidebar open/close controls, but no `selectFile` or `selectHunk`. A "next blocked hunk" command therefore cannot navigate directly; it would need to rely on a mounted sidebar to perform navigation, which is both indirect and unreliable on a narrow terminal. Review triage avoids shipping that misleading command and makes hunk rows clickable instead. -**Suggested addition:** place the existing guarded `selectFile` / `selectHunk` navigation methods on command context as well as sidebar actions. +**Implemented:** command context now exposes the guarded navigation methods used by pane actions. ### Dialogs are intentionally simple, but triage exposes their limits diff --git a/docs/extension-architecture.md b/docs/extension-architecture.md index 9bd50888b..28880bf0d 100644 --- a/docs/extension-architecture.md +++ b/docs/extension-architecture.md @@ -9,21 +9,22 @@ exists so you know which module owns what. ## Tiers and loading Extensions come in two tiers running through the same per-extension API -object and registry collection (`src/extensions/runExtension.ts`): - -- **User extensions** load at interactive-app startup, before - `loadAppBootstrap` (`src/extensions/startup.ts`, `src/extensions/host.ts`). - Discovery groups and trust gating: `src/extensions/discovery.ts`, - `src/extensions/trust.ts`. -- **Bundled extensions** live in `src/extensions/default/` and are compiled - into the binary. `default/vcs/{git,jujutsu,sapling}` is statically imported - by the app composition root (`app/vcsCatalog.ts`) and loaded synchronously - before config resolution, so backends exist without making core import the - extension host. `default/ui/index.ts` is deliberately not part of that list: - it synchronously loads the bundled files and delegated review-info panes through - `runExtensionFactory` only where the app resolves UI panes. - -Git, built-in file navigation, and delegated change-request identity use the public +object and registry collection (`packages/hunk/src/extensions/runExtension.ts`): + +- **User extensions** load during app bootstrap after initial config resolution and before final + session bootstrap (`packages/hunk/src/app/extensionBootstrap.ts`, + `packages/hunk/src/extensions/startup.ts`, `packages/hunk/src/extensions/host.ts`). Discovery + groups and trust gating live in `packages/hunk/src/extensions/discovery.ts` and + `packages/hunk/src/extensions/trust.ts`. +- **Bundled extensions** are compiled into the binary. The private + `packages/hunk-{git,jj,sapling}` provider workspaces are statically imported by + `packages/hunk/src/extensions/default/vcs/index.ts`; the app composition root + (`app/vcsCatalog.ts`) loads them synchronously before config resolution, so backends exist + without making core import the extension host. `default/ui/index.ts` is deliberately not part of + that list: the UI pane planner loads its bundled files and delegated review-info registrations + through `runExtensionFactory`. + +Git, built-in file navigation, and change-request or history-commit identity use the public `registerVcsAdapter` and `registerPane` paths. The external [Hunk Lens](https://github.com/modem-dev/hunk-lens) extension exercises current-line pane paint through that same public contract. @@ -39,15 +40,18 @@ reserved ids (`hunk`, plus the base catalog's bundled backend ids), ids outside `/^[A-Za-z0-9][A-Za-z0-9_-]*$/` (a dot or colon would make the composed ids unsplittable), and the later of two sources claiming one id; each refusal is a load issue and costs only that extension. The rules themselves are stated in -`src/extensions/extensionIds.ts`. +`packages/hunk/src/extensions/extensionIds.ts`. -## One registry, one apply path +## One registry model, separate composition paths Registrations (session behavior, themes, file languages, VCS adapters, changeset transforms, panes, interactive commands, top-level CLI commands, -lifecycle/UI events, and inter-extension bus listeners) collect into one -`ExtensionRegistry` (`src/extensions/types.ts`) and are resolved/applied -through `src/extensions/apply.ts` on both startup and reload. File-language registrations stay as +lifecycle/UI events, and inter-extension bus listeners) collect into an +`ExtensionRegistry` (`packages/hunk/src/extensions/types.ts`). Bundled VCS, bundled UI, and user +extensions use separate registry instances and lifecycle owners. `app/vcsCatalog.ts` composes +bundled VCS registrations directly; `ui/lib/extensionPanes.ts` reads bundled UI pane registrations; +and `extensions/apply.ts` applies user registrations during session bootstrap and reload. +File-language registrations stay as declarative extension, filename, or glob selectors until `fileLanguageLookup.ts` resolves them; Hunk then pins that answer into Pierre's metadata so rendering cannot re-derive a conflicting language. A live reload replaces the compiled selector generation while preparing its changeset @@ -55,9 +59,12 @@ and restores the previous generation if any pre-commit step fails. Staged extern retains the provisional candidate/config snapshot: a final pass that only appends repo candidates extends the same registry, while a changed prefix receives bounded `shutdown` before being rebuilt. Live registry replacement uses -the same shutdown/startup lifecycle. A factory that throws is rolled back to its -pre-run registration counts (`runExtension.ts`); failures cost a warning, not the -session. +the same shutdown/startup lifecycle. `src/extensions/session.ts` owns active, provisional, and +retiring registries by identity; it synchronously closes authority at adoption/shutdown and drains +all known bounded retirements. Surfaces borrow that session and cannot retire it independently. +A user or bundled-VCS factory that throws is rolled back to its pre-run registration counts +(`runExtension.ts`); failures cost a warning, not the session. Bundled UI registration instead +requires every expected pane and throws if Hunk's own invariant fails. Generic CLI commands deliberately remain separate from the interactive named-command table. `parseCli` resolves known built-ins first, preserving static help/version and @@ -80,7 +87,7 @@ the handler releases I/O. ## Host-served runtime modules Extension files import `react`, `@opentui/*`, and `hunkdiff/extension` as -host-served runtime modules (`src/extensions/hostRuntimeModules.ts`): a +host-served runtime modules (`packages/hunk/src/extensions/hostRuntimeModules.ts`): a per-extension-directory Bun loader hook transpiles extension source and rewrites those specifiers to prefixed virtual modules backed by the host's own instances. That identity is what lets `registerPane` components @@ -91,17 +98,17 @@ commands never pay OpenTUI's native-library extraction). ## Four-edge pane system -`src/ui/lib/extensionPanes.ts` owns open state, availability, and one rectangle +`packages/hunk/src/ui/lib/extensionPanes.ts` owns open state, availability, and one rectangle plan for panes, dividers, and review bounds. Left/right panes consume columns; top/bottom panes consume rows from the central review column, outside review stream coordinates. Pane registrations may opt into a body-axis `fraction`; the planner resolves it to an integer target before applying bounds and lets a session-local divider drag override that automatic size. -`src/ui/components/panes/ExtensionPane.tsx` mounts panes with guarded actions, -immutable delegated review metadata, and failure containment. The fixed three-row -`hunk:review-info` top pane uses one border row above two metadata rows and is available only for -delegated change requests, so ordinary reviews spend no geometry on it. `DiffPane` exposes optional current-line paint — the row +`packages/hunk/src/ui/components/panes/ExtensionPane.tsx` mounts panes with guarded actions, +immutable review metadata, and failure containment. The fixed three-row `hunk:review-info` top +pane uses one border row above two metadata rows and is available for delegated change requests or +commits selected from interactive history, so ordinary reviews spend no geometry on it. `DiffPane` exposes optional current-line paint — the row painter plus the public `{ side, line }` address — without publishing Pierre rows, plans, cursor keys, or caches. Deprecated sidebar APIs normalize into this same registry and layout path. @@ -109,22 +116,22 @@ normalize into this same registry and layout path. ## File-view system File-view registrations are selected per file but remain inside the one -host-owned review stream. `src/ui/fileViews/useFileViews.ts` bounds asynchronous +host-owned review stream. `packages/hunk/src/ui/fileViews/useFileViews.ts` bounds asynchronous extension work and retains only immutable layouts accepted by -`src/ui/fileViews/layout.ts`; width and registration identity are part of that +`packages/hunk/src/ui/fileViews/layout.ts`; width and registration identity are part of that accepted geometry. A stateful view has no such identity to change, so `ctx.fileViews.refresh` bumps an invalidation epoch owned by -`src/ui/fileViews/useFilePresentationController.ts` and modeled in -`src/ui/fileViews/state.ts`. That epoch participates in the same retention key, +`packages/hunk/src/ui/fileViews/useFilePresentationController.ts` and modeled in +`packages/hunk/src/ui/fileViews/state.ts`. That epoch participates in the same retention key, re-preparing the files presenting that view while their current rows stay visible. One map counts both view-wide and per-file invalidation, and `fileViewLayoutEpoch` is the single place that composes them into the epoch a -`(file, view)` preparation is retained under. `src/ui/fileViews/renderPlan.ts` is the shared insertion +`(file, view)` preparation is retained under. `packages/hunk/src/ui/fileViews/renderPlan.ts` is the shared insertion plan for validated extension rows and host-owned inline notes. It resolves only unambiguous exact-source bindings and returns an explicit unresolved set, so `DiffPane` falls the complete file back to Pierre rather than guessing or -silently dropping review data. `src/ui/fileViews/geometry.ts` measures that same -plan, and `src/ui/components/panes/FileView.tsx` windows and paints it. Extension +silently dropping review data. `packages/hunk/src/ui/fileViews/geometry.ts` measures that same +plan, and `packages/hunk/src/ui/components/panes/FileView.tsx` windows and paints it. Extension components can paint only their fixed validated rectangles; note cards, scrolling, hunk bounds, and navigation remain host-owned. @@ -132,46 +139,46 @@ scrolling, hunk bounds, and navigation remain host-owned. Line highlighters mark character ranges inside Hunk's own diff rendering, so the system is deliberately split between a pull-based preparation half and a -paint-only application half. `src/ui/highlights/useLineHighlights.ts` bounds +paint-only application half. `packages/hunk/src/ui/highlights/useLineHighlights.ts` bounds asynchronous extension work with the same timeout/concurrency discipline as file views and retains only marks accepted by -`src/ui/highlights/validate.ts`; results cache under `(file, highlighter, +`packages/hunk/src/ui/highlights/validate.ts`; results cache under `(file, highlighter, epoch)`, and each file's merged mark array keeps a stable identity while its inputs are unchanged so row memoization can hold. The epoch is owned by -`src/ui/highlights/useLineHighlightsController.ts` behind +`packages/hunk/src/ui/highlights/useLineHighlightsController.ts` behind `ctx.highlights.refresh`, using the shared scoped-epoch policy in -`src/ui/lib/scopedEpochs.ts` — the same module `src/ui/fileViews/state.ts` +`packages/hunk/src/ui/lib/scopedEpochs.ts` — the same module `packages/hunk/src/ui/fileViews/state.ts` delegates to — and the shared bounded `readDocument` capability lives in -`src/ui/lib/extensionDocumentReader.ts`. +`packages/hunk/src/ui/lib/extensionDocumentReader.ts`. -Application is paint-time by construction. `src/ui/diff/lineHighlightPaint.ts` +Application is paint-time by construction. `packages/hunk/src/ui/diff/lineHighlightPaint.ts` owns the one mapping from source coordinates (raw code-unit offsets) to terminal columns — sanitize-aware, tab-aware, snapped outward to grapheme clusters, with context and gap lines sharing one range list under both side keys — and the one span transform that repaints backgrounds without changing -text. `src/ui/diff/rowStyle.ts` resolves tones against the actual line +text. `packages/hunk/src/ui/diff/rowStyle.ts` resolves tones against the actual line background with the word-diff minimum-contrast guarantee. -`src/ui/diff/CodeRowView.tsx` applies the transform through the cell painter, +`packages/hunk/src/ui/diff/CodeRowView.tsx` applies the transform through the cell painter, which keeps highlights out of `buildDiffSectionRowPlan`, its caches, and every geometry measurement: a highlight change is a repaint, never a re-plan. -`src/ui/diff/DiffRowView.tsx` remains only the memoized dispatch facade; raw-row +`packages/hunk/src/ui/diff/DiffRowView.tsx` remains only the memoized dispatch facade; raw-row adaptation there supports the public OpenTUI and extension current-line surfaces, while -`src/ui/diff/cursorHighlight.ts` owns stable-key cursor matching. The static pager never +`packages/hunk/src/ui/diff/cursorHighlight.ts` owns stable-key cursor matching. The static pager never runs extension code, so highlights are interactive-only. Agent attention marks (`hunk session highlight add` / `clear`) join this same pipeline rather than growing a second one: `useTerminalReview.ts` validates each daemon-pushed mark with the same `validate.ts` contract and caps, holds -them per file, and `src/ui/highlights/merge.ts` appends them after extension +them per file, and `packages/hunk/src/ui/highlights/merge.ts` appends them after extension marks in the one map `DiffPane` paints from — so agent marks share paint, contrast, and geometry guarantees, and win where ranges overlap. Unlike extension marks, nothing re-derives agent marks after a reload, so -`src/ui/highlights/reconcile.ts` carries them across a document replacement only +`packages/hunk/src/ui/highlights/reconcile.ts` carries them across a document replacement only for files whose `contentIdentity` is unchanged — those still show the same characters — and drops the rest. Line-target `session navigate` reuses the same `revealLine` landing policy `ctx.navigation.revealLine` gets. -`src/ui/fileViews/mode.ts` owns file-view mode activation, validity, and callback +`packages/hunk/src/ui/fileViews/mode.ts` owns file-view mode activation, validity, and callback containment. The presentation controller stores the active mode and funnels all exit paths through one teardown, including re-entrant handoffs. @@ -181,17 +188,17 @@ keyboard modes and app commands. `"handled"` and `"exit"` consume the key; Session-wide modes registered through `registerKeyboardMode` are resolved with the same extension ownership and first-registration rules as other surfaces. -`src/ui/keyboardModes/useKeyboardModeController.ts` owns the one active session +`packages/hunk/src/ui/keyboardModes/useKeyboardModeController.ts` owns the one active session mode, with eager ref state for input chunks, registry-generation authority, contained synchronous lifecycle callbacks, and one teardown used by Escape, status, menu, reload, and unmount. Mode controls are activation-scoped; `onEnter` and `onExit` cannot change ownership, while `onKey` may deliberately replace its activation without letting the outgoing callback defeat recovery or manipulate the replacement. -`src/ui/lib/extensionKeyEvent.ts` freezes the method-free public key snapshot +`packages/hunk/src/ui/lib/extensionKeyEvent.ts` freezes the method-free public key snapshot used by both session and file-view mode delivery, so OpenTUI events and their consumption methods never cross the extension boundary. Their shared -`src/ui/lib/synchronousExtensionCallback.ts` path contains lifecycle failures, +`packages/hunk/src/ui/lib/synchronousExtensionCallback.ts` path contains lifecycle failures, rejects thenables without leaving unhandled rejections, and normalizes key results; each mode module supplies only its context and attributed warnings. A focused file-view mode may overlap and temporarily outrank a session mode; @@ -200,20 +207,20 @@ leaving it resumes the session mode rather than destroying unrelated state. ## Command system Every app-level keyboard shortcut is a named command in one dispatch table -(`src/ui/lib/appCommands.ts`), each id under Hunk's reserved vendor namespace +(`packages/hunk/src/ui/lib/appCommands.ts`), each id under Hunk's reserved vendor namespace (`hunk.app.quit`, `hunk.review.nextHunk`) — which is what keeps built-in ids and extension-owned ids in disjoint spaces however either grows; modal surfaces (dialogs, menus, focused inputs) own their keys first and are deliberately not commands. Extension `registerCommand` entries join the same table via -`src/ui/lib/extensionCommands.ts` — built-ins win key conflicts, refused one +`packages/hunk/src/ui/lib/extensionCommands.ts` — built-ins win key conflicts, refused one chord at a time and detected by probing matchers with a synthesized event -(`src/lib/commandKeys.ts`). Command handlers receive pane controls and a selection snapshot from -`src/ui/lib/extensionSelection.ts`, derived from the same frozen file views the +(`packages/hunk/src/lib/commandKeys.ts`). Command handlers receive pane controls and a selection snapshot from +`packages/hunk/src/ui/lib/extensionSelection.ts`, derived from the same frozen file views the panes render plus a copied source address for the active current-line cursor. App reads it through a ref so the dispatch table stays stable while line navigation moves. `ctx.review.snapshot()` takes the complementary whole-review -path: `src/extensions/reviewSnapshot.ts` copies the active shared ReviewStore's +path: `packages/hunk/src/extensions/reviewSnapshot.ts` copies the active shared ReviewStore's document identities and complete saved-note collections, preserving core-owned anchors and reconciliation verdicts. App pairs that state with the producer's current generation under the same review capability lease, so retained controls @@ -221,7 +228,7 @@ return `null` after reload instead of reading replacement content. The extension projection is registered in `test/review-conformance/` as a real semantic consumer rather than rebuilding note placement in the command host. -`src/ui/lib/extensionNavigation.ts` mints the guarded navigation behind both +`packages/hunk/src/ui/lib/extensionNavigation.ts` mints the guarded navigation behind both `ctx.navigation` and a pane's `actions`, so a jump from either surface is validated, attributed, and reported the same way. It owns argument policy only — visible-file validation, hunk clamping, `revealLine`'s side and line-number @@ -243,12 +250,12 @@ modal keys also remain outside the table and therefore outside the event. `ctx.dialogs` is the one place extension code can interrupt the user, so its ordering and settlement live outside React in -`src/ui/lib/extensionDialogs.ts` — one FIFO queue per App instance, minting a +`packages/hunk/src/ui/lib/extensionDialogs.ts` — one FIFO queue per App instance, minting a per-extension `dialogs` object, normalizing (and sanitizing) extension-authored text into a request the host draws, and answering by request id so a duplicated Enter cannot spill onto whatever was queued behind. App subscribes with `useSyncExternalStore`, renders the current request through -`src/ui/components/chrome/ExtensionDialog.tsx` (confirm reuses `ConfirmDialog`; +`packages/hunk/src/ui/components/chrome/ExtensionDialog.tsx` (confirm reuses `ConfirmDialog`; select and input are `ModalFrame` surfaces), and unmount calls `shutdown()` so every pending and queued dialog resolves its cancel value instead of leaving a handler awaiting forever. Key precedence in `useAppKeyboardShortcuts` places @@ -260,7 +267,7 @@ frame carries an `ext ` attribution row — the toast marker — for every user-installed extension, because its title is extension-authored and a prompt must not be able to impersonate Hunk. The host derives the extension's trusted bundled origin from registry metadata and omits the redundant marker only for -Hunk-owned bundled UI. `src/ui/lib/modalGeometry.ts` clamps the frame before +Hunk-owned bundled UI. `packages/hunk/src/ui/lib/modalGeometry.ts` clamps the frame before extension text is wrapped or windowed, so measurement and rendering use the same terminal width; body/options yield rows to a pinned mouse-clickable action footer on short terminals. @@ -270,11 +277,12 @@ same guarded live navigation commands use. They can also request a current-input the current-review controller registers the latest reloadable descriptor, then AppHost resolves that descriptor at queue execution and coalesces extension requests while serializing them with manual, watch, workspace, and daemon reloads. `App` installs these controls through the -per-extension event-context provider, while `AppHost` publishes mounted +per-extension event-context provider, while `AppHost` keeps the content/broker/React commit gate +and asks the owning `ExtensionSession` to adopt only after those facts agree. `AppHost` publishes mounted lifecycle order (`startup`, then `changeset_loaded`; reloads add `session_reload`) only after the matching child commit. Headless or pre-mount delivery resolves dialogs to their cancel values and refuses navigation with a warning. -`src/ui/lib/extensionCapabilityLease.ts` binds retained pane, navigation, +`packages/hunk/src/ui/lib/extensionCapabilityLease.ts` binds retained pane, navigation, dialog, review-reload, and workspace controls to one App, extension registry, and review generation. Soft reload or registry retirement therefore makes old host-mediated capabilities @@ -284,7 +292,7 @@ inert before shutdown begins. Session behavior requests are registry data too: presentation view changes ephemeral without teaching `App` about an extension id. -`src/ui/lib/extensionWorkspace.ts` owns the policy for `ctx.workspace`. Reads +`packages/hunk/src/ui/lib/extensionWorkspace.ts` owns the policy for `ctx.workspace`. Reads resolve reviewed file ids through the existing source fetcher, which retains ownership of caching and size limits. Missing or unreadable sources become `null`. @@ -295,27 +303,27 @@ through refs so soft reloads update the policy inputs. The host verifies the filesystem target before and after consent, writes it, then calls `refreshCurrentInput`. Consent uses the existing extension-dialog queue. -Commands declare chords, not matchers: `src/ui/lib/keymap.ts` folds every +Commands declare chords, not matchers: `packages/hunk/src/ui/lib/keymap.ts` folds every command's `defaultKeys` against the user's `[keybindings]` table (user config layer only) into one id-to-chords answer, from which matchers, key labels, and conflict probes are all derived — a user-bound chord is exclusive, so whatever held it by default gives it up. The chord grammar itself lives in -`src/extension-api/keys.ts` because it is published as `hunkdiff/extension` +`packages/hunk/src/extension-api/keys.ts` because it is published as `hunkdiff/extension` (`matchesKey`, `parseKeyChord`, `matchesKeyChord`) for extension components -that need internal keys; `src/lib/commandKeys.ts` re-exports it inward and +that need internal keys; `packages/hunk/src/lib/commandKeys.ts` re-exports it inward and keeps the host-only pieces. The table is also the only description of what each action is called and which key runs it, so the mouse surfaces read from it rather than restating it: the -dropdown menus (`src/ui/lib/appMenus.ts`) declare items as command ids plus +dropdown menus (`packages/hunk/src/ui/lib/appMenus.ts`) declare items as command ids plus menu-specific wording and checkbox state, and the controls help dialog -(`src/ui/lib/helpContent.ts`) declares curated rows the same way — both render +(`packages/hunk/src/ui/lib/helpContent.ts`) declares curated rows the same way — both render their key text from resolved `keyLabels` and run entries through `executeAppCommand`. A few commands ship with `defaultKeys: []` because they exist for a menu item; they never match a key but remain bindable by id. Command handlers receive guarded `ctx.commands` controls built by -`src/ui/lib/extensionCommandControls.ts`. They resolve the live App command table on every call, +`packages/hunk/src/ui/lib/extensionCommandControls.ts`. They resolve the live App command table on every call, then expose only built-ins carrying explicit public metadata. Counted movement reaches the same command callback once with a normalized delta; it is never implemented as repeated synchronous dispatch. Current-line alignment is also semantic: App raises an alignment request and `DiffPane` @@ -325,33 +333,46 @@ Extension commands remain private to prevent recursion and cross-extension execu The **Extensions** menu is generated from the registered extension commands, one item per command grouped by extension, and is absent entirely when there are none — which is why the visible menu list is derived from the menus record -(`buildMenuSpecs` in `src/ui/components/chrome/menu.ts`) rather than fixed. +(`buildMenuSpecs` in `packages/hunk/src/ui/components/chrome/menu.ts`) rather than fixed. ## VCS adapters -`src/core/vcs/index.ts` owns provider-neutral catalog ordering, lookup, -detection, and operation dispatch. `src/app/vcsCatalog.ts` composes bundled -registrations, while `src/app/sessionBootstrap.ts` extends that catalog with +`packages/hunk/src/core/vcs/index.ts` owns provider-neutral catalog ordering, lookup, +detection, and operation dispatch. `packages/hunk/src/app/vcsCatalog.ts` composes bundled +registrations, while `packages/hunk/src/app/sessionBootstrap.ts` extends that catalog with accepted user adapters and threads the same value through loading, reload, and watch. Detection is uniform across tiers: nearest checkout wins, priority breaks equal-distance ties, and an explicit `vcs` id owned by the catalog wins. Provider implementations — command construction, spawning, error translation, -and exact-source reading — live entirely under -`src/extensions/default/vcs//`. `src/extensions/vcsPatchResult.ts` is +and exact-source reading — live entirely in the private `packages/hunk-{git,jj,sapling}` +workspaces and import the public `hunkdiff/extension` contract plus only explicit +provider-neutral `@hunk/vcs/*` implementation subpaths. +`packages/hunk/src/extensions/vcsPatchResult.ts` is the one conversion boundary where a published `ExtensionVcsPatchResult` becomes Hunk's internal diff model, including structural `too-large` source -results. `src/core/process/projectRoot.ts` treats `.hunk` as a provider-independent +results. `packages/hunk/src/core/process/projectRoot.ts` treats `.hunk` as a provider-independent bootstrap marker and also consults the available catalog; startup performs a second root/config pass when a global, config-path, or CLI adapter recognizes a repository unavailable to the bundled catalog. +`hunk log` follows the same boundary. Core/app and `src/ui/history/` own the built-in command, +validated graph planning, presentation, themes, paging, and child-process orchestration. The shared +`src/ui/session/` runner owns the process-level renderer/root lifetime, while its closed host routes +retained history and fresh review surfaces without giving either surface terminal ownership. The +selected adapter's public `history` capability owns traversal, filtering, +immutable revision and parent identities, structured decorations, and the declarative review action +for a selected item. The host treats those ids as opaque and never constructs provider revision +syntax or decides root/merge comparison semantics. History pages remain child-before-parent across +the full cursor, including page boundaries; the extension conversion boundary validates that +ordering before core or UI consumes it. + ## Public contract rules The authoring surface is the `hunkdiff/extension` export — a façade over -internal types, declared in `src/extension-api/types.ts`. That module must +internal types, declared in `packages/hunk/src/extension-api/types.ts`. That module must stay import-free: declaration emission ships every module the entry reaches, -so an import there publishes Hunk internals (`scripts/check-pack.ts` fails +so an import there publishes Hunk internals (`scripts/packaging/check-pack.ts` fails the pack when it does, and typechecks every `docs/extensions.md` example as a consumer). Shapes shared with internal code are declared there and re-exported inward. diff --git a/docs/extension-system-exploration.md b/docs/extension-system-exploration.md index baa994f74..37ac2682d 100644 --- a/docs/extension-system-exploration.md +++ b/docs/extension-system-exploration.md @@ -1,8 +1,13 @@ # Hunk extensions: an exploration -What a JavaScript/TypeScript extension system for Hunk could look like, modeled -on the extension system of [pi](https://pi.dev) (`@earendil-works/pi`), grounded -in Hunk's actual architecture. This is a design exploration, not a spec. +> **Historical design note (July 2026).** This proposal preceded Hunk's implemented extension +> system. See the [extension guide](extensions.md) and +> [extension architecture](extension-architecture.md) for the current contract. The implementation +> now includes trust-gated TypeScript loading, named commands, panes, file views, VCS adapters, +> review controls, and API versioning. + +The proposal modeled a JavaScript/TypeScript extension system on +[pi](https://pi.dev) (`@earendil-works/pi`) and Hunk's architecture. ## 1. How pi does it @@ -106,21 +111,21 @@ seams in very different states of readiness: **Nearly plugin-shaped already:** -- `vcsAdapters` (`src/core/vcs/index.ts`) — a real adapter pattern: detection, +- `vcsAdapters` (`packages/hunk/src/core/vcs/index.ts`) — a real adapter pattern: detection, per-operation handler maps, capability probing. Making the array appendable is almost the whole job for third-party VCS support. - The session broker (`packages/session-broker*`) is generic over session info/state/message types by design, and the app installs its command dispatcher at runtime via `createHunkSessionBridge(handlers)` + - `hostClient.setBridge(...)` (`src/app/session/bridge.ts`). That bridge is + `hostClient.setBridge(...)` (`packages/hunk/src/app/session/bridge.ts`). That bridge is the single clearest injection point for extension-provided session commands. - Dynamic theme registration exists (`ensureSyntaxHighlightThemeRegistered` → Pierre's `registerCustomTheme`), but the config layer caps custom themes at one `"custom"` slot. -- Pierre's `setCustomExtension` (`src/core/changeset/fileLanguage.ts`) is already used at +- Pierre's `setCustomExtension` (`packages/hunk/src/core/changeset/fileLanguage.ts`) is already used at import time for `.mts`/`.cts`; extending it to plugin-declared mappings is trivial. -- `StartupDeps` (`src/app/startup.ts`) fully injects the startup pipeline +- `StartupDeps` (`packages/hunk/src/app/startup.ts`) fully injects the startup pipeline (currently only tests use it), and unknown TOML keys are silently ignored, so an `[extensions]` config section is backward-compatible on day one. @@ -174,7 +179,7 @@ export default function (hunk: HunkExtensionAPI) { ```` - Discovery: `~/.config/hunk/extensions/*.ts` and `*/index.ts` (global, follows - our existing XDG path logic in `src/core/run/paths.ts`), `.hunk/extensions/` + our existing XDG path logic in `packages/hunk/src/core/run/paths.ts`), `.hunk/extensions/` (repo-local, **trust-gated**, same posture as pi's project trust), explicit `[extensions] paths = [...]` in `config.toml`, and a `--extension ` dev flag. `--no-extensions` for a clean run and for bug triage. @@ -215,10 +220,8 @@ Every UI-facing capability above lands on the same prerequisite: core actions become data (`{id, label, hint, keys, menuId, isAvailable, run}`) in one registry that `useAppKeyboardShortcuts`, `buildAppMenus`, and `HelpDialog` all derive from. This is worth doing even if extensions never ship — it deletes the -existing triplication and the 650-line key ladder — and it is exactly the -"single source of truth per user-visible behavior" rule in our own guidance. -User keybinding remapping (a long-standing wish in tools like this) falls out -of the same structure for free. +existing triplication and the 650-line key ladder while giving each user-visible behavior one +owner. The same structure can support user keybinding remapping. ## 6. Suggested phasing diff --git a/docs/extensions.md b/docs/extensions.md index 179eb1dbc..da974f9e4 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -192,16 +192,16 @@ run without installing anything. ## Bundled extensions Every VCS backend Hunk ships — **Git, Jujutsu, and Sapling** — is an extension, -and so is the **built-in file-navigation pane**. They live in -`src/extensions/default/`, are compiled into the binary, and register through -the same `hunk.registerVcsAdapter` and `hunk.registerPane` this guide -documents. There is no private registration path. +and so is the **built-in file-navigation pane**. Provider implementations live in the private +`packages/hunk-{git,jj,sapling}` workspaces and are statically imported by +`packages/hunk/src/extensions/default/vcs/index.ts`. Bundled UI registrations live under +`packages/hunk/src/extensions/default/ui/`. All register through the same +`hunk.registerVcsAdapter` and `hunk.registerPane` contract documented here; there is no private +registration path. -Git in particular is the reason: it is the backend that exercises every -integration point there is — exact file sources, skipped-too-large placeholders, -untracked files, watch plans, rich failures — so running it through the -published API is what keeps that API honest. Anything Git can do, your adapter -can do, because Git does it the same way you would. +Git exercises exact file sources, skipped-too-large placeholders, untracked files, watch plans, +and structured failures through the public adapter contract. Its package and boundary tests keep +those capabilities on the same registration path available to third-party adapters. Bundled extensions differ from yours in three ways, all of them consequences of being Hunk's own code: @@ -214,9 +214,10 @@ being Hunk's own code: Those switches exist to triage extensions _you_ installed; losing VCS support from a debugging flag would break every workflow there is. -Failure isolation still applies to them. The ids `git`, `jj`, and `sl` are -reserved as a result — see `registerVcsAdapter` below — and so is `hunk`, the -id the bundled files pane and every built-in command are named under. +A bundled VCS factory failure becomes a load issue rather than crashing the session. Bundled UI +panes are required host code, so failure to register the expected panes aborts startup. The ids +`git`, `jj`, and `sl` are reserved as a result — see `registerVcsAdapter` below — and so is `hunk`, +the id the bundled files pane and every built-in command are named under. ## Trust @@ -278,12 +279,35 @@ start watchers, processes, connections, and other long-lived resources from `startup`, and release them from `shutdown`. Extension-registry reloads create new instances and run that shutdown/startup pair around the replacement. +One host-owned `ExtensionSession` holds active, provisional, and retiring registries for a +command lifetime. It revokes replaced authority at the review commit gate, drains bounded shutdown +handlers before terminal teardown, and prevents surfaces from independently replacing or retiring +the shared registry. + +An interactive history workspace owns one extension instance for its complete +lifetime. Opening a commit review inside that workspace borrows the same +instance: the factory and `startup` do not run again, and returning to history +does not send `shutdown`. Review-specific `changeset_loaded` events still run +for each opened commit. The owning history workspace sends the one eventual +`shutdown` when it exits. This makes module-local clients and stores safe to +share deliberately between retained history and its commit reviews without a +review closing resources that history still uses. + +Keep mutable review-generation data keyed by the identities in event payloads +or replace it on `changeset_loaded`; module scope is workspace state, not a +fresh namespace per opened commit. An embedded review cannot replace its +borrowed registry; extension replacement remains an operation of the owning +workspace. A true owner-driven extension-registry reload creates a new instance +and retires the replaced instance at that explicit ownership boundary. + ### `hunk.apiVersion` -The API generation this Hunk speaks (currently `18`). Branch on it if you want -one file to support several Hunk versions. Version 18 lets lifecycle and custom-event handlers -request a host-owned review reload; version 17 adds structured review metadata to delegated -patch commands and projects it into pane availability and component props; version 16 adds pane-wide +The API generation this Hunk speaks (currently `20`). Branch on it if you want +one file to support several Hunk versions. Version 20 adds optional commit timestamps to review +metadata, pane clipboard actions, and the `theme.copyAction` paint token; version 19 adds provider-owned history +enumeration and review planning; version 18 lets lifecycle and custom-event handlers request +a host-owned review reload; version 17 adds structured review metadata to delegated patch +commands and projects it into pane availability and component props; version 16 adds pane-wide `onActivate`; version 15 added `{ side, line }` to opted-in pane `currentLine` paint; version 14 added structured `rangeEndpoints` to two-revision VCS diff requests; version 13 added saved-note parent identities and @@ -347,15 +371,18 @@ A delegated built-in `patch` command may include a provider-neutral `review` des strings with an optional credential-free HTTPS URL. Hunk rejects unknown fields, control characters, invalid types, unsafe URLs, fields over their byte limits, and descriptors over 4 KiB, then copies and freezes the accepted value. `provider` and change-request `id` allow 256 bytes; -`repository`, `author`, `base`, `head`, and `revision` allow 512; `title` and `url` allow 2 KiB. -Change requests may also carry `state` (`open`, `closed`, or `merged`) and boolean `draft`. Exit results and delegation to any built-in other than +`repository`, `author`, `base`, `head`, and `revision` allow 512; `authoredAt` allows 128; +`title` and `url` allow 2 KiB. Change requests may also carry `state` (`open`, `closed`, or +`merged`) and boolean `draft`; commits may carry a parseable `authoredAt` date-time. Exit results and delegation to any built-in other than `patch` cannot carry review metadata. An ordinary `hunk patch` has no descriptor. The descriptor describes the review source rather than its diff contents: it stays on the app bootstrap and does not enter changeset transforms or `ReviewDocumentV1`. Refreshing the same file-backed patch preserves it, including watch and manual refresh; an explicit reload to a -different patch path or input kind clears it. Live-session list, context, and review JSON snapshots -project the same optional descriptor from registration metadata; it remains outside the semantic +different patch path or input kind clears it. Opening a commit from interactive `hunk log` attaches +a commit descriptor from the selected provider history row and preserves it while refreshing that +exact provider review request. Live-session list, context, and review JSON snapshots project the +same optional descriptor from registration metadata; it remains outside the semantic review document and grants no remote reload or provider capability. Delegation cannot target another extension command or change extension bootstrap @@ -395,7 +422,7 @@ removes the patch on extension shutdown. Run it from this checkout with: ```bash -bun run src/main.tsx --extension ./examples/extensions/github-pr gh 123 +bun run packages/hunk/src/main.tsx --extension ./examples/extensions/github-pr gh 123 ``` ### `hunk.configureSession(options)` @@ -500,6 +527,73 @@ reuses one is skipped with a notice. map off entirely — produces a clear "not supported" error for that command instead of a crash. +API version 19 adds the optional, read-only `history` capability used by the built-in `hunk log` surface: + +```ts +hunk.registerVcsAdapter({ + id: "hg-history", + name: "Mercurial history", + detect: () => null, + history: { + async open() { + return { + async read({ signal }) { + signal?.throwIfAborted(); + return { commits: [], done: true }; + }, + close() {}, + }; + }, + planReview(commit) { + return commit.parentRevisionIds[0] + ? { + kind: "revision-range", + fromRevisionId: commit.parentRevisionIds[0], + toRevisionId: commit.revisionId, + } + : { kind: "revision-show", revisionId: commit.revisionId }; + }, + }, +}); +``` + +The snippet above demonstrates static history production only; it is not a complete interactive +adapter. Add a `revision-show` operation for `revision-show` actions and a `working-tree-diff` +operation that accepts `rangeEndpoints` for `revision-range` actions before advertising interactive +opening. Otherwise Enter reports that the corresponding review operation is unsupported. + +History is deliberately separate from patch-producing `operations`. The built-in host owns command +routing, graph planning, themes, terminal lifecycle, and static/interactive presentation. The +adapter owns every repository semantic: traversal and filtering, immutable identities, refs, and +`planReview`'s decision about how roots and merges open through that adapter's ordinary review +operations. Hunk treats revision ids as opaque strings and never invents provider revision syntax. + +Commits must carry an immutable full `revisionId`, display id, ordered parent ids, subject, optional +message body, author (and optional email), ISO authored time, and structured ref decorations. The +optional `logicalId` identifies the same logical change across provider rewrites (for example, a +Jujutsu change id); Hunk treats it as metadata and continues to key graph and review operations by +immutable `revisionId`. A `head` decoration carries an optional `attachedLocalBranch`; use that field +rather than embedding an arrow or branch identity in its display label. + +Every source must emit commits in **child-before-parent topological order**. If both a child and one +of its parents are included, the child appears first. This invariant spans the source's complete +lifetime: page boundaries do not reset it, and a parent returned on one page cannot be followed by +its child on a later page. Reads may return at most the requested limit and must distinguish a page +boundary from repository end with `done`. Hunk copies and validates every page, strips terminal +controls from display metadata, rejects duplicate revisions and parent-before-child output across +pages, forwards cancellation, and closes the source at EOF or failure. + +The bundled Git and Jujutsu extensions implement this public capability today; Sapling currently +reports it as unsupported. Jujutsu supplies commit/change identities, bookmarks, tags, traversal, +and native merge-review semantics without routing through a colocated Git repository. Third-party +adapters use exactly the same contract. + +Every operation `load` receives `context.signal`. Use asynchronous subprocess +APIs, pass cancellation through, and terminate plus reap provider processes when +it aborts; a synchronous spawn blocks Hunk's renderer and prevents the abort +handler from running. Watch signatures remain synchronous because the watch +runtime calls them as short, noninteractive probes. + A `load` result is patch text plus how to label it. Everything else on it is optional, and each optional field buys one thing: @@ -555,9 +649,9 @@ hunk.registerVcsAdapter({ Detection runs the same way for every adapter, whichever tier registered it: the nearest checkout wins, `detectionPriority` breaks ties between adapters that recognize the same root, and equal priorities fall back to registration -order. Config resolves the session's VCS before your extension has been -imported, so detection runs again once extensions are loaded — with the full -adapter list — and that second answer is the one the session uses. +order. Hunk first resolves config and project root with the available catalog. If newly loaded +adapters change the detected project root, it reruns root and config resolution before loading the +session. What detection never overrides is an explicit choice: a `vcs = ""` in Hunk config naming a backend this session loaded is honored as-is, however near a @@ -782,9 +876,9 @@ registration owns that slot and later claims are skipped with a warning. `replaces` may also name another pane by its fully qualified `":"` key, and Hunk follows those replacement chains. Both `available(context)` and the mounted component receive `review`: immutable -metadata supplied by a delegated patch command, or `null` for ordinary reviews. -The bundled `hunk:review-info` top pane uses this to show change-request identity -without taking any rows when no change-request descriptor exists. Pane extensions that read +metadata supplied by a delegated patch command or an interactive history selection, or `null` for +ordinary reviews. The bundled `hunk:review-info` top pane uses this to show change-request and +commit identity without taking any rows when no supported descriptor exists. Pane extensions that read `review` should declare `"hunk": { "apiVersion": 17 }` in their manifest so older Hunk versions refuse them cleanly instead of mounting with an incomplete prop contract. @@ -822,7 +916,7 @@ The component receives fresh props as the app changes: | Prop | What it is | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `review` | immutable delegated review metadata (`change-request`, `commit`, or `comparison`), or `null` for ordinary reviews | +| `review` | immutable review-source metadata (`change-request`, `commit`, or `comparison`), or `null` for ordinary reviews | | `files` | the visible reviewed files, review-stream order, filtered, frozen views (each carries `changeType`, `statsTruncated`, and `hunks` summaries beside the usual file fields) | | `selectedFileId` | the selected file, or `null` | | `selectedHunkIndex` | the selected hunk within that file, or `null` | @@ -832,7 +926,7 @@ The component receives fresh props as the app changes: | `currentLine` | selected-row painter plus `{ side, line }` when the registration opts in, otherwise `null` | | `theme` | hex color tokens from the active theme, updated on theme switch | | `keybindings` | the current command bindings, resolved from defaults and the user's `[keybindings]` table | -| `actions` | navigation and notifications the pane may trigger | +| `actions` | navigation, clipboard, and notifications the pane may trigger | API-v3 sidebar names remain as deprecated aliases: use `registerPane`, `ExtensionPane*`, `ctx.panes`, and `replaces: "hunk:files"` in new code. @@ -841,7 +935,9 @@ API-v3 sidebar names remain as deprecated aliases: use `registerPane`, `actions.revealLine(fileId, side, line)` route through the same review controller as the built-in files pane and the keyboard shortcuts, so the review stream scrolls, selection updates, and the `selection_changed` event fires -exactly as if the user had clicked a built-in row. `actions.notify(message, +exactly as if the user had clicked a built-in row. `actions.copyText(text)` uses the terminal's +OSC 52 clipboard integration and returns `false` when unavailable. Extensions that call it or read +`theme.copyAction` should declare `"hunk": { "apiVersion": 20 }` in their manifest. `actions.notify(message, type?)` shows a toast attributed to your extension. An action given a file id that is not currently visible is refused with a warning rather than corrupting the selection. A pane's `actions` carry the same navigation methods a command @@ -969,7 +1065,7 @@ whatever version Hunk pins — a wider surface than `hunkdiff/extension` itself. The built-in files pane uses the same calls, so changes that break this contract break Hunk first. Keep scroll handling small and behind your own helpers. -Its implementation lives in `src/extensions/default/ui/sidebar/` and serves as +Its implementation lives in `packages/hunk/src/extensions/default/ui/sidebar/` and serves as the reference for third-party panes. #### Pane state from events @@ -2117,6 +2213,6 @@ Menu entries, standalone keybindings (a chord contributed without a command — commands registered through `registerCommand` **are** already user-remappable via `[keybindings]`), custom note renderers, and session commands are not contributable yet. Generic top-level CLI trees use `registerCliCommand`; TUI -commands and their default key bindings use `registerCommand`. See -[docs/extension-system-exploration.md](extension-system-exploration.md) for the -design and phasing. +commands and their default key bindings use `registerCommand`. See the +[extension architecture](extension-architecture.md) for the current host design. The +[original exploration](extension-system-exploration.md) records historical rationale and phasing. diff --git a/docs/keybindings.md b/docs/keybindings.md index 356b8f1e3..b255b73a4 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -35,7 +35,10 @@ shifted form (`"G"`), or a named key (`"tab"`, `"pageup"`, `"left"`, `"f2"`). `shift` applies to letters and named keys only — for a shifted symbol or digit, write the character the shift produces (`"!"`, not `"shift+1"`), since that is what terminals report. `ctrl+` also matches an unnamed bare control -byte; named Tab and Enter events stay distinct. +byte; named Tab and Enter events stay distinct. `alt`/`option` matches both +explicit Alt events and the Escape-prefixed form used by legacy terminals. A +legacy terminal cannot distinguish Alt from Meta, so `alt+n` and `meta+n` may +overlap there; Kitty keyboard events keep them distinct. Inline saved notes also expose clickable **Edit**, **Reply**, and (for reply-free user notes) **Delete** actions. `E` edits the first editable user note in the selected hunk and `R` replies @@ -45,6 +48,18 @@ deleted until its replies are removed. The built-in commands and the keys they ship with: +On a terminal, `hunk log` opens its read-only history browser automatically. Its controls are +separate from the configurable review command table. `F10` opens File, View, Navigate, Commit, and Help menus; +View includes Hunk's shared theme selector and an optional **Graph view** that replaces the default +day-grouped timeline with commit-topology lanes. It uses `Up`/`Down` or `j`/`k` to move, `PageUp`/`PageDown`, +`g`/`G` or `Home`/`End` to jump, `/` to search, `n`/`N` for matches, `t` to choose a theme, `r` to refresh, `y` to copy +the full commit id, `Enter` to open the commit in normal Hunk review, and `q` to quit. With a mouse, click a commit +id to open it immediately, click the adjacent copy icon to copy its full immutable id, click elsewhere +on a row to select it, or double-click a row to open it. +Quitting the opened review returns to the retained history selection and viewport. The Commit menu's +**Compare with first parent** and **Compare with parent…** actions compare the selected commit against +an ordered provider-owned parent; they do not navigate the history selection to that parent. + | Command id | Does | Default keys | | ---------------------------------------------- | ---------------------------------------------- | ---------------------------- | | `hunk.app.openAgentSkill` | Show agent skill | _(none)_ | diff --git a/docs/module-boundaries.md b/docs/module-boundaries.md index 68c4c9547..3f5cfbf3f 100644 --- a/docs/module-boundaries.md +++ b/docs/module-boundaries.md @@ -1,9 +1,8 @@ # Module boundaries -Defines the target import boundaries between Hunk's top-level source trees and records what the -dependency graph actually looks like today. The boundaries are enforced by +Defines Hunk's current import boundaries and preserves the migration record that established them. The boundaries are enforced by [dependency-cruiser](https://github.com/sverweij/dependency-cruiser) over the production import -graph (`src/` plus `packages/`, tests excluded): +graph (`packages/hunk/src/` plus `packages/`, tests excluded): - `bun run deps:check` — fails CI on any boundary violation not in the baseline. - `bun run deps:baseline` — regenerates `.dependency-cruiser-known-violations.json` after fixing @@ -11,37 +10,44 @@ graph (`src/` plus `packages/`, tests excluded): and nothing is ever added. New code must respect the boundaries from day one. - Rules live in `.dependency-cruiser.cjs`; each rule's comment states the boundary it protects. -`scripts/source-boundaries.test.ts` remains the deeper, hand-authored gate for the review seam +`scripts/quality/source-boundaries.test.ts` remains the deeper, hand-authored gate for the review seam (browser-safe closure, Node-debt tombstones). The dependency-cruiser rules are the coarse tier-level complement, with real module resolution instead of regex import scanning. -## Target architecture +## Current architecture -Tiers, bottom to top. A tier may import anything strictly below it and nothing above it: +The product source follows these tiers, bottom to top. A tier imports only lower tiers unless an +explicit rule below names a facade or adapter exception. ```text -src/extension-api published contract; imports nothing -src/lib dependency-free helpers; may import extension-api only -src/core domain model (changesets, review, vcs catalog, config) -packages/* standalone publishable units (session broker, term-video); - never import src/; the per-app broker contract is in - docs/session-broker-sdk.md -src/extensions extension host + bundled extensions; consume core, never surfaces -src/session daemon/broker transport + protocol; consumes core and packages -src/app startup composition: CLI parsing plus the wiring of core, - extensions, and the session broker; no rendering -src/ui terminal surface; only the composition shell (App, AppHost, - runInteractiveApp), the named session adapter hooks - (useTerminalReview, useHunkSessionBridge), and their shared - navigation helper (ui/lib/reviewState) may import app/session -src/opentui published facade re-exporting ui/core pieces for `hunkdiff/opentui` -src/main.tsx CLI entry +packages/hunk/src/extension-api published contract; imports nothing +packages/hunk-vcs/ private dependency-bottom VCS helpers; explicit leaf exports only +packages/hunk/src/lib small compatibility helpers +packages/hunk/src/core product model, non-rendering runtime primitives, and policy +packages/hunk/src/extensions extension host and bundled registration adapters +packages/hunk/src/session daemon/broker transport and protocol +packages/hunk/src/app CLI/startup/session composition; no rendering +packages/hunk/src/ui terminal surface and named app/session adapters +packages/hunk/src/opentui published facade re-exporting selected ui/core pieces +packages/hunk/src/main.tsx final CLI composition and entrypoint ``` +Other workspaces are bounded units rather than one tier in that stack: + +- `packages/hunk-{git,jj,sapling}` implement private bundled providers through + `hunkdiff/extension` and explicit `@hunk/vcs/*` leaves. +- `packages/session-broker{,-core,-bun,-node}` form a runtime-neutral broker stack plus listener + adapters. They do not import Hunk source internals. +- `packages/term-video` owns terminal capture tooling and does not import Hunk source internals. + +Current `core/` modules are `changeset/`, `history/`, `install/`, `patch/`, `process/`, `review/`, +`run/`, `theme/`, `vcs/`, and `watch/`. Its root contains `bootstrap.ts`, `liveComments.ts`, +`reviewDescriptor.ts`, and `reviewDigest.ts` plus tests. + Intentional exceptions, allowed by the rules: -- `src/opentui` imports `src/ui` internals: it is a packaging facade whose job is re-export. -- `src/hunk-review` imports `src/session/agent`: the skill document is generated from the agent +- `packages/hunk/src/opentui` imports `packages/hunk/src/ui` internals: it is a packaging facade whose job is re-export. +- `packages/hunk/src/hunk-review` imports `packages/hunk/src/session/agent`: the skill document is generated from the agent surface by design. - Tests are excluded: they are colocated and free to reach across boundaries. @@ -54,22 +60,27 @@ accidental reach-in fails `bun run deps:check` instead of quietly becoming API. Two supporting rules keep the interiors honest: -- **`no-dead-modules`** flags any module under `src/` that no entry point reaches +- **`no-dead-modules`** flags any module under `packages/hunk/src/` that no entry point reaches (`main.tsx`, `highlightWorkerEntry.ts`, the `opentui` and `extension-api` facades, and the skill generator). It uses `reachable: false` rather than `orphan`, which only catches fully disconnected files and so misses dead code that still imports. A hit is deleted, or — when tests are its only genuine consumer — listed in the rule's `TEST_ONLY_MODULES` allowlist with the reason. That allowlist is **shrink-only**, like the baseline. - **`core-leaves-stay-below-bootstrap`** freezes the cycle fix below: `core/bootstrap.ts` - composes the module directories to describe one launch, so none of them may import it back. + describes one composed launch, so the core module paths named by the rule may not import it back. (Through phase 3 this rule was `core-leaves-never-reimport-types` and guarded `core/types.ts`; phase 4 melted that shell and repointed the rule at what replaced it.) +## Historical migration record + +The following dated phases explain why the current rules exist. Paths and inventories describe the +repository at that phase unless a later note updates them. + Phase 0 (2026-08-17) established the mechanism: it deleted `core/review/address.ts` (a speculative primitive with no consumers), added the two rules above, and froze the first interior — `core/review/reducer.ts` is importable only from within `core/review/`, because callers state intent and `planReviewIntent` owns the transition. Later phases extend the same -pattern across `src/core` as its subdirectories take shape; the review model's named modules +pattern across `packages/hunk/src/core` as its subdirectories take shape; the review model's named modules (`document`, `identity`, `geometry`, `state`, …) stay public by design. Phase 1 (2026-08-17) grouped the changeset model and its acquisition pipeline — twelve loose @@ -133,7 +144,7 @@ model: `terminal`, `jobControl`, `shutdown`, and `updateNotice` serve the intera `pager` serves the CLI entry and the startup plan; `projectRoot` and `appStateFile` serve the extension host and config resolution. -After this phase `src/core/` root holds only `types.ts`, `reviewDigest.ts`, and `liveComments.ts` +After this phase `packages/hunk/src/core/` root holds only `types.ts`, `reviewDigest.ts`, and `liveComments.ts` beside the seven subdirectories. Phase 4 melts what is left of `core/types.ts`. Phase 4 (2026-08-17) melted that shell. `core/types.ts` had stopped declaring most of what it @@ -156,9 +167,9 @@ module that owns their behaviour, one home each: is the line a user note hangs on, and every consumer reaches it through note code. Deleting the re-exports made one hidden dependency visible: `core/review/annotations.ts` names -`AgentAnnotation`, which is declared in `src/extension-api/types.ts` because it is +`AgentAnnotation`, which is declared in `packages/hunk/src/extension-api/types.ts` because it is simultaneously an internal model type and part of the published contract. Routing that through -`core/types.ts` had disguised it as a core-local import, and `scripts/source-boundaries.test.ts` +`core/types.ts` had disguised it as a core-local import, and `scripts/quality/source-boundaries.test.ts` ("keeps the review model contained in core") caught it the moment the disguise came off. The allowance is now explicit and narrow — that one file, not the tree — and it cannot widen the seam, since `extension-api-is-import-free` forbids `extension-api/types.ts` any import at all. @@ -171,9 +182,9 @@ changeset and command-input models they now name. `core/bootstrap.ts` imports do edge from every module directory. One exception is carved out and named in the rule: `core/changeset/loaders.ts` returns an `AppBootstrap` from `loadAppBootstrap`, so it names the shape it assembles; that function is composition living in the domain tier, and moving it to -`src/app` retires the exception. +`packages/hunk/src/app` retires the exception. -`src/core/` root now holds `bootstrap.ts`, `reviewDigest.ts`, and `liveComments.ts` beside the +`packages/hunk/src/core/` root now holds `bootstrap.ts`, `reviewDigest.ts`, and `liveComments.ts` beside the eight module directories. Phase 5 (2026-08-18) grouped **how this binary was installed and how it gets replaced** into @@ -185,9 +196,10 @@ process a run lives in. `process/updateNotice` stays where phase 3 put it — it startup-notice producer built on `appStateFile` — and consumes `core/install` for detection and release lookup. The move is path-only; no exported symbol changed. -## Snapshot (2026-08-17, v0.19.0) +## Historical snapshot (2026-08-17, v0.19.0) -331 production modules, 1322 internal edges, **zero boundary violations and zero import +This snapshot records the migration baseline; it is not a count of current main. At that point the +graph contained 331 production modules, 1322 internal edges, **zero boundary violations and zero import cycles** — the baseline is empty. (The edge count grew from 1282 in phase 4: import sites that used to funnel through one re-export shell now name the modules they actually depend on, so the same dependencies are finally visible in the graph.) The initial audit (2026-08-16) found 28 @@ -206,42 +218,42 @@ rules: moved beside the client class it aliases; `CopySelectedRowRange` moved into `ui/lib/diffSpatial.ts`; `extensions/notifications.ts` now imports `ExtensionNotifyType` from its declaring module. -- **`src/core/cli.ts` → `src/app/cli.ts`.** CLI parsing that registers every tier's command +- **`packages/hunk/src/core/cli.ts` → `packages/hunk/src/app/cli.ts`.** CLI parsing that registers every tier's command surface (including `hunk session *` from `session/agent/surface.ts`) is composition, not domain — moving it made the core→session edges legal app→session edges. -- **`src/session/app/` → `src/app/session/`.** The mounted-review registration, bridge, and +- **`packages/hunk/src/session/app/` → `packages/hunk/src/app/session/`.** The mounted-review registration, bridge, and reload-authorization modules compose the app process with the session broker, and nothing - inside `src/session` imported them — they were app-tier code homed on the wrong side. + inside `packages/hunk/src/session` imported them — they were app-tier code homed on the wrong side. Moving the directory removed every session→app edge at once. -- **`src/lib/reviewDigest.ts` → `src/core/reviewDigest.ts`.** The Node digest implementation +- **`packages/hunk/src/lib/reviewDigest.ts` → `packages/hunk/src/core/reviewDigest.ts`.** The Node digest implementation is review-semantic and platform-bound; core root (Node-full, outside the platform-free `core/review/` seam) is its tier. - **`ui/lib/reviewState.ts`** resolves session-daemon navigation for the adapter hooks and is now a named entry in the adapter allowlist rather than an accidental reach-in. -- **The bundled sidebar's `src/ui` imports are documented design, not debt.** Its module +- **The bundled sidebar's `packages/hunk/src/ui` imports are documented design, not debt.** Its module header defines the dogfooding boundary as the published props contract (data, actions, theme); rendering helpers are host code. The rules now encode exactly that: - `src/extensions/default/ui/` may consume `src/ui`, and still may never touch - `src/app`/`src/session`. + `packages/hunk/src/extensions/default/ui/` may consume `packages/hunk/src/ui`, and still may never touch + `packages/hunk/src/app`/`packages/hunk/src/session`. ## After the baseline: next targets The tier rules now hold with no exceptions. Two follow-ups are worth doing next: -1. **Give `src/core` an interior.** _Done (phases 0–4, see Module interiors)._ Every group is a +1. **Give `packages/hunk/src/core` an interior.** _Done (phases 0–4, see Module interiors)._ Every group is a module directory — `review/`, `vcs/`, `theme/`, `watch/`, `patch/`, `changeset/`, - `run/`, `process/` — and `core/*` root is down to `bootstrap.ts`, `reviewDigest.ts`, - and `liveComments.ts`, with no grab-bag left to import. What remains is per-file public + `history/`, `install/`, `run/`, `process/` — and `core/*` root contains `bootstrap.ts`, + `reviewDigest.ts`, `reviewDescriptor.ts`, and `liveComments.ts`, with no grab-bag re-export shell. What remains is per-file public surfaces for the modules that never got one: `changeset` has `changeset-internals-stay-in-module` and `review` has `review-reducer-is-module-internal`, while `run`, `process`, `theme`, `vcs`, `watch`, and `patch` are still public in full because every file in them has an outside importer today. Two named follow-ups: move - `loadAppBootstrap` out of `core/changeset/loaders.ts` into `src/app` (it is composition, and + `loadAppBootstrap` out of `core/changeset/loaders.ts` into `packages/hunk/src/app` (it is composition, and it is the one exception `core-leaves-stay-below-bootstrap` has to carve out), and split `core/run/config.ts`, whose readers reach it for three unrelated reasons — the resolved `HunkConfigResolution`, the persisted view preferences, and the extension/keybinding tables. The review seam's named modules (`document`, `geometry`, `state`, …) stay public; their helpers become internal. -2. **Tighten the adapter allowlist.** `ui-couples-to-session-via-adapters` currently allowlists - six files. As session coupling consolidates into `useTerminalReview` / - `useHunkSessionBridge`, shrink the list. +2. **Tighten the adapter allowlist.** `ui-couples-to-session-via-adapters` names its current files + in `UI_SESSION_ADAPTERS` inside `.dependency-cruiser.cjs`. As session coupling consolidates into + `useTerminalReview` / `useHunkSessionBridge`, shrink that list rather than copying a count here. diff --git a/docs/opentui-component.md b/docs/opentui-component.md index 9ea0c52f7..5cc67b5ae 100644 --- a/docs/opentui-component.md +++ b/docs/opentui-component.md @@ -231,4 +231,4 @@ If you need direct access to Pierre's parser, `parsePatchFiles(...)` is still re - Runnable demo overview: [`examples/README.md`](../examples/README.md) - Component demos: [`examples/7-opentui-component/README.md`](../examples/7-opentui-component/README.md) -The in-repo demos import from `../../src/opentui` so they run from source. Published consumers should import from `hunkdiff/opentui`. +The in-repo demos import from `../../packages/hunk/src/opentui` so they run from source. Published consumers should import from `hunkdiff/opentui`. diff --git a/docs/session-broker-sdk.md b/docs/session-broker-sdk.md index 14d273013..443a57423 100644 --- a/docs/session-broker-sdk.md +++ b/docs/session-broker-sdk.md @@ -40,7 +40,7 @@ all associated UI semantics. Publish one package, `@hunk/session-broker`, with protocol/state, daemon/connection, managed-host, and Node/Bun adapter boundaries kept as internal modules. Its only first-release entry point exports the shared primitives, broker APIs, supervision, managed producer API, and an automatically selected -`serveSessionBrokerDaemon`. Hunk composes the package; the package never imports `src/*`. +`serveSessionBrokerDaemon`. Hunk composes the package; the package never imports `packages/hunk/src/*`. Publication requires verified npm `@hunk` scope ownership and trusted publishing. An approved replacement name must not alter `appId`, wire identity, or runtime namespaces. diff --git a/docs/source-architecture.md b/docs/source-architecture.md index 20cbc37ea..36adfe92c 100644 --- a/docs/source-architecture.md +++ b/docs/source-architecture.md @@ -1,78 +1,142 @@ # Source architecture -This is the maintainer map for source ownership and import direction. It complements -feature-specific documents such as [the extension architecture](extension-architecture.md). -Use it when adding a new module or deciding where an existing responsibility belongs. +This is the current maintainer map for ownership and import direction. Use it when adding a module +or deciding where an existing responsibility belongs. See [module boundaries](module-boundaries.md) +for executable rules and migration history. -## Ownership +## Workspace ownership + +| Path | Ownership | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `packages/hunk/` | Published `hunkdiff` CLI, application, and the `hunkdiff/extension` and `hunkdiff/opentui` entrypoints | +| `packages/hunk-vcs/` | Private, dependency-bottom VCS helpers exposed through explicit `@hunk/vcs/*` leaves | +| `packages/hunk-{git,jj,sapling}/` | Private bundled VCS providers; each owns its commands, source interpretation, and provider tests | +| `packages/session-broker-core/` | Low-level broker envelopes, parsing, limits, and in-memory state | +| `packages/session-broker/` | Runtime-neutral broker, daemon, authentication, and connection lifecycle | +| `packages/session-broker-{bun,node}/` | Bun and Node listener adapters for the runtime-neutral broker | +| `packages/term-video/` | Private terminal capture and video composition tooling | +| `website/` | Astro/Starlight product and documentation site; it is not the browser review client | +| `scripts/`, `test/`, `docs/` | Repository-wide automation, cross-boundary tests, and maintainer documentation | + +Of the checked-in workspaces, only `hunkdiff` is published. Release automation also generates the +public `hunkdiff-{platform}-{arch}` binary packages from the matrix in +`scripts/packaging/prebuilt-package-helpers.ts`. Provider, VCS helper, broker, and term-video workspaces remain +private. + +## Hunk source ownership + +| Path | Ownership | +| ----------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `packages/hunk/src/app/` | CLI parsing, startup plans, executable composition, and shared session bootstrap | +| `packages/hunk/src/app/review/` | Review publication, resource caching, chunking, and producer-side protocol projection | +| `packages/hunk/src/app/session/` | Mounted review runtime, registration, bridge, and reload authorization | +| `packages/hunk/src/core/` | Product models, non-rendering runtime primitives, and policy without a narrower owner | +| `packages/hunk/src/core/changeset/` | Changeset model and acquisition: loaders, per-file construction, sidecars, sources, and hunk formatting | +| `packages/hunk/src/core/history/` | Provider-neutral revision graph and lane planning | +| `packages/hunk/src/core/install/` | Install channel, release lookup, updater, and installed-version policy | +| `packages/hunk/src/core/run/` | Command inputs, layered configuration, command catalog, user-facing errors, paths, and version | +| `packages/hunk/src/core/process/` | TTY capabilities, pager/stdout, job control/relaunch, project-root discovery, persisted app state, and notices | +| `packages/hunk/src/core/review/` | Ordered review document, identities, state, intents, reducer, selectors, and shared derivations | +| `packages/hunk/src/core/theme/` | Bundled theme metadata, custom-theme rules, and terminal theme detection | +| `packages/hunk/src/core/watch/` | Input signatures, observation plans/backends, and refresh coordination | +| `packages/hunk/src/core/vcs/` | Provider-neutral VCS catalog, contracts, operation dispatch, and host support | +| `packages/hunk/src/extensions/` | Extension host, registries, trust, lifecycle, and bundled registration adapters | +| `packages/hunk/src/session/` | Shared session protocol, schemas, agent surface, and broker transport | +| `packages/hunk/src/session/client/` | Session-daemon HTTP and compatibility clients | +| `packages/hunk/src/session/agent/` | Agent-facing session CLI, command manifest, errors, and formatting | +| `packages/hunk/src/session/broker/` | Hunk daemon transport, launcher, broker state, wire parsing, and projections | +| `packages/hunk/src/ui/` | Interactive review application, terminal rendering, interaction, and chrome | +| `packages/hunk/src/ui/history/` | History surface and provider cursor lifecycle | +| `packages/hunk/src/ui/log/` | Log rendering and presentation helpers | +| `packages/hunk/src/ui/session/` | One OpenTUI renderer/root lifetime and the history/review surface router | +| `packages/hunk/src/extension-api/` | Public, import-free `hunkdiff/extension` declaration and runtime boundary | +| `packages/hunk/src/opentui/` | Public `hunkdiff/opentui` component boundary | +| `packages/hunk/src/lib/` | Small product-wide utilities with no feature owner | + +`app/` composes subsystems but does not own terminal rendering. `core/` is the shared product layer, +not a bucket for everything outside React. Prefer a specific existing owner over `lib/`. + +## Runtime flow + +```text +packages/hunk/src/main.tsx + -> app/startup.ts prepares a lazy StartupPlan + -> app/cli.ts parses the invocation + -> headless/history plan returned to main.tsx for dispatch, or + -> app plan: app/extensionBootstrap.ts loads user extensions and reconciles root/config + -> app/sessionBootstrap.ts selects VCS and loads one Changeset + -> main.tsx lazy-loads ui/runInteractiveApp.tsx + -> HunkSessionHost routes history and review in one React root + -> AppHost owns reload, extension adoption, and review publication ordering + -> App owns review interaction and layout coordination + -> pane/diff modules plan, window, and paint terminal rows with Pierre metadata +``` + +All review inputs normalize to `Changeset` / `DiffFile[]`. Shared semantic review behavior follows: ```text -src/app/ executable composition: CLI parsing, startup plans, and shared session bootstrap -src/app/session/ mounted-review registration, bridge, and reload authorization -src/core/ review model, patch handling, VCS contracts, configuration, and - runtime primitives -src/core/changeset/ the changeset model and the pipeline that acquires one: loaders, - per-file construction, sidecar/source reads, and hunk formatting -src/core/run/ how a run is asked for: command inputs, layered configuration, the - command catalog, user-facing errors, paths, and version -src/core/process/ the process and terminal a run lives in: TTY capabilities, the pager, - job control, shutdown, project-root discovery, persisted app state, - and startup/update notices -src/core/theme/ bundled theme metadata, custom-theme rules, and terminal theme detection -src/core/watch/ input signatures, observation plans/backends, and refresh coordination -src/core/vcs/ provider-neutral VCS catalog, contracts, operation dispatch, and host support -src/extensions/ extension host, registry, trust, lifecycle, and bundled extensions -src/session/ shared session protocol, schemas, types, agent surface, and broker transport -src/session/client/ shared session-daemon HTTP and compatibility client support -src/session/agent/ agent-facing session CLI, command manifest, errors, and formatting -src/session/broker/ local daemon transport, launcher, Hunk broker state, wire parsing, projections -src/ui/ interactive review application, rendering, interaction, and chrome -src/extension-api/ public `hunkdiff/extension` declaration and runtime boundary -src/opentui/ public `hunkdiff/opentui` component boundary -src/lib/ small product-wide utilities with no feature ownership +DiffFile[] -> projectReviewDocument -> ReviewDocumentV1 -> ReviewStore +ReviewIntent + caller facts -> planReviewIntent -> ReviewAction[] -> reducer -> projection ``` -`src/app/` is intentionally small: it composes subsystems but does not become a second -application framework. `src/core/` remains the shared product layer, not a synonym for -"anything outside React". Put a module in a more specific existing subdirectory whenever -one owns its behaviour. +Terminal, agent/session, producer, broker, protocol, extension, and HTTP/SSE consumers reuse that +path. The browser client and UI remain planned. ## Dependency direction -- `app` may compose `core`, `extensions`, `session`, and `ui`. -- `ui` may consume core models and the extension/session contracts; it owns terminal rendering. -- `extensions` may consume provider-neutral core models and contracts, but bundled VCS provider - implementations must depend only on `hunkdiff/extension`, local modules, and `src/lib` utilities. - Renderer access remains limited to `extensions/default/ui/`, the bundled-sidebar boundary. -- `core` must not import `ui` or `extensions`. Shared data needed by both belongs in core-owned - structural contracts or `src/lib`, never in a reverse dependency. -- `extension-api/types.ts` stays import-free. It is a published declaration boundary, enforced by - the package checks. -- `opentui` and `extension-api` are public entrypoint directories, not general internal buckets. - -`scripts/source-boundaries.test.ts` mechanically enforces `core -> ui`, `core -> extensions`, -and bundled-provider -> core boundaries, including the public extension-barrel requirement. - -## Bootstrap invariant - -Initial launch and live-session reload use `app/sessionBootstrap.ts`. That service is the one -place that applies extension registrations, resolves extension-aware VCS selection, loads the -normalized changeset, applies changeset transforms, and attaches session theme/config state. -Callers retain their distinct lifecycle work (terminal setup, extension rediscovery, notices, -and mounted-app state), but must not recreate this ordering. - -## Migration policy - -This is an incremental migration, not a bulk rename: - -1. Move code only when a feature changes it or when a small cohesive cluster can move with its - colocated tests. -2. Add the destination module and update all consumers in one change; do not maintain permanent - duplicate implementations. -3. Keep product-visible import paths and public package exports stable. -4. Prefer a named ownership boundary over a generic `lib` folder. -5. Update this map and feature architecture docs when a boundary changes. - -Current composition lives in `app/`: `app/vcsCatalog.ts` assembles bundled registrations into a -provider-neutral catalog, and `app/sessionBootstrap.ts` extends that catalog with user adapters. -Provider commands, source readers, and tests live under `extensions/default/vcs//`. +- `main.tsx` and UI entry adapters perform final runtime and surface composition. +- `app` may compose `core`, `extensions`, and `session`; it must not import `ui`. +- `ui` may consume `core` and `extensions`. Only the composition shell and named adapters in + `UI_SESSION_ADAPTERS` may import `app` or `session`; ordinary UI modules may not. +- `extensions` may consume provider-neutral core contracts. Renderer access is limited to the + bundled UI boundary under `extensions/default/ui/`. +- Bundled provider packages may import provider-local modules, platform built-ins, + `hunkdiff/extension`, and explicit `@hunk/vcs/*` leaves. They do not import Hunk core, app, + session, UI, or generic `packages/hunk/src/lib` modules. +- `@hunk/vcs` imports no other workspace package and has no root export. +- `core` must not import `ui`, `app`, `session`, `extensions`, or `opentui`. Its narrow imports + from the lower, import-free `extension-api` contract are explicit in the boundary rules. +- `extension-api/types.ts` stays import-free. `opentui` deliberately re-exports selected UI/core + pieces as a public facade; neither public directory is a general internal bucket. +- Standalone workspaces do not reach into `packages/hunk/src` except through explicitly allowed + public/provider contracts. + +`bun run deps:check` applies `.dependency-cruiser.cjs` to every package and to Hunk's internal tiers. +`scripts/quality/source-boundaries.test.ts` adds browser-safe review and provider checks. The +executable rules take precedence over this summary. + +## Bootstrap and lifecycle + +Initial review launch and reload use `app/sessionBootstrap.ts` for extension-aware VCS selection, +changeset loading, changeset transforms, and session theme/config state. Callers retain lifecycle +work such as extension discovery, notices, and mounted UI state. + +`ui/runInteractiveApp.tsx` owns the renderer, extension session, review runtime, and ordered cleanup. +`HunkSessionHost` routes history and review while retaining one `ExtensionSession`; that session owns +user-extension registry authority and retirement. `AppHost` owns review reload serialization, +content loading, broker publication, React commit, and post-layout event ordering. History closes +its provider cursor before extension shutdown. + +Bundled Git, Jujutsu, and Sapling implementations register through the public extension contract in +`extensions/default/vcs/index.ts`. `app/vcsCatalog.ts` composes those registrations into the +provider-neutral catalog; `app/sessionBootstrap.ts` extends it with accepted user adapters. Bundled +providers remain active under `--no-extensions`. + +## Boundary changes + +When moving ownership: + +1. Move a cohesive responsibility with its tests and update all consumers in the same change. +2. Remove replaced paths instead of retaining parallel implementations. +3. Keep public exports stable unless the change explicitly updates the public contract. +4. Update this map, `.dependency-cruiser.cjs`, and the relevant feature architecture document. +5. Run `bun run deps:check`; never add a known violation to avoid completing the move. + +## Related documents + +- [Module boundaries](module-boundaries.md) — executable import model and historical migration record +- [Extension architecture](extension-architecture.md) — current extension host and lifecycle design +- [Extension guide](extensions.md) — current public extension API and examples +- [Session broker SDK](session-broker-sdk.md) — normative future public SDK contract; current packages remain private +- [Browser review rebuild](browser-review-rebuild.md) — rollout plan and current browser boundary +- [Browser review seam audit](browser-review-seam-audit.md) — finding-level status and conformance record diff --git a/docs/watch-benchmark-final.md b/docs/watch-benchmark-final.md index 629a2a9b9..d69a1388b 100644 --- a/docs/watch-benchmark-final.md +++ b/docs/watch-benchmark-final.md @@ -259,7 +259,7 @@ Fourteen commits implement the production change: - `c152100`, `0659a30`, `d36c33f`, `fd00dd6` add resource fallback, Git ignored-root pruning, native recursive selection, and a bounded readiness deadline - `f6165bd`, `4f35a0e` stabilize cross-platform and sidecar watch coverage -Production implementation/seams are in `src/core/watch/signature.ts`, `plan.ts`, `controller.ts`, `observer.ts`, `src/core/vcs/*`, loader/type plumbing, `src/ui/hooks/useWatchedInput.ts`, and the App/AppHost integration. Matching unit, filesystem, AppHost, helper, and PTY coverage lives beside those files and in `test/helpers/watchTest.ts` and `test/pty/watch.test.ts`. CI and PR CI add a compiled-binary watch PTY check. README documents continuous observation with fallback. +Production implementation/seams are in `packages/hunk/src/core/watch/signature.ts`, `plan.ts`, `controller.ts`, `observer.ts`, `packages/hunk/src/core/vcs/*`, loader/type plumbing, `packages/hunk/src/ui/hooks/useWatchedInput.ts`, and the App/AppHost integration. Matching unit, filesystem, AppHost, helper, and PTY coverage lives beside those files and in `test/helpers/watchTest.ts` and `test/pty/watch.test.ts`. CI and PR CI add a compiled-binary watch PTY check. README documents continuous observation with fallback. The only new runtime dependency is `chokidar@^4.0.3` (and transitive `readdirp@4.1.2`), recorded in `package.json`, `bun.lock`, and `nix/bun.lock.nix`. The two user-visible patch changesets are `.changeset/calm-files-watch.md` and `.changeset/native-recursive-watch.md`. Existing benchmark fixture imports received small type/data updates, but no benchmark runner is a production runtime dependency. @@ -274,8 +274,8 @@ Complete harness file inventory: - **Protocol/fixture/schema/runner/report:** `benchmarks/watch/README.md`, `fixture.ts`, `campaign.ts`, `schema.ts`, `run.ts`, `report.ts`, and their `fixture.test.ts`, `campaign.test.ts`, `schema.test.ts`, and `run.test.ts`. - **Terminal, ConPTY, sampling, and Git instrumentation:** `terminal.ts`, `sampler.ts`, `git-log.ts`, `observer-probe.ts`, `artifacts.ts`, with `terminal.test.ts`, `sampler.test.ts`, `git-log.test.ts`, and `observer-probe.test.ts`. - **Freeze, provenance, transfer, build, and preflight:** `freeze.ts`, `stage.ts`, `build-host.ts`, `host-identity.ts`, `host-preflight.ts`, `prepare-preflight.ts`, with `stage.test.ts`, `build-host.test.ts`, `host-identity.test.ts`, `host-preflight.test.ts`, and shared `test-helpers.ts`. -- **Package/build integration:** seven `bench:watch*` scripts in `package.json`; dev-only `@xterm/headless@5.5.0` and `ghostty-opentui@1.4.10` plus `bun.lock`; `scripts/build-bin.ts` reuses `process.execPath` to prevent ambient-Bun builds. -- **Benchmark-only production seams:** `src/core/watch/observer.ts` and `.test.ts` accept an injected `auto|native|chokidar` backend for forced probes; production remains `auto`. +- **Package/build integration:** seven `bench:watch*` scripts in `package.json`; dev-only `@xterm/headless@5.5.0` and `ghostty-opentui@1.4.10` plus `bun.lock`; `scripts/build/build-bin.ts` reuses `process.execPath` to prevent ambient-Bun builds. +- **Benchmark-only production seams:** `packages/hunk/src/core/watch/observer.ts` and `.test.ts` accept an injected `auto|native|chokidar` backend for forced probes; production remains `auto`. - **Workflow:** `.github/workflows/benchmarks.yml` contains the Windows adapter. - **Maintenance changesets:** `.changeset/watch-benchmark-harness.md` and `.changeset/witty-schools-sit.md` are empty/non-release changesets. @@ -306,4 +306,4 @@ The ARM64 VM uses native ARM64 Bun 1.3.14 and builds/checksums a native PE. Beca Campaign-owned worktrees, binaries, fixture reconstructions, terminal logs, and temporary libraries are cleanup-owned by the campaign operator. Linux recovery inventoried and removed only campaign-created extraction files; unrelated paths were not touched. Remote copies may be deleted after archive/checksum acceptance, but the canonical frozen bundle, raw JSON, terminal evidence, failure records, manifests, provenance, summaries, and checksum index should be retained together. -The reusable fixture/freeze/schema/report pieces have value for a future benchmark, but maintaining the full harness means owning ConPTY adapters, two terminal parsers, OS samplers, workflow behavior, and cross-platform tests. **Recommended disposition:** retain the canonical campaign archive and this report; keep PR #531 free of harness-only code; preserve or extract the reusable core in a focused benchmark follow-up only if another campaign is planned; otherwise delete the temporary harness branch, manual workflow additions, dev-only terminal dependencies, empty changesets, forced-backend seam, and recovery script after the archive is secured. The exact-Bun `scripts/build-bin.ts` fix is independently useful but should be proposed separately if retained. +The reusable fixture/freeze/schema/report pieces have value for a future benchmark, but maintaining the full harness means owning ConPTY adapters, two terminal parsers, OS samplers, workflow behavior, and cross-platform tests. **Recommended disposition:** retain the canonical campaign archive and this report; keep PR #531 free of harness-only code; preserve or extract the reusable core in a focused benchmark follow-up only if another campaign is planned; otherwise delete the temporary harness branch, manual workflow additions, dev-only terminal dependencies, empty changesets, forced-backend seam, and recovery script after the archive is secured. The exact-Bun `scripts/build/build-bin.ts` fix is independently useful but should be proposed separately if retained. diff --git a/examples/7-opentui-component/README.md b/examples/7-opentui-component/README.md index 2ab36d2ec..48a87ba89 100644 --- a/examples/7-opentui-component/README.md +++ b/examples/7-opentui-component/README.md @@ -19,4 +19,4 @@ bun run examples/7-opentui-component/from-patch.tsx - switching between split and stacked layouts with example shell controls - a scrollable terminal diff component that other OpenTUI apps can reuse -The in-repo demos import from `../../src/opentui` so they run from source. Published consumers should import from `hunkdiff/opentui` instead. +The in-repo demos import from `../../packages/hunk/src/opentui` so they run from source. Published consumers should import from `hunkdiff/opentui` instead. diff --git a/examples/7-opentui-component/from-files.tsx b/examples/7-opentui-component/from-files.tsx index 98e2fb63b..d6321097f 100644 --- a/examples/7-opentui-component/from-files.tsx +++ b/examples/7-opentui-component/from-files.tsx @@ -1,6 +1,6 @@ #!/usr/bin/env bun -import { parseDiffFromFile } from "../../src/opentui"; +import { parseDiffFromFile } from "../../packages/hunk/src/opentui"; import { readExampleFile, runExample } from "./support"; const path = "src/reviewSummary.ts"; diff --git a/examples/7-opentui-component/from-patch.tsx b/examples/7-opentui-component/from-patch.tsx index 56445c9bf..9fab70ef7 100644 --- a/examples/7-opentui-component/from-patch.tsx +++ b/examples/7-opentui-component/from-patch.tsx @@ -1,6 +1,6 @@ #!/usr/bin/env bun -import { parsePatchFiles } from "../../src/opentui"; +import { parsePatchFiles } from "../../packages/hunk/src/opentui"; import { readExampleFile, runExample } from "./support"; const patch = readExampleFile("change.patch"); diff --git a/examples/7-opentui-component/support.tsx b/examples/7-opentui-component/support.tsx index c01946dab..c1fe2c5e7 100644 --- a/examples/7-opentui-component/support.tsx +++ b/examples/7-opentui-component/support.tsx @@ -3,9 +3,9 @@ import path from "node:path"; import { createCliRenderer } from "@opentui/core"; import { createRoot, useTerminalDimensions } from "@opentui/react"; import { useState } from "react"; -import type { HunkDiffFile, HunkDiffLayout } from "../../src/opentui"; -import { HunkDiffView } from "../../src/opentui"; -import { fitText } from "../../src/ui/lib/text"; +import type { HunkDiffFile, HunkDiffLayout } from "../../packages/hunk/src/opentui"; +import { HunkDiffView } from "../../packages/hunk/src/opentui"; +import { fitText } from "../../packages/hunk/src/ui/lib/text"; interface ExampleProps { title: string; diff --git a/examples/8-opentui-primitives/README.md b/examples/8-opentui-primitives/README.md index 61374dae3..5a443929c 100644 --- a/examples/8-opentui-primitives/README.md +++ b/examples/8-opentui-primitives/README.md @@ -19,4 +19,4 @@ bun run examples/8-opentui-primitives/primitives-demo.tsx - Host-owned window borders/chrome around each primitive so you can inspect component boundaries - Host-owned state for selected file and split/stack layout -The in-repo demo imports from `../../src/opentui` so it runs from source. Published consumers should import from `hunkdiff/opentui` instead. +The in-repo demo imports from `../../packages/hunk/src/opentui` so it runs from source. Published consumers should import from `hunkdiff/opentui` instead. diff --git a/examples/8-opentui-primitives/primitives-demo.tsx b/examples/8-opentui-primitives/primitives-demo.tsx index 6098d4c32..2f193825d 100644 --- a/examples/8-opentui-primitives/primitives-demo.tsx +++ b/examples/8-opentui-primitives/primitives-demo.tsx @@ -10,8 +10,8 @@ import { HunkReviewStream, createHunkDiffFilesFromPatch, type HunkDiffLayout, -} from "../../src/opentui"; -import { fitText, padText } from "../../src/ui/lib/text"; +} from "../../packages/hunk/src/opentui"; +import { fitText, padText } from "../../packages/hunk/src/ui/lib/text"; const PATCH = `diff --git a/src/search.ts b/src/search.ts --- a/src/search.ts diff --git a/examples/extensions/cli-tools/README.md b/examples/extensions/cli-tools/README.md index af29dc753..8dab9cd7b 100644 --- a/examples/extensions/cli-tools/README.md +++ b/examples/extensions/cli-tools/README.md @@ -5,8 +5,8 @@ Demonstrates a generic top-level command tree. The handler owns every token belo Run it directly from this checkout: ```bash -bun run src/main.tsx --extension ./examples/extensions/cli-tools cli-tools status -bun run src/main.tsx --extension ./examples/extensions/cli-tools cli-tools review +bun run packages/hunk/src/main.tsx --extension ./examples/extensions/cli-tools cli-tools status +bun run packages/hunk/src/main.tsx --extension ./examples/extensions/cli-tools cli-tools review ``` `status` writes to stdout and exits. `review` performs signal-aware asynchronous preprocessing, writes progress to stderr, then delegates to `hunk diff`. A delegating handler must not write stdout or read stdin because the built-in command or TUI takes ownership of both. diff --git a/examples/extensions/github-pr/README.md b/examples/extensions/github-pr/README.md index 94d477fe1..642e585f1 100644 --- a/examples/extensions/github-pr/README.md +++ b/examples/extensions/github-pr/README.md @@ -13,7 +13,7 @@ The extension fetches the PR metadata and diff directly from GitHub's API, write Place `--extension` before the extension-owned command: ```bash -bun run src/main.tsx --extension ./examples/extensions/github-pr gh 123 +bun run packages/hunk/src/main.tsx --extension ./examples/extensions/github-pr gh 123 ``` A bare number infers `owner/repo` from the current checkout's GitHub `origin`. Explicit forms work outside a checkout and do not invoke Git: diff --git a/examples/extensions/inline-edit/README.md b/examples/extensions/inline-edit/README.md index 83bf95cdb..cf286fb7c 100644 --- a/examples/extensions/inline-edit/README.md +++ b/examples/extensions/inline-edit/README.md @@ -16,7 +16,7 @@ It exists to demonstrate that Hunk's interactive extension surfaces compose, so ## Try it from this checkout ```bash -bun run src/main.tsx -- diff --extension ./examples/extensions/inline-edit +bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/inline-edit ``` ## Install it globally diff --git a/examples/extensions/jsx-file-view-gallery/README.md b/examples/extensions/jsx-file-view-gallery/README.md index 174298bae..eb097c91b 100644 --- a/examples/extensions/jsx-file-view-gallery/README.md +++ b/examples/extensions/jsx-file-view-gallery/README.md @@ -7,7 +7,7 @@ Three opt-in presentations exercise the constrained React/OpenTUI row contract a Nested boxes, responsive meters, semantic color, and selected-hunk styling summarize a multi-hunk TypeScript refactor. It uses no source parser and works from public hunk/change metadata alone. ```bash -bun run src/main.tsx -- diff \ +bun run packages/hunk/src/main.tsx -- diff \ --extension ./examples/extensions/jsx-file-view-gallery \ --mode stack \ examples/extensions/jsx-file-view-gallery/fixtures/change-atlas/before.ts \ @@ -19,7 +19,7 @@ bun run src/main.tsx -- diff \ The extension lazily reads both exact documents, associates changed opaque three- or six-digit hexadecimal custom properties with each real diff hunk, and paints old/new terminal color swatches inside deterministic two-row rectangles. ```bash -bun run src/main.tsx -- diff \ +bun run packages/hunk/src/main.tsx -- diff \ --extension ./examples/extensions/jsx-file-view-gallery \ --mode stack \ examples/extensions/jsx-file-view-gallery/fixtures/css-palette/before.css \ @@ -31,7 +31,7 @@ bun run src/main.tsx -- diff \ A conservative package-file parser highlights only the changed semantic-version segment: patch-only changes emphasize the patch number, minor upgrades emphasize the minor number, and major upgrades emphasize the full old/new strings. It retains positional bounds for every parsed hunk; invalid JSON or unavailable source falls back to raw diff. ```bash -bun run src/main.tsx -- diff \ +bun run packages/hunk/src/main.tsx -- diff \ --extension ./examples/extensions/jsx-file-view-gallery \ --mode stack \ examples/extensions/jsx-file-view-gallery/fixtures/package-dependencies/before/package.json \ diff --git a/examples/extensions/jsx-file-view-gallery/mixed-review/run.ts b/examples/extensions/jsx-file-view-gallery/mixed-review/run.ts index 86518dd1c..f8a048379 100644 --- a/examples/extensions/jsx-file-view-gallery/mixed-review/run.ts +++ b/examples/extensions/jsx-file-view-gallery/mixed-review/run.ts @@ -73,7 +73,14 @@ try { const result = spawnSync( process.execPath, - [join(repoRoot, "src/main.tsx"), "diff", "--extension", galleryRoot, "--mode", "stack"], + [ + join(repoRoot, "packages/hunk/src/main.tsx"), + "diff", + "--extension", + galleryRoot, + "--mode", + "stack", + ], { cwd: demoRepo, stdio: "inherit", env: process.env }, ); if (result.error) throw result.error; diff --git a/examples/extensions/jsx-file-view/README.md b/examples/extensions/jsx-file-view/README.md index b90032124..82ebbdee4 100644 --- a/examples/extensions/jsx-file-view/README.md +++ b/examples/extensions/jsx-file-view/README.md @@ -5,7 +5,7 @@ An opt-in proof of concept for fixed-height React/OpenTUI rows in alternate file Run it from this checkout against a multi-hunk working-tree change: ```bash -bun run src/main.tsx -- diff --extension ./examples/extensions/jsx-file-view +bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/jsx-file-view ``` Choose **Extensions → Toggle JSX hunk cards (POC)**. The row component uses a React state hook and OpenTUI `box`/`text` elements. Its registered F8 command/menu item is the supported keyboard path. A cooperatively delivered, un-dragged left-button mouse-up toggles local detail and stops propagation; wheel, drag, and unhandled input remain host-owned. The row is a non-focusable paint surface, with no portal, renderer, focus, or input-delivery guarantee. Each component is a closure over the hunk summary; Hunk passes it only bounded paint props, including a live semantic theme palette that does not participate in layout. The `spans` on every row are the host-rendered fallback, clipped to the same declared fixed height if the component fails. Hook state survives selected-hunk updates while mounted, but is intentionally lost when windowing unmounts the row or a new layout generation replaces it. diff --git a/examples/extensions/pane-layout/README.md b/examples/extensions/pane-layout/README.md index f11d28be3..d559bd38b 100644 --- a/examples/extensions/pane-layout/README.md +++ b/examples/extensions/pane-layout/README.md @@ -5,7 +5,7 @@ Registers a resizable right pane and fixed two-row top and bottom panes. Run it from this checkout: ```bash -bun run src/main.tsx -- diff --extension ./examples/extensions/pane-layout +bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/pane-layout ``` Press `ctrl+p` or use the **Extensions** menu. Drag the right divider to resize. diff --git a/examples/extensions/rendered-markdown/README.md b/examples/extensions/rendered-markdown/README.md index 8b6b9411d..53883bcb7 100644 --- a/examples/extensions/rendered-markdown/README.md +++ b/examples/extensions/rendered-markdown/README.md @@ -9,7 +9,7 @@ This example is **not bundled or loaded by Hunk**. Install it explicitly if you The repository's root install supplies the example's development dependency: ```bash -bun run src/main.tsx -- diff \ +bun run packages/hunk/src/main.tsx -- diff \ --extension ./examples/extensions/rendered-markdown \ before.md after.md ``` diff --git a/examples/extensions/review-note-navigator/README.md b/examples/extensions/review-note-navigator/README.md index 79e302cea..3e215145b 100644 --- a/examples/extensions/review-note-navigator/README.md +++ b/examples/extensions/review-note-navigator/README.md @@ -5,7 +5,7 @@ Lists every note currently saved in Hunk's shared ReviewStore, then navigates to Run it directly from this checkout: ```bash -bun run src/main.tsx -- diff --extension ./examples/extensions/review-note-navigator +bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/review-note-navigator ``` Save one or more review notes, then run **Extensions → Navigate saved review note…** (`F8`). Each choice includes its reconciliation status, file, preferred line, side, and summary. diff --git a/examples/extensions/review-snapshot-export/README.md b/examples/extensions/review-snapshot-export/README.md index f831b6d75..3b792d431 100644 --- a/examples/extensions/review-snapshot-export/README.md +++ b/examples/extensions/review-snapshot-export/README.md @@ -5,7 +5,7 @@ Exports Hunk's authoritative saved review state as JSON. The example shows why ` Run it directly from this checkout: ```bash -bun run src/main.tsx -- diff --extension ./examples/extensions/review-snapshot-export +bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/review-snapshot-export ``` Add one or more review notes, then run **Extensions → Export review snapshot…** (`F9`) and choose a new output path. Relative paths resolve from the review's working directory; the example refuses to overwrite an existing file. diff --git a/examples/extensions/review-snapshot-export/index.test.ts b/examples/extensions/review-snapshot-export/index.test.ts index e0e481d62..bf73c685d 100644 --- a/examples/extensions/review-snapshot-export/index.test.ts +++ b/examples/extensions/review-snapshot-export/index.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { resolve } from "node:path"; import { resolveSnapshotExportPath, snapshotPositionMatches } from "./index"; -import type { ExtensionReviewSnapshot } from "../../../src/extension-api/types"; +import type { ExtensionReviewSnapshot } from "../../../packages/hunk/src/extension-api/types"; /** Build the minimal immutable snapshot position these helper tests compare. */ function createTestSnapshot( diff --git a/examples/extensions/review-triage/README.md b/examples/extensions/review-triage/README.md index 31d4f606b..55f4b1173 100644 --- a/examples/extensions/review-triage/README.md +++ b/examples/extensions/review-triage/README.md @@ -5,7 +5,7 @@ A session-local hunk review board for Hunk. It records which hunks you have visi Run it directly from this checkout: ```bash -bun run src/main.tsx -- diff --extension ./examples/extensions/review-triage +bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/review-triage ``` Or copy the directory to your Hunk extensions directory and keep its `package.json`; its manifest makes the folder a single `review-triage` extension. diff --git a/examples/extensions/vim-navigation/README.md b/examples/extensions/vim-navigation/README.md index 074c0bc4d..f67e46be3 100644 --- a/examples/extensions/vim-navigation/README.md +++ b/examples/extensions/vim-navigation/README.md @@ -7,7 +7,7 @@ This example is **not bundled or loaded by Hunk**. Install it explicitly if you ## Try it from this checkout ```bash -bun run src/main.tsx -- diff --extension ./examples/extensions/vim-navigation +bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/vim-navigation ``` Press `F6` or choose **Extensions → Toggle Vim navigation**. The persistent status badge shows when the mode owns review-level keys; click the badge, choose the host-owned exit menu item, or press `Esc` to leave. diff --git a/install.sh b/install.sh index c6725cdd9..631ff3962 100755 --- a/install.sh +++ b/install.sh @@ -22,7 +22,7 @@ # macOS and Linux only. On Windows, install with `npm install -g hunkdiff`. # # This file's canonical home is the repository root; the website build stages it into the -# deploy output so hunk.dev serves it (scripts/stage-install-script.ts). +# deploy output so hunk.dev serves it (scripts/packaging/stage-install-script.ts). # # Everything below only defines functions; the last line runs main. A partially delivered # script therefore dies on a syntax error instead of executing a truncated prefix. diff --git a/knip.json b/knip.json index ef1382aca..6b7975990 100644 --- a/knip.json +++ b/knip.json @@ -3,31 +3,57 @@ "workspaces": { ".": { "entry": [ - "src/extension-api/index.ts", - "src/opentui/index.ts", - "src/**/*.test.{ts,tsx}", "scripts/**/*.test.ts", "test/**/*.test.{ts,tsx}", "test/cli/fixtures/compiled-opentui-positive-control.ts", + "test/cli/fixtures/compiled-highlight-worker-control.ts", + "test/cli/install-vm/validate-release-result.ts", + "test/session-broker-node/*.ts", + "test/session-broker-runtime/*.ts", "scripts/launch-video/*.{ts,mjs}", - "scripts/probe-terminal-theme.ts", - "scripts/test-large-untracked-render.tsx", + "scripts/dev/probe-terminal-theme.ts", + "scripts/dev/test-large-untracked-render.tsx", "examples/extensions/**/index.{ts,tsx}", - "examples/extensions/**/run.ts" + "examples/extensions/**/run.ts", + "examples/**/*.test.ts" ], "project": [ - "src/**/*.{ts,tsx}", "scripts/**/*.{ts,tsx,mjs}", "test/**/*.{ts,tsx}", "benchmarks/**/*.ts", - "examples/extensions/**/*.{ts,tsx}", - "bin/**/*.cjs" + "examples/extensions/**/*.{ts,tsx}" ], "ignore": ["examples/extensions/**/fixtures/**"], "ignoreBinaries": ["nix"], - "ignoreDependencies": ["@changesets/changelog-github", "@shikijs/themes"], + "ignoreDependencies": ["@changesets/changelog-github", "@shikijs/themes", "playwright"], + "ignoreExportsUsedInFile": true + }, + "packages/hunk": { + "entry": [ + "src/highlightWorkerEntry.ts", + "src/extension-api/index.ts", + "src/opentui/index.ts", + "src/**/*.test.{ts,tsx}" + ], + "project": ["src/**/*.{ts,tsx}", "bin/**/*.cjs"], "ignoreExportsUsedInFile": true }, + "packages/hunk-git": { + "entry": ["src/**/*.test.ts"], + "project": ["src/**/*.ts"] + }, + "packages/hunk-jj": { + "entry": ["src/**/*.test.ts"], + "project": ["src/**/*.ts"] + }, + "packages/hunk-sapling": { + "entry": ["src/**/*.test.ts"], + "project": ["src/**/*.ts"] + }, + "packages/hunk-vcs": { + "entry": ["src/**/*.test.ts"], + "project": ["src/**/*.ts"] + }, "packages/session-broker*": { "entry": ["src/**/*.test.ts"], "project": ["src/**/*.ts"] @@ -39,7 +65,13 @@ } }, "ignoreIssues": { - "src/app/review/capability.ts": ["exports"], - "src/extension-api/types.ts": ["duplicates"] + "packages/hunk/src/app/review/capability.ts": ["exports"], + "packages/hunk/src/extension-api/types.ts": ["duplicates"], + "packages/hunk/src/core/install/latestRelease.ts": ["exports", "types"], + "packages/hunk/src/core/process/updateNotice.ts": ["exports", "types"], + "packages/hunk/src/extensions/types.ts": ["exports", "types"], + "packages/hunk/src/extensions/default/ui/sidebar/index.tsx": ["exports", "types"], + "packages/hunk/src/session/broker/appContract.ts": ["exports", "types"], + "packages/hunk/src/ui/diff/worker/index.ts": ["exports", "types"] } } diff --git a/nix/bun.lock.nix b/nix/bun.lock.nix index 7d5849c2c..47e5194ae 100644 --- a/nix/bun.lock.nix +++ b/nix/bun.lock.nix @@ -53,11 +53,15 @@ url = "https://registry.npmjs.org/@hono/node-ws/-/node-ws-1.3.1.tgz"; hash = "sha512-vo/MwCnpJAVHBkGzWjCJ28wF45fYHAfbPZcH2rodZODHtch2GHA94KtMfusmVycTUtsLAsaNsHhtY6P8X3RQsA=="; }; + "@hunk/git" = copyPathToStore ../packages/hunk-git; + "@hunk/jj" = copyPathToStore ../packages/hunk-jj; + "@hunk/sapling" = copyPathToStore ../packages/hunk-sapling; "@hunk/session-broker" = copyPathToStore ../packages/session-broker; "@hunk/session-broker-bun" = copyPathToStore ../packages/session-broker-bun; "@hunk/session-broker-core" = copyPathToStore ../packages/session-broker-core; "@hunk/session-broker-node" = copyPathToStore ../packages/session-broker-node; "@hunk/term-video" = copyPathToStore ../packages/term-video; + "@hunk/vcs" = copyPathToStore ../packages/hunk-vcs; "@napi-rs/wasm-runtime@1.2.2" = fetchurl { url = "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz"; hash = "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw=="; @@ -142,69 +146,53 @@ url = "https://registry.npmjs.org/@opentui/react/-/react-0.5.6.tgz"; hash = "sha512-dMQTlcD1zlDTQC8r9rZKlHD7CORkY2M5D7tqhgoccoOK9p6q05FqrdRCTYHu1+VZYafqIV0k2IpMY2e34sngiQ=="; }; - "@oven/bun-darwin-aarch64@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.14.tgz"; - hash = "sha512-Omj20SuiHBOUjUBIyqtkNjSUIjOtEOJwmbix/ZyFH4BaQ6OZTaaRWIR4TjHVz0yadHgli6lLTiAh1uarnvD49A=="; + "@oven/bun-darwin-aarch64@1.4.2" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.4.2.tgz"; + hash = "sha512-MXdZkP1featqxZ+/VTXWG1BVjM4OGBehVY2Q88EeUj/7L0UMeCGItmyPYTN+wxvlGJ6F66JEtzsw+GvQWewnag=="; }; - "@oven/bun-darwin-x64-baseline@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.14.tgz"; - hash = "sha512-OSfsTZstc898HHElhU4NccaBGOSSDn5VfahiVTnidZ9B/+wb7WTyfZJaBeJcfjwJ9H2W9uTh2TGtl3UfcXgV9g=="; + "@oven/bun-darwin-x64@1.4.2" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.4.2.tgz"; + hash = "sha512-gZTxZuLjkUhAWjTETu3tw0WhsEdNkJ64daj60ybhPf835a2yollV3yTkK9JozvzKPx4TRFzLSl8C+U525pxVbw=="; }; - "@oven/bun-darwin-x64@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.14.tgz"; - hash = "sha512-FFj3QdU/OhlDyZOJ8CWfN5eWLpRlT4qjZg7lMQi7jA6GuoY5ajlO1zWLP/MuHYRSbXQUvV52RejNi8DVnAp13w=="; + "@oven/bun-freebsd-aarch64@1.4.2" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-freebsd-aarch64/-/bun-freebsd-aarch64-1.4.2.tgz"; + hash = "sha512-SMNItMw1Z8QeeQVKnw8jA7xQNkeXdP+OPgin4Wi/QTx/B8RHHLnuZfqmFy7NtVeT2NF0kKYppW4WWd2CCYZjhQ=="; }; - "@oven/bun-freebsd-aarch64@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-freebsd-aarch64/-/bun-freebsd-aarch64-1.3.14.tgz"; - hash = "sha512-LIKrXaFxAHybVO5Pf+9XP2FHUj/5APvXTUKk9dqHm5iFz4oH+W24cmhjkJirNujh9hKeTyrpWSe3no9JZKowIw=="; + "@oven/bun-freebsd-x64@1.4.2" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-freebsd-x64/-/bun-freebsd-x64-1.4.2.tgz"; + hash = "sha512-THbPKXhO54N0DpFRKZNDZpQ7dpbX0bWASuARckAUS9wRtFIHsiY+uULXJvxJGo2YD1YewvXQ4G8Fj7XT5oBCiw=="; }; - "@oven/bun-freebsd-x64@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-freebsd-x64/-/bun-freebsd-x64-1.3.14.tgz"; - hash = "sha512-uwD+fGUH1ADpIF3B1U2jWzzb20QwRLZfj5QZ28GUCGrAJ/nTmWrD6YYGsblCY1wuhldRez3lU40AyuvSCyLYmw=="; + "@oven/bun-linux-aarch64-android@1.4.2" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-linux-aarch64-android/-/bun-linux-aarch64-android-1.4.2.tgz"; + hash = "sha512-3mZKO2rhsNgbAUtAHC1UKUlF2zTxFraDZT/Elv8wzyH0fJL9h+Iv3TgB9lO63w89PRn3eFe+NRA1bhVgikKNPQ=="; }; - "@oven/bun-linux-aarch64-android@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-linux-aarch64-android/-/bun-linux-aarch64-android-1.3.14.tgz"; - hash = "sha512-y4kq5b85lsrmFb9Xvi4w9mA5IEFJkLMrSmYn06q24KjL9rUWDWO3VFZEtteZxUN5+ec3Zm5S8OnJw1umaCbVjA=="; + "@oven/bun-linux-aarch64-musl@1.4.2" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.4.2.tgz"; + hash = "sha512-+Sm6y+lSiSFBOtXmnekp5Q6n1tUKlyv71FCPWBc61Cgb14T5eBs8SN/nh4MUCOKzONkI3O+as3MGUgikS4aCBQ=="; }; - "@oven/bun-linux-aarch64-musl@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.14.tgz"; - hash = "sha512-jmqOA92Cd1NL/1XBd4bFkJLxQ86K0RW7ohxS2qzzAvuitO4JiIxjjTeCspoU44zCozH72HpfZfUE2On31OjnWA=="; + "@oven/bun-linux-aarch64@1.4.2" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.4.2.tgz"; + hash = "sha512-3BBP9ovJ2RGHFH6Ae1CAtxNtG1+YY6GD6rmYbsUosoAk9+OEl6zeDQ/k4fBkc6dYOJCtWnx8hUxzNzQATSmvYQ=="; }; - "@oven/bun-linux-aarch64@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.14.tgz"; - hash = "sha512-X5SsPZHs+iYO8R/efIcRtc7gT2Q2DgPfliCxEkx4cXBumwkw0c/EsHMNwH3EgGpCDaZ7IYVPhpCG/xBOQHEwZw=="; + "@oven/bun-linux-x64-android@1.4.2" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-linux-x64-android/-/bun-linux-x64-android-1.4.2.tgz"; + hash = "sha512-6HC5tzcC79113n2IHCTJMWv+HsQImv4ZFEK2XpYLxY6HbT8tM4cUM2Zv1bHZBQsS3jv/zYBamDJ1UX7If0d5tw=="; }; - "@oven/bun-linux-x64-android@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-linux-x64-android/-/bun-linux-x64-android-1.3.14.tgz"; - hash = "sha512-qe9e1d+3VAEU7nAA2ol9Jvmy/o99PVMSgZhHn7Q/9O3YcDrfEqyQ8zm4zoe5qTEo8HZH0dN03Le0Ys2eQPs7eg=="; + "@oven/bun-linux-x64-musl@1.4.2" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.4.2.tgz"; + hash = "sha512-vVTKUg1bnPhRP/Hp73jIVoFh2vPFNYEqYX0ERKfZBOQEEHitNAeukZzzuUDZS0SoDCIpuWUGSpd/CDMbjdR+Uw=="; }; - "@oven/bun-linux-x64-baseline@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.14.tgz"; - hash = "sha512-q/8EdOC0yUE8FPeoOVq8/Pw5I9/tJaYmUfO/uDUAREx8IUnOJH1RJ5A3BjFqre8pvJoiZA9AovPJq5FnNNjSxA=="; + "@oven/bun-linux-x64@1.4.2" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.4.2.tgz"; + hash = "sha512-9/E/UXOTpSo3YsV5g+FhtTd/qTpiWoKuxS12cqtuYA1ssu9fRAoPQnipFgGyck3tWO63iUdxBiygq+kELFawng=="; }; - "@oven/bun-linux-x64-musl-baseline@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.14.tgz"; - hash = "sha512-n6iE71G4lQE4XkrZhQQcL5YUlxDbnq6nqV7zeQi33PMsLT/0kYE+RvHOtBWZ3w0wMdXZfINmp63hIb9ijUBGtw=="; + "@oven/bun-windows-aarch64@1.4.2" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-windows-aarch64/-/bun-windows-aarch64-1.4.2.tgz"; + hash = "sha512-8EJ1ST7339WJE3poPW5nBgVW/lWf9HBz4W27ZUNhburKmcBLOByPyE6DP9fHD8FQGm5c+ilUN2hX1mrW0jxq9Q=="; }; - "@oven/bun-linux-x64-musl@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.14.tgz"; - hash = "sha512-GBCB/k/sIqcr06eTNgg7g46qiUv35Jasx4XiccJ/n7RGqrE4RWUD/XJBbWFprVPjvqd59+QtSnS99XGqvftHfg=="; - }; - "@oven/bun-linux-x64@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.3.14.tgz"; - hash = "sha512-7OVTAKvwfPmSbIV1HpdOoVVx5VRc427GuPPne93N6vk4eQBPId9nXmZDh9/zGaKPdbVjVtQSZafWQoUjx38Utw=="; - }; - "@oven/bun-windows-aarch64@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-windows-aarch64/-/bun-windows-aarch64-1.3.14.tgz"; - hash = "sha512-T7s3x/BsVKQObGU6QDkZeI6wKynzqGbBH1yI77jrrj5siElclxr3DQrDIk8CV4G5/SJq2HHq4kpLyYY2DKCSmA=="; - }; - "@oven/bun-windows-x64-baseline@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.14.tgz"; - hash = "sha512-uIjLUC1S9DWgICzuoMba7vurBJnBruE4S5CxnvmZkdqWVXRzx1Rgu636HoH+k0qeaQCFh3jeG3JQ1y6fRHv0sw=="; - }; - "@oven/bun-windows-x64@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.3.14.tgz"; - hash = "sha512-mUFWL3BoYkNpjd8e9PqROiFF/1Xeotq20mABJsiQH62jM1g5zqWh4khw1RZ6bX8Q8fWvlPaxG1PjofkmjUi3vg=="; + "@oven/bun-windows-x64@1.4.2" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.4.2.tgz"; + hash = "sha512-+bN6OuVld/9diT/RLSXSW7JE6CvNE3gL9XsAEjULi1nUsXd6DNO6GuA9jNdNb3r8PdJFnYHr5aypNV1Oj3Rd9g=="; }; "@oxc-parser/binding-android-arm-eabi@0.142.0" = fetchurl { url = "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.142.0.tgz"; @@ -578,9 +566,9 @@ url = "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz"; hash = "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="; }; - "@types/bun@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/@types/bun/-/bun-1.3.14.tgz"; - hash = "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="; + "@types/bun@1.4.1" = fetchurl { + url = "https://registry.npmjs.org/@types/bun/-/bun-1.4.1.tgz"; + hash = "sha512-0AVGiTXGajf1rgKom3N+c5L7CBxuoyyv1i44M0nX4UDK0G/fnRAMiri93nHuVPIb429KKtAgj7HatVmmOjeQLA=="; }; "@types/hast@3.0.4" = fetchurl { url = "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz"; @@ -654,13 +642,13 @@ url = "https://registry.npmjs.org/bun-ffi-structs/-/bun-ffi-structs-0.3.1.tgz"; hash = "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="; }; - "bun-types@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/bun-types/-/bun-types-1.3.14.tgz"; - hash = "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="; + "bun-types@1.4.1" = fetchurl { + url = "https://registry.npmjs.org/bun-types/-/bun-types-1.4.1.tgz"; + hash = "sha512-loKuVrAFZKfEv+JvWkHRS9GW5IqLuLRjVXN9p+vZvBN86O5hf/pBZQ5hSoyipsrMmWObZBDvWnlmKvjKTM0PdA=="; }; - "bun@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/bun/-/bun-1.3.14.tgz"; - hash = "sha512-aB6GVd42x1Y5ie1K16SF+oLGtgSkwX9hgoDdIW88pjvfTccU8F1vfpoOt34QLv0dZ1v3XimtaxPlZUG81Gx9Zg=="; + "bun@1.4.2" = fetchurl { + url = "https://registry.npmjs.org/bun/-/bun-1.4.2.tgz"; + hash = "sha512-TrSXo6HJfIEaczpb3kjX82I2pL47vK1QUNmHRCUdz9IzaOwa9lzOXSWwu2l18YHE3sNfGRapVLd4nNm+22vVVA=="; }; "ccount@2.0.1" = fetchurl { url = "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz"; @@ -878,6 +866,7 @@ url = "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz"; hash = "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="; }; + "hunkdiff" = copyPathToStore ../packages/hunk; "ignore@7.0.6" = fetchurl { url = "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz"; hash = "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="; @@ -1162,9 +1151,9 @@ url = "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz"; hash = "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="; }; - "simple-git-hooks@2.13.1" = fetchurl { - url = "https://registry.npmjs.org/simple-git-hooks/-/simple-git-hooks-2.13.1.tgz"; - hash = "sha512-WszCLXwT4h2k1ufIXAgsbiTOazqqevFCIncOuUBZJ91DdvWcC5+OFkluWRQPrcuSYd8fjq+o2y1QfWqYMoAToQ=="; + "simple-git-hooks@2.14.0" = fetchurl { + url = "https://registry.npmjs.org/simple-git-hooks/-/simple-git-hooks-2.14.0.tgz"; + hash = "sha512-kkTORPAuxQz2g1QUuN0J8yGWT28tjGBfPOdJ4xT7Vbeu30veKUZQIoID2qGgCDAakaQn59UeZJJ4cFGB8PVP2A=="; }; "sisteransi@1.0.5" = fetchurl { url = "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz"; diff --git a/nix/package.nix b/nix/package.nix index a9ee5818c..7ccaa846b 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -1,11 +1,46 @@ { - bun, + autoPatchelfHook, bun2nix, + fetchurl, lib, makeWrapper, + stdenv, ... }: let - packageJson = lib.importJSON ../package.json; + packageJson = lib.importJSON ../packages/hunk/package.json; + bunVersion = lib.removePrefix "bun@" packageJson.packageManager; + bunCompilerArchives = { + "aarch64-darwin" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-${bunVersion}.tgz"; + hash = "sha512-MXdZkP1featqxZ+/VTXWG1BVjM4OGBehVY2Q88EeUj/7L0UMeCGItmyPYTN+wxvlGJ6F66JEtzsw+GvQWewnag=="; + }; + "x86_64-darwin" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-${bunVersion}.tgz"; + hash = "sha512-gZTxZuLjkUhAWjTETu3tw0WhsEdNkJ64daj60ybhPf835a2yollV3yTkK9JozvzKPx4TRFzLSl8C+U525pxVbw=="; + }; + "aarch64-linux" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-${bunVersion}.tgz"; + hash = "sha512-3BBP9ovJ2RGHFH6Ae1CAtxNtG1+YY6GD6rmYbsUosoAk9+OEl6zeDQ/k4fBkc6dYOJCtWnx8hUxzNzQATSmvYQ=="; + }; + "x86_64-linux" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-${bunVersion}.tgz"; + hash = "sha512-9/E/UXOTpSo3YsV5g+FhtTd/qTpiWoKuxS12cqtuYA1ssu9fRAoPQnipFgGyck3tWO63iUdxBiygq+kELFawng=="; + }; + }; + bunCompilerArchive = bunCompilerArchives.${stdenv.hostPlatform.system}; + bunCompiler = stdenv.mkDerivation { + pname = "bun-compiler"; + version = bunVersion; + src = bunCompilerArchive; + sourceRoot = "package"; + dontBuild = true; + dontStrip = true; + nativeBuildInputs = lib.optionals stdenv.isLinux [autoPatchelfHook]; + installPhase = '' + mkdir -p $out/bin + cp -p bin/bun $out/bin/bun + ''; + }; in bun2nix.mkDerivation { pname = "hunkdiff"; @@ -22,11 +57,23 @@ in buildPhase = '' runHook preBuild mkdir -p .bun-tmp .bun-install + + # Compile with the pinned release archive instead of nixpkgs' older Bun. + bun_compiler=${bunCompiler}/bin/bun + if [ ! -x "$bun_compiler" ]; then + echo "Bun compiler archive did not contain an executable" >&2 + exit 1 + fi + if [ "$("$bun_compiler" --version)" != "${bunVersion}" ]; then + echo "Expected Bun compiler ${bunVersion}" >&2 + exit 1 + fi + BUN_TMPDIR=$PWD/.bun-tmp \ BUN_INSTALL=$PWD/.bun-install \ - ${bun}/bin/bun build --compile \ + "$bun_compiler" build --compile \ --no-compile-autoload-bunfig \ - "./src/main.tsx" \ + "./packages/hunk/src/main.tsx" \ --outfile "hunk-bin" runHook postBuild ''; @@ -35,7 +82,7 @@ in runHook preInstall mkdir -p $out/bin cp -p ./hunk-bin $out/bin/hunk - cp -r ./skills $out/ + cp -r ./packages/hunk/skills $out/ wrapProgram $out/bin/hunk --set HUNK_INSTALL_SOURCE nix runHook postInstall ''; diff --git a/package.json b/package.json index 351997caf..3be751641 100644 --- a/package.json +++ b/package.json @@ -1,75 +1,30 @@ { - "name": "hunkdiff", - "version": "0.21.1", - "description": "Desktop-inspired terminal diff viewer for understanding agent-authored changesets.", - "keywords": [ - "ai", - "code-review", - "diff", - "git", - "terminal", - "tui" - ], - "homepage": "https://hunk.dev", - "bugs": { - "url": "https://github.com/modem-dev/hunk/issues" - }, - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/modem-dev/hunk.git" - }, - "bin": { - "hunk": "./bin/hunk.cjs", - "hunkdiff": "./bin/hunk.cjs" - }, + "name": "@hunk/workspace", + "private": true, "workspaces": [ - ".", "packages/*" ], - "files": [ - "bin", - "dist/npm", - "skills/hunk-review", - "skills/hunk-extensions", - "README.md", - "LICENSE" - ], "type": "module", - "exports": { - "./extension": { - "types": "./dist/npm/extension/index.d.ts", - "import": "./dist/npm/extension/index.js" - }, - "./opentui": { - "types": "./dist/npm/opentui/index.d.ts", - "import": "./dist/npm/opentui/index.js" - }, - "./package.json": "./package.json" - }, - "publishConfig": { - "access": "public" - }, "scripts": { - "start": "bun run src/main.tsx", - "dev": "bun --watch src/main.tsx", - "build:npm": "bun run ./scripts/build-npm.ts", - "build:bin": "bun run ./scripts/build-bin.ts", - "build:prebuilt:npm": "bun run build:npm && bun run build:bin && bun run ./scripts/stage-prebuilt-npm.ts", - "build:prebuilt:artifact": "bun run build:bin && bun run ./scripts/build-prebuilt-artifact.ts", - "stage:prebuilt:release": "bun run build:npm && bun run ./scripts/stage-prebuilt-npm.ts --artifact-root ./dist/release/artifacts", - "install:bin": "bun run ./scripts/install-bin.ts", - "generate:skill": "bun run ./scripts/generate-skill.ts", - "generate:theme-colors": "bun run ./scripts/generate-theme-diff-colors.ts", - "generate:docs": "bun run ./scripts/generate-docs.ts", - "generate:changelog": "bun run ./scripts/generate-changelog.ts", + "start": "bun run packages/hunk/src/main.tsx", + "dev": "bun --watch packages/hunk/src/main.tsx", + "build:npm": "bun run ./scripts/build/build-npm.ts", + "build:bin": "bun run ./scripts/build/build-bin.ts", + "build:prebuilt:npm": "bun run build:npm && bun run build:bin && bun run ./scripts/packaging/stage-prebuilt-npm.ts", + "build:prebuilt:artifact": "bun run build:bin && bun run ./scripts/packaging/build-prebuilt-artifact.ts", + "stage:prebuilt:release": "bun run build:npm && bun run ./scripts/packaging/stage-prebuilt-npm.ts --artifact-root ./dist/release/artifacts", + "install:bin": "bun run ./scripts/build/install-bin.ts", + "generate:skill": "bun run ./scripts/generate/generate-skill.ts", + "generate:theme-colors": "bun run ./scripts/generate/generate-theme-diff-colors.ts", + "generate:docs": "bun run ./scripts/generate/generate-docs.ts", + "generate:changelog": "bun run ./scripts/generate/generate-changelog.ts", "generate:og": "bun run ./website/scripts/generate-og.ts", - "check:docs": "bun run ./scripts/generate-docs.ts --check", - "check:changelog": "bun run ./scripts/generate-changelog.ts --check", + "check:docs": "bun run ./scripts/generate/generate-docs.ts --check", + "check:changelog": "bun run ./scripts/generate/generate-changelog.ts --check", "website:dev": "bun run --cwd website dev", - "website:build": "bun run check:docs && bun run check:changelog && bun run --cwd website build && bun run ./scripts/stage-install-script.ts", + "website:build": "bun run check:docs && bun run check:changelog && bun run --cwd website build && bun run ./scripts/packaging/stage-install-script.ts", "website:check": "bun run check:docs && bun run check:changelog && bun run --cwd website check", - "website:links": "bun run ./scripts/check-website-links.ts", + "website:links": "bun run ./scripts/website/check-website-links.ts", "website:test:browser": "bun run --cwd website test:browser", "typecheck": "tsc --noEmit", "format": "oxfmt --write .", @@ -77,27 +32,27 @@ "lint": "oxlint . --deny-warnings", "lint:fix": "oxlint . --fix", "knip": "knip", - "deps:check": "depcruise src packages --config .dependency-cruiser.cjs --ignore-known", - "deps:baseline": "depcruise src packages --config .dependency-cruiser.cjs --output-type baseline --output-to .dependency-cruiser-known-violations.json", + "deps:check": "depcruise packages --config .dependency-cruiser.cjs --ignore-known", + "deps:baseline": "depcruise packages --config .dependency-cruiser.cjs --output-type baseline --output-to .dependency-cruiser-known-violations.json", "changeset": "bunx @changesets/cli@2.31.0", "changeset:status": "bunx @changesets/cli@2.31.0 status", - "release:version": "bunx @changesets/cli@2.31.0 version", + "release:version": "bun run ./scripts/release/changeset-version.ts", "prepare": "simple-git-hooks", - "test": "bun run ./scripts/run-test-suite.ts", - "test:theme-contrast": "bun test src/ui/themes.test.ts --test-name-pattern contrast", + "test": "bun run ./scripts/test/run-test-suite.ts", + "test:theme-contrast": "bun test packages/hunk/src/ui/themes.test.ts --test-name-pattern contrast", "test:integration": "\"${npm_execpath:-bun}\" test ./test/pty", - "test:session-broker-node": "bun run ./scripts/test-session-broker-node.ts", + "test:session-broker-node": "bun run ./scripts/test/test-session-broker-node.ts", "test:tty-smoke": "HUNK_RUN_TTY_SMOKE=1 \"${npm_execpath:-bun}\" test ./test/smoke", "test:install-vm": "bun run ./test/cli/install-vm/runner.ts", "test:install-vm:clean": "bun run ./test/cli/install-vm/runner.ts --clean", - "check:pack": "bun run ./scripts/check-pack.ts", - "check:prebuilt-pack": "bun run ./scripts/check-prebuilt-pack.ts", - "smoke:prebuilt-install": "bun run ./scripts/smoke-prebuilt-install.ts", - "publish:prebuilt:npm": "bun run ./scripts/publish-prebuilt-npm.ts", - "prepack": "bun run build:npm", + "vm:shell": "bun run ./test/cli/install-vm/vm-shell.ts", + "check:pack": "bun run ./scripts/packaging/check-pack.ts", + "check:prebuilt-pack": "bun run ./scripts/packaging/check-prebuilt-pack.ts", + "smoke:prebuilt-install": "bun run ./scripts/packaging/smoke-prebuilt-install.ts", + "publish:prebuilt:npm": "bun run ./scripts/packaging/publish-prebuilt-npm.ts", "bench": "bun run benchmarks/run.ts", - "bench:release": "bun run ./scripts/run-release-benchmark.ts", - "bench:release:compare": "bun run ./scripts/compare-release-benchmarks.ts", + "bench:release": "bun run ./scripts/benchmarks/run-release-benchmark.ts", + "bench:release:compare": "bun run ./scripts/benchmarks/compare-release-benchmarks.ts", "bench:bootstrap-load": "bun run benchmarks/bootstrap-load.ts", "bench:working-tree-load": "bun run benchmarks/working-tree-load.ts", "bench:changeset-parse": "bun run benchmarks/changeset-parse.ts", @@ -117,55 +72,41 @@ "bench:geometry-memory": "bun run benchmarks/geometry-memory.ts", "bench:navigation-memory": "bun run benchmarks/navigation-memory.ts", "bench:resize-memory": "bun run benchmarks/resize-memory.ts", - "bench:daemon-memory": "bun run scripts/daemon-memory-check.ts", + "bench:daemon-memory": "bun run scripts/benchmarks/daemon-memory-check.ts", "bench:competitors": "bun run benchmarks/competitors.ts", "nix:update-lock": "nix run .#update-bun-lock" }, - "dependencies": { - "bun": "^1.3.14", - "chokidar": "^4.0.3", - "commander": "^14.0.3", - "diff": "^8.0.3", - "get-east-asian-width": "^1.5.0", - "shell-quote": "1.9.0", - "string-width": "^8.2.1", - "zod": "~4.4.3" - }, "devDependencies": { "@changesets/changelog-github": "^0.7.0", + "@hunk/git": "workspace:*", + "@hunk/jj": "workspace:*", + "@hunk/sapling": "workspace:*", "@hunk/session-broker": "workspace:*", "@hunk/session-broker-bun": "workspace:*", "@hunk/session-broker-core": "workspace:*", "@hunk/term-video": "workspace:*", + "@hunk/vcs": "workspace:*", "@opentui/core": "^0.5.6", "@opentui/react": "^0.5.6", "@pierre/diffs": "1.3.5", "@shikijs/themes": "3.23.0", - "@types/bun": "1.3.14", + "@types/bun": "1.4.1", "@types/react": "^19.2.14", "@types/ws": "^8.18.1", + "bun": "^1.4.2", "dependency-cruiser": "18.2.0", + "diff": "^8.0.3", "knip": "^6.32.0", "lint-staged": "^16.4.0", "marked": "17.0.1", "oxfmt": "^0.41.0", "oxlint": "^1.56.0", "react": "^19.2.4", - "simple-git-hooks": "^2.13.1", + "simple-git-hooks": "^2.14.0", + "string-width": "^8.2.1", "tuistory": "^0.11.0", "typescript": "^5.9.3" }, - "peerDependencies": { - "@opentui/core": "^0.5.6", - "@opentui/react": "^0.5.6", - "@pierre/diffs": "1.3.5", - "react": "^19.2.4" - }, - "peerDependenciesMeta": { - "@pierre/diffs": { - "optional": true - } - }, "overrides": { "shell-quote": "1.9.0" }, @@ -175,10 +116,11 @@ "engines": { "node": ">=22" }, - "packageManager": "bun@1.3.14", + "packageManager": "bun@1.4.2", "pi": { "skills": [ - "./skills" + "./skills", + "./packages/hunk/skills" ] } } diff --git a/packages/hunk-git/README.md b/packages/hunk-git/README.md new file mode 100644 index 000000000..1cc207d97 --- /dev/null +++ b/packages/hunk-git/README.md @@ -0,0 +1,16 @@ +# @hunk/git + +Private, statically bundled Git provider for Hunk. This `0.0.0` workspace is not a public or +versioned SDK. + +The package exports its provider from `src/index.ts`. It supports working-tree, revision, and stash +reviews; history; exact old/new source reads; staged changes; untracked and large-file handling; +and filesystem-assisted watch plans. + +Provider code may import local modules, platform built-ins, the public `hunkdiff/extension` +contract, and explicit `@hunk/vcs/*` leaves. It must not import Hunk core, app, session, or UI +internals. + +Hunk registers the provider in `packages/hunk/src/extensions/default/vcs/index.ts` and composes it +into the provider-neutral catalog in `packages/hunk/src/app/vcsCatalog.ts`. Keep tests beside the +provider source. Run `bun test packages/hunk-git` and `bun run deps:check` after changes. diff --git a/packages/hunk-git/package.json b/packages/hunk-git/package.json new file mode 100644 index 000000000..92b7604b3 --- /dev/null +++ b/packages/hunk-git/package.json @@ -0,0 +1,26 @@ +{ + "name": "@hunk/git", + "version": "0.0.0", + "private": true, + "description": "Private statically bundled Git provider for Hunk.", + "license": "MIT", + "files": [ + "src" + ], + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "dependencies": { + "@hunk/vcs": "workspace:*", + "hunkdiff": "workspace:*" + }, + "engines": { + "bun": ">=1.3.14", + "node": ">=22" + } +} diff --git a/src/extensions/default/vcs/git/commands.test.ts b/packages/hunk-git/src/commands.test.ts similarity index 100% rename from src/extensions/default/vcs/git/commands.test.ts rename to packages/hunk-git/src/commands.test.ts diff --git a/src/extensions/default/vcs/git/commands.ts b/packages/hunk-git/src/commands.ts similarity index 78% rename from src/extensions/default/vcs/git/commands.ts rename to packages/hunk-git/src/commands.ts index 161554a8e..10625ba89 100644 --- a/src/extensions/default/vcs/git/commands.ts +++ b/packages/hunk-git/src/commands.ts @@ -6,16 +6,16 @@ import { type ExtensionVcsShowInput, type ExtensionVcsStashShowInput, } from "hunkdiff/extension"; -import { LARGE_DIFF_FILE_MAX_BYTES, LARGE_DIFF_FILE_MAX_LINES } from "../../../../lib/largeFile"; -import { normalizePathForOS } from "../../../../lib/osPath"; -import { describeDiffRange, describeDiffTargets } from "../diffRange"; +import { LARGE_DIFF_FILE_MAX_BYTES, LARGE_DIFF_FILE_MAX_LINES } from "@hunk/vcs/large-file"; +import { normalizePathForOS } from "@hunk/vcs/path"; +import { describeDiffRange, describeDiffTargets } from "@hunk/vcs/diff-target"; +import { runAbortableCommand } from "@hunk/vcs/async-process"; /** * Every Git command Hunk runs, and the failures they translate into. * - * This is the implementation layer behind the bundled Git backend - * (`src/extensions/default/vcs/git/`), so nothing here reaches into core, the - * diff engine, or the adapter registry — user-facing failures are raised as the + * This is the implementation layer behind the bundled Git backend. Nothing here + * reaches into core, the diff engine, or the adapter registry — user-facing failures are raised as the * published `HunkExtensionUserError`, which is exactly what a third-party * backend would throw. */ @@ -31,6 +31,7 @@ export interface RunGitTextOptions { cwd?: string; gitExecutable?: string; preventOptionalLocks?: boolean; + signal?: AbortSignal; } interface RunGitCommandResult { @@ -49,7 +50,7 @@ export interface GitColorMovedOptions { } /** Append Git pathspec arguments only when the caller requested them. */ -export function appendGitPathspecs(args: string[], pathspecs?: string[]) { +function appendGitPathspecs(args: string[], pathspecs?: string[]) { if (!pathspecs || pathspecs.length === 0) { return; } @@ -63,7 +64,7 @@ export function appendGitPathspecs(args: string[], pathspecs?: string[]) { * example `--output=`) that Git would parse as a flag; it fails closed here instead * of reaching the spawned command. */ -export function requireGitRevisionArg(input: GitBackedInput, value: string) { +function requireGitRevisionArg(input: GitBackedInput, value: string) { if (value.length === 0) { throw new HunkExtensionUserError( `\`${formatGitCommandLabel(input)}\` refused an empty revision.`, @@ -304,7 +305,7 @@ export function buildGitStashShowArgs( return withNormalizedDiffPrefixes(withGitMovedLineColorConfig(args, colorMoved)); } -export function formatGitCommandLabel(input: GitBackedInput) { +function formatGitCommandLabel(input: GitBackedInput) { switch (input.kind) { case "vcs": { if (input.staged) { @@ -513,29 +514,44 @@ export function runGitText(options: RunGitTextOptions) { return runGitCommand(options).stdout; } -const GIT_BOOLEAN_TRUE_VALUES = new Set(["true", "yes", "on", "1", "always"]); -const GIT_BOOLEAN_FALSE_VALUES = new Set(["false", "no", "off", "0", "never"]); - -/** Read an optional Git config value without treating an unset key as an error. */ -function readOptionalGitConfig( - input: GitBackedInput, - key: string, - options: Omit = {}, -) { - const result = runGitCommand({ - input, - args: ["config", "--get", key], - ...options, - acceptedExitCodes: [0, 1], - }); - - if (result.exitCode !== 0) { - return undefined; +/** Run one Git command asynchronously so embedded review preparation remains cancellable. */ +async function runGitCommandAsync({ + input, + args, + cwd = process.cwd(), + gitExecutable = "git", + preventOptionalLocks = false, + signal, + acceptedExitCodes = [0], +}: RunGitCommandOptions): Promise { + let result: Awaited>; + try { + result = await runAbortableCommand([gitExecutable, ...args], { + cwd, + signal, + env: preventOptionalLocks ? { ...process.env, GIT_OPTIONAL_LOCKS: "0" } : undefined, + }); + } catch (error) { + if (signal?.aborted) signal.throwIfAborted(); + throw translateGitSpawnFailure(input, error, gitExecutable); } + if (!acceptedExitCodes.includes(result.exitCode)) { + throw translateGitExitFailure( + input, + result.stderr.trim() || `Command failed: ${gitExecutable} ${args.join(" ")}`, + ); + } + return result; +} - return result.stdout.trim() || undefined; +/** Run one Git command asynchronously and return its decoded stdout. */ +export async function runGitTextAsync(options: RunGitTextOptions): Promise { + return (await runGitCommandAsync(options)).stdout; } +const GIT_BOOLEAN_TRUE_VALUES = new Set(["true", "yes", "on", "1", "always"]); +const GIT_BOOLEAN_FALSE_VALUES = new Set(["false", "no", "off", "0", "never"]); + /** Normalize Git's diff.colorMoved config into the mode Hunk should request from Git. */ function normalizeGitColorMovedMode(value: string | undefined) { if (!value) { @@ -554,29 +570,25 @@ function normalizeGitColorMovedMode(value: string | undefined) { return value; } -/** Resolve whether Hunk should ask Git to color moved lines for this patch command. */ -export function resolveGitColorMovedOptions( +/** Resolve moved-line configuration without blocking an embedded renderer. */ +export async function resolveGitColorMovedOptionsAsync( input: GitBackedInput, options: Omit = {}, -): GitColorMovedOptions | null { - const gitMode = normalizeGitColorMovedMode( - readOptionalGitConfig(input, "diff.colorMoved", options), - ); - - if (gitMode === null) { - return null; - } - - const mode = gitMode ?? (input.options.colorMoved ? "zebra" : undefined); - if (!mode) { - return null; - } - - const whitespaceMode = readOptionalGitConfig(input, "diff.colorMovedWS", options); - return { - mode, - whitespaceMode, +): Promise { + const readConfig = async (key: string) => { + const result = await runGitCommandAsync({ + input, + args: ["config", "--get", key], + ...options, + acceptedExitCodes: [0, 1], + }); + return result.exitCode === 0 ? result.stdout.trim() || undefined : undefined; }; + const gitMode = normalizeGitColorMovedMode(await readConfig("diff.colorMoved")); + if (gitMode === null) return null; + const mode = gitMode ?? (input.options.colorMoved ? "zebra" : undefined); + if (!mode) return null; + return { mode, whitespaceMode: await readConfig("diff.colorMovedWS") }; } /** @@ -753,6 +765,63 @@ export function listGitUntrackedFiles( ); } +/** Return untracked files without blocking review preparation. */ +export async function listGitUntrackedFilesAsync( + input: ExtensionVcsDiffInput, + { + cwd = process.cwd(), + repoRoot, + gitExecutable = "git", + preventOptionalLocks = false, + signal, + }: Omit & { repoRoot?: string } = {}, +) { + if (input.staged || input.options.excludeUntracked === true) return []; + const range = requireGitDiffRangeArg(input); + if (range) { + const revs = ( + await runGitTextAsync({ + input, + args: ["rev-parse", "--revs-only", requireGitRevisionArg(input, range)], + cwd: repoRoot ?? cwd, + gitExecutable, + preventOptionalLocks, + signal, + }) + ) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + if ( + revs.filter((line) => !line.startsWith("^")).length !== 1 || + revs.some((line) => line.startsWith("^")) + ) { + return []; + } + } + const statusText = await runGitTextAsync({ + input, + args: buildGitStatusArgs(input), + cwd, + gitExecutable, + preventOptionalLocks, + signal, + }); + const untrackedFiles = parseUntrackedFilePaths(statusText); + if (untrackedFiles.length === 0) return []; + const normalizedRepoRoot = + repoRoot ?? + (await resolveGitRepoRootAsync(input, { + cwd, + gitExecutable, + preventOptionalLocks, + signal, + })); + return untrackedFiles.filter((filePath) => + isReviewableUntrackedPath(normalizedRepoRoot, filePath), + ); +} + export interface GitMetadata { repoRoot: string; gitDir: string; @@ -799,7 +868,7 @@ export function resolveGitRepoRoot( } /** Resolve one commit-ish ref to the exact commit object used for later blob reads. */ -export function resolveGitCommitRef( +function resolveGitCommitRef( input: GitBackedInput, ref: string, options: Omit = {}, @@ -992,3 +1061,123 @@ export function resolveGitDiffEndpoints( // from the wrong revision. return null; } + +/** Resolve a Git repository root without blocking renderer input. */ +export async function resolveGitRepoRootAsync( + input: GitBackedInput, + options: Omit = {}, +) { + return normalizePathForOS( + (await runGitTextAsync({ input, args: ["rev-parse", "--show-toplevel"], ...options })).trim(), + ); +} + +/** Resolve one exact commit ref without blocking renderer input. */ +export async function resolveGitCommitRefAsync( + input: GitBackedInput, + ref: string, + options: Omit = {}, +) { + return ( + await runGitTextAsync({ + input, + args: ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`], + ...options, + }) + ) + .split("\n")[0]! + .trim(); +} + +/** Resolve old/new Git endpoints asynchronously for review-load source capabilities. */ +export async function resolveGitDiffEndpointsAsync( + input: ExtensionVcsDiffInput, + { + cwd = process.cwd(), + gitExecutable = "git", + repoRoot, + signal, + }: Omit & { repoRoot?: string } = {}, +): Promise { + const range = requireGitDiffRangeArg(input); + const commandCwd = repoRoot ?? cwd; + const resolveRef = (ref: string) => + resolveGitCommitRefAsync(input, ref, { cwd: commandCwd, gitExecutable, signal }); + const resolveRevisions = async (value: string) => { + const revs = ( + await runGitTextAsync({ + input, + args: ["rev-parse", "--revs-only", requireGitRevisionArg(input, value)], + cwd: commandCwd, + gitExecutable, + signal, + }) + ) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + return { + positives: revs.filter((rev) => !rev.startsWith("^")), + negatives: revs.filter((rev) => rev.startsWith("^")).map((rev) => rev.slice(1)), + }; + }; + + if (input.staged) { + if (!range) { + const result = await runGitCommandAsync({ + input, + args: ["rev-parse", "--verify", "--end-of-options", "HEAD^{commit}"], + cwd: commandCwd, + gitExecutable, + signal, + acceptedExitCodes: [0, 1, 128], + }); + const headRef = result.exitCode === 0 ? result.stdout.split("\n")[0]!.trim() : null; + if (!headRef && !isUnknownRevisionMessage(result.stderr)) { + throw translateGitExitFailure( + input, + result.stderr.trim() || "Could not resolve Git ref HEAD.", + ); + } + return { + old: headRef ? { kind: "git-ref", ref: headRef } : { kind: "none" }, + new: { kind: "index" }, + }; + } + const { positives, negatives } = await resolveRevisions(range); + return positives.length === 1 && negatives.length === 0 + ? { old: { kind: "git-ref", ref: positives[0]! }, new: { kind: "index" } } + : null; + } + if (!range) return { old: { kind: "index" }, new: { kind: "worktree" } }; + const symmetric = parseSymmetricDiffRange(range); + if (symmetric) { + const mergeBase = ( + await runGitTextAsync({ + input, + args: ["merge-base", symmetric.left, symmetric.right], + cwd: commandCwd, + gitExecutable, + signal, + }) + ) + .split("\n")[0] + ?.trim(); + if (!mergeBase) return null; + return { + old: { kind: "git-ref", ref: mergeBase }, + new: { kind: "git-ref", ref: await resolveRef(symmetric.right) }, + }; + } + const { positives, negatives } = await resolveRevisions(range); + if (positives.length === 1 && negatives.length === 0) { + return { old: { kind: "git-ref", ref: positives[0]! }, new: { kind: "worktree" } }; + } + if (positives.length === 1 && negatives.length === 1) { + return { + old: { kind: "git-ref", ref: negatives[0]! }, + new: { kind: "git-ref", ref: positives[0]! }, + }; + } + return null; +} diff --git a/packages/hunk-git/src/history.test.ts b/packages/hunk-git/src/history.test.ts new file mode 100644 index 000000000..e18710b06 --- /dev/null +++ b/packages/hunk-git/src/history.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, test } from "bun:test"; +import { createGitVcsAdapter } from "./index"; +import { buildGitHistoryArgs, gitHistoryUsesBoundaryTopology, parseGitHistory } from "./history"; + +describe("Git history production", () => { + test("builds the strict supported query with literal pathspec separation", () => { + expect( + buildGitHistoryArgs({ + revision: "main..feature", + all: true, + firstParent: true, + maxCount: 12, + author: "Ada", + grep: "parser", + since: "2.weeks", + until: "yesterday", + pathspecs: ["src/file with spaces.ts", "--not-an-option"], + }), + ).toEqual([ + "log", + "--topo-order", + "--parents", + "--no-show-signature", + "--no-color", + "--abbrev=8", + "-z", + "--format=%H%x00%h%x00%P%x00%an%x00%ae%x00%aI%x00%s%x00%b", + "--all", + "--first-parent", + "--max-count=12", + "--author=Ada", + "--grep=parser", + "--since=2.weeks", + "--until=yesterday", + "main..feature", + "--", + "src/file with spaces.ts", + "--not-an-option", + ]); + }); + + test("refuses option-like revisions", () => { + expect(() => buildGitHistoryArgs({ revision: "--output=/tmp/pwn" })).toThrow( + "Refused history revision", + ); + }); + + test("parses NUL-delimited commits and copies structured decorations", () => { + const decorations = new Map([["a".repeat(40), [{ kind: "head" as const, label: "HEAD" }]]]); + const text = [ + "a".repeat(40), + "aaaaaaaa", + `${"b".repeat(40)} ${"c".repeat(40)}`, + "Ada Lovelace", + "ada@example.com", + "2026-01-02T03:04:05Z", + "Merge work", + "Detailed rationale.\n", + ].join("\0"); + expect(parseGitHistory(text, decorations)).toEqual([ + { + revisionId: "a".repeat(40), + displayId: "aaaaaaaa", + parentRevisionIds: ["b".repeat(40), "c".repeat(40)], + subject: "Merge work", + body: "Detailed rationale.\n", + authorName: "Ada Lovelace", + authorEmail: "ada@example.com", + authoredAt: "2026-01-02T03:04:05Z", + decorations: [{ kind: "head", label: "HEAD" }], + }, + ]); + }); + + test("marks repeated filtered gaps as graph boundaries without losing review parents", () => { + expect(gitHistoryUsesBoundaryTopology({ author: "Ada" })).toBe(true); + expect(gitHistoryUsesBoundaryTopology({ grep: "fix" })).toBe(true); + expect(gitHistoryUsesBoundaryTopology({})).toBe(false); + + const record = (revision: string, parent: string, subject: string) => + [ + revision.repeat(40), + revision.repeat(8), + parent.repeat(40), + "Ada", + "ada@example.com", + "2026-01-01T00:00:00Z", + subject, + "", + ].join("\0"); + const commits = parseGitHistory( + `${record("a", "d", "match one")}\0${record("b", "e", "match two")}\0${record("c", "f", "match three")}`, + new Map(), + false, + true, + ); + expect(commits.map((commit) => commit.parentRevisionIds)).toEqual([ + ["d".repeat(40)], + ["e".repeat(40)], + ["f".repeat(40)], + ]); + expect(commits.map((commit) => commit.graphParentRevisionIds)).toEqual([[], [], []]); + }); + + test("drops excluded secondary parents for first-parent topology", () => { + const text = [ + "a".repeat(40), + "aaaaaaaa", + `${"b".repeat(40)} ${"c".repeat(40)}`, + "Ada", + "ada@example.com", + "2026-01-01T00:00:00Z", + "Merge", + "", + ].join("\0"); + expect(parseGitHistory(text, new Map(), true)[0]!.parentRevisionIds).toEqual(["b".repeat(40)]); + }); + + test("owns first-parent merge and root review semantics", async () => { + const history = createGitVcsAdapter().history!; + const root = { + revisionId: "a".repeat(40), + displayId: "aaaaaaaa", + parentRevisionIds: [], + subject: "Root", + authorName: "Ada", + authoredAt: "2026-01-01T00:00:00Z", + decorations: [], + }; + expect(await history.planReview(root)).toEqual({ + kind: "revision-show", + revisionId: root.revisionId, + }); + expect( + await history.planReview({ + ...root, + revisionId: "b".repeat(40), + parentRevisionIds: ["c".repeat(40), "d".repeat(40)], + }), + ).toEqual({ + kind: "revision-range", + fromRevisionId: "c".repeat(40), + toRevisionId: "b".repeat(40), + }); + expect( + await history.planReview( + { + ...root, + revisionId: "b".repeat(40), + parentRevisionIds: ["c".repeat(40), "d".repeat(40)], + }, + undefined, + { parentRevisionId: "d".repeat(40) }, + ), + ).toEqual({ + kind: "revision-range", + fromRevisionId: "d".repeat(40), + toRevisionId: "b".repeat(40), + }); + }); + + test("rejects truncated records and invalid SHA object ids", () => { + expect(() => parseGitHistory("id\0short\0parent")).toThrow("truncated history record"); + expect(() => + parseGitHistory( + [ + "not-a-sha", + "short", + "", + "Ada", + "ada@example.com", + "2026-01-01T00:00:00Z", + "Bad", + "", + ].join("\0"), + ), + ).toThrow("invalid history object id"); + }); +}); diff --git a/packages/hunk-git/src/history.ts b/packages/hunk-git/src/history.ts new file mode 100644 index 000000000..b99c05947 --- /dev/null +++ b/packages/hunk-git/src/history.ts @@ -0,0 +1,381 @@ +import { spawn } from "node:child_process"; +import { StringDecoder } from "node:string_decoder"; +import { + HunkExtensionUserError, + type ExtensionVcsHistoryCommit, + type ExtensionVcsHistoryDecoration, + type ExtensionVcsHistoryInput, + type ExtensionVcsHistorySource, +} from "hunkdiff/extension"; + +const HISTORY_FIELDS_PER_COMMIT = 8; +const FULL_OBJECT_ID_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/; +const ABBREVIATED_OBJECT_ID_PATTERN = /^[0-9a-f]{4,64}$/; +const MAX_SYNC_OUTPUT_BYTES = 8 * 1024 * 1024; +const MAX_DECORATION_REFS = 10_000; +const MAX_HISTORY_STDERR_BYTES = 16 * 1024; + +interface GitHistoryOptions { + cwd: string; + gitExecutable?: string; +} + +/** Run one shell-free bounded Git query and retain byte-exact NUL delimiters. */ +function runGit( + args: string[], + { cwd, gitExecutable = "git" }: GitHistoryOptions, + acceptedExitCodes: readonly number[] = [0], +) { + let result: ReturnType; + try { + result = Bun.spawnSync([gitExecutable, ...args], { + cwd, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + maxBuffer: MAX_SYNC_OUTPUT_BYTES, + env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, + }); + } catch { + throw new HunkExtensionUserError(`Could not run ${gitExecutable}.`, { + suggestions: ["Install Git or configure Hunk to use another VCS backend."], + }); + } + + if (!acceptedExitCodes.includes(result.exitCode)) { + const message = result.stderr?.toString().trim().split("\n")[0]; + throw new HunkExtensionUserError(message || "Git could not read this repository.", { + suggestions: ["Check the revision, filters, and repository, then try again."], + }); + } + return result.stdout?.toString() ?? ""; +} + +/** Resolve the repository root, including a bare repository with no worktree. */ +function resolveHistoryRepoRoot(options: GitHistoryOptions) { + const worktree = runGit(["rev-parse", "--show-toplevel"], options, [0, 128]).trim(); + if (worktree) return worktree; + const gitDir = runGit(["rev-parse", "--absolute-git-dir"], options).trim(); + if (!gitDir) { + throw new HunkExtensionUserError("Not inside a Git repository.", { + suggestions: ["Run `hunk log` from a Git worktree or bare repository."], + }); + } + return gitDir; +} + +/** Return whether a default HEAD traversal has no first commit yet. */ +function hasHead(options: GitHistoryOptions) { + return runGit(["rev-parse", "--verify", "--quiet", "HEAD"], options, [0, 1]).trim().length > 0; +} + +/** Refuse a positional revision that Git could reinterpret as an option. */ +function requireRevision(value: string) { + if (!value || value.startsWith("-")) { + throw new HunkExtensionUserError(`Refused history revision \`${value}\`.`, { + suggestions: ["Pass a revision or range such as `HEAD`, `main`, or `main..feature`."], + }); + } + return value; +} + +/** Snapshot ref labels separately so display punctuation is never parsed as structure. */ +function readDecorations(options: GitHistoryOptions) { + const byCommit = new Map(); + const add = (revisionId: string, decoration: ExtensionVcsHistoryDecoration) => { + const entries = byCommit.get(revisionId) ?? []; + entries.push(decoration); + byCommit.set(revisionId, entries); + }; + + const raw = runGit( + [ + "for-each-ref", + `--count=${MAX_DECORATION_REFS + 1}`, + "--format=%(objectname)%00%(objecttype)%00%(refname)%00%(*objectname)%00", + "refs/heads", + "refs/remotes", + "refs/tags", + ], + options, + ); + const records = raw.split("\n").filter(Boolean); + if (records.length > MAX_DECORATION_REFS) { + throw new HunkExtensionUserError( + `Git history has more than ${MAX_DECORATION_REFS.toLocaleString("en-US")} decorated refs.`, + { suggestions: ["Reduce repository refs before running `hunk log`."] }, + ); + } + for (const record of records) { + const [objectId, objectType, refName, peeledId] = record.split("\0"); + if (!objectId || !objectType || !refName) continue; + const revisionId = objectType === "tag" && peeledId ? peeledId : objectId; + const decoration: ExtensionVcsHistoryDecoration = refName.startsWith("refs/heads/") + ? { kind: "local-branch", label: refName.slice("refs/heads/".length) } + : refName.startsWith("refs/remotes/") + ? { kind: "remote-branch", label: refName.slice("refs/remotes/".length) } + : refName.startsWith("refs/tags/") + ? { kind: "tag", label: refName.slice("refs/tags/".length) } + : { kind: "ref", label: refName }; + add(revisionId, decoration); + } + + const headId = runGit(["rev-parse", "--verify", "--quiet", "HEAD"], options, [0, 1]).trim(); + if (headId) { + const branch = runGit(["symbolic-ref", "--quiet", "--short", "HEAD"], options, [0, 1]).trim(); + add(headId, { + kind: "head", + label: "HEAD", + ...(branch ? { attachedLocalBranch: branch } : {}), + }); + } + + const order = new Map([ + ["head", 0], + ["local-branch", 1], + ["remote-branch", 2], + ["tag", 3], + ["ref", 4], + ]); + for (const entries of byCommit.values()) { + entries.sort( + (left, right) => + (order.get(left.kind) ?? 9) - (order.get(right.kind) ?? 9) || + (left.label < right.label ? -1 : left.label > right.label ? 1 : 0), + ); + } + return byCommit; +} + +/** Build deterministic Git log argv from the deliberately small public grammar. */ +export function buildGitHistoryArgs(input: ExtensionVcsHistoryInput) { + const args = [ + "log", + "--topo-order", + "--parents", + "--no-show-signature", + "--no-color", + "--abbrev=8", + "-z", + "--format=%H%x00%h%x00%P%x00%an%x00%ae%x00%aI%x00%s%x00%b", + ]; + if (input.all) args.push("--all"); + if (input.firstParent) args.push("--first-parent"); + if (input.maxCount !== undefined) args.push(`--max-count=${input.maxCount}`); + if (input.author !== undefined) args.push(`--author=${input.author}`); + if (input.grep !== undefined) args.push(`--grep=${input.grep}`); + if (input.since !== undefined) args.push(`--since=${input.since}`); + if (input.until !== undefined) args.push(`--until=${input.until}`); + if (input.revision !== undefined) args.push(requireRevision(input.revision)); + if (input.pathspecs?.length) args.push("--", ...input.pathspecs); + return args; +} + +/** Parse fixed NUL-delimited machine fields into immutable commit summaries. */ +export function parseGitHistory( + text: string, + decorations: ReadonlyMap = new Map(), + firstParent = false, + omitGraphParents = false, +): ExtensionVcsHistoryCommit[] { + if (!text) return []; + const fields = text.split("\0"); + if (fields.length % HISTORY_FIELDS_PER_COMMIT === 1 && fields.at(-1) === "") fields.pop(); + if (fields.length % HISTORY_FIELDS_PER_COMMIT !== 0) { + throw new Error("Git returned a truncated history record."); + } + + const commits: ExtensionVcsHistoryCommit[] = []; + for (let offset = 0; offset < fields.length; offset += HISTORY_FIELDS_PER_COMMIT) { + const revisionId = fields[offset]!; + const displayId = fields[offset + 1]!; + const parents = fields[offset + 2]!; + const authorName = fields[offset + 3]!; + const authorEmail = fields[offset + 4]!; + const authoredAt = fields[offset + 5]!; + const subject = fields[offset + 6]!; + const body = fields[offset + 7]!; + if (!revisionId || !displayId || !authoredAt) { + throw new Error("Git returned an incomplete history record."); + } + const allParents = parents ? parents.split(" ").filter(Boolean) : []; + const parentRevisionIds = firstParent ? allParents.slice(0, 1) : allParents; + if ( + !FULL_OBJECT_ID_PATTERN.test(revisionId) || + !ABBREVIATED_OBJECT_ID_PATTERN.test(displayId) || + parentRevisionIds.some((parent) => !FULL_OBJECT_ID_PATTERN.test(parent)) + ) { + throw new Error("Git returned an invalid history object id."); + } + commits.push({ + revisionId, + displayId, + parentRevisionIds, + ...(omitGraphParents ? { graphParentRevisionIds: [] } : {}), + subject: subject || "(no commit message)", + ...(body ? { body } : {}), + authorName: authorName || "Unknown author", + ...(authorEmail ? { authorEmail } : {}), + authoredAt, + decorations: [...(decorations.get(revisionId) ?? [])], + }); + } + return commits; +} + +/** Return whether traversal filters can omit direct parents from the emitted commit stream. */ +export function gitHistoryUsesBoundaryTopology(input: ExtensionVcsHistoryInput) { + return Boolean( + input.author !== undefined || + input.grep !== undefined || + input.since !== undefined || + input.until !== undefined || + input.pathspecs?.length, + ); +} + +/** Open a cancellable streaming history cursor over one long-lived Git process. */ +export function openGitHistory( + input: ExtensionVcsHistoryInput, + { cwd, gitExecutable = "git" }: GitHistoryOptions, +): ExtensionVcsHistorySource & { repoRoot: string } { + const repoRoot = resolveHistoryRepoRoot({ cwd, gitExecutable }); + const queryOptions = { cwd: repoRoot, gitExecutable }; + const empty = input.maxCount === 0 || (!input.revision && !input.all && !hasHead(queryOptions)); + if (empty) { + return { + repoRoot, + async read() { + return { commits: [], done: true }; + }, + close() {}, + }; + } + + const decorations = readDecorations(queryOptions); + const omitGraphParents = gitHistoryUsesBoundaryTopology(input); + + let child: ReturnType; + try { + child = spawn(gitExecutable, buildGitHistoryArgs(input), { + cwd: repoRoot, + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, + windowsHide: true, + }); + } catch { + throw new HunkExtensionUserError(`Could not run ${gitExecutable}.`, { + suggestions: ["Install Git or configure Hunk to use another VCS backend."], + }); + } + + const decoder = new StringDecoder("utf8"); + const queue: ExtensionVcsHistoryCommit[] = []; + const fields: string[] = []; + const waiters = new Set<() => void>(); + let buffered = ""; + let stderr = ""; + let completed = false; + let closed = false; + let failure: unknown; + let reading = false; + const wake = () => { + for (const waiter of waiters) waiter(); + waiters.clear(); + }; + const consume = (text: string) => { + buffered += text; + for (;;) { + const delimiter = buffered.indexOf("\0"); + if (delimiter < 0) break; + fields.push(buffered.slice(0, delimiter)); + buffered = buffered.slice(delimiter + 1); + if (fields.length === HISTORY_FIELDS_PER_COMMIT) { + queue.push( + ...parseGitHistory( + `${fields.join("\0")}\0`, + decorations, + input.firstParent, + omitGraphParents, + ), + ); + fields.length = 0; + } + } + wake(); + }; + + child.stdout!.on("data", (chunk: Buffer) => { + consume(decoder.write(chunk)); + if (queue.length >= 512) child.stdout!.pause(); + }); + child.stderr!.setEncoding("utf8"); + child.stderr!.on("data", (chunk: string) => { + if (stderr.length < MAX_HISTORY_STDERR_BYTES) { + stderr += chunk.slice(0, MAX_HISTORY_STDERR_BYTES - stderr.length); + } + }); + child.once("error", (error) => { + failure = error; + completed = true; + wake(); + }); + child.once("close", (code) => { + consume(decoder.end()); + if (!failure && (fields.length > 0 || buffered.length > 0)) { + failure = new Error("Git returned a truncated history record."); + } else if (!failure && code !== 0 && !closed) { + failure = new HunkExtensionUserError( + stderr.trim().split("\n")[0] || "Git could not read this history.", + { + suggestions: ["Check the revision, filters, and repository, then try again."], + }, + ); + } + completed = true; + wake(); + }); + + const waitForData = (signal?: AbortSignal) => + new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason ?? new Error("History read aborted.")); + return; + } + const ready = () => { + signal?.removeEventListener("abort", abort); + resolve(); + }; + const abort = () => { + waiters.delete(ready); + reject(signal?.reason ?? new Error("History read aborted.")); + }; + waiters.add(ready); + signal?.addEventListener("abort", abort, { once: true }); + }); + + return { + repoRoot, + async read({ limit, signal }) { + if (reading) throw new Error("Concurrent Git history reads are not supported."); + reading = true; + try { + const target = Math.min(limit, 256); + while (queue.length < target && !completed && !closed) await waitForData(signal); + if (signal?.aborted) throw signal.reason ?? new Error("History read aborted."); + if (failure) throw failure; + const commits = queue.splice(0, target); + if (!completed && !closed && queue.length < 256) child.stdout!.resume(); + return { commits, done: (completed || closed) && queue.length === 0 }; + } finally { + reading = false; + } + }, + close() { + if (closed) return; + closed = true; + if (!completed) child.kill(); + wake(); + }, + }; +} diff --git a/src/extensions/default/vcs/git/index.test.ts b/packages/hunk-git/src/index.test.ts similarity index 92% rename from src/extensions/default/vcs/git/index.test.ts rename to packages/hunk-git/src/index.test.ts index fc43fe21c..6881f3577 100644 --- a/src/extensions/default/vcs/git/index.test.ts +++ b/packages/hunk-git/src/index.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { platform, tmpdir } from "node:os"; import { isAbsolute, join, resolve } from "node:path"; -import { GitVcsAdapter, statSignature } from "."; +import { describeGitDiffTitleRange, GitVcsAdapter, statSignature } from "."; import type { ExtensionVcsDiffInput, ExtensionVcsOperations, @@ -78,6 +78,29 @@ afterEach(() => { }); describe("GitVcsAdapter", () => { + test("uses GitHub-style seven-character object IDs in comparison titles", () => { + const sha1From = "0123456789abcdef0123456789abcdef01234567"; + const sha1To = "89abcdef0123456789abcdef0123456789abcdef"; + const sha256 = "a".repeat(64); + + expect(describeGitDiffTitleRange({ rangeEndpoints: { from: sha1From, to: sha1To } })).toBe( + "0123456..89abcde", + ); + expect(describeGitDiffTitleRange({ range: `${sha1From}...${sha256}` })).toBe( + "0123456...aaaaaaa", + ); + expect(describeGitDiffTitleRange({ range: `..${sha1To}` })).toBe("..89abcde"); + expect(describeGitDiffTitleRange({ range: `${sha1From}..` })).toBe("0123456.."); + expect(describeGitDiffTitleRange({ rangeEndpoints: { from: "main", to: "feature" } })).toBe( + "main..feature", + ); + expect( + describeGitDiffTitleRange({ + rangeEndpoints: { from: `refs/heads/${"a".repeat(40)}`, to: "main" }, + }), + ).toBe(`refs/heads/${"a".repeat(40)}..main`); + }); + test("detects Git repositories from nested directories", () => { const repo = createTempRepo("hunk-git-adapter-detect-"); const nested = join(repo, "src", "nested"); @@ -165,7 +188,9 @@ describe("GitVcsAdapter", () => { const result = await GitVcsAdapter.operations["working-tree-diff"]!.load(input, { cwd: repo }); const file = { path: "tracked.txt", changeType: "change", isUntracked: false } as const; - expect(result.title).toContain(`${from}..${to}`); + expect(result.title).toContain(`${from.slice(0, 7)}..${to.slice(0, 7)}`); + expect(result.title).not.toContain(from); + expect(result.title).not.toContain(to); expect(result.untrackedPaths).toEqual([]); expect(await result.readFileSource?.({ ...file, side: "old" })).toBe("old\ncontext\n"); expect(await result.readFileSource?.({ ...file, side: "new" })).toBe("new\ncontext\n"); diff --git a/src/extensions/default/vcs/git/index.ts b/packages/hunk-git/src/index.ts similarity index 68% rename from src/extensions/default/vcs/git/index.ts rename to packages/hunk-git/src/index.ts index d3a1b27ff..049209f22 100644 --- a/src/extensions/default/vcs/git/index.ts +++ b/packages/hunk-git/src/index.ts @@ -8,19 +8,23 @@ import { buildGitStashShowArgs, listGitIgnoredDirectoryRoots, listGitUntrackedFiles, + listGitUntrackedFilesAsync, parseGitNumstat, - resolveGitColorMovedOptions, - resolveGitCommitRef, + resolveGitColorMovedOptionsAsync, + resolveGitCommitRefAsync, resolveGitDiffEndpoints, + resolveGitDiffEndpointsAsync, resolveGitMetadata, resolveGitRepoRoot, + resolveGitRepoRootAsync, runGitText, + runGitTextAsync, shouldSkipLargeTrackedDiff, type GitBackedInput, type GitDiffEndpoints, } from "./commands"; +import { openGitHistory } from "./history"; import { gitEndpointSourceSpec, readGitFileSource } from "./source"; -import { describeDiffRange } from "../diffRange"; import { HUNK_VCS_DETECTION_BASELINE_PRIORITY, type ExtensionVcsAdapter, @@ -39,11 +43,11 @@ import { * file sources, skipped-too-large placeholders, untracked files, watch plans, * rich failures — so it is deliberately written the way a third-party backend * would be: it sees only the published `hunkdiff/extension` contract plus - * implementation helpers owned by this extension directory and generic `src/lib` - * utilities. Nothing here reaches into core, the diff engine, or the - * adapter registry. If something Git needs cannot be said in these types, the - * published contract is missing it, and that is the point of shipping it this - * way. + * implementation helpers owned by this package and explicit `@hunk/vcs` + * infrastructure leaves. Nothing here reaches into core, the diff engine, or + * the adapter registry. If something Git needs cannot be said in these types, + * the published contract is missing it, and that is the point of shipping it + * this way. */ /** Return the last path segment for review titles. */ @@ -51,6 +55,34 @@ function basename(path: string) { return path.split(/[\\/]/).filter(Boolean).pop() ?? path; } +const GIT_SHORT_OBJECT_ID_LENGTH = 7; +const FULL_GIT_OBJECT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i; +const GIT_RANGE = /^(.*?)(\.\.\.?)(.*)$/; + +/** Shorten one complete Git object ID while preserving every named revision spelling. */ +function shortenGitObjectId(revision: string) { + return FULL_GIT_OBJECT_ID.test(revision) + ? revision.slice(0, GIT_SHORT_OBJECT_ID_LENGTH) + : revision; +} + +/** Shorten full Git object IDs in a display range while preserving named revisions. */ +export function describeGitDiffTitleRange(input: { + readonly range?: string; + readonly rangeEndpoints?: { readonly from: string; readonly to: string }; +}) { + if (input.rangeEndpoints) { + return `${shortenGitObjectId(input.rangeEndpoints.from)}..${shortenGitObjectId(input.rangeEndpoints.to)}`; + } + + const range = input.range; + if (range === undefined) return undefined; + const parsedRange = GIT_RANGE.exec(range); + return parsedRange + ? `${shortenGitObjectId(parsedRange[1]!)}${parsedRange[2]}${shortenGitObjectId(parsedRange[3]!)}` + : shortenGitObjectId(range); +} + /** Walk upward to detect a Git worktree marker without spawning Git during config resolution. */ function detectGitRepo(cwd: string) { let current = resolve(cwd); @@ -86,13 +118,19 @@ interface GitSourceCapability { sourceCacheKey: string; } -/** Hash semantic index entries so filesystem-stat refreshes do not defeat cache reuse. */ -function gitIndexCacheKey(input: GitBackedInput, repoRoot: string, gitExecutable: string) { - const entries = runGitText({ +/** Hash index entries without blocking an embedded renderer. */ +async function gitIndexCacheKeyAsync( + input: GitBackedInput, + repoRoot: string, + gitExecutable: string, + signal?: AbortSignal, +) { + const entries = await runGitTextAsync({ input, args: ["ls-files", "--stage", "-z"], cwd: repoRoot, gitExecutable, + signal, }); return createHash("sha256").update(entries).digest("hex"); } @@ -108,22 +146,40 @@ function gitEndpointCacheKey(endpoint: GitDiffEndpoints["old"], indexCacheKey: s return endpoint.kind; } -/** - * Build a source reader that answers from exact Git old/new endpoints. - * - * The endpoint key omits worktree contents because the per-file patch fingerprint - * already describes their delta. Immutable refs and the index hash identify the - * base, so unchanged files can safely retain highlighting across watch reloads. - */ -function createGitSourceCapability( +/** Build a pinned revision source capability without blocking renderer input. */ +async function createGitRevisionSourceCapabilityAsync( + input: GitBackedInput, + ref: string, + repoRoot: string, + gitExecutable: string, + signal?: AbortSignal, +): Promise { + const newRef = await resolveGitCommitRefAsync(input, ref, { + cwd: repoRoot, + gitExecutable, + signal, + }); + return createGitSourceCapabilityAsync( + input, + repoRoot, + { old: { kind: "git-ref", ref: `${newRef}^` }, new: { kind: "git-ref", ref: newRef } }, + gitExecutable, + signal, + ); +} + +/** Build exact source capability data while asynchronously hashing a possible index. */ +async function createGitSourceCapabilityAsync( input: GitBackedInput, repoRoot: string, endpoints: GitDiffEndpoints, gitExecutable: string, -): GitSourceCapability { + signal?: AbortSignal, +): Promise { const needsIndex = endpoints.old.kind === "index" || endpoints.new.kind === "index"; - const indexCacheKey = needsIndex ? gitIndexCacheKey(input, repoRoot, gitExecutable) : "unused"; - + const indexCacheKey = needsIndex + ? await gitIndexCacheKeyAsync(input, repoRoot, gitExecutable, signal) + : "unused"; return { sourceCacheKey: [ "git-source-v1", @@ -131,19 +187,14 @@ function createGitSourceCapability( gitEndpointCacheKey(endpoints.new, indexCacheKey), ].join(":"), readFileSource: ({ path, previousPath, changeType, side }) => { - // An added file has no old side and a deleted one has no new side; asking - // Git for either would just be a failed lookup. if (side === "old") { return changeType === "new" ? Promise.resolve(null) : readGitFileSource( gitEndpointSourceSpec(endpoints.old, repoRoot, previousPath ?? path), - { - gitExecutable, - }, + { gitExecutable }, ); } - return changeType === "deleted" ? Promise.resolve(null) : readGitFileSource(gitEndpointSourceSpec(endpoints.new, repoRoot, path), { @@ -153,38 +204,22 @@ function createGitSourceCapability( }; } -/** Build a pinned source capability for a single-revision review. */ -function createGitRevisionSourceCapability( - input: GitBackedInput, - ref: string, - repoRoot: string, - gitExecutable: string, -): GitSourceCapability { - const newRef = resolveGitCommitRef(input, ref, { cwd: repoRoot, gitExecutable }); - return createGitSourceCapability( - input, - repoRoot, - { old: { kind: "git-ref", ref: `${newRef}^` }, new: { kind: "git-ref", ref: newRef } }, - gitExecutable, - ); -} - -/** - * Build a source capability for a working-tree review, when both sides are exact. - * - * Ranges that do not reduce to one old/new pair — octopus merges, multi-positive - * revision sets — deliberately get no reader at all rather than a guess, so - * expanded rows are never read from the wrong revision. - */ -function createGitDiffSourceCapability( +/** Build working-tree source capability without blocking renderer input. */ +async function createGitDiffSourceCapabilityAsync( input: ExtensionVcsDiffInput, repoRoot: string, cwd: string, gitExecutable: string, -): GitSourceCapability | undefined { - const endpoints = resolveGitDiffEndpoints(input, { cwd, repoRoot, gitExecutable }); + signal?: AbortSignal, +): Promise { + const endpoints = await resolveGitDiffEndpointsAsync(input, { + cwd, + repoRoot, + gitExecutable, + signal, + }); return endpoints - ? createGitSourceCapability(input, repoRoot, endpoints, gitExecutable) + ? createGitSourceCapabilityAsync(input, repoRoot, endpoints, gitExecutable, signal) : undefined; } @@ -279,12 +314,30 @@ export function createGitVcsAdapter({ name: "Git", detect: detectGitRepo, detectionPriority: HUNK_VCS_DETECTION_BASELINE_PRIORITY, + history: { + open(input, { cwd }) { + return openGitHistory(input, { cwd, gitExecutable }); + }, + planReview(commit, _context?: unknown, options?: { parentRevisionId?: string }) { + const parent = options?.parentRevisionId ?? commit.parentRevisionIds[0]; + if (parent && !commit.parentRevisionIds.includes(parent)) { + throw new Error("The selected revision is not a parent of this Git commit."); + } + return parent + ? { + kind: "revision-range" as const, + fromRevisionId: parent, + toRevisionId: commit.revisionId, + } + : { kind: "revision-show" as const, revisionId: commit.revisionId }; + }, + }, operations: { "working-tree-diff": { - async load(input, { cwd }) { - const repoRoot = resolveGitRepoRoot(input, { cwd, gitExecutable }); + async load(input, { cwd, signal }) { + const repoRoot = await resolveGitRepoRootAsync(input, { cwd, gitExecutable, signal }); const repoName = basename(repoRoot); - const range = describeDiffRange(input); + const range = describeGitDiffTitleRange(input); const title = input.staged ? `${repoName} staged changes` : range @@ -292,22 +345,40 @@ export function createGitVcsAdapter({ : `${repoName} working tree`; // Ask for stats before the patch so files too large to render can be // excluded from the diff instead of generating output nobody reads. - const largeTrackedFiles = parseGitNumstat( - runGitText({ input, args: buildGitDiffNumstatArgs(input), cwd, gitExecutable }), - ).filter((file) => shouldSkipLargeTrackedDiff(file, repoRoot)); - const colorMoved = resolveGitColorMovedOptions(input, { cwd, gitExecutable }); - const sourceCapability = createGitDiffSourceCapability( + const numstat = await runGitTextAsync({ + input, + args: buildGitDiffNumstatArgs(input), + cwd, + gitExecutable, + signal, + }); + const colorMoved = await resolveGitColorMovedOptionsAsync(input, { + cwd, + gitExecutable, + signal, + }); + const sourceCapability = await createGitDiffSourceCapabilityAsync( input, repoRoot, cwd, gitExecutable, + signal, + ); + const untrackedPaths = await listGitUntrackedFilesAsync(input, { + cwd, + repoRoot, + gitExecutable, + signal, + }); + const largeTrackedFiles = parseGitNumstat(numstat).filter((file) => + shouldSkipLargeTrackedDiff(file, repoRoot), ); return { repoRoot, sourceLabel: repoRoot, title, - patchText: runGitText({ + patchText: await runGitTextAsync({ input, args: buildGitDiffArgs( input, @@ -316,6 +387,7 @@ export function createGitVcsAdapter({ ), cwd, gitExecutable, + signal, }), ...sourceCapability, extraFiles: largeTrackedFiles.map( @@ -331,7 +403,7 @@ export function createGitVcsAdapter({ // diff in-process. Rendering them through `git diff --no-index` // instead costs one subprocess per file, which made working-tree // review scale with the untracked file count. - untrackedPaths: listGitUntrackedFiles(input, { cwd, repoRoot, gitExecutable }), + untrackedPaths, }; }, watchPlan(input, { cwd }) { @@ -360,28 +432,30 @@ export function createGitVcsAdapter({ }, }, "revision-show": { - async load(input, { cwd }) { - const repoRoot = resolveGitRepoRoot(input, { cwd, gitExecutable }); + async load(input, { cwd, signal }) { + const repoRoot = await resolveGitRepoRootAsync(input, { cwd, gitExecutable, signal }); const repoName = basename(repoRoot); - const sourceCapability = createGitRevisionSourceCapability( + const sourceCapability = await createGitRevisionSourceCapabilityAsync( input, input.ref ?? "HEAD", repoRoot, gitExecutable, + signal, ); return { repoRoot, sourceLabel: repoRoot, title: input.ref ? `${repoName} show ${input.ref}` : `${repoName} show HEAD`, - patchText: runGitText({ + patchText: await runGitTextAsync({ input, args: buildGitShowArgs( input, - resolveGitColorMovedOptions(input, { cwd, gitExecutable }), + await resolveGitColorMovedOptionsAsync(input, { cwd, gitExecutable, signal }), ), cwd, gitExecutable, + signal, }), ...sourceCapability, }; @@ -400,28 +474,30 @@ export function createGitVcsAdapter({ }, }, "stash-show": { - async load(input, { cwd }) { - const repoRoot = resolveGitRepoRoot(input, { cwd, gitExecutable }); + async load(input, { cwd, signal }) { + const repoRoot = await resolveGitRepoRootAsync(input, { cwd, gitExecutable, signal }); const repoName = basename(repoRoot); - const sourceCapability = createGitRevisionSourceCapability( + const sourceCapability = await createGitRevisionSourceCapabilityAsync( input, input.ref ?? "stash@{0}", repoRoot, gitExecutable, + signal, ); return { repoRoot, sourceLabel: repoRoot, title: input.ref ? `${repoName} stash ${input.ref}` : `${repoName} stash`, - patchText: runGitText({ + patchText: await runGitTextAsync({ input, args: buildGitStashShowArgs( input, - resolveGitColorMovedOptions(input, { cwd, gitExecutable }), + await resolveGitColorMovedOptionsAsync(input, { cwd, gitExecutable, signal }), ), cwd, gitExecutable, + signal, }), ...sourceCapability, }; diff --git a/src/extensions/default/vcs/git/source.test.ts b/packages/hunk-git/src/source.test.ts similarity index 100% rename from src/extensions/default/vcs/git/source.test.ts rename to packages/hunk-git/src/source.test.ts diff --git a/src/extensions/default/vcs/git/source.ts b/packages/hunk-git/src/source.ts similarity index 99% rename from src/extensions/default/vcs/git/source.ts rename to packages/hunk-git/src/source.ts index b5a767291..ca60beb88 100644 --- a/src/extensions/default/vcs/git/source.ts +++ b/packages/hunk-git/src/source.ts @@ -9,7 +9,7 @@ import { readFileTextWithLimit, readStreamTextWithLimit, terminateSourceSubprocess, -} from "../../../../lib/sourceText"; +} from "@hunk/vcs/source"; import type { GitDiffEndpoint } from "./commands"; /** A provider-local signal converted to the public structural result at this boundary. */ diff --git a/packages/hunk-jj/README.md b/packages/hunk-jj/README.md new file mode 100644 index 000000000..f543a2f46 --- /dev/null +++ b/packages/hunk-jj/README.md @@ -0,0 +1,16 @@ +# @hunk/jj + +Private, statically bundled Jujutsu provider for Hunk. This `0.0.0` workspace is not a public or +versioned SDK. + +The package exports its provider from `src/index.ts`. It supports working-copy and revision reviews, +history, exact source reads where the revision shape permits them, and signature-based refresh. +Staged and stash reviews are not supported. + +Provider code may import local modules, platform built-ins, the public `hunkdiff/extension` +contract, and explicit `@hunk/vcs/*` leaves. It must not import Hunk core, app, session, or UI +internals. + +Hunk registers the provider in `packages/hunk/src/extensions/default/vcs/index.ts` and composes it +into the provider-neutral catalog in `packages/hunk/src/app/vcsCatalog.ts`. Keep tests beside the +provider source. Run `bun test packages/hunk-jj` and `bun run deps:check` after changes. diff --git a/packages/hunk-jj/package.json b/packages/hunk-jj/package.json new file mode 100644 index 000000000..8c0e540d5 --- /dev/null +++ b/packages/hunk-jj/package.json @@ -0,0 +1,26 @@ +{ + "name": "@hunk/jj", + "version": "0.0.0", + "private": true, + "description": "Private statically bundled Jujutsu provider for Hunk.", + "license": "MIT", + "files": [ + "src" + ], + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "dependencies": { + "@hunk/vcs": "workspace:*", + "hunkdiff": "workspace:*" + }, + "engines": { + "bun": ">=1.3.14", + "node": ">=22" + } +} diff --git a/src/extensions/default/vcs/jujutsu/commands.test.ts b/packages/hunk-jj/src/commands.test.ts similarity index 100% rename from src/extensions/default/vcs/jujutsu/commands.test.ts rename to packages/hunk-jj/src/commands.test.ts diff --git a/src/extensions/default/vcs/jujutsu/commands.ts b/packages/hunk-jj/src/commands.ts similarity index 77% rename from src/extensions/default/vcs/jujutsu/commands.ts rename to packages/hunk-jj/src/commands.ts index 39c2272a4..143b5ac44 100644 --- a/src/extensions/default/vcs/jujutsu/commands.ts +++ b/packages/hunk-jj/src/commands.ts @@ -4,8 +4,9 @@ import { type ExtensionVcsRangeEndpoints, type ExtensionVcsShowInput, } from "hunkdiff/extension"; -import { normalizePathForOS } from "../../../../lib/osPath"; -import { describeDiffTargets } from "../diffRange"; +import { normalizePathForOS } from "@hunk/vcs/path"; +import { describeDiffTargets } from "@hunk/vcs/diff-target"; +import { runAbortableCommand } from "@hunk/vcs/async-process"; export type JjBackedInput = ExtensionVcsDiffInput | ExtensionVcsShowInput; @@ -14,6 +15,7 @@ export interface RunJjTextOptions { args: string[]; cwd?: string; jjExecutable?: string; + signal?: AbortSignal; } /** Identifies the reviewed new commit and every commit JJ used to build the old side. */ @@ -34,7 +36,7 @@ function parseJjCommitIds(output: string) { } /** Reject a Jujutsu revision that could be interpreted as a command option. */ -export function requireJjRevisionArg(input: JjBackedInput, value: string) { +function requireJjRevisionArg(input: JjBackedInput, value: string) { if (value.length === 0) { throw new HunkExtensionUserError( `\`${formatJjCommandLabel(input)}\` refused an empty revision.`, @@ -98,7 +100,7 @@ export function buildJjShowArgs(input: ExtensionVcsShowInput, pinnedRevision?: s return args; } -export function formatJjCommandLabel(input: JjBackedInput) { +function formatJjCommandLabel(input: JjBackedInput) { if (input.kind === "vcs") { if (input.staged) { return "hunk diff --staged"; @@ -250,6 +252,31 @@ export function runJjText(options: RunJjTextOptions) { return runJjCommand(options).stdout; } +/** Run a Jujutsu command asynchronously so embedded review preparation stays cancellable. */ +export async function runJjTextAsync({ + input, + args, + cwd = process.cwd(), + jjExecutable = "jj", + signal, +}: RunJjTextOptions): Promise { + const command = [jjExecutable, "--no-pager", "--color", "never", ...args]; + let result: Awaited>; + try { + result = await runAbortableCommand(command, { cwd, signal }); + } catch (error) { + if (signal?.aborted) signal.throwIfAborted(); + throw translateJjSpawnFailure(input, error, jjExecutable); + } + if (result.exitCode !== 0) { + throw translateJjExitFailure( + input, + result.stderr.trim() || `Command failed: ${command.join(" ")}`, + ); + } + return result.stdout; +} + /** * Resolve a JJ revset once so the patch and later source reads use the same commit. * @@ -332,14 +359,65 @@ export function resolveJjRangeEndpoints( return { newCommitId: toCommitIds[0]!, oldCommitIds: [fromCommitIds[0]!] }; } -export function resolveJjRepoRoot( +/** Resolve immutable JJ endpoints without blocking renderer input. */ +export async function resolveJjDiffEndpointsAsync( + input: JjBackedInput, + revset: string, + options: Omit = {}, +): Promise { + const commitIds = parseJjCommitIds( + await runJjTextAsync({ + input, + args: ["log", "--no-graph", "-r", revset, "-T", JjCommitIdTemplate], + ...options, + }), + ); + if (commitIds.length !== 1) return undefined; + const commitId = commitIds[0]!; + const parentCommitIds = parseJjCommitIds( + await runJjTextAsync({ + input, + args: [ + "log", + "--no-graph", + "--ignore-working-copy", + "-r", + `${commitId}-`, + "-T", + JjCommitIdTemplate, + ], + ...options, + }), + ); + return { newCommitId: commitId, oldCommitIds: parentCommitIds.sort() }; +} + +/** Resolve two immutable JJ range endpoints without blocking renderer input. */ +export async function resolveJjRangeEndpointsAsync( + input: ExtensionVcsDiffInput, + endpoints: ExtensionVcsRangeEndpoints, + options: Omit = {}, +): Promise { + const from = requireJjRevisionArg(input, endpoints.from); + const to = requireJjRevisionArg(input, endpoints.to); + const resolveOne = async (revset: string) => + parseJjCommitIds( + await runJjTextAsync({ + input, + args: ["log", "--no-graph", "-r", revset, "-T", JjCommitIdTemplate], + ...options, + }), + ); + const fromCommitIds = await resolveOne(from); + const toCommitIds = await resolveOne(to); + if (fromCommitIds.length !== 1 || toCommitIds.length !== 1) return undefined; + return { newCommitId: toCommitIds[0]!, oldCommitIds: [fromCommitIds[0]!] }; +} + +/** Resolve the JJ repository root without blocking renderer input. */ +export async function resolveJjRepoRootAsync( input: JjBackedInput, options: Omit = {}, ) { - const repoRoot = runJjText({ - input, - args: ["root"], - ...options, - }).trim(); - return normalizePathForOS(repoRoot); + return normalizePathForOS((await runJjTextAsync({ input, args: ["root"], ...options })).trim()); } diff --git a/packages/hunk-jj/src/history.test.ts b/packages/hunk-jj/src/history.test.ts new file mode 100644 index 000000000..4c35bb363 --- /dev/null +++ b/packages/hunk-jj/src/history.test.ts @@ -0,0 +1,263 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createJjVcsAdapter } from "./index"; +import { + buildJjHistoryArgs, + buildJjHistoryRevset, + jjHistoryUsesBoundaryTopology, + openJjHistory, + parseJjHistory, +} from "./history"; + +const tempDirs: string[] = []; +const jjTest = Bun.which("jj") ? test : test.skip; + +/** Create a real JJ-only workspace without a colocated `.git` directory. */ +function createJjOnlyTestRepo() { + const repo = realpathSync(mkdtempSync(join(tmpdir(), "hunk-jj-history-"))); + tempDirs.push(repo); + const init = Bun.spawnSync(["jj", "git", "init", "--no-colocate", repo], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + if (init.exitCode !== 0) throw new Error(init.stderr.toString()); + return repo; +} + +/** Run JJ with a deterministic identity for fixture commits. */ +function jj(repo: string, ...args: string[]) { + const result = Bun.spawnSync( + [ + "jj", + "--config", + 'user.name="Ada Lovelace"', + "--config", + 'user.email="ada@example.com"', + ...args, + ], + { cwd: repo, stdin: "ignore", stdout: "pipe", stderr: "pipe" }, + ); + if (result.exitCode !== 0) throw new Error(result.stderr.toString()); + return result.stdout.toString(); +} + +afterEach(() => { + for (const repo of tempDirs.splice(0)) rmSync(repo, { recursive: true, force: true }); +}); + +describe("Jujutsu history production", () => { + test("builds a provider-owned revset and preserves literal filesets", () => { + expect( + buildJjHistoryArgs({ + revision: "main..@", + firstParent: true, + maxCount: 12, + author: 'Ada "A"', + grep: "parser", + since: "2026-01-01", + until: "2026-02-01", + pathspecs: ["src/file with spaces.ts", "--not-an-option"], + }), + ).toEqual([ + "--ignore-working-copy", + "--no-pager", + "--color", + "never", + "log", + "--no-graph", + "--revisions", + '(first_ancestors((main..@)) ~ root()) & author(substring:"Ada \\"A\\"") & description(substring:"parser") & author_date(after:"2026-01-01") & author_date(before:"2026-02-01")', + "--template", + expect.any(String), + "--limit", + "12", + "--", + "src/file with spaces.ts", + "--not-an-option", + ]); + expect(buildJjHistoryRevset({ all: true })).toBe("(ancestors(visible_heads()) ~ root())"); + }); + + test("rejects option-like revisions", () => { + expect(() => buildJjHistoryRevset({ revision: "--at-operation=@" })).toThrow( + "Refused Jujutsu history revision", + ); + }); + + test("parses descriptions, logical ids, parents, and structured JJ refs", () => { + const raw = [ + "a".repeat(40), + "kkkkkkkk", + "k".repeat(32), + `${"b".repeat(40)} ${"c".repeat(40)}`, + "Ada Lovelace", + "ada@example.com", + "2026-01-02T03:04:05+00:00", + "Subject\n\nBody paragraph.\n", + "1", + `main\x1ffeature`, + "main@git", + "v1.0.0", + "v1.0.0@origin", + ].join("\0"); + expect(parseJjHistory(raw)).toEqual([ + { + revisionId: "a".repeat(40), + displayId: "kkkkkkkk", + logicalId: "k".repeat(32), + parentRevisionIds: ["b".repeat(40), "c".repeat(40)], + subject: "Subject", + body: "Body paragraph.", + authorName: "Ada Lovelace", + authorEmail: "ada@example.com", + authoredAt: "2026-01-02T03:04:05+00:00", + decorations: [ + { kind: "head", label: "@" }, + { kind: "local-branch", label: "main" }, + { kind: "local-branch", label: "feature" }, + { kind: "remote-branch", label: "main@git" }, + { kind: "tag", label: "v1.0.0" }, + { kind: "tag", label: "v1.0.0@origin" }, + ], + }, + ]); + }); + + test("keeps review parents but marks filtered topology as a boundary", () => { + expect(jjHistoryUsesBoundaryTopology({})).toBe(false); + expect(jjHistoryUsesBoundaryTopology({ revision: "main..@" })).toBe(false); + expect(jjHistoryUsesBoundaryTopology({ pathspecs: ["src"] })).toBe(true); + expect(jjHistoryUsesBoundaryTopology({ author: "Ada" })).toBe(true); + + const raw = [ + "a".repeat(40), + "aaaaaaaa", + "k".repeat(32), + "b".repeat(40), + "Ada", + "", + "2026-01-01T00:00:00+00:00", + "Commit\n", + "0", + "", + "", + "", + "", + ].join("\0"); + const parsed = parseJjHistory(raw, false, true)[0]!; + expect(parsed.parentRevisionIds).toEqual(["b".repeat(40)]); + expect(parsed.graphParentRevisionIds).toEqual([]); + }); + + test("drops JJ's synthetic root and excluded merge parents", () => { + const raw = [ + "a".repeat(40), + "aaaaaaaa", + "k".repeat(32), + `${"b".repeat(40)} ${"0".repeat(40)} ${"c".repeat(40)}`, + "Ada", + "", + "2026-01-01T00:00:00+00:00", + "Commit\n", + "0", + "", + "", + "", + "", + ].join("\0"); + expect(parseJjHistory(raw, true)[0]!.parentRevisionIds).toEqual(["b".repeat(40)]); + }); + + test("owns default and explicitly selected parent review semantics", async () => { + const history = createJjVcsAdapter().history!; + for (const parentRevisionIds of [[], ["b".repeat(40)], ["b".repeat(40), "c".repeat(40)]]) { + const commit = { + revisionId: "a".repeat(40), + displayId: "aaaaaaaa", + logicalId: "k".repeat(32), + parentRevisionIds, + subject: "Commit", + authorName: "Ada", + authoredAt: "2026-01-01T00:00:00+00:00", + decorations: [], + }; + expect(await history.planReview(commit)).toEqual({ + kind: "revision-show", + revisionId: commit.revisionId, + }); + if (parentRevisionIds[0]) { + expect( + await history.planReview(commit, undefined, { + parentRevisionId: parentRevisionIds[0], + }), + ).toEqual({ + kind: "revision-range", + fromRevisionId: parentRevisionIds[0], + toRevisionId: commit.revisionId, + }); + } + } + }); + + jjTest( + "streams bounded child-before-parent pages from a JJ-only repository", + async () => { + const repo = createJjOnlyTestRepo(); + expect(existsSync(join(repo, ".git"))).toBe(false); + writeFileSync(join(repo, "history.txt"), "one\n"); + jj(repo, "commit", "-m", "First commit"); + writeFileSync(join(repo, "history.txt"), "two\n"); + jj(repo, "commit", "-m", "Second commit\n\nDetailed body."); + jj(repo, "bookmark", "create", "main", "-r", "@-"); + jj(repo, "tag", "set", "v1.0.0", "-r", "@-"); + + const source = openJjHistory({ maxCount: 4 }, { cwd: repo }); + const commits = []; + for (;;) { + const page = await source.read({ limit: 1 }); + expect(page.commits.length).toBeLessThanOrEqual(1); + commits.push(...page.commits); + if (page.done) break; + } + await source.close(); + + expect(commits.map((commit) => commit.subject)).toEqual([ + "(no description set)", + "Second commit", + "First commit", + ]); + expect(commits[1]!.body).toBe("Detailed body."); + expect(commits[1]!.displayId).toMatch(/^[k-z]{8}$/); + expect(commits[1]!.logicalId).toMatch(/^[k-z]{32}$/); + expect(commits[1]!.decorations).toContainEqual({ kind: "local-branch", label: "main" }); + expect(commits[1]!.decorations).toContainEqual({ kind: "tag", label: "v1.0.0" }); + for (let index = 0; index < commits.length; index += 1) { + for (const parent of commits[index]!.parentRevisionIds) { + const parentIndex = commits.findIndex((commit) => commit.revisionId === parent); + if (parentIndex >= 0) expect(parentIndex).toBeGreaterThan(index); + } + } + + const filtered = openJjHistory({ pathspecs: ["history.txt"] }, { cwd: repo }); + const filteredPage = await filtered.read({ limit: 4 }); + await filtered.close(); + const filteredWithParent = filteredPage.commits.find( + (commit) => commit.parentRevisionIds.length > 0, + ); + expect(filteredWithParent?.parentRevisionIds).toHaveLength(1); + expect(filteredWithParent?.graphParentRevisionIds).toEqual([]); + + const cancelled = openJjHistory({}, { cwd: repo }); + const abort = new AbortController(); + abort.abort(new Error("cancel fixture")); + await expect(cancelled.read({ limit: 1, signal: abort.signal })).rejects.toThrow( + "cancel fixture", + ); + await cancelled.close(); + }, + 20_000, + ); +}); diff --git a/packages/hunk-jj/src/history.ts b/packages/hunk-jj/src/history.ts new file mode 100644 index 000000000..e650f9ac0 --- /dev/null +++ b/packages/hunk-jj/src/history.ts @@ -0,0 +1,369 @@ +import { spawn } from "node:child_process"; +import { StringDecoder } from "node:string_decoder"; +import { + HunkExtensionUserError, + type ExtensionVcsHistoryCommit, + type ExtensionVcsHistoryDecoration, + type ExtensionVcsHistoryInput, + type ExtensionVcsHistorySource, +} from "hunkdiff/extension"; +import { normalizePathForOS } from "@hunk/vcs/path"; + +const HISTORY_FIELDS_PER_COMMIT = 13; +const REF_SEPARATOR = "\x1f"; +const FULL_COMMIT_ID_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/; +const DISPLAY_ID_PATTERN = /^[a-z0-9]{4,64}$/; +const ROOT_COMMIT_ID_PATTERN = /^0+$/; + +/** Emits stable machine fields without consulting a user's log template. */ +const JJ_HISTORY_TEMPLATE = + [ + "commit_id", + "change_id.short(8)", + "change_id", + 'parents.map(|p| p.commit_id()).join(" ")', + "author.name()", + "author.email()", + 'author.timestamp().format("%Y-%m-%dT%H:%M:%S%:z")', + "description", + 'if(current_working_copy, "1", "0")', + 'local_bookmarks.map(|r| r.name()).join("\\x1f")', + 'remote_bookmarks.map(|r| r.name() ++ "@" ++ r.remote()).join("\\x1f")', + 'local_tags.map(|r| r.name()).join("\\x1f")', + 'remote_tags.map(|r| r.name() ++ "@" ++ r.remote()).join("\\x1f")', + ].join(' ++ "\\0" ++ ') + ' ++ "\\0"'; + +export interface JjHistoryOptions { + cwd: string; + jjExecutable?: string; +} + +/** Return one safely quoted Jujutsu string literal for a generated revset. */ +function quoteRevsetString(value: string) { + return JSON.stringify(value); +} + +/** Reject a positional revset that the CLI could reinterpret as an option. */ +function requireHistoryRevision(value: string) { + if (!value || value.startsWith("-")) { + throw new HunkExtensionUserError(`Refused Jujutsu history revision \`${value}\`.`, { + suggestions: ["Pass a revision or revset such as `@`, `main`, or `main..@`."], + }); + } + return value; +} + +/** Build the provider-owned revset while preserving honest Jujutsu semantics. */ +export function buildJjHistoryRevset(input: ExtensionVcsHistoryInput) { + const start = input.revision + ? `(${requireHistoryRevision(input.revision)})` + : input.all + ? "visible_heads()" + : "@"; + const traversal = input.firstParent ? `first_ancestors(${start})` : `ancestors(${start})`; + const filters = [`(${traversal} ~ root())`]; + if (input.author !== undefined) { + filters.push(`author(substring:${quoteRevsetString(input.author)})`); + } + if (input.grep !== undefined) { + filters.push(`description(substring:${quoteRevsetString(input.grep)})`); + } + if (input.since !== undefined) { + filters.push(`author_date(after:${quoteRevsetString(input.since)})`); + } + if (input.until !== undefined) { + filters.push(`author_date(before:${quoteRevsetString(input.until)})`); + } + return filters.join(" & "); +} + +/** Build one deterministic, non-mutating `jj log` invocation. */ +export function buildJjHistoryArgs(input: ExtensionVcsHistoryInput) { + const args = [ + "--ignore-working-copy", + "--no-pager", + "--color", + "never", + "log", + "--no-graph", + "--revisions", + buildJjHistoryRevset(input), + "--template", + JJ_HISTORY_TEMPLATE, + ]; + if (input.maxCount !== undefined) args.push("--limit", String(input.maxCount)); + if (input.pathspecs?.length) args.push("--", ...input.pathspecs); + return args; +} + +/** Split a Jujutsu description into the medium-format subject and body fields. */ +function splitDescription(description: string) { + const normalized = description.endsWith("\n") ? description.slice(0, -1) : description; + const boundary = normalized.indexOf("\n"); + if (boundary < 0) { + return { subject: normalized || "(no description set)" }; + } + const subject = normalized.slice(0, boundary) || "(no description set)"; + const body = normalized.slice(boundary + 1).replace(/^\n/, ""); + return { subject, ...(body ? { body } : {}) }; +} + +/** Convert one ref-list field into typed, provider-neutral decorations. */ +function appendDecorations( + target: ExtensionVcsHistoryDecoration[], + field: string, + kind: ExtensionVcsHistoryDecoration["kind"], +) { + for (const label of field.split(REF_SEPARATOR)) { + if (label) target.push({ kind, label }); + } +} + +/** Parse fixed NUL-delimited Jujutsu template records. */ +export function parseJjHistory( + text: string, + firstParent = false, + omitGraphParents = false, +): ExtensionVcsHistoryCommit[] { + if (!text) return []; + const fields = text.split("\0"); + if (fields.length % HISTORY_FIELDS_PER_COMMIT === 1 && fields.at(-1) === "") fields.pop(); + if (fields.length % HISTORY_FIELDS_PER_COMMIT !== 0) { + throw new Error("Jujutsu returned a truncated history record."); + } + + const commits: ExtensionVcsHistoryCommit[] = []; + for (let offset = 0; offset < fields.length; offset += HISTORY_FIELDS_PER_COMMIT) { + const revisionId = fields[offset]!; + const displayId = fields[offset + 1]!; + const logicalId = fields[offset + 2]!; + const parents = fields[offset + 3]!; + const authorName = fields[offset + 4]!; + const authorEmail = fields[offset + 5]!; + const authoredAt = fields[offset + 6]!; + const description = fields[offset + 7]!; + const currentWorkingCopy = fields[offset + 8]!; + const localBookmarks = fields[offset + 9]!; + const remoteBookmarks = fields[offset + 10]!; + const localTags = fields[offset + 11]!; + const remoteTags = fields[offset + 12]!; + + if ( + !FULL_COMMIT_ID_PATTERN.test(revisionId) || + !DISPLAY_ID_PATTERN.test(displayId) || + !logicalId || + !authoredAt + ) { + throw new Error("Jujutsu returned an invalid history record."); + } + const allParents = parents + ? parents.split(" ").filter((parent) => parent && !ROOT_COMMIT_ID_PATTERN.test(parent)) + : []; + if (allParents.some((parent) => !FULL_COMMIT_ID_PATTERN.test(parent))) { + throw new Error("Jujutsu returned an invalid history parent commit id."); + } + + const decorations: ExtensionVcsHistoryDecoration[] = []; + if (currentWorkingCopy === "1") decorations.push({ kind: "head", label: "@" }); + appendDecorations(decorations, localBookmarks, "local-branch"); + appendDecorations(decorations, remoteBookmarks, "remote-branch"); + appendDecorations(decorations, localTags, "tag"); + appendDecorations(decorations, remoteTags, "tag"); + + commits.push({ + revisionId, + displayId, + parentRevisionIds: firstParent ? allParents.slice(0, 1) : allParents, + ...(omitGraphParents ? { graphParentRevisionIds: [] } : {}), + ...splitDescription(description), + authorName: authorName || "Unknown author", + ...(authorEmail ? { authorEmail } : {}), + authoredAt, + decorations, + logicalId, + }); + } + return commits; +} + +/** Run a small synchronous JJ query used only to establish the repository root. */ +function resolveHistoryRepoRoot({ cwd, jjExecutable = "jj" }: JjHistoryOptions) { + let result: ReturnType; + try { + result = Bun.spawnSync( + [jjExecutable, "--ignore-working-copy", "--no-pager", "--color", "never", "root"], + { cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe" }, + ); + } catch { + throw new HunkExtensionUserError(`Could not run ${jjExecutable}.`, { + suggestions: ["Install Jujutsu or configure Hunk to use another VCS backend."], + }); + } + if (result.exitCode !== 0) { + const message = result.stderr?.toString().trim().split("\n")[0]; + throw new HunkExtensionUserError(message || "Jujutsu could not read this repository.", { + suggestions: ["Run `hunk log --vcs jj` from a Jujutsu workspace."], + }); + } + const repoRoot = result.stdout?.toString().trim(); + if (!repoRoot) throw new Error("Jujutsu returned an empty repository root."); + return normalizePathForOS(repoRoot); +} + +/** Return whether traversal filters can omit direct parents from the emitted commit stream. */ +export function jjHistoryUsesBoundaryTopology(input: ExtensionVcsHistoryInput) { + return Boolean( + input.author !== undefined || + input.grep !== undefined || + input.since !== undefined || + input.until !== undefined || + input.pathspecs?.length, + ); +} + +/** Open a bounded, cancellable cursor over one long-lived `jj log` process. */ +export function openJjHistory( + input: ExtensionVcsHistoryInput, + { cwd, jjExecutable = "jj" }: JjHistoryOptions, +): ExtensionVcsHistorySource & { repoRoot: string } { + const repoRoot = resolveHistoryRepoRoot({ cwd, jjExecutable }); + if (input.maxCount === 0) { + return { + repoRoot, + async read() { + return { commits: [], done: true }; + }, + close() {}, + }; + } + + const omitGraphParents = jjHistoryUsesBoundaryTopology(input); + const child = spawn(jjExecutable, buildJjHistoryArgs(input), { + cwd: repoRoot, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + const decoder = new StringDecoder("utf8"); + const queue: ExtensionVcsHistoryCommit[] = []; + const fields: string[] = []; + const waiters = new Set<() => void>(); + let buffered = ""; + let stderr = ""; + let completed = false; + let closed = false; + let reading = false; + let failure: unknown; + + const wake = () => { + for (const waiter of waiters) waiter(); + waiters.clear(); + }; + const stop = () => { + if (closed) return; + closed = true; + if (!completed) child.kill(); + wake(); + }; + const consume = (text: string) => { + buffered += text; + for (;;) { + const delimiter = buffered.indexOf("\0"); + if (delimiter < 0) break; + fields.push(buffered.slice(0, delimiter)); + buffered = buffered.slice(delimiter + 1); + if (fields.length === HISTORY_FIELDS_PER_COMMIT) { + queue.push( + ...parseJjHistory(`${fields.join("\0")}\0`, input.firstParent, omitGraphParents), + ); + fields.length = 0; + } + } + wake(); + }; + + child.stdout!.on("data", (chunk: Buffer) => { + try { + consume(decoder.write(chunk)); + if (queue.length >= 512) child.stdout!.pause(); + } catch (error) { + failure = error; + completed = true; + child.kill(); + wake(); + } + }); + child.stderr!.setEncoding("utf8"); + child.stderr!.on("data", (chunk: string) => { + stderr += chunk; + }); + child.once("error", () => { + failure = new HunkExtensionUserError(`Could not run ${jjExecutable}.`, { + suggestions: ["Install Jujutsu or configure Hunk to use another VCS backend."], + }); + completed = true; + wake(); + }); + child.once("close", (code) => { + if (!failure) { + try { + consume(decoder.end()); + } catch (error) { + failure = error; + } + } + if (!failure && (fields.length > 0 || buffered.length > 0)) { + failure = new Error("Jujutsu returned a truncated history record."); + } else if (!failure && code !== 0 && !closed) { + failure = new HunkExtensionUserError( + stderr.trim().split("\n")[0] || "Jujutsu could not read this history.", + { suggestions: ["Check the revset, filters, and filesets, then try again."] }, + ); + } + completed = true; + wake(); + }); + + const waitForData = (signal?: AbortSignal) => + new Promise((resolve, reject) => { + if (signal?.aborted) { + stop(); + reject(signal.reason ?? new Error("History read aborted.")); + return; + } + const ready = () => { + signal?.removeEventListener("abort", abort); + resolve(); + }; + const abort = () => { + waiters.delete(ready); + stop(); + reject(signal?.reason ?? new Error("History read aborted.")); + }; + waiters.add(ready); + signal?.addEventListener("abort", abort, { once: true }); + }); + + return { + repoRoot, + async read({ limit, signal }) { + if (reading) throw new Error("Concurrent Jujutsu history reads are not supported."); + if (signal?.aborted) { + stop(); + throw signal.reason ?? new Error("History read aborted."); + } + reading = true; + try { + const target = Math.min(Math.max(1, limit), 256); + while (queue.length < target && !completed && !closed) await waitForData(signal); + if (signal?.aborted) throw signal.reason ?? new Error("History read aborted."); + if (failure) throw failure; + const commits = queue.splice(0, target); + if (!completed && !closed && queue.length < 256) child.stdout!.resume(); + return { commits, done: (completed || closed) && queue.length === 0 }; + } finally { + reading = false; + } + }, + close: stop, + }; +} diff --git a/src/extensions/default/vcs/jujutsu/index.test.ts b/packages/hunk-jj/src/index.test.ts similarity index 100% rename from src/extensions/default/vcs/jujutsu/index.test.ts rename to packages/hunk-jj/src/index.test.ts diff --git a/src/extensions/default/vcs/jujutsu/index.ts b/packages/hunk-jj/src/index.ts similarity index 77% rename from src/extensions/default/vcs/jujutsu/index.ts rename to packages/hunk-jj/src/index.ts index 48018f053..af577c4dc 100644 --- a/src/extensions/default/vcs/jujutsu/index.ts +++ b/packages/hunk-jj/src/index.ts @@ -4,14 +4,16 @@ import { buildJjDiffArgs, buildJjShowArgs, createJjStagedError, - resolveJjDiffEndpoints, - resolveJjRangeEndpoints, - resolveJjRepoRoot, + resolveJjDiffEndpointsAsync, + resolveJjRangeEndpointsAsync, + resolveJjRepoRootAsync, runJjText, + runJjTextAsync, type JjDiffEndpoints, } from "./commands"; +import { openJjHistory } from "./history"; import { readJjFileSource } from "./source"; -import { describeDiffRange } from "../diffRange"; +import { describeDiffRange } from "@hunk/vcs/diff-target"; import { HUNK_VCS_DETECTION_BASELINE_PRIORITY, type ExtensionVcsAdapter, @@ -24,8 +26,9 @@ import { * * This file is written the way a third-party VCS extension would be: it sees * only the published `hunkdiff/extension` contract plus implementation helpers - * owned by this extension directory. If something here cannot be said in those - * types, the contract is missing something. + * owned by this package and explicit `@hunk/vcs` infrastructure leaves. If + * something here cannot be said in those types, the contract is missing + * something. */ /** Return the last path segment for review titles. */ @@ -131,17 +134,45 @@ export function createJjVcsAdapter({ jjExecutable = "jj" }: Readonly=1.3.14", + "node": ">=22" + } +} diff --git a/src/extensions/default/vcs/sapling/commands.test.ts b/packages/hunk-sapling/src/commands.test.ts similarity index 100% rename from src/extensions/default/vcs/sapling/commands.test.ts rename to packages/hunk-sapling/src/commands.test.ts diff --git a/src/extensions/default/vcs/sapling/commands.ts b/packages/hunk-sapling/src/commands.ts similarity index 80% rename from src/extensions/default/vcs/sapling/commands.ts rename to packages/hunk-sapling/src/commands.ts index 91d162a27..4cd543dd8 100644 --- a/src/extensions/default/vcs/sapling/commands.ts +++ b/packages/hunk-sapling/src/commands.ts @@ -5,8 +5,9 @@ import { type ExtensionVcsDiffInput, type ExtensionVcsShowInput, } from "hunkdiff/extension"; -import { normalizePathForOS } from "../../../../lib/osPath"; -import { describeDiffTargets } from "../diffRange"; +import { normalizePathForOS } from "@hunk/vcs/path"; +import { describeDiffTargets } from "@hunk/vcs/diff-target"; +import { runAbortableCommand } from "@hunk/vcs/async-process"; export type SlBackedInput = ExtensionVcsDiffInput | ExtensionVcsShowInput; @@ -15,10 +16,11 @@ export interface RunSlTextOptions { args: string[]; cwd?: string; slExecutable?: string; + signal?: AbortSignal; } /** Reject a Sapling revision that could be interpreted as a command option. */ -export function requireSlRevisionArg(input: SlBackedInput, value: string) { +function requireSlRevisionArg(input: SlBackedInput, value: string) { if (value.length === 0) { throw new HunkExtensionUserError( `\`${formatSlCommandLabel(input)}\` refused an empty revision.`, @@ -85,7 +87,7 @@ function buildSlStatusArgs(input: ExtensionVcsDiffInput) { } /** Format a user-facing label for the Sapling command being run. */ -export function formatSlCommandLabel(input: SlBackedInput) { +function formatSlCommandLabel(input: SlBackedInput) { if (input.kind === "vcs") { if (input.staged) { return "hunk diff --staged"; @@ -238,6 +240,31 @@ export function runSlText(options: RunSlTextOptions) { return runSlCommand(options).stdout; } +/** Run a Sapling command asynchronously so embedded review preparation stays cancellable. */ +export async function runSlTextAsync({ + input, + args, + cwd = process.cwd(), + slExecutable = "sl", + signal, +}: RunSlTextOptions): Promise { + const command = [slExecutable, "--noninteractive", "--color", "never", ...args]; + let result: Awaited>; + try { + result = await runAbortableCommand(command, { cwd, signal }); + } catch (error) { + if (signal?.aborted) signal.throwIfAborted(); + throw translateSlSpawnFailure(input, error, slExecutable); + } + if (result.exitCode !== 0) { + throw translateSlExitFailure( + input, + result.stderr.trim() || `Command failed: ${command.join(" ")}`, + ); + } + return result.stdout; +} + /** Return whether working-copy review should synthesize unknown Sapling files into the patch stream. */ function shouldIncludeUntrackedFiles(input: ExtensionVcsDiffInput) { return !input.staged && !input.rangeEndpoints && input.options.excludeUntracked !== true; @@ -309,6 +336,34 @@ export function listSlUntrackedFiles( ); } +/** Return unknown files without blocking review preparation. */ +export async function listSlUntrackedFilesAsync( + input: ExtensionVcsDiffInput, + { + cwd = process.cwd(), + repoRoot, + slExecutable = "sl", + signal, + }: Omit & { repoRoot?: string } = {}, +) { + validateSlDiffEndpoints(input); + if (!shouldIncludeUntrackedFiles(input)) return []; + const statusText = await runSlTextAsync({ + input, + args: buildSlStatusArgs(input), + cwd, + slExecutable, + signal, + }); + const untrackedFiles = parseUntrackedFilePaths(statusText); + if (untrackedFiles.length === 0) return []; + const normalizedRepoRoot = + repoRoot ?? (await resolveSlRepoRootAsync(input, { cwd, slExecutable, signal })); + return untrackedFiles.filter((filePath) => + isReviewableUntrackedPath(normalizedRepoRoot, filePath), + ); +} + /** Resolve the repo root by running `sl root`. */ export function resolveSlRepoRoot( input: SlBackedInput, @@ -321,3 +376,11 @@ export function resolveSlRepoRoot( }).trim(); return normalizePathForOS(repoRoot); } + +/** Resolve the Sapling repository root without blocking renderer input. */ +export async function resolveSlRepoRootAsync( + input: SlBackedInput, + options: Omit = {}, +) { + return normalizePathForOS((await runSlTextAsync({ input, args: ["root"], ...options })).trim()); +} diff --git a/src/extensions/default/vcs/sapling/index.test.ts b/packages/hunk-sapling/src/index.test.ts similarity index 93% rename from src/extensions/default/vcs/sapling/index.test.ts rename to packages/hunk-sapling/src/index.test.ts index 04e0031e2..20dad2254 100644 --- a/src/extensions/default/vcs/sapling/index.test.ts +++ b/packages/hunk-sapling/src/index.test.ts @@ -165,14 +165,29 @@ describe("SaplingVcsAdapter without the sl binary", () => { test("adapter range loads do not probe working-copy unknown files", async () => { const repo = createTempDir("hunk-sl-adapter-range-untracked-"); const commands: string[][] = []; - const mutableBun = Bun as unknown as { spawnSync: typeof Bun.spawnSync }; + const mutableBun = Bun as unknown as { + spawnSync: typeof Bun.spawnSync; + spawn: typeof Bun.spawn; + }; const originalSpawnSync = mutableBun.spawnSync; + const originalSpawn = mutableBun.spawn; mutableBun.spawnSync = ((command: string[]) => { commands.push(command); const operation = command.slice(4); const stdout = operation[0] === "root" ? `${repo}\n` : ""; return { exitCode: 0, stdout: Buffer.from(stdout), stderr: Buffer.from("") }; }) as typeof Bun.spawnSync; + mutableBun.spawn = ((command: string[]) => { + commands.push(command); + const operation = command.slice(4); + const stdout = operation[0] === "root" ? `${repo}\n` : ""; + return { + stdout: new Blob([stdout]).stream(), + stderr: new Blob([""]).stream(), + exited: Promise.resolve(0), + kill() {}, + }; + }) as unknown as typeof Bun.spawn; try { const input = { @@ -192,6 +207,7 @@ describe("SaplingVcsAdapter without the sl binary", () => { ).toBe(true); } finally { mutableBun.spawnSync = originalSpawnSync; + mutableBun.spawn = originalSpawn; } }); diff --git a/src/extensions/default/vcs/sapling/index.ts b/packages/hunk-sapling/src/index.ts similarity index 81% rename from src/extensions/default/vcs/sapling/index.ts rename to packages/hunk-sapling/src/index.ts index 37eacb1df..54c8c6c83 100644 --- a/src/extensions/default/vcs/sapling/index.ts +++ b/packages/hunk-sapling/src/index.ts @@ -5,10 +5,13 @@ import { buildSlShowArgs, createSlStagedError, listSlUntrackedFiles, + listSlUntrackedFilesAsync, resolveSlRepoRoot, + resolveSlRepoRootAsync, runSlText, + runSlTextAsync, } from "./commands"; -import { describeDiffRange } from "../diffRange"; +import { describeDiffRange } from "@hunk/vcs/diff-target"; import { HUNK_VCS_DETECTION_BASELINE_PRIORITY, type ExtensionVcsAdapter, @@ -18,8 +21,9 @@ import { /** * Hunk's Sapling backend, as a bundled extension. * - * Like the Jujutsu one, this file sees only the published contract plus helpers - * owned by this extension directory. + * Like the Jujutsu one, this file sees only the published `hunkdiff/extension` + * contract, explicit `@hunk/vcs` infrastructure leaves, and modules owned by + * this package. */ /** Return the last path segment for review titles. */ @@ -76,20 +80,20 @@ export const SaplingVcsAdapter = { detectionPriority: HUNK_VCS_DETECTION_BASELINE_PRIORITY + 100, operations: { "working-tree-diff": { - async load(input, { cwd }) { + async load(input, { cwd, signal }) { if (input.staged) { throw createSlStagedError(input); } const diffArgs = buildSlDiffArgs(input); - const repoRoot = resolveSlRepoRoot(input, { cwd }); + const repoRoot = await resolveSlRepoRootAsync(input, { cwd, signal }); const repoName = basename(repoRoot); const range = describeDiffRange(input); return { repoRoot, sourceLabel: repoRoot, title: range ? `${repoName} ${range}` : `${repoName} working copy`, - patchText: runSlText({ input, args: diffArgs, cwd }), - untrackedPaths: listSlUntrackedFiles(input, { cwd, repoRoot }), + patchText: await runSlTextAsync({ input, args: diffArgs, cwd, signal }), + untrackedPaths: await listSlUntrackedFilesAsync(input, { cwd, repoRoot, signal }), }; }, watchSignature(input, { cwd }) { @@ -102,15 +106,15 @@ export const SaplingVcsAdapter = { }, }, "revision-show": { - async load(input, { cwd }) { - const repoRoot = resolveSlRepoRoot(input, { cwd }); + async load(input, { cwd, signal }) { + const repoRoot = await resolveSlRepoRootAsync(input, { cwd, signal }); const repoName = basename(repoRoot); const revset = input.ref ?? "."; return { repoRoot, sourceLabel: repoRoot, title: `${repoName} show ${revset}`, - patchText: runSlText({ input, args: buildSlShowArgs(input), cwd }), + patchText: await runSlTextAsync({ input, args: buildSlShowArgs(input), cwd, signal }), }; }, watchSignature(input, { cwd }) { diff --git a/packages/hunk-vcs/README.md b/packages/hunk-vcs/README.md new file mode 100644 index 000000000..f3f4a077a --- /dev/null +++ b/packages/hunk-vcs/README.md @@ -0,0 +1,20 @@ +# @hunk/vcs + +Private implementation helpers shared by Hunk's bundled VCS providers. This `0.0.0` workspace is +not a public or versioned SDK. + +The package has no root export. Providers import only the leaf they need: + +- `@hunk/vcs/async-process` +- `@hunk/vcs/diff-target` +- `@hunk/vcs/large-file` +- `@hunk/vcs/path` +- `@hunk/vcs/source` + +`@hunk/vcs` sits at the bottom of the workspace graph and does not import other workspace packages. +Provider implementations combine these helpers with the public `hunkdiff/extension` contract; Hunk +registers them in `packages/hunk/src/extensions/default/vcs/index.ts` and composes the catalog in +`packages/hunk/src/app/vcsCatalog.ts`. + +Keep unit tests beside the source. Run `bun test packages/hunk-vcs` and `bun run deps:check` after +changing this package. diff --git a/packages/hunk-vcs/package.json b/packages/hunk-vcs/package.json new file mode 100644 index 000000000..619494cd9 --- /dev/null +++ b/packages/hunk-vcs/package.json @@ -0,0 +1,38 @@ +{ + "name": "@hunk/vcs", + "version": "0.0.0", + "private": true, + "description": "Shared implementation infrastructure for Hunk VCS backends.", + "license": "MIT", + "files": [ + "src" + ], + "type": "module", + "sideEffects": false, + "exports": { + "./async-process": { + "types": "./src/async-process.ts", + "import": "./src/async-process.ts" + }, + "./diff-target": { + "types": "./src/diff-target.ts", + "import": "./src/diff-target.ts" + }, + "./large-file": { + "types": "./src/large-file.ts", + "import": "./src/large-file.ts" + }, + "./path": { + "types": "./src/path.ts", + "import": "./src/path.ts" + }, + "./source": { + "types": "./src/source.ts", + "import": "./src/source.ts" + } + }, + "engines": { + "bun": ">=1.3.14", + "node": ">=22" + } +} diff --git a/packages/hunk-vcs/src/async-process.test.ts b/packages/hunk-vcs/src/async-process.test.ts new file mode 100644 index 000000000..526bb8ce2 --- /dev/null +++ b/packages/hunk-vcs/src/async-process.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test"; +import { runAbortableCommand } from "./async-process"; + +describe("abortable bundled VCS subprocesses", () => { + test("does not spawn after cancellation already won", async () => { + const abort = new AbortController(); + abort.abort(new Error("cancelled before spawn")); + await expect( + runAbortableCommand([process.execPath, "-e", "process.exit(99)"], { + cwd: process.cwd(), + signal: abort.signal, + }), + ).rejects.toThrow("cancelled before spawn"); + }); + + test("terminates, escalates, and reaps a command that ignores graceful cancellation", async () => { + const abort = new AbortController(); + const startedAt = Date.now(); + const pending = runAbortableCommand( + [ + process.execPath, + "-e", + 'process.on("SIGTERM",()=>{}); process.stdout.write("started\\n"); setTimeout(()=>process.stdout.write("late\\n"),5000)', + ], + { cwd: process.cwd(), signal: abort.signal, terminationGraceMs: 25 }, + ); + setTimeout(() => abort.abort(new Error("provider cancelled")), 20); + await expect(pending).rejects.toThrow("provider cancelled"); + expect(Date.now() - startedAt).toBeLessThan(2_000); + }); + + 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")'], + { cwd: process.cwd() }, + ); + expect(result).toEqual({ stdout: "ok", stderr: "note", exitCode: 0 }); + }); +}); diff --git a/packages/hunk-vcs/src/async-process.ts b/packages/hunk-vcs/src/async-process.ts new file mode 100644 index 000000000..6dce708f9 --- /dev/null +++ b/packages/hunk-vcs/src/async-process.ts @@ -0,0 +1,98 @@ +const DEFAULT_TERMINATION_GRACE_MS = 250; + +export interface AsyncCommandResult { + stdout: string; + stderr: string; + exitCode: number; +} + +/** Run one provider command without blocking renderer input and reap it after cancellation. */ +export async function runAbortableCommand( + command: string[], + { + cwd, + env, + signal, + terminationGraceMs = DEFAULT_TERMINATION_GRACE_MS, + }: { + cwd: string; + env?: Record; + signal?: AbortSignal; + terminationGraceMs?: number; + }, +): Promise { + signal?.throwIfAborted(); + const ownsProcessGroup = process.platform !== "win32"; + const proc = Bun.spawn(command, { + cwd, + env, + detached: ownsProcessGroup, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + + let killTimer: ReturnType | undefined; + let terminating = false; + const treeTerminationTasks: Promise[] = []; + const kill = (signal: "SIGTERM" | "SIGKILL") => { + if (ownsProcessGroup) { + try { + process.kill(-proc.pid, signal); + return; + } catch { + // Fall back when the child exited before its process group was signalled. + } + } + if (process.platform === "win32") { + // Bun cannot signal a Windows process group. taskkill owns the complete descendant + // tree so helpers that inherited our pipes cannot keep stream collection pending. + const task = Bun.spawn( + ["taskkill", "/pid", String(proc.pid), "/t", ...(signal === "SIGKILL" ? ["/f"] : [])], + { stdin: "ignore", stdout: "ignore", stderr: "ignore" }, + ); + treeTerminationTasks.push(task.exited.catch(() => undefined)); + return; + } + proc.kill(signal); + }; + const abort = () => { + if (terminating) return; + terminating = true; + try { + kill("SIGTERM"); + } catch { + // The process may already have exited between the abort and this handler. + } + killTimer = setTimeout(() => { + try { + kill("SIGKILL"); + } catch { + // Reaping below remains authoritative when the process already exited. + } + }, terminationGraceMs); + killTimer.unref?.(); + }; + signal?.addEventListener("abort", abort, { once: true }); + // Close the race between the pre-spawn check and listener registration. + if (signal?.aborted) abort(); + + try { + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + signal?.throwIfAborted(); + return { stdout, stderr, exitCode }; + } catch (error) { + if (signal?.aborted) signal.throwIfAborted(); + throw error; + } finally { + signal?.removeEventListener("abort", abort); + 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]); + } +} diff --git a/src/extensions/default/vcs/diffRange.ts b/packages/hunk-vcs/src/diff-target.ts similarity index 65% rename from src/extensions/default/vcs/diffRange.ts rename to packages/hunk-vcs/src/diff-target.ts index 78fdfb1c6..351351064 100644 --- a/src/extensions/default/vcs/diffRange.ts +++ b/packages/hunk-vcs/src/diff-target.ts @@ -1,4 +1,11 @@ -import type { ExtensionVcsDiffInput } from "hunkdiff/extension"; +/** The revision fields needed to describe a provider's diff target. */ +interface DiffTargetInput { + readonly range?: string; + readonly rangeEndpoints?: { + readonly from: string; + readonly to: string; + }; +} /** * Describe a diff's comparison in compact range form for titles and Git arguments. @@ -7,18 +14,18 @@ import type { ExtensionVcsDiffInput } from "hunkdiff/extension"; * revset rather than Git's direct two-tree comparison, so they must build process * arguments from `rangeEndpoints` instead. */ -export function describeDiffRange(input: ExtensionVcsDiffInput) { +export function describeDiffRange(input: DiffTargetInput) { const endpoints = input.rangeEndpoints; return endpoints ? `${endpoints.from}..${endpoints.to}` : input.range; } /** Describe the targets using the positional spelling the user supplied. */ -export function describeDiffTargets(input: ExtensionVcsDiffInput) { +export function describeDiffTargets(input: DiffTargetInput) { const endpoints = input.rangeEndpoints; return endpoints ? `${endpoints.from} ${endpoints.to}` : input.range; } /** Return whether a VCS diff has any explicit revision or range target. */ -export function hasExplicitDiffTarget(input: ExtensionVcsDiffInput) { +export function hasExplicitDiffTarget(input: DiffTargetInput) { return describeDiffRange(input) !== undefined; } diff --git a/src/lib/largeFile.ts b/packages/hunk-vcs/src/large-file.ts similarity index 100% rename from src/lib/largeFile.ts rename to packages/hunk-vcs/src/large-file.ts diff --git a/src/lib/osPath.test.ts b/packages/hunk-vcs/src/path.test.ts similarity index 96% rename from src/lib/osPath.test.ts rename to packages/hunk-vcs/src/path.test.ts index cbfe4b561..4ecd79f36 100644 --- a/src/lib/osPath.test.ts +++ b/packages/hunk-vcs/src/path.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { normalizePathForOS } from "./osPath"; +import { normalizePathForOS } from "./path"; describe("normalizePathForOS", () => { test("normalizes Unix-style Windows paths for native subprocess cwd", () => { diff --git a/src/lib/osPath.ts b/packages/hunk-vcs/src/path.ts similarity index 100% rename from src/lib/osPath.ts rename to packages/hunk-vcs/src/path.ts diff --git a/src/lib/sourceText.test.ts b/packages/hunk-vcs/src/source.test.ts similarity index 99% rename from src/lib/sourceText.test.ts rename to packages/hunk-vcs/src/source.test.ts index 6e3ae939f..ff65a5702 100644 --- a/src/lib/sourceText.test.ts +++ b/packages/hunk-vcs/src/source.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { readFileTextWithLimit, terminateSourceSubprocess } from "./sourceText"; +import { readFileTextWithLimit, terminateSourceSubprocess } from "./source"; const tempDirs: string[] = []; diff --git a/src/lib/sourceText.ts b/packages/hunk-vcs/src/source.ts similarity index 100% rename from src/lib/sourceText.ts rename to packages/hunk-vcs/src/source.ts diff --git a/packages/hunk/LICENSE b/packages/hunk/LICENSE new file mode 100644 index 000000000..21f202055 --- /dev/null +++ b/packages/hunk/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Modem Labs Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/hunk/README.md b/packages/hunk/README.md new file mode 100644 index 000000000..151610466 --- /dev/null +++ b/packages/hunk/README.md @@ -0,0 +1,324 @@ +# hunk + +Hunk is a review-first terminal diff viewer for agent-authored changesets, built on [OpenTUI](https://github.com/anomalyco/opentui) and [Pierre diffs](https://www.npmjs.com/package/@pierre/diffs). + +**[hunk.dev](https://hunk.dev)** · [Documentation](https://hunk.dev/docs/) + +[![CI status](https://img.shields.io/github/actions/workflow/status/modem-dev/hunk/ci.yml?branch=main&style=for-the-badge&label=CI)](https://github.com/modem-dev/hunk/actions/workflows/ci.yml?branch=main) +[![Latest release](https://img.shields.io/github/v/release/modem-dev/hunk?style=for-the-badge)](https://github.com/modem-dev/hunk/releases) +[![MIT License](https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge)](LICENSE) +[![Join the Discord community](https://img.shields.io/badge/Discord-Join%20community-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/WZFjaP6Gt8) + +- multi-file review stream with sidebar navigation +- inline AI and agent annotations beside the code +- split, stack, and responsive auto layouts +- watch mode for auto-reloading file and Git-backed reviews +- keyboard, mouse, pager, and Git difftool support + + + + + + +
+ image +
+ Split view with sidebar and inline AI notes +
+ image +
+ Stacked view and mouse-selectable menus +
+ +## Install + +The default installation method on macOS and Linux downloads a standalone binary and installs it into `~/.hunk`. It checks the archive against the release checksum when both `SHA256SUMS` and a supported checksum tool are available, and warns otherwise: + +```bash +curl -fsSL https://hunk.dev/install.sh | sh +``` + +Windows users can install with npm or mise. Other installation methods are also available: + +```bash +npm i -g hunkdiff # macOS, Linux, or Windows; requires Node.js 22+ +brew install hunk # macOS or Linux +mise use -g hunk # macOS, Linux, or Windows +``` + +> [!NOTE] +> If you previously installed hunk via `modem-dev/tap`, be sure to uninstall it first with `brew uninstall modem-dev/tap/hunk`. + +Windows requires mise 2026.8.6 or newer. Nix users can use the `default` package exported in `flake.nix`; see [the Nix guide](https://github.com/modem-dev/hunk/blob/main/nix/README.md) for details. Hunk also ships as a default tool in [Omarchy](https://omarchy.org), installed through mise. + +Requirements: + +- macOS, Linux, or Windows +- On x86-64, a CPU with SSE4.2 (Intel Nehalem 2008+, AMD Bulldozer 2011+); arm64 has no CPU feature floor +- Node.js 22+ for the npm install; the install script, Homebrew, mise, and Nix ship a standalone binary that does not require Node.js +- Git recommended for most workflows + +### Update Hunk + +Starting with Hunk 0.20, npm, Homebrew, and default install-script installs use Hunk’s canonical update command: + +```bash +hunk update # install the newest release +hunk update --check # check without installing +hunk update 0.20.0 # select an exact npm or default install-script release +``` + +On an older release, update once with the installer or package manager that installed Hunk, then use `hunk update` going forward. Custom `HUNK_INSTALL_DIR` installs must re-run the installer with the same directory; mise, Nix, and source installs use their owning tools instead. + +## Quick start + +```bash +hunk # show help +hunk --version # print the installed version +``` + +### Working with Git + +Hunk mirrors Git's diff-style commands, but opens the changeset in a review UI instead of plain text. + +```bash +hunk diff # review current repo changes, including untracked files +hunk --fast # experimentally offload eligible syntax highlighting +hunk diff --watch # auto-reload as the working tree changes +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 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 +owns traversal, filtering, refs, and how a history item opens for review; the bundled Git and +Jujutsu adapters both implement that public capability. Static output keeps full commit, author, +date, message, branch/bookmark, remote, and tag details; `--oneline` provides compact records, and +`--theme` uses the same palette as Hunk review. Interactive history groups commits by local-calendar +day with account-like author handles and relative times, while keeping commit ids right-aligned and +clickable. Enable **Graph view** from the View menu to replace day groups with commit-topology lanes. +After opening a commit, quit its normal Hunk review to return to the same selection. + +### Working with Jujutsu and Sapling + +Hunk auto-detects Jujutsu and Sapling checkouts, so `hunk diff [revset]` and `hunk show [revset]` use native revsets inside jj or Sapling workspaces. `hunk log --vcs jj` also reads JJ history directly, including in a non-colocated workspace. To override VCS detection, set `vcs = "git"` or `vcs = "jj"` or `vcs = "sl"` in [config](#config). + +### Working with raw files and patches + +```bash +hunk diff --files before.ts after.ts # compare two files directly +hunk diff --files before.ts after.ts --watch # auto-reload when either file changes +git diff --no-color | hunk patch - # review a patch from stdin +``` + +Watch mode remains continuous. Direct-file and Git-backed reviews normally use filesystem observation to refresh promptly, with periodic polling retained as a fallback for missed events or unavailable watchers. Jujutsu and Sapling reviews currently use polling rather than filesystem observation. + +### Working with agents + +1. Open Hunk in another terminal with `hunk diff` or `hunk show`. +2. Tell your agent to add the skill file returned by `hunk skill path`. +3. Ask your agent to use the skill against the live Hunk session. + +A good generic prompt is: + +```text +Load the Hunk skill and use it for this review. Run `hunk skill path` to get the skill path. +``` + +For the full live-session and `--agent-context` workflow guide, see [the agent workflow guide](https://github.com/modem-dev/hunk/blob/main/docs/agent-workflows.md). Experimental rich STML note bodies require starting the review with `--experimental`; plain agent notes remain the default. + +## Feature comparison + +| Capability | [hunk](https://github.com/modem-dev/hunk) | [lumen](https://github.com/jnsahaj/lumen) | [difftastic](https://github.com/Wilfred/difftastic) | [delta](https://github.com/dandavison/delta) | [diff-so-fancy](https://github.com/so-fancy/diff-so-fancy) | [diff](https://www.gnu.org/software/diffutils/) | +| ---------------------------------- | ----------------------------------------- | ----------------------------------------- | --------------------------------------------------- | -------------------------------------------- | ---------------------------------------------------------- | ----------------------------------------------- | +| Review-first interactive UI | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | +| Multi-file review stream + sidebar | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | +| Inline agent / AI annotations | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | +| Responsive auto split/stack layout | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | +| Mouse support inside the viewer | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | +| Runtime view toggles | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | +| Syntax highlighting | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | +| Structural diffing | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | +| Pager-compatible mode | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | + +Hunk is optimized for reviewing a full changeset interactively. + +## Advanced + +### Config + +You can persist preferences to a config file: + +- `~/.config/hunk/config.toml` +- `.hunk/config.toml` + +Example: + +```toml +theme = "github-dark-default" # any built-in theme id, auto, or custom +mode = "auto" # auto, split, stack +vcs = "git" # git, jj, sl +watch = false +exclude_untracked = false +line_numbers = true +tab_width = 4 # tab stops, 1-16 +file_gap = 1 # rows between files, including the ─ rule; 0 hides it +hunk_gap = 0 # blank rows before later hunks +wrap_lines = false +menu_bar = true +sidebar = "auto" # "auto", true, false +agent_notes = false +prompt_save_view_preferences = true +transparent_background = false +``` + +Choose a built-in theme, `auto`, or a custom theme with `theme`. See +[the theme guide](https://hunk.dev/docs/configure/themes/) for automatic selection, custom theme tables, +syntax scopes, and legacy syntax-table migration. + +`exclude_untracked` affects Git/Sapling working-tree `hunk diff` sessions only. +`tab_width` controls source-code tab stops and can be overridden with `-x4` or `--tab-width 4`. +`file_gap` is separator height between files, including the `─` rule; `hunk_gap` is blank rows before later hunks. +`prompt_save_view_preferences = false` disables the quit prompt for saving changed view preferences. +`transparent_background` can also be written as `transparentBackground`. + +### Keybindings + +Every keyboard shortcut is a named command, and a `[keybindings]` table in your +user config remaps command ids to the keys you want them on — several keys per +command, exclusive claims over defaults, and `false` to unbind. See +[the keybinding guide](https://hunk.dev/docs/configure/keybindings/) for the rules, the chord grammar, +and the full table of built-in commands and their default keys. + +### Git integration + +Set Hunk as your Git pager so `git diff` and `git show` open in Hunk automatically: + +> [!NOTE] +> Untracked files are auto-included only for Hunk's own `hunk diff` working-tree loader. If you open `git diff` through `hunk pager`, Git still decides the patch contents, so untracked files will not appear there. + +```bash +git config --global core.pager "hunk pager" +``` + +Or in your Git config: + +```ini +[core] + pager = hunk pager +``` + +If you want to keep Git's default pager and add opt-in aliases instead: + +```bash +git config --global alias.hdiff "-c core.pager=\"hunk pager\" diff" +git config --global alias.hshow "-c core.pager=\"hunk pager\" show" +``` + +### Jujutsu pager integration + +To use Hunk as jj's pager, run `jj config edit --user` and update: + +```toml +[ui] +pager = ["hunk", "pager"] +diff-formatter = ":git" +``` + +### Sapling pager integration + +To use Hunk as Sapling's pager, run `sl config -u` and update: + +```ini +[pager] +pager = hunk pager +``` + +### Extensions (experimental) + +The extension API is experimental and may change in breaking ways between +minor releases while it stabilizes; breaking changes are called out in +release notes. + +Hunk loads plain TypeScript extensions from `~/.config/hunk/extensions/`, from a +repository's `.hunk/extensions/` (after you explicitly trust that repository), +and from `--extension ` for development. `--no-extensions` turns those off +for one run; Hunk's own bundled backends (Git, Jujutsu, and Sapling) stay loaded. + +An extension can add generic top-level CLI workflows, contribute themes and +file-extension → language mappings, add a VCS backend, rewrite the changeset +before review (collapse lockfiles, reorder files by review priority), replace +the file-navigation sidebar with its own React component, react to lifecycle +events, and show transient messages: + +```ts +// ~/.config/hunk/extensions/collapse-lockfiles.ts +import type { HunkExtensionAPI } from "hunkdiff/extension"; + +export default function (hunk: HunkExtensionAPI) { + hunk.transformChangeset((changeset, ctx) => { + const files = changeset.files.filter((file) => !file.path.endsWith(".lock")); + ctx.notify(`Collapsed ${changeset.files.length - files.length} lockfiles`); + return { ...changeset, files }; + }); +} +``` + +Extensions shared as git repositories install straight from their host, and a +`hunk-extension` GitHub topic marks community ones: + +```bash +hunk extension install acme/hunk-word-diff@v1.2.0 # or git:host/path, a URL, a local path +hunk extension list # then update [name] / remove +``` + +Browse community extensions at +[github.com/topics/hunk-extension](https://github.com/topics/hunk-extension); +publish yours by pushing the extension to a repository root and adding that +topic. + +See [the extension guide](https://hunk.dev/docs/extend/extensions/) for the full API, the trust model, +publishing guidance, and the `[extensions]` / `[extension.]` config reference. +Installable examples include a dependency-free +[`hunk gh 123` GitHub PR workflow](https://github.com/modem-dev/hunk/tree/main/examples/extensions/github-pr), +[review triage](https://github.com/modem-dev/hunk/tree/main/examples/extensions/review-triage), +[authoritative review snapshot export](https://github.com/modem-dev/hunk/tree/main/examples/extensions/review-snapshot-export), an optional +[rendered Markdown file view](https://github.com/modem-dev/hunk/tree/main/examples/extensions/rendered-markdown), and a +[Vim navigation mode](https://github.com/modem-dev/hunk/tree/main/examples/extensions/vim-navigation) built from public semantic commands. + +### OpenTUI component + +Hunk also publishes `HunkDiffView` and lower-level primitives from `hunkdiff/opentui` for embedding the same diff renderer in your own OpenTUI app. + +See [the OpenTUI component guide](https://hunk.dev/docs/reference/opentui-components/) for install, API, and runnable examples. + +## Examples + +Ready-to-run demo diffs live in [the examples directory](https://github.com/modem-dev/hunk/tree/main/examples). + +Each example includes the exact command to run from the repository root. + +## Contributing + +💬 _Chat with users/contributors on the [Modem Discord server](https://discord.gg/WZFjaP6Gt8)_ + +For source setup, tests, packaging checks, and repo architecture, see [the contribution guide](https://github.com/modem-dev/hunk/blob/main/CONTRIBUTING.md). + +## Sponsor + +Sponsored by [Modem](https://modem.dev?utm_source=github&utm_medium=oss&utm_campaign=oss_hunk&utm_content=readme_footer). + + + + + + Modem + + + +## License + +[MIT](https://github.com/modem-dev/hunk/blob/main/LICENSE) diff --git a/bin/hunk.cjs b/packages/hunk/bin/hunk.cjs similarity index 100% rename from bin/hunk.cjs rename to packages/hunk/bin/hunk.cjs diff --git a/packages/hunk/package.json b/packages/hunk/package.json new file mode 100644 index 000000000..1e443e1ec --- /dev/null +++ b/packages/hunk/package.json @@ -0,0 +1,92 @@ +{ + "name": "hunkdiff", + "version": "0.21.1", + "description": "Desktop-inspired terminal diff viewer for understanding agent-authored changesets.", + "keywords": [ + "ai", + "code-review", + "diff", + "git", + "terminal", + "tui" + ], + "homepage": "https://hunk.dev", + "bugs": { + "url": "https://github.com/modem-dev/hunk/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/modem-dev/hunk.git" + }, + "bin": { + "hunk": "./bin/hunk.cjs", + "hunkdiff": "./bin/hunk.cjs" + }, + "files": [ + "bin", + "dist/npm", + "skills/hunk-review", + "skills/hunk-extensions", + "README.md", + "LICENSE" + ], + "type": "module", + "exports": { + "./extension": { + "types": "./dist/npm/extension/index.d.ts", + "import": "./dist/npm/extension/index.js" + }, + "./opentui": { + "types": "./dist/npm/opentui/index.d.ts", + "import": "./dist/npm/opentui/index.js" + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "prepack": "bun run ../../scripts/build/build-npm.ts" + }, + "dependencies": { + "bun": "^1.4.2", + "chokidar": "^4.0.3", + "commander": "^14.0.3", + "diff": "^8.0.3", + "get-east-asian-width": "^1.5.0", + "shell-quote": "1.9.0", + "string-width": "^8.2.1", + "zod": "~4.4.3" + }, + "devDependencies": { + "@opentui/core": "^0.5.6", + "@opentui/react": "^0.5.6", + "@pierre/diffs": "1.3.5", + "@shikijs/themes": "3.23.0", + "@types/bun": "1.4.1", + "@types/react": "^19.2.14", + "react": "^19.2.4", + "typescript": "^5.9.3" + }, + "peerDependencies": { + "@opentui/core": "^0.5.6", + "@opentui/react": "^0.5.6", + "@pierre/diffs": "1.3.5", + "react": "^19.2.4" + }, + "peerDependenciesMeta": { + "@pierre/diffs": { + "optional": true + } + }, + "engines": { + "node": ">=22" + }, + "packageManager": "bun@1.4.2", + "pi": { + "skills": [ + "./skills" + ] + } +} diff --git a/skills/hunk-extensions/SKILL.md b/packages/hunk/skills/hunk-extensions/SKILL.md similarity index 95% rename from skills/hunk-extensions/SKILL.md rename to packages/hunk/skills/hunk-extensions/SKILL.md index f4c8ae713..2dcc21106 100644 --- a/skills/hunk-extensions/SKILL.md +++ b/packages/hunk/skills/hunk-extensions/SKILL.md @@ -24,13 +24,13 @@ material before writing code. ## Sources of truth — read before writing -| Source | What it answers | -| --------------------------------------- | ------------------------------------------------------------ | -| `docs/extensions.md` | The authoring guide. Every call, every rule. Start here. | -| `src/extension-api/types.ts` | The contract — exact field names, optionality, doc comments. | -| `examples/extensions/*` | Working extensions. Copy these patterns rather than invent. | -| `docs/extension-architecture.md` | Hunk's internals. Needed only when changing the host. | -| `docs/keybindings.md`, `docs/themes.md` | Chord grammar and theme token rules that extensions inherit. | +| Source | What it answers | +| ------------------------------------------ | ------------------------------------------------------------ | +| `docs/extensions.md` | The authoring guide. Every call, every rule. Start here. | +| `packages/hunk/src/extension-api/types.ts` | The contract — exact field names, optionality, doc comments. | +| `examples/extensions/*` | Working extensions. Copy these patterns rather than invent. | +| `docs/extension-architecture.md` | Hunk's internals. Needed only when changing the host. | +| `docs/keybindings.md`, `docs/themes.md` | Chord grammar and theme token rules that extensions inherit. | Outside a Hunk checkout the guide is split across (discovery, trust, config) and its @@ -315,12 +315,12 @@ Practical checks, in order of cost: Only when the work is in the `hunk` repo rather than in a user extension: - Shipped VCS backends and the built-in files pane are **bundled extensions** in - `src/extensions/default/`, registering through the same public API. That + `packages/hunk/src/extensions/default/`, registering through the same public API. That dogfooding is deliberate — if the public contract cannot express something, that is a real gap, not a reason for a private path. `default/vcs/` loads from VCS adapter resolution and must stay renderer-free. -- `src/extension-api/types.ts` must stay **import-free**; declaration emission - publishes whatever it reaches, and `scripts/check-pack.ts` fails the pack +- `packages/hunk/src/extension-api/types.ts` must stay **import-free**; declaration emission + publishes whatever it reaches, and `scripts/packaging/check-pack.ts` fails the pack otherwise. Shapes shared with internal code are declared there and re-exported inward. - New API surface means updating `docs/extensions.md` (its examples are diff --git a/skills/hunk-review/SKILL.md b/packages/hunk/skills/hunk-review/SKILL.md similarity index 94% rename from skills/hunk-review/SKILL.md rename to packages/hunk/skills/hunk-review/SKILL.md index 3148599cb..bbf1526c7 100644 --- a/skills/hunk-review/SKILL.md +++ b/packages/hunk/skills/hunk-review/SKILL.md @@ -118,7 +118,7 @@ hunk session reload --session-path /path/to/live-window --source /path/to/other- ### Comments ```bash -hunk session comment add ( | --repo ) --file (--old-line | --new-line ) --summary [--rationale ] [--author ] [--markup ] [--focus] [--json] +hunk session comment add ( | --repo ) (--reply-to | --file (--old-line | --new-line )) --summary [--rationale ] [--author ] [--markup ] [--focus] [--json] hunk session comment apply ( | --repo ) --stdin [--focus] [--json] hunk session comment list ( | --repo ) [--file ] [--type ] [--json] hunk session comment rm ( | --repo ) [--json] @@ -129,13 +129,14 @@ Examples: ```bash hunk session comment add --repo . --file README.md --new-line 103 --summary "Tighten this wording" +hunk session comment add --repo . --reply-to user:123 --summary "Addressed in the latest revision" printf '%s\n' '{"comments":[{"filePath":"README.md","newLine":103,"summary":"Tighten this wording"}]}' | hunk session comment apply --repo . --stdin ``` - `comment list --type user` shows human-authored inline notes; without `--type`, `comment list` preserves the legacy live-agent-comment view - `comment add` is best for one note; `comment apply` is best when an agent already has several notes ready -- `comment add` requires `--file`, `--summary`, and exactly one of `--old-line` or `--new-line` -- `comment apply` payload items require `filePath`, `summary`, and exactly one target such as `hunk`, `hunkNumber`, `oldLine`, or `newLine` +- Root `comment add` notes require `--file`, `--summary`, and exactly one of `--old-line` or `--new-line`; replies use `--reply-to ` with `--summary` and inherit the parent's anchor +- `comment apply` items require `summary` plus either `replyTo` by itself or `filePath` with exactly one target such as `hunk`, `hunkNumber`, `oldLine`, or `newLine` - `comment apply` reads a JSON batch from stdin and validates the full batch before mutating the live session - Pass `--focus` when you want to jump to the new note or the first note in a batch - `comment list` and `comment clear` accept optional `--file` diff --git a/src/app/cli.test.ts b/packages/hunk/src/app/cli.test.ts similarity index 91% rename from src/app/cli.test.ts rename to packages/hunk/src/app/cli.test.ts index dd9333691..20311ffb3 100644 --- a/src/app/cli.test.ts +++ b/packages/hunk/src/app/cli.test.ts @@ -58,6 +58,9 @@ describe("parseCli", () => { expect(parsed.text).toContain("Usage:"); expect(parsed.text).toContain("hunk diff"); expect(parsed.text).toContain("hunk show"); + expect(parsed.text).toContain("hunk log"); + expect(parsed.text).toContain("attractive repository history"); + expect(parsed.text).not.toContain("Git commit history"); expect(parsed.text).toContain("hunk skill path"); expect(parsed.text).toContain("Global options:"); expect(parsed.text).toContain("Common review options:"); @@ -488,6 +491,109 @@ describe("parseCli", () => { }); }); + test("parses automatic and forced-static log options without review flags", async () => { + expect( + await parseCli([ + "bun", + "hunk", + "log", + "main..feature", + "--first-parent", + "-n", + "25", + "--author", + "Ada", + "--color", + "never", + "--ascii", + "--interactive", + "--", + "src/app.ts", + ]), + ).toEqual({ + kind: "history", + revision: "main..feature", + firstParent: true, + maxCount: 25, + author: "Ada", + pathspecs: ["src/app.ts"], + color: "never", + format: "medium", + ascii: true, + static: false, + extensionsEnabled: true, + extensionPaths: [], + }); + expect(await parseCli(["bun", "hunk", "log", "--static"])).toMatchObject({ + kind: "history", + static: true, + }); + }); + + test("parses compact aliases, themes, and command-local extension disabling", async () => { + expect( + await parseCli(["bun", "hunk", "log", "--oneline", "--theme", "nord", "--no-extensions"]), + ).toMatchObject({ + kind: "history", + format: "compact", + theme: "nord", + extensionsEnabled: false, + }); + expect(await parseCli(["bun", "hunk", "--no-extensions", "log"])).toMatchObject({ + kind: "history", + extensionsEnabled: false, + }); + }); + + test("rejects unsupported log flags and conflicting traversal starts", async () => { + await expect(parseCli(["bun", "hunk", "log", "--pretty=raw"])).rejects.toThrow( + "unknown option", + ); + await expect(parseCli(["bun", "hunk", "log", "HEAD", "--all"])).rejects.toThrow( + "either a revision/range or --all", + ); + await expect(parseCli(["bun", "hunk", "--fast", "log"])).rejects.toThrow("review command"); + }); + + test("preserves opaque provider-planned history reviews through the private handoff", async () => { + const encoded = (value: unknown) => + Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); + expect( + await parseCli([ + "bun", + "hunk", + "diff", + "--history-review", + encoded({ + kind: "revision-range", + fromRevisionId: "-opaque:parent/α", + toRevisionId: "opaque:commit/β", + }), + "--vcs", + "-custom", + ]), + ).toMatchObject({ + kind: "vcs", + rangeEndpoints: { from: "-opaque:parent/α", to: "opaque:commit/β" }, + options: { vcs: "-custom" }, + }); + expect( + await parseCli([ + "bun", + "hunk", + "show", + "--history-review", + encoded({ kind: "revision-show", revisionId: "-opaque:root/γ" }), + "--vcs", + "demo", + ]), + ).toMatchObject({ + kind: "show", + ref: "-opaque:root/γ", + options: { vcs: "demo" }, + }); + }); + test("parses show mode with optional ref and pathspecs", async () => { const parsed = await parseCli(["bun", "hunk", "show", "HEAD~1", "--", "src/app.ts"]); @@ -867,6 +973,85 @@ describe("parseCli", () => { }); }); + test("parses a reply-only session comment add", async () => { + const parsed = await parseCli([ + "bun", + "hunk", + "session", + "comment", + "add", + "session-1", + "--reply-to", + "user:parent", + "--summary", + "Addressed in the latest revision", + "--json", + ]); + + expect(parsed).toEqual({ + kind: "session", + action: "comment-add", + selector: { sessionId: "session-1" }, + replyTo: "user:parent", + summary: "Addressed in the latest revision", + reveal: false, + output: "json", + }); + }); + + test("rejects mixing a reply target with root comment fields", async () => { + await expect( + parseCli([ + "bun", + "hunk", + "session", + "comment", + "add", + "session-1", + "--reply-to", + "user:parent", + "--file", + "README.md", + "--new-line", + "7", + "--summary", + "mixed", + ]), + ).rejects.toThrow("Do not mix --reply-to with --file, --old-line, or --new-line."); + }); + + test("rejects an empty reply target", async () => { + await expect( + parseCli([ + "bun", + "hunk", + "session", + "comment", + "add", + "session-1", + "--reply-to", + "", + "--summary", + "empty target", + ]), + ).rejects.toThrow("--reply-to requires a non-empty note id."); + }); + + test("rejects comment add without a root or reply target", async () => { + await expect( + parseCli([ + "bun", + "hunk", + "session", + "comment", + "add", + "session-1", + "--summary", + "missing target", + ]), + ).rejects.toThrow("Root comments require --file ."); + }); + test("parses markup render with defaults and options", async () => { expect(await parseCli(["bun", "hunk", "markup", "render"])).toEqual({ kind: "markup-render", @@ -1655,6 +1840,10 @@ describe("parseCli command help text", () => { expect(await expectHelp(["patch", "--help"])).toContain("review a patch file"); expect(await expectHelp(["pager", "--help"])).toContain("general Git pager wrapper"); expect(await expectHelp(["difftool", "--help"])).toContain("review Git difftool file pairs"); + const logHelp = await expectHelp(["log", "--help"]); + expect(logHelp).toContain("browse an attractive repository history"); + expect(logHelp).toContain("--static"); + expect(logHelp).not.toContain("--interactive"); }); test("renders the stash command overview and the stash show command help", async () => { @@ -2079,6 +2268,45 @@ describe("parseCli session comment apply payload", () => { }); }); + test("parses reply-only batch items", async () => { + const parsed = await applyWithPayload( + JSON.stringify({ + comments: [ + { replyTo: "user:parent", summary: "Reply", rationale: "Details", author: "Pi" }, + ], + }), + ); + + expect(parsed).toMatchObject({ + kind: "session", + action: "comment-apply", + comments: [ + { + replyTo: "user:parent", + summary: "Reply", + rationale: "Details", + author: "Pi", + }, + ], + }); + }); + + test("rejects reply batch items mixed with root fields", async () => { + await expect( + applyWithPayload( + JSON.stringify({ + comments: [{ replyTo: "user:parent", filePath: "a.ts", newLine: 1, summary: "mixed" }], + }), + ), + ).rejects.toThrow("must not mix `replyTo` with `filePath` or an explicit target"); + }); + + test("rejects an invalid replyTo batch field", async () => { + await expect( + applyWithPayload(JSON.stringify({ comments: [{ replyTo: "", summary: "empty" }] })), + ).rejects.toThrow("field `replyTo` must be a non-empty string"); + }); + test("rejects an empty stdin payload", async () => { await expect(applyWithPayload(" ")).rejects.toThrow( "Session comment apply expected one JSON object on stdin.", diff --git a/src/app/cli.ts b/packages/hunk/src/app/cli.ts similarity index 86% rename from src/app/cli.ts rename to packages/hunk/src/app/cli.ts index 4c6d4483b..ca8197705 100644 --- a/src/app/cli.ts +++ b/packages/hunk/src/app/cli.ts @@ -55,6 +55,7 @@ import { import { DEFAULT_FILE_GAP, DEFAULT_HUNK_GAP, parseReviewGap } from "../core/run/reviewGap"; import { DEFAULT_TAB_WIDTH, parseTabWidth } from "../core/run/tabWidth"; import { resolveCliVersion } from "../core/run/version"; +import type { ExtensionVcsHistoryReviewAction } from "../extension-api/types"; /** Structured option metadata shared by Commander registration and generated CLI docs. */ export interface CliReferenceOption { @@ -64,6 +65,7 @@ export interface CliReferenceOption { | "layout" | "cursorLine" | "positiveInt" + | "nonNegativeInt" | "tabWidth" | "fileGap" | "hunkGap" @@ -72,6 +74,10 @@ export interface CliReferenceOption { /** Default applied directly by Commander (as opposed to a config-resolved default). */ readonly commanderDefault?: string; readonly hidden?: boolean; + /** Include this option in released website documentation; defaults to true. */ + readonly publicDocs?: boolean; + /** Additional generated-documentation context for a hidden option. */ + readonly hiddenNote?: string; } /** Structured command metadata used by runtime parsers and generated CLI docs. */ @@ -80,6 +86,8 @@ export interface CliReferenceCommand { readonly summary: string; readonly synopsis: readonly string[]; readonly aliases?: readonly string[]; + /** Include this command in released website documentation; defaults to true. */ + readonly publicDocs?: boolean; /** Additional prose rendered after this command's generated usage block. */ readonly details?: readonly string[]; readonly options?: readonly CliReferenceOption[]; @@ -96,6 +104,12 @@ export const COMMON_REVIEW_OPTIONS = [ parse: "cursorLine", }, { flag: "--theme ", description: "named theme override" }, + { + flag: "--vcs ", + description: "select a VCS provider", + hidden: true, + publicDocs: false, + }, AUXILIARY_AGENT_OPTIONS.agentContext, { flag: "--pager", description: "use pager-style chrome" }, AUXILIARY_AGENT_OPTIONS.experimental, @@ -159,6 +173,7 @@ const DIFF_OPTIONS = [ flag: `--no-${AUXILIARY_AGENT_OPTIONS.excludeUntracked.flag.slice(2)}`, description: "include untracked files in working tree reviews", hidden: true, + hiddenNote: "Compatibility inverse; omitted from `--help`.", }, ] as const satisfies readonly CliReferenceOption[]; @@ -188,6 +203,52 @@ export const CLI_REFERENCE_COMMANDS = { commonReviewOptions: true, watch: true, }, + log: { + path: "log", + // Release preparation enables this when an installable build contains history browsing. + publicDocs: false, + summary: "browse an attractive repository history", + synopsis: ["hunk log [revision-expression] [-- ]"], + details: [ + "A terminal opens the responsive browser; pipes and redirects receive static output.", + "Use --static to force static output, paging when it exceeds the terminal.", + "The selected VCS provider defines revision, filtering, and review semantics.", + ], + options: [ + { flag: "--all", description: "include history from every provider-visible head" }, + { flag: "--first-parent", description: "follow only the first parent of merge commits" }, + { + flag: "-n, --max-count ", + description: "stop after this many commits", + parse: "nonNegativeInt", + }, + { flag: "--author ", description: "limit commits by author" }, + { flag: "--grep ", description: "limit commits by subject or message" }, + { flag: "--since ", description: "show commits newer than a provider date" }, + { flag: "--until ", description: "show commits older than a provider date" }, + { + flag: "--color ", + description: "color output: auto, always, never", + commanderDefault: "auto", + }, + { + flag: "--format ", + description: "static record format: medium or compact", + commanderDefault: "medium", + }, + { flag: "--oneline", description: "alias for static --format compact" }, + { flag: "--theme ", description: "use the same theme as Hunk review" }, + { flag: "--ascii", description: "use an ASCII graph" }, + { flag: "--static", description: "print static output, paging when needed" }, + { flag: "--vcs ", description: "select a VCS history provider" }, + { + flag: "--extension ", + description: "load an extension entry file or directory (repeatable)", + parse: "collect", + }, + { flag: "--no-extensions", description: "disable user extensions for this run" }, + ], + }, "stash-show": { path: "stash show", summary: "review a stash entry as a full Hunk changeset", @@ -379,6 +440,7 @@ function buildCommonOptions( mode?: LayoutMode; cursorLine?: CursorLine; theme?: string; + vcs?: string; agentContext?: string; pager?: boolean; watch?: boolean; @@ -396,6 +458,7 @@ function buildCommonOptions( mode: options.mode, cursorLine: options.cursorLine, theme: options.theme, + vcs: options.vcs, agentContext: options.agentContext, pager: options.pager ? true : undefined, watch: options.watch ? true : undefined, @@ -435,6 +498,8 @@ function applyReferenceOption(command: Command, option: CliReferenceOption) { commanderOption.argParser(parseCursorLine); } else if (option.parse === "positiveInt") { commanderOption.argParser(parsePositiveInt); + } else if (option.parse === "nonNegativeInt") { + commanderOption.argParser(parseNonNegativeInt); } else if (option.parse === "tabWidth") { commanderOption.argParser(parseTabWidth); } else if (option.parse === "fileGap") { @@ -512,6 +577,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 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", " hunk pager general Git pager wrapper with diff detection", @@ -645,9 +711,9 @@ function parseSessionCommentApplyPayload(raw: string): SessionCommentApplyItemIn } const item = comment as Record; - const filePath = item.filePath; - if (typeof filePath !== "string" || filePath.length === 0) { - throw new Error(`Comment ${itemNumber} requires a non-empty \`filePath\`.`); + const replyTo = item.replyTo; + if (replyTo !== undefined && (typeof replyTo !== "string" || replyTo.length === 0)) { + throw new Error(`Comment ${itemNumber} field \`replyTo\` must be a non-empty string.`); } const summary = item.summary; @@ -664,28 +730,53 @@ function parseSessionCommentApplyPayload(raw: string): SessionCommentApplyItemIn const oldLine = parsePositiveJsonInt(item.oldLine, { field: "oldLine", itemNumber }); const newLine = parsePositiveJsonInt(item.newLine, { field: "newLine", itemNumber }); const resolvedHunkNumber = hunk ?? hunkNumber; + const filePath = item.filePath; + const hasExplicitRootFields = + filePath !== undefined || + resolvedHunkNumber !== undefined || + oldLine !== undefined || + newLine !== undefined; - const selectors = [ - resolvedHunkNumber !== undefined, - oldLine !== undefined, - newLine !== undefined, - ].filter(Boolean); - if (selectors.length !== 1) { + if (replyTo !== undefined && hasExplicitRootFields) { throw new Error( - `Comment ${itemNumber} must specify exactly one of \`hunk\`, \`hunkNumber\`, \`oldLine\`, or \`newLine\`.`, + `Comment ${itemNumber} must not mix \`replyTo\` with \`filePath\` or an explicit target.`, ); } - return { - filePath, - hunkNumber: resolvedHunkNumber, - side: oldLine !== undefined ? "old" : newLine !== undefined ? "new" : undefined, - line: oldLine ?? newLine, + if (replyTo === undefined) { + if (typeof filePath !== "string" || filePath.length === 0) { + throw new Error(`Comment ${itemNumber} requires a non-empty \`filePath\`.`); + } + const selectors = [ + resolvedHunkNumber !== undefined, + oldLine !== undefined, + newLine !== undefined, + ].filter(Boolean); + if (selectors.length !== 1) { + throw new Error( + `Comment ${itemNumber} must specify exactly one of \`hunk\`, \`hunkNumber\`, \`oldLine\`, or \`newLine\`.`, + ); + } + } + + const body = { summary, rationale: typeof item.rationale === "string" ? item.rationale : undefined, markup: typeof item.markup === "string" && item.markup.length > 0 ? item.markup : undefined, author: typeof item.author === "string" ? item.author : undefined, }; + if (typeof replyTo === "string") { + return { ...body, replyTo }; + } + if (resolvedHunkNumber !== undefined) { + return { ...body, filePath: filePath as string, hunkNumber: resolvedHunkNumber }; + } + return { + ...body, + filePath: filePath as string, + side: oldLine !== undefined ? ("old" as const) : ("new" as const), + line: oldLine ?? newLine!, + }; }); } @@ -758,10 +849,48 @@ function resolveReloadSelector( ); } -/** Parse the overloaded `hunk diff` command. */ +/** Decode a private child-process handoff without interpreting provider revision ids. */ +function decodeHistoryReviewAction(payload: unknown): ExtensionVcsHistoryReviewAction { + if (typeof payload !== "string" || payload.length === 0) { + throw new Error("Invalid history review handoff."); + } + let value: unknown; + try { + value = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); + } catch { + throw new Error("Invalid history review handoff."); + } + if (!value || typeof value !== "object") throw new Error("Invalid history review handoff."); + const action = value as Record; + if ( + action.kind === "revision-show" && + typeof action.revisionId === "string" && + action.revisionId.length > 0 + ) { + return { kind: "revision-show", revisionId: action.revisionId }; + } + if ( + action.kind === "revision-range" && + typeof action.fromRevisionId === "string" && + action.fromRevisionId.length > 0 && + typeof action.toRevisionId === "string" && + action.toRevisionId.length > 0 + ) { + return { + kind: "revision-range", + fromRevisionId: action.fromRevisionId, + toRevisionId: action.toRevisionId, + }; + } + throw new Error("Invalid history review handoff."); +} + +/** Parse the provider-neutral `hunk diff` command. */ async function parseDiffCommand(tokens: string[], argv: string[]): Promise { const { commandTokens, pathspecs } = splitPathspecArgs(tokens); - const command = createCliReferenceCommand("diff").argument("[targets...]"); + const command = createCliReferenceCommand("diff") + .addOption(new Option("--history-review ").hideHelp()) + .argument("[targets...]"); let parsedTargets: string[] = []; let parsedOptions: Record = {}; @@ -783,6 +912,26 @@ async function parseDiffCommand(tokens: string[], argv: string[]): Promise typeof value === "string") : undefined; + const historyReview = parsedOptions.historyReview; + + if (historyReview !== undefined) { + const action = decodeHistoryReviewAction(historyReview); + if ( + action.kind !== "revision-range" || + files || + parsedTargets.length > 0 || + staged || + normalizedPathspecs + ) { + throw new Error("Invalid history review handoff for `hunk diff`."); + } + return { + kind: "vcs", + rangeEndpoints: { from: action.fromRevisionId, to: action.toRevisionId }, + staged: false, + options, + }; + } if (files) { if (files.length !== 2 || parsedTargets.length > 0 || staged || normalizedPathspecs) { @@ -842,10 +991,12 @@ async function parseDiffCommand(tokens: string[], argv: string[]): Promise { const { commandTokens, pathspecs } = splitPathspecArgs(tokens); - const command = createCliReferenceCommand("show").argument("[ref]"); + const command = createCliReferenceCommand("show") + .addOption(new Option("--history-review ").hideHelp()) + .argument("[ref]"); let parsedRef: string | undefined; let parsedOptions: Record = {}; @@ -861,11 +1012,80 @@ async function parseShowCommand(tokens: string[], argv: string[]): Promise 0) { + throw new Error("Invalid history review handoff for `hunk show`."); + } + return { kind: "show", ref: action.revisionId, options }; + } + return { kind: "show", ref: parsedRef, pathspecs: pathspecs.length > 0 ? pathspecs : undefined, - options: buildCommonOptions(parsedOptions, argv), + options, + }; +} + +/** Parse the deliberately small auto-responsive `hunk log` grammar. */ +async function parseHistoryCommand( + tokens: string[], + extensionsEnabled: boolean, +): Promise { + const { commandTokens, pathspecs } = splitPathspecArgs(tokens); + const command = createCliReferenceCommand("log") + .argument("[revision]") + // Accept the former opt-in spelling during migration without presenting two experiences. + .addOption(new Option("--interactive").hideHelp()); + let revision: string | undefined; + let options: Record = {}; + + command.action((parsedRevision: string | undefined, parsedOptions: Record) => { + revision = parsedRevision; + options = parsedOptions; + }); + if (commandTokens.includes("--help") || commandTokens.includes("-h")) { + return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` }; + } + await parseStandaloneCommand(command, commandTokens); + + const color = options.color; + if (color !== "auto" && color !== "always" && color !== "never") { + throw new Error(`Invalid color mode: ${String(color)}`); + } + const requestedFormat = options.format; + if (requestedFormat !== "medium" && requestedFormat !== "compact") { + throw new Error(`Invalid history format: ${String(requestedFormat)}`); + } + const format = options.oneline ? "compact" : requestedFormat; + if (revision && options.all) { + throw new Error("`hunk log` accepts either a revision/range or --all, not both."); + } + const extensionPaths = Array.isArray(options.extension) + ? options.extension.filter((value): value is string => typeof value === "string") + : []; + + return { + kind: "history", + ...(revision ? { revision } : {}), + ...(options.all ? { all: true } : {}), + ...(options.firstParent ? { firstParent: true } : {}), + ...(typeof options.maxCount === "number" ? { maxCount: options.maxCount } : {}), + ...(typeof options.author === "string" ? { author: options.author } : {}), + ...(typeof options.grep === "string" ? { grep: options.grep } : {}), + ...(typeof options.since === "string" ? { since: options.since } : {}), + ...(typeof options.until === "string" ? { until: options.until } : {}), + ...(pathspecs.length ? { pathspecs } : {}), + color, + format, + ascii: Boolean(options.ascii), + static: Boolean(options.static), + ...(typeof options.theme === "string" ? { theme: options.theme } : {}), + ...(typeof options.vcs === "string" ? { vcs: options.vcs } : {}), + extensionsEnabled: extensionsEnabled && options.extensions !== false, + extensionPaths, }; } @@ -963,6 +1183,7 @@ function requireReloadableCliInput(input: ParsedCliInput): CliInput { input.kind === "markup-guide" || input.kind === "extension-manage" || input.kind === "extension-cli" || + input.kind === "history" || input.kind === "update" ) { throw new Error( @@ -1271,7 +1492,6 @@ async function parseSessionCommentAddCommand(tokens: string[]): Promise = { - file: "", summary: "", }; @@ -1285,16 +1505,35 @@ async function parseSessionCommentAddCommand(tokens: string[]): Promise."); + } + enforceConstraint(COMMENT_TARGET_CONSTRAINT, parsedOptions); + } return { kind: "session", action: "comment-add", output: resolveJsonOutput(parsedOptions), selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), - filePath: parsedOptions.file, - side: parsedOptions.oldLine !== undefined ? "old" : "new", - line: parsedOptions.oldLine ?? parsedOptions.newLine ?? 0, + ...(parsedOptions.replyTo !== undefined + ? { replyTo: parsedOptions.replyTo } + : { + filePath: parsedOptions.file!, + side: parsedOptions.oldLine !== undefined ? ("old" as const) : ("new" as const), + line: parsedOptions.oldLine ?? parsedOptions.newLine!, + }), summary: parsedOptions.summary, rationale: parsedOptions.rationale, markup: parsedOptions.markup, @@ -1966,6 +2205,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"]); interface LeadingCliFlags { args: string[]; @@ -2080,7 +2320,7 @@ export async function parseCli(argv: string[]): Promise { if ( extensionFlagTokens.length > 0 && isBuiltInCliCommandName(commandName) && - !REVIEW_COMMAND_NAMES.has(commandName) + !EXTENSION_AWARE_COMMAND_NAMES.has(commandName) ) { throw new Error( `\`${extensionFlagTokens[0]}\` must be used with a Hunk review command or an ` + @@ -2095,6 +2335,8 @@ export async function parseCli(argv: string[]): Promise { return parseDiffCommand(reviewRest, argv); case "show": return parseShowCommand(reviewRest, argv); + case "log": + return parseHistoryCommand(reviewRest, extensionsEnabled); case "patch": return parsePatchCommand(reviewRest, argv); case "pager": diff --git a/packages/hunk/src/app/delegatedReview.test.ts b/packages/hunk/src/app/delegatedReview.test.ts new file mode 100644 index 000000000..145dde8d2 --- /dev/null +++ b/packages/hunk/src/app/delegatedReview.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, test } from "bun:test"; +import type { ExtensionReviewDescriptor } from "../extension-api/types"; +import { reviewDescriptorAfterReload, reviewDescriptorResourceCwd } from "./delegatedReview"; + +const review: ExtensionReviewDescriptor = { + kind: "change-request", + provider: "GitHub", + title: "PR title", + id: "#123", +}; +const commitReview: ExtensionReviewDescriptor = { + kind: "commit", + provider: "Git", + title: "Commit title", + revision: "abc1234", +}; +const patch = (file?: string) => ({ kind: "patch" as const, file, options: {} }); + +describe("delegated review reload identity", () => { + test("uses the repository root for VCS reviews launched from a subdirectory", () => { + expect( + reviewDescriptorResourceCwd( + { kind: "show", ref: "revision-a", options: { vcs: "git" } }, + "/repo/packages/example", + "/repo", + ), + ).toBe("/repo"); + expect( + reviewDescriptorResourceCwd(patch("review.diff"), "/repo/packages/example", "/repo"), + ).toBe("/repo/packages/example"); + }); + + test("preserves metadata while refreshing the same patch path", () => { + expect( + reviewDescriptorAfterReload( + patch("review.diff"), + "/tmp", + review, + patch("/tmp/review.diff"), + "/", + ), + ).toBe(review); + }); + + test("clears metadata for unrelated explicit and non-file reloads", () => { + expect( + reviewDescriptorAfterReload( + patch("/tmp/pr.diff"), + "/", + review, + patch("/tmp/other.diff"), + "/", + ), + ).toBeUndefined(); + expect( + reviewDescriptorAfterReload( + patch("/tmp/pr.diff"), + "/", + review, + { kind: "vcs", staged: false, options: {} }, + "/", + ), + ).toBeUndefined(); + expect(reviewDescriptorAfterReload(patch("-"), "/", review, patch("-"), "/")).toBeUndefined(); + }); + + test("preserves history commit metadata only for the same provider review request", () => { + const show = (ref: string, vcs = "git") => ({ + kind: "show" as const, + ref, + options: { vcs }, + }); + const range = (from: string, to: string, vcs = "git") => ({ + kind: "vcs" as const, + rangeEndpoints: { from, to }, + staged: false, + options: { vcs }, + }); + + expect( + reviewDescriptorAfterReload( + show("revision-a"), + "/repo", + commitReview, + show("revision-a"), + "/repo", + ), + ).toBe(commitReview); + expect( + reviewDescriptorAfterReload( + range("parent-a", "revision-a"), + "/repo", + commitReview, + range("parent-a", "revision-a"), + "/repo", + ), + ).toBe(commitReview); + expect( + reviewDescriptorAfterReload( + show("revision-a"), + "/repo", + commitReview, + show("revision-b"), + "/repo", + ), + ).toBeUndefined(); + expect( + reviewDescriptorAfterReload( + show("revision-a"), + "/repo", + commitReview, + show("revision-a", "jj"), + "/repo", + ), + ).toBeUndefined(); + }); + + test("does not preserve change-request metadata onto a VCS review", () => { + expect( + reviewDescriptorAfterReload( + { kind: "show", ref: "revision-a", options: { vcs: "git" } }, + "/repo", + review, + { kind: "show", ref: "revision-a", options: { vcs: "git" } }, + "/repo", + ), + ).toBeUndefined(); + }); + + test("does not invent metadata for ordinary patches", () => { + expect( + reviewDescriptorAfterReload( + patch("/tmp/pr.diff"), + "/", + undefined, + patch("/tmp/pr.diff"), + "/", + ), + ).toBeUndefined(); + }); +}); diff --git a/packages/hunk/src/app/delegatedReview.ts b/packages/hunk/src/app/delegatedReview.ts new file mode 100644 index 000000000..bf49a375c --- /dev/null +++ b/packages/hunk/src/app/delegatedReview.ts @@ -0,0 +1,64 @@ +import { resolve } from "node:path"; +import type { ExtensionReviewDescriptor } from "../extension-api/types"; +import { resolveCanonicalPath } from "../core/run/paths"; +import type { CliInput } from "../core/run/commandInputs"; +import { isVcsReviewInput } from "../core/vcs"; + +/** Resolve the file identity already used by session reload bounds. */ +function patchFileIdentity(input: CliInput, cwd: string): string | undefined { + if (input.kind !== "patch" || !input.file || input.file === "-") return undefined; + return resolveCanonicalPath(resolve(cwd, input.file)); +} + +/** Use the authoritative repository root as stable VCS identity across subdirectory reloads. */ +export function reviewDescriptorResourceCwd( + input: CliInput, + startupCwd: string, + repoRoot: string | undefined, +): string { + return isVcsReviewInput(input) ? (repoRoot ?? startupCwd) : startupCwd; +} + +/** Resolve one exact provider review identity used by commits opened from interactive history. */ +function historyCommitInputIdentity(input: CliInput, cwd: string): string | undefined { + const root = resolveCanonicalPath(cwd); + if (input.kind === "show" && input.ref && !input.pathspecs?.length) { + return JSON.stringify([root, input.options.vcs ?? null, "show", input.ref]); + } + if (input.kind === "vcs" && input.rangeEndpoints && !input.pathspecs?.length) { + return JSON.stringify([ + root, + input.options.vcs ?? null, + "range", + input.rangeEndpoints.from, + input.rangeEndpoints.to, + ]); + } + return undefined; +} + +/** + * Preserve review metadata only while reloading the same underlying review resource. + * + * File-backed delegated patches use their canonical path. History-selected commits use the exact + * provider review request so refresh cannot transfer their identity to another revision or backend. + */ +export function reviewDescriptorAfterReload( + previousInput: CliInput, + previousCwd: string, + previousReview: ExtensionReviewDescriptor | undefined, + nextInput: CliInput, + nextCwd: string, +): ExtensionReviewDescriptor | undefined { + if (!previousReview) return undefined; + const previousPatchIdentity = patchFileIdentity(previousInput, previousCwd); + if (previousPatchIdentity && previousPatchIdentity === patchFileIdentity(nextInput, nextCwd)) { + return previousReview; + } + if (previousReview.kind !== "commit") return undefined; + const previousCommitIdentity = historyCommitInputIdentity(previousInput, previousCwd); + return previousCommitIdentity && + previousCommitIdentity === historyCommitInputIdentity(nextInput, nextCwd) + ? previousReview + : undefined; +} diff --git a/src/app/extensionBootstrap.test.ts b/packages/hunk/src/app/extensionBootstrap.test.ts similarity index 69% rename from src/app/extensionBootstrap.test.ts rename to packages/hunk/src/app/extensionBootstrap.test.ts index 692d87fa7..1a45aee54 100644 --- a/src/app/extensionBootstrap.test.ts +++ b/packages/hunk/src/app/extensionBootstrap.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from "bun:test"; import type { HunkConfigResolution } from "../core/run/config"; import type { CliInput } from "../core/run/commandInputs"; +import type { HunkExtensionAPI } from "../extension-api/types"; +import { emitExtensionEvent, retireExtensionLoadResult } from "../extensions/events"; +import { loadExtensions } from "../extensions/host"; import { createEmptyExtensionLoadResult } from "../extensions/types"; import { resolveConfiguredExtensions } from "./extensionBootstrap"; import { getBundledVcsCatalog } from "./vcsCatalog"; @@ -16,6 +19,63 @@ function createTestConfig(input: CliInput): HunkConfigResolution { } describe("resolveConfiguredExtensions", () => { + test("borrows one stateful extension instance across repeated review generations", async () => { + const input: CliInput = { kind: "show", ref: "opaque", options: { vcs: "git" } }; + const state = { factories: 0, startups: 0, changesets: 0, shutdowns: 0 }; + const borrowed = await loadExtensions({ + candidates: [{ id: "stateful", path: "/repo/stateful.ts", origin: "flag" }], + cwd: "/repo", + importExtensionModuleImpl: async () => ({ + default(hunk: HunkExtensionAPI) { + state.factories += 1; + hunk.on("startup", () => { + state.startups += 1; + }); + hunk.on("changeset_loaded", () => { + state.changesets += 1; + }); + hunk.on("shutdown", () => { + state.shutdowns += 1; + }); + }, + }), + }); + emitExtensionEvent(borrowed, "startup", { cwd: "/repo" }); + let loaderCalls = 0; + + for (let generation = 0; generation < 2; generation += 1) { + const resolved = await resolveConfiguredExtensions( + { + runtimeInput: input, + configured: createTestConfig(input), + cwd: "/repo", + baseVcsCatalog: getBundledVcsCatalog(), + borrowedLoad: borrowed, + }, + { + loadStartupExtensionsImpl: async () => { + loaderCalls += 1; + throw new Error("borrowed extensions must not reload"); + }, + }, + ); + expect(resolved.extensions).toBe(borrowed); + emitExtensionEvent(resolved.extensions, "changeset_loaded", { + changeset: { + id: `generation-${generation}`, + title: "Stateful review", + files: [], + sourceLabel: `generation-${generation}`, + }, + }); + } + + expect(state).toEqual({ factories: 1, startups: 1, changesets: 2, shutdowns: 0 }); + expect(loaderCalls).toBe(0); + await retireExtensionLoadResult(borrowed); + expect(state.shutdowns).toBe(1); + }); + test("retires provisional authority when the loader rejects before returning it", async () => { const input: CliInput = { kind: "vcs", staged: false, options: { vcs: "git" } }; const provisional = createEmptyExtensionLoadResult("/repo"); diff --git a/src/app/extensionBootstrap.ts b/packages/hunk/src/app/extensionBootstrap.ts similarity index 88% rename from src/app/extensionBootstrap.ts rename to packages/hunk/src/app/extensionBootstrap.ts index 15adc2d84..d833f9de6 100644 --- a/src/app/extensionBootstrap.ts +++ b/packages/hunk/src/app/extensionBootstrap.ts @@ -21,6 +21,8 @@ export interface ResolveConfiguredExtensionsOptions { notifications?: ExtensionNotificationHub; /** Registry already loaded by an extension CLI command before built-in delegation. */ previousLoad?: ExtensionLoadResult; + /** Session-owned registry borrowed by an embedded surface without lifecycle authority. */ + borrowedLoad?: ExtensionLoadResult; /** Publish provisional ownership before imports or asynchronous factories can suspend. */ onProvisionalLoad?: (result: ExtensionLoadResult) => void; /** Throw when the caller's lifetime ended so no later staged registry can be created. */ @@ -59,6 +61,24 @@ export async function resolveConfiguredExtensions( vcsCatalog: options.discoveryCatalog ?? options.baseVcsCatalog, }); + if (options.borrowedLoad) { + const adapters = resolveExtensionVcsAdapters( + options.borrowedLoad.registry, + options.baseVcsCatalog, + ).adapters; + const catalog = extendVcsCatalog(options.baseVcsCatalog, adapters); + const projectRoot = findProjectRootCandidateImpl(options.cwd, catalog); + if (projectRoot !== configured.projectRoot) { + configured = resolveConfiguredCliInputImpl(options.runtimeInput, { + cwd: options.cwd, + env: options.env, + vcsCatalog: catalog, + }); + } + options.assertActive?.(); + return { configured, extensions: options.borrowedLoad }; + } + let extensions: ExtensionLoadResult | undefined; let provisionalExtensions: ExtensionLoadResult | undefined; /** Retain loader ownership locally before forwarding it to a caller that may also suspend. */ diff --git a/src/app/extensionCliBootstrap.test.ts b/packages/hunk/src/app/extensionCliBootstrap.test.ts similarity index 100% rename from src/app/extensionCliBootstrap.test.ts rename to packages/hunk/src/app/extensionCliBootstrap.test.ts diff --git a/src/app/extensionCliBootstrap.ts b/packages/hunk/src/app/extensionCliBootstrap.ts similarity index 100% rename from src/app/extensionCliBootstrap.ts rename to packages/hunk/src/app/extensionCliBootstrap.ts diff --git a/packages/hunk/src/app/historyBootstrap.test.ts b/packages/hunk/src/app/historyBootstrap.test.ts new file mode 100644 index 000000000..3d6c13adc --- /dev/null +++ b/packages/hunk/src/app/historyBootstrap.test.ts @@ -0,0 +1,99 @@ +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 type { HistoryCommandInput } from "../core/run/commandInputs"; +import type { VcsAdapter, VcsCatalog, VcsHistorySource } from "../core/vcs/types"; +import { loadHistoryBootstrap } from "./historyBootstrap"; + +const input: HistoryCommandInput = { + kind: "history", + color: "never", + format: "compact", + ascii: false, + static: false, + vcs: "test", + extensionsEnabled: false, + extensionPaths: [], +}; + +describe("history bootstrap cursor ownership", () => { + test("cancels refresh before opening and closes each active provider cursor once", async () => { + const cwd = mkdtempSync(join(tmpdir(), "hunk-history-bootstrap-")); + const configHome = mkdtempSync(join(tmpdir(), "hunk-history-config-")); + const configPath = join(configHome, "hunk", "config.toml"); + mkdirSync(join(configHome, "hunk"), { recursive: true }); + writeFileSync( + configPath, + 'theme = "github-dark-dimmed"\nline_numbers = false\nprompt_save_view_preferences = false\n', + ); + const closeCounts: number[] = []; + let opens = 0; + const makeSource = (): VcsHistorySource => { + const index = opens++; + closeCounts[index] = 0; + return { + async read() { + return { commits: [], done: true }; + }, + async close() { + closeCounts[index]! += 1; + }, + }; + }; + const adapter: VcsAdapter = { + id: "test", + name: "Test", + detect: () => ({ id: "test", repoRoot: cwd }), + operations: {}, + history: { + async open() { + return makeSource(); + }, + async planReview(commit) { + return { kind: "revision-show", revisionId: commit.revisionId }; + }, + }, + }; + const catalog: VcsCatalog = { + adapters: [adapter], + defaultAdapterId: "test", + reservedIds: new Set(["test"]), + }; + + try { + const bootstrap = await loadHistoryBootstrap({ + input, + cwd, + env: { ...process.env, XDG_CONFIG_HOME: configHome }, + baseVcsCatalog: catalog, + }); + expect(bootstrap.input.theme).toBe("github-dark-dimmed"); + expect(bootstrap.initialViewPreferences).toMatchObject({ + theme: "github-dark-dimmed", + showLineNumbers: false, + }); + expect(bootstrap.viewPreferencesConfigPath).toBe(configPath); + expect(bootstrap.promptSaveViewPreferences).toBe(false); + + const cancelled = new AbortController(); + cancelled.abort(); + await expect(bootstrap.reopenSource(cancelled.signal)).rejects.toThrow(); + expect(opens).toBe(1); + + await bootstrap.reopenSource(); + expect(opens).toBe(2); + expect(closeCounts).toEqual([1, 0]); + await bootstrap.close(); + await bootstrap.close(); + expect(closeCounts).toEqual([1, 1]); + expect(bootstrap.extensionSession.current.registry.eventBusPhase).toBe("ready"); + await bootstrap.extensionSession.shutdown(); + await bootstrap.extensionSession.shutdown(); + expect(bootstrap.extensionSession.current.registry.eventBusPhase).toBe("closed"); + } finally { + rmSync(cwd, { recursive: true, force: true }); + rmSync(configHome, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/hunk/src/app/historyBootstrap.ts b/packages/hunk/src/app/historyBootstrap.ts new file mode 100644 index 000000000..f06f11066 --- /dev/null +++ b/packages/hunk/src/app/historyBootstrap.ts @@ -0,0 +1,186 @@ +import type { HistoryCommandInput } from "../core/run/commandInputs"; +import { + persistedViewPreferencesFromOptions, + type PersistedViewPreferences, +} from "../core/run/config"; +import { collectSessionCustomThemes } from "../core/theme/customThemes"; +import type { + ExtensionVcsHistoryCommit, + ExtensionVcsHistoryReviewAction, + ExtensionVcsHistoryReviewOptions, + NamedCustomThemeConfig, +} from "../extension-api/types"; +import { sanitizeTerminalLine } from "../lib/terminalText"; +import { + detectVcs, + extendVcsCatalog, + getDefaultVcsAdapter, + getVcsAdapter, + openVcsHistory, + planVcsHistoryReview, +} from "../core/vcs"; +import type { VcsCatalog, VcsHistorySource } from "../core/vcs/types"; +import { resolveExtensionVcsAdapters, resolveSessionVcsId } from "../extensions/apply"; +import { mergeStartupNotices } from "../extensions/startup"; +import { createExtensionSession, type ExtensionSession } from "../extensions/session"; +import type { ExtensionLoadResult } from "../extensions/types"; +import { resolveConfiguredExtensions } from "./extensionBootstrap"; + +/** Fully owned resources required by static or interactive history output. */ +export interface HistoryBootstrap { + input: HistoryCommandInput; + source: VcsHistorySource; + providerId: string; + providerName: string; + startupCwd: string; + repoRoot: string; + /** Command-owned extension authority borrowed by embedded reviews. */ + extensionSession: ExtensionSession; + notices: readonly string[]; + customThemes: readonly NamedCustomThemeConfig[]; + /** Launch baseline retained so the owning history surface can persist theme changes on quit. */ + initialViewPreferences: PersistedViewPreferences; + viewPreferencesConfigPath?: string; + promptSaveViewPreferences: boolean; + planReview( + commit: ExtensionVcsHistoryCommit, + options?: ExtensionVcsHistoryReviewOptions, + ): Promise; + reopenSource(signal?: AbortSignal): Promise; + close(): Promise; +} + +/** Resolve configured/user VCS adapters and open the selected history capability. */ +export async function loadHistoryBootstrap({ + input, + cwd = process.cwd(), + env = process.env, + baseVcsCatalog, + previousLoad, +}: { + input: HistoryCommandInput; + cwd?: string; + env?: NodeJS.ProcessEnv; + baseVcsCatalog: VcsCatalog; + previousLoad?: ExtensionLoadResult; +}): Promise { + // Reuse the established extension/config discovery with a non-executed review-shaped input. + // History-specific flags remain separate and never inherit review view preferences. + const runtimeInput = { + kind: "show" as const, + options: { + ...(input.vcs ? { vcs: input.vcs } : {}), + ...(input.theme ? { theme: input.theme } : {}), + extensions: input.extensionsEnabled, + ...(input.extensionPaths.length ? { extensionPaths: [...input.extensionPaths] } : {}), + }, + }; + const resolved = await resolveConfiguredExtensions({ + runtimeInput, + cwd, + env, + baseVcsCatalog, + previousLoad, + }); + const extensionSession = createExtensionSession(resolved.extensions, cwd); + const extensionAdapters = resolveExtensionVcsAdapters( + resolved.extensions.registry, + baseVcsCatalog, + ); + const sessionThemes = collectSessionCustomThemes( + resolved.configured.customThemes, + resolved.extensions.registry.themes, + ); + const catalog = extendVcsCatalog(baseVcsCatalog, extensionAdapters.adapters); + const explicitVcsId = input.vcs ?? resolved.configured.explicitVcsId; + const detection = detectVcs(cwd, catalog); + const settledVcs = resolveSessionVcsId(explicitVcsId, cwd, catalog); + const providerId = settledVcs.vcsId ?? detection?.id ?? getDefaultVcsAdapter(catalog).id; + let adapter; + try { + adapter = getVcsAdapter(providerId, catalog); + } catch (error) { + await extensionSession.shutdown(); + throw error; + } + let selectedDetection; + try { + selectedDetection = adapter.detect(cwd); + } catch { + selectedDetection = null; + } + const repoRoot = selectedDetection?.repoRoot ?? cwd; + + const historyInput = { + ...(input.revision ? { revision: input.revision } : {}), + ...(input.all ? { all: true } : {}), + ...(input.firstParent ? { firstParent: true } : {}), + ...(input.maxCount !== undefined ? { maxCount: input.maxCount } : {}), + ...(input.author !== undefined ? { author: input.author } : {}), + ...(input.grep !== undefined ? { grep: input.grep } : {}), + ...(input.since !== undefined ? { since: input.since } : {}), + ...(input.until !== undefined ? { until: input.until } : {}), + ...(input.pathspecs ? { pathspecs: [...input.pathspecs] } : {}), + }; + const openSource = (signal?: AbortSignal) => + openVcsHistory(adapter, historyInput, { cwd: repoRoot, signal }, catalog); + let source: VcsHistorySource; + try { + source = await openSource(); + extensionSession.startCurrent(cwd); + } catch (error) { + await extensionSession.shutdown(); + throw error; + } + + const resolvedTheme = resolved.configured.input.options.theme; + let closed = false; + return { + input: resolvedTheme ? { ...input, theme: resolvedTheme } : input, + source, + providerId: sanitizeTerminalLine(adapter.id), + providerName: sanitizeTerminalLine(adapter.name), + startupCwd: cwd, + repoRoot, + extensionSession, + customThemes: sessionThemes.themes, + initialViewPreferences: persistedViewPreferencesFromOptions(resolved.configured.input.options), + viewPreferencesConfigPath: resolved.configured.viewPreferencesConfigPath, + promptSaveViewPreferences: + resolved.configured.input.options.promptSaveViewPreferences !== false, + notices: [ + ...(mergeStartupNotices(resolved.configured.startupNotices, resolved.extensions) ?? []).map( + (notice) => sanitizeTerminalLine(notice.message), + ), + ...sessionThemes.notices.map((notice) => sanitizeTerminalLine(notice.message)), + ...extensionAdapters.issues.map((issue) => sanitizeTerminalLine(issue.message)), + ...(settledVcs.unknownVcsId + ? [ + `Configured VCS "${sanitizeTerminalLine(settledVcs.unknownVcsId)}" is unavailable; using ${sanitizeTerminalLine(adapter.name)}.`, + ] + : []), + ], + planReview(commit, options) { + return planVcsHistoryReview(adapter, commit, { cwd: repoRoot }, options); + }, + async reopenSource(signal) { + if (closed) throw new Error("History session is closed."); + signal?.throwIfAborted(); + const previous = source; + const replacement = await openSource(signal); + if (closed || source !== previous || signal?.aborted) { + await replacement.close(); + signal?.throwIfAborted(); + throw new Error("History session changed while refreshing."); + } + source = replacement; + await previous.close(); + return replacement; + }, + async close() { + if (closed) return; + closed = true; + await source.close(); + }, + }; +} diff --git a/packages/hunk/src/app/historyReview.test.ts b/packages/hunk/src/app/historyReview.test.ts new file mode 100644 index 000000000..652ca1042 --- /dev/null +++ b/packages/hunk/src/app/historyReview.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, mock, test } from "bun:test"; +import { resolve } from "node:path"; +import { createEmptyExtensionLoadResult } from "../extensions/types"; +import { prepareEmbeddedHistoryReview } from "./historyReview"; + +/** Provide only the provider-neutral fields embedded review startup consumes. */ +function createTestRequest() { + const extensionSession = createEmptyExtensionLoadResult(resolve("invocation")); + return { + action: { kind: "revision-show", revisionId: "--opaque:id" } as const, + startupCwd: resolve("invocation"), + providerId: "opaque-vcs", + extensionPaths: ["extensions/provider.ts"], + extensionsEnabled: true, + extensionSession, + }; +} + +describe("embedded history review bootstrap", () => { + test("preserves opaque actions, invocation-relative extensions, cwd, theme, and signal", async () => { + const abort = new AbortController(); + let captured: { argv: string[]; deps: Record } | undefined; + const request = createTestRequest(); + const result = await prepareEmbeddedHistoryReview( + { ...request, themeId: "github-dark", themeMode: "dark" }, + { + signal: abort.signal, + env: {}, + prepareStartupPlanImpl: (async (argv: string[], deps: Record) => { + captured = { argv, deps }; + return { + kind: "app", + bootstrap: { extensions: { ...request.extensionSession } }, + cliInput: {}, + controllingTerminal: null, + }; + }) as never, + }, + ); + + expect(result.bootstrap).toBeDefined(); + expect(captured?.argv).toContain(resolve("invocation", "extensions/provider.ts")); + expect(captured?.deps).toMatchObject({ + cwd: resolve("invocation"), + terminalThemeMode: "dark", + signal: abort.signal, + }); + expect(captured?.deps.borrowedExtensionLoad).toBe(request.extensionSession); + expect(result.borrowsExtensions).toBe(true); + expect(captured?.argv.join(" ")).not.toContain("--opaque:id"); + }); + + test("does not retire an aliased borrowed registry when cancellation wins after startup", async () => { + const abort = new AbortController(); + const request = createTestRequest(); + const close = mock(() => undefined); + + await expect( + prepareEmbeddedHistoryReview(request, { + signal: abort.signal, + prepareStartupPlanImpl: (async () => { + abort.abort(); + return { + kind: "app", + bootstrap: { extensions: { ...request.extensionSession } }, + cliInput: {}, + controllingTerminal: { close }, + }; + }) as never, + }), + ).rejects.toThrow(); + + expect(close).toHaveBeenCalledTimes(1); + expect(request.extensionSession.registry.retirementPromise).toBeUndefined(); + }); + + test("refuses an already-cancelled bootstrap before startup", async () => { + const abort = new AbortController(); + abort.abort(); + let called = false; + await expect( + prepareEmbeddedHistoryReview( + { ...createTestRequest(), action: { kind: "revision-show", revisionId: "opaque" } }, + { + signal: abort.signal, + prepareStartupPlanImpl: (async () => { + called = true; + return { kind: "help", text: "unexpected" }; + }) as never, + }, + ), + ).rejects.toThrow(); + expect(called).toBe(false); + }); +}); diff --git a/packages/hunk/src/app/historyReview.ts b/packages/hunk/src/app/historyReview.ts new file mode 100644 index 000000000..068364d66 --- /dev/null +++ b/packages/hunk/src/app/historyReview.ts @@ -0,0 +1,81 @@ +import { resolve } from "node:path"; +import type { AppBootstrap } from "../core/bootstrap"; +import type { TerminalThemeMode } from "../core/theme/detection"; +import type { ExtensionVcsHistoryReviewAction } from "../extension-api/types"; +import { retireExtensionLoadResult } from "../extensions/events"; +import type { ExtensionLoadResult } from "../extensions/types"; +import { prepareStartupPlan } from "./startup"; + +export interface EmbeddedHistoryReviewRequest { + action: ExtensionVcsHistoryReviewAction; + providerId: string; + startupCwd: string; + extensionsEnabled: boolean; + extensionPaths: readonly string[]; + extensionSession?: ExtensionLoadResult; + themeId?: string; + themeMode?: TerminalThemeMode; +} + +export interface EmbeddedHistoryReview { + bootstrap: AppBootstrap; + /** The history session owns this bootstrap's extension registry. */ + borrowsExtensions: boolean; +} + +/** Convert a provider-owned review declaration into one option-safe internal invocation. */ +export function historyReviewArgs(action: ExtensionVcsHistoryReviewAction) { + const payload = Buffer.from(JSON.stringify(action), "utf8").toString("base64url"); + return [action.kind === "revision-range" ? "diff" : "show", "--history-review", payload]; +} + +/** Bootstrap one provider-planned history review without creating or claiming a renderer. */ +export async function prepareEmbeddedHistoryReview( + request: EmbeddedHistoryReviewRequest, + { + signal, + env = process.env, + prepareStartupPlanImpl = prepareStartupPlan, + }: { + signal?: AbortSignal; + env?: NodeJS.ProcessEnv; + prepareStartupPlanImpl?: typeof prepareStartupPlan; + } = {}, +): Promise { + signal?.throwIfAborted(); + const extensionArgs = request.extensionPaths.flatMap((path) => [ + "--extension", + resolve(request.startupCwd, path), + ]); + const args = [ + ...historyReviewArgs(request.action), + "--vcs", + request.providerId, + ...(request.themeId ? ["--theme", request.themeId] : []), + ...(request.extensionsEnabled ? extensionArgs : ["--no-extensions"]), + ]; + const plan = await prepareStartupPlanImpl(["hunk", "hunk", ...args], { + cwd: request.startupCwd, + env, + signal, + borrowedExtensionLoad: request.extensionSession, + stdinIsTTY: true, + stdoutIsTTY: true, + terminalThemeMode: request.themeMode, + }); + if (signal?.aborted && plan.kind === "app") { + plan.controllingTerminal?.close(); + if (plan.bootstrap.extensions?.registry !== request.extensionSession?.registry) { + await retireExtensionLoadResult(plan.bootstrap.extensions); + } + signal.throwIfAborted(); + } + if (plan.kind !== "app") { + throw new Error("The selected commit did not produce an interactive review."); + } + plan.controllingTerminal?.close(); + return { + bootstrap: plan.bootstrap as AppBootstrap, + borrowsExtensions: plan.bootstrap.extensions?.registry === request.extensionSession?.registry, + }; +} diff --git a/src/app/review/capability.ts b/packages/hunk/src/app/review/capability.ts similarity index 95% rename from src/app/review/capability.ts rename to packages/hunk/src/app/review/capability.ts index 901fcc7be..00047bb5a 100644 --- a/src/app/review/capability.ts +++ b/packages/hunk/src/app/review/capability.ts @@ -13,7 +13,7 @@ * that outlives its first minute needs. * * The grammar — token shape, fragment key, URL layout — is the shared browser-safe - * contract (`src/session/reviewHttpProtocol.ts`); this module only supplies the randomness + * contract (`packages/hunk/src/session/reviewHttpProtocol.ts`); this module only supplies the randomness * and the hashing that a platform has to provide. */ import { randomBytes } from "node:crypto"; diff --git a/src/app/review/producer.test.ts b/packages/hunk/src/app/review/producer.test.ts similarity index 99% rename from src/app/review/producer.test.ts rename to packages/hunk/src/app/review/producer.test.ts index aa05a8886..87c6329b2 100644 --- a/src/app/review/producer.test.ts +++ b/packages/hunk/src/app/review/producer.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createTestDiffFile, lines } from "../../../test/helpers/diff-helpers"; +import { createTestDiffFile, lines } from "../../../../../test/helpers/diff-helpers"; import { SourceTextTooLargeError } from "../../core/changeset/fileSource"; import { parseReviewGeneration } from "../../core/review/generationOrder"; import { diff --git a/src/app/review/producer.ts b/packages/hunk/src/app/review/producer.ts similarity index 100% rename from src/app/review/producer.ts rename to packages/hunk/src/app/review/producer.ts diff --git a/src/app/review/publication.ts b/packages/hunk/src/app/review/publication.ts similarity index 96% rename from src/app/review/publication.ts rename to packages/hunk/src/app/review/publication.ts index b9af070b7..5a8588d8c 100644 --- a/src/app/review/publication.ts +++ b/packages/hunk/src/app/review/publication.ts @@ -7,10 +7,10 @@ * mutating this one — which is what makes "which generation is this?" answerable rather * than a matter of timing. * - * The document itself knows nothing about any of this (`src/core/review/document.ts`); + * The document itself knows nothing about any of this (`packages/hunk/src/core/review/document.ts`); * publication is layered on top so the shared model stays a description of a review rather * than of a transport. The vocabulary for addressing and ordering those generations is - * `src/core/review/generationOrder.ts`. + * `packages/hunk/src/core/review/generationOrder.ts`. */ import { buildReviewContentManifest, diff --git a/src/app/review/resourceStore.ts b/packages/hunk/src/app/review/resourceStore.ts similarity index 100% rename from src/app/review/resourceStore.ts rename to packages/hunk/src/app/review/resourceStore.ts diff --git a/src/app/session/bridge.test.ts b/packages/hunk/src/app/session/bridge.test.ts similarity index 97% rename from src/app/session/bridge.test.ts rename to packages/hunk/src/app/session/bridge.test.ts index 33e4d71e2..fb4930008 100644 --- a/src/app/session/bridge.test.ts +++ b/packages/hunk/src/app/session/bridge.test.ts @@ -100,8 +100,8 @@ describe("createHunkSessionBridge", () => { sessionId: "session-1", revealMode: "first", comments: [ - { filePath: "src/example.ts", summary: "First" }, - { filePath: "src/example.ts", summary: "Second" }, + { filePath: "src/example.ts", hunkIndex: 0, summary: "First" }, + { filePath: "src/example.ts", hunkIndex: 0, summary: "Second" }, ], }, }); diff --git a/src/app/session/bridge.ts b/packages/hunk/src/app/session/bridge.ts similarity index 100% rename from src/app/session/bridge.ts rename to packages/hunk/src/app/session/bridge.ts diff --git a/src/app/session/registration.test.ts b/packages/hunk/src/app/session/registration.test.ts similarity index 95% rename from src/app/session/registration.test.ts rename to packages/hunk/src/app/session/registration.test.ts index 1e119b635..823c1b3c5 100644 --- a/src/app/session/registration.test.ts +++ b/packages/hunk/src/app/session/registration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createTestDiffFile } from "../../../test/helpers/diff-helpers"; +import { createTestDiffFile } from "../../../../../test/helpers/diff-helpers"; import { reviewProcessCapability } from "../review/capability"; import { buildReviewPublication } from "../review/publication"; import type { AppBootstrap } from "../../core/bootstrap"; @@ -211,6 +211,12 @@ describe("session registration", () => { // Intent: the daemon can address every resource of the generation it mirrors, and can // map the renderer file ids the session surface uses onto the semantic keys resources // are addressed by. + test("createSessionRegistration accepts the owning surface's authoritative cwd", () => { + const bootstrap = createBootstrap(); + const registration = createSessionRegistration(bootstrap, publish(bootstrap), "embedded-cwd"); + expect(registration.cwd).toBe("embedded-cwd"); + }); + test("createSessionRegistration advertises the generation's resource catalog", () => { const bootstrap = createBootstrap(); const publication = publish(bootstrap); diff --git a/src/app/session/registration.ts b/packages/hunk/src/app/session/registration.ts similarity index 99% rename from src/app/session/registration.ts rename to packages/hunk/src/app/session/registration.ts index bacf67637..748a5c14a 100644 --- a/src/app/session/registration.ts +++ b/packages/hunk/src/app/session/registration.ts @@ -81,6 +81,7 @@ function buildReviewCatalog(publication: ReviewPublication): HunkReviewResourceC export function createSessionRegistration( bootstrap: AppBootstrap, publication: ReviewPublication, + cwd = process.cwd(), ): HunkSessionRegistration { const terminal = resolveSessionTerminalMetadata({ tty: ttyname() }); @@ -88,7 +89,7 @@ export function createSessionRegistration( registrationVersion: SESSION_BROKER_REGISTRATION_VERSION, sessionId: randomUUID(), pid: process.pid, - cwd: process.cwd(), + cwd, repoRoot: inferRepoRoot(bootstrap), launchedAt: new Date().toISOString(), terminal, diff --git a/src/app/session/reloadBounds.test.ts b/packages/hunk/src/app/session/reloadBounds.test.ts similarity index 100% rename from src/app/session/reloadBounds.test.ts rename to packages/hunk/src/app/session/reloadBounds.test.ts diff --git a/src/app/session/reloadBounds.ts b/packages/hunk/src/app/session/reloadBounds.ts similarity index 100% rename from src/app/session/reloadBounds.ts rename to packages/hunk/src/app/session/reloadBounds.ts diff --git a/src/app/session/reviewCommands.test.ts b/packages/hunk/src/app/session/reviewCommands.test.ts similarity index 99% rename from src/app/session/reviewCommands.test.ts rename to packages/hunk/src/app/session/reviewCommands.test.ts index e76d5afa9..4ec409263 100644 --- a/src/app/session/reviewCommands.test.ts +++ b/packages/hunk/src/app/session/reviewCommands.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createTestDiffFile } from "../../../test/helpers/diff-helpers"; +import { createTestDiffFile } from "../../../../../test/helpers/diff-helpers"; import { ReviewProducer } from "../review/producer"; import { reviewGapId } from "../../core/review/expansion"; import { reviewResourceId } from "../../core/review/resources"; diff --git a/src/app/session/reviewCommands.ts b/packages/hunk/src/app/session/reviewCommands.ts similarity index 97% rename from src/app/session/reviewCommands.ts rename to packages/hunk/src/app/session/reviewCommands.ts index a3e9107be..69c909f3c 100644 --- a/src/app/session/reviewCommands.ts +++ b/packages/hunk/src/app/session/reviewCommands.ts @@ -3,8 +3,8 @@ * * The daemon brokers two review commands to a live session: read one bounded slice of a * published resource, and apply one semantic action. Both are answered here, at the seam - * between the wire schema (`src/session/reviewProtocol.ts`) and the producer that owns - * the generation (`src/app/review/producer.ts`), so neither of those has to know about + * between the wire schema (`packages/hunk/src/session/reviewProtocol.ts`) and the producer that owns + * the generation (`packages/hunk/src/app/review/producer.ts`), so neither of those has to know about * the other's concerns. * * Three rules shape it: diff --git a/packages/hunk/src/app/session/reviewRuntime.ts b/packages/hunk/src/app/session/reviewRuntime.ts new file mode 100644 index 000000000..061187f96 --- /dev/null +++ b/packages/hunk/src/app/session/reviewRuntime.ts @@ -0,0 +1,41 @@ +import { createNativeSessionBrokerLifecycleClock } from "@hunk/session-broker"; +import type { AppBootstrap } from "../../core/bootstrap"; +import { SessionBrokerClient } from "../../session/broker/brokerClient"; +import { reportHunkSessionBrokerLifecycleDefect } from "../../session/broker/lifecycleDefect"; +import { ReviewProducer } from "../review/producer"; +import { createInitialSessionSnapshot, createSessionRegistration } from "./registration"; + +export interface ReviewSessionRuntime { + hostClient: SessionBrokerClient; + reviewProducer: ReviewProducer; + stop(): void; +} + +/** Create broker and producer resources for one independently mountable review surface. */ +export function createReviewSessionRuntime( + bootstrap: AppBootstrap, + cwd = process.cwd(), +): ReviewSessionRuntime { + const reviewProducer = new ReviewProducer({ + files: bootstrap.changeset.files, + sourceLabel: bootstrap.changeset.sourceLabel, + }); + const publication = reviewProducer.getPublication(); + const lifecycleClock = createNativeSessionBrokerLifecycleClock(); + const hostClient = new SessionBrokerClient( + createSessionRegistration(bootstrap, publication, cwd), + createInitialSessionSnapshot(bootstrap, publication), + { lifecycleClock, onDefect: reportHunkSessionBrokerLifecycleDefect }, + ); + hostClient.start(); + let stopped = false; + return { + hostClient, + reviewProducer, + stop() { + if (stopped) return; + hostClient.stop(); + stopped = true; + }, + }; +} diff --git a/src/app/sessionBootstrap.test.ts b/packages/hunk/src/app/sessionBootstrap.test.ts similarity index 100% rename from src/app/sessionBootstrap.test.ts rename to packages/hunk/src/app/sessionBootstrap.test.ts diff --git a/src/app/sessionBootstrap.ts b/packages/hunk/src/app/sessionBootstrap.ts similarity index 94% rename from src/app/sessionBootstrap.ts rename to packages/hunk/src/app/sessionBootstrap.ts index eb36b7ed7..25ad868b5 100644 --- a/src/app/sessionBootstrap.ts +++ b/packages/hunk/src/app/sessionBootstrap.ts @@ -30,6 +30,8 @@ export interface SessionBootstrapOptions { loadAppBootstrapImpl?: typeof loadAppBootstrap; /** Base product adapters composed before user extensions are applied. */ baseVcsCatalog?: VcsCatalog; + /** Cancel initial provider-backed loading for an abandoned embedded surface. */ + signal?: AbortSignal; } export interface SessionBootstrapResult { @@ -57,7 +59,9 @@ export async function loadConfiguredSessionBootstrap({ loadAtCwd = false, loadAppBootstrapImpl = loadAppBootstrap, baseVcsCatalog = getBundledVcsCatalog(), + signal, }: SessionBootstrapOptions): Promise { + signal?.throwIfAborted(); const previousFileLanguages = fileLanguageRegistrationSnapshot(); try { @@ -84,8 +88,11 @@ export async function loadConfiguredSessionBootstrap({ ...(loadAtCwd ? { cwd } : {}), customThemes: sessionThemes.themes, vcsCatalog: applied.vcsCatalog, + signal, })) as AppBootstrap; + signal?.throwIfAborted(); bootstrap.changeset = await applyExtensionChangesetTransforms(extensions, bootstrap.changeset); + signal?.throwIfAborted(); bootstrap.initialThemeMode = initialThemeMode ?? bootstrap.initialThemeMode; bootstrap.extensions = extensions; bootstrap.viewPreferencesConfigPath = configured.viewPreferencesConfigPath; diff --git a/src/app/sessionSelector.test.ts b/packages/hunk/src/app/sessionSelector.test.ts similarity index 100% rename from src/app/sessionSelector.test.ts rename to packages/hunk/src/app/sessionSelector.test.ts diff --git a/src/app/sessionSelector.ts b/packages/hunk/src/app/sessionSelector.ts similarity index 100% rename from src/app/sessionSelector.ts rename to packages/hunk/src/app/sessionSelector.ts diff --git a/src/app/startup.test.ts b/packages/hunk/src/app/startup.test.ts similarity index 89% rename from src/app/startup.test.ts rename to packages/hunk/src/app/startup.test.ts index df653c998..a9e19d05d 100644 --- a/src/app/startup.test.ts +++ b/packages/hunk/src/app/startup.test.ts @@ -3,7 +3,7 @@ import { createEmptyExtensionLoadResult } from "../extensions/types"; import { resolveExtensionCliCommands } from "../extensions/cliCommands"; import type { HunkConfigResolution } from "../core/run/config"; import { HunkUserError } from "../core/run/errors"; -import { prepareStartupPlan } from "./startup"; +import { prepareStartupPlan, shouldUseInteractiveHistory } from "./startup"; import type { AppBootstrap } from "../core/bootstrap"; import type { CliInput, ParsedCliInput } from "../core/run/commandInputs"; import type { NamedCustomThemeConfig } from "../extension-api/types"; @@ -41,6 +41,23 @@ function createBootstrap(input: CliInput): AppBootstrap { }; } +describe("history surface selection", () => { + test("opens one automatic browser only when both standard streams are terminals", () => { + expect( + shouldUseInteractiveHistory({ forceStatic: false, stdinIsTTY: true, stdoutIsTTY: true }), + ).toBe(true); + expect( + shouldUseInteractiveHistory({ forceStatic: false, stdinIsTTY: false, stdoutIsTTY: true }), + ).toBe(false); + expect( + shouldUseInteractiveHistory({ forceStatic: false, stdinIsTTY: true, stdoutIsTTY: false }), + ).toBe(false); + expect( + shouldUseInteractiveHistory({ forceStatic: true, stdinIsTTY: true, stdoutIsTTY: true }), + ).toBe(false); + }); +}); + describe("startup planning", () => { test("runs an extension CLI command and retires its registry before returning", async () => { const invocation = { @@ -172,6 +189,7 @@ describe("startup planning", () => { loadStartupExtensionsImpl: async () => extensions, loadAppBootstrapImpl: async (input) => createBootstrap(input), usesPipedPatchInputImpl: () => false, + stdoutIsTTY: true, }); expect(plan.kind).toBe("app"); @@ -602,6 +620,7 @@ describe("startup planning", () => { createTestConfigResolution(input, { startupNotices: [startupNotice] }), loadAppBootstrapImpl: async (input) => createBootstrap(input), usesPipedPatchInputImpl: () => false, + stdoutIsTTY: true, }); expect(plan.kind).toBe("app"); @@ -655,10 +674,35 @@ describe("startup planning", () => { parseCliImpl: async () => cliInput as ParsedCliInput, resolveRuntimeCliInputImpl: (input) => input, resolveConfiguredCliInputImpl: (input) => createTestConfigResolution(input), + stdoutIsTTY: true, }), ).rejects.toBeInstanceOf(HunkUserError); }); + test("rejects watch mode when stdout is not a terminal", async () => { + const cliInput: CliInput = { + kind: "vcs", + staged: false, + options: { watch: true }, + }; + let loaded = false; + + await expect( + prepareStartupPlan(["bun", "hunk", "diff", "--watch"], { + parseCliImpl: async () => cliInput as ParsedCliInput, + resolveRuntimeCliInputImpl: (input) => input, + resolveConfiguredCliInputImpl: (input) => createTestConfigResolution(input), + loadAppBootstrapImpl: async (input) => { + loaded = true; + return createBootstrap(input); + }, + stdinIsTTY: true, + stdoutIsTTY: false, + }), + ).rejects.toThrow("`--watch` requires an interactive output terminal"); + expect(loaded).toBe(false); + }); + test("opens the controlling terminal for any app startup with piped stdin", async () => { const cliInput: CliInput = { kind: "vcs", @@ -732,6 +776,32 @@ describe("startup planning", () => { expect(opened).toBe(1); }); + test("inherits an embedded renderer theme mode without querying the shared terminal", async () => { + const cliInput: CliInput = { + kind: "show", + ref: "opaque:id", + options: { theme: "auto" }, + }; + let detected = 0; + + const plan = await prepareStartupPlan(["bun", "hunk", "show", "opaque:id"], { + parseCliImpl: async () => cliInput as ParsedCliInput, + resolveRuntimeCliInputImpl: (input) => input, + resolveConfiguredCliInputImpl: (input) => createTestConfigResolution(input), + loadAppBootstrapImpl: async (input) => createBootstrap(input), + detectTerminalThemeModeFromBackgroundImpl: async () => { + detected += 1; + return "light"; + }, + stdinIsTTY: true, + stdoutIsTTY: true, + terminalThemeMode: "dark", + }); + + expect(plan).toMatchObject({ kind: "app", bootstrap: { initialThemeMode: "dark" } }); + expect(detected).toBe(0); + }); + test("opens the controlling terminal for piped patch startup", async () => { const cliInput: CliInput = { kind: "patch", @@ -761,6 +831,7 @@ describe("startup planning", () => { opened += 1; return controllingTerminal; }, + stdoutIsTTY: true, }); expect(plan).toMatchObject({ @@ -771,6 +842,33 @@ describe("startup planning", () => { expect(opened).toBe(1); }); + test("does not open a controlling terminal for static output", async () => { + const cliInput: CliInput = { + kind: "patch", + file: "-", + text: "diff --git a/a.ts b/a.ts\n@@ -1 +1 @@\n-old\n+new\n", + options: {}, + }; + let opened = 0; + + const plan = await prepareStartupPlan(["bun", "hunk", "patch", "-"], { + parseCliImpl: async () => cliInput as ParsedCliInput, + resolveRuntimeCliInputImpl: (input) => input, + resolveConfiguredCliInputImpl: (input) => createTestConfigResolution(input), + loadAppBootstrapImpl: async (input) => createBootstrap(input), + usesPipedPatchInputImpl: () => true, + openControllingTerminalImpl: () => { + opened += 1; + return { stdin: {} as never, close: () => {} }; + }, + stdinIsTTY: false, + stdoutIsTTY: false, + }); + + expect(plan.kind).toBe("static-diff"); + expect(opened).toBe(0); + }); + test("loads extensions before the changeset and attaches them to the bootstrap", async () => { const cliInput: CliInput = { kind: "patch", @@ -808,6 +906,7 @@ describe("startup planning", () => { return createBootstrap(input); }, usesPipedPatchInputImpl: () => false, + stdoutIsTTY: true, }); expect(order).toEqual(["extensions", "bootstrap"]); @@ -847,6 +946,7 @@ describe("startup planning", () => { return createBootstrap(input); }, usesPipedPatchInputImpl: () => false, + stdoutIsTTY: true, }); // Config themes keep their id; extension themes fill the ids that are still free. diff --git a/src/app/startup.ts b/packages/hunk/src/app/startup.ts similarity index 85% rename from src/app/startup.ts rename to packages/hunk/src/app/startup.ts index bea624496..b6425cd46 100644 --- a/src/app/startup.ts +++ b/packages/hunk/src/app/startup.ts @@ -18,6 +18,7 @@ import type { CliInput, ExtensionCliInvocationInput, ExtensionManageCommandInput, + HistoryCommandInput, MarkupRenderCommandInput, ParsedCliInput, SelfUpdateCommandInput, @@ -58,6 +59,11 @@ export type StartupPlan = kind: "session-command"; input: SessionCommandInput; } + | { + kind: "history-static" | "history-interactive"; + bootstrap: import("./historyBootstrap").HistoryBootstrap; + input: HistoryCommandInput; + } | { kind: "plain-text-pager"; text: string; @@ -73,6 +79,10 @@ export type StartupPlan = options: CliInput["options"]; customThemes?: AppBootstrap["customThemes"]; } + | { + kind: "static-diff"; + bootstrap: AppBootstrap; + } | { kind: "markup-render"; input: MarkupRenderCommandInput; @@ -129,6 +139,14 @@ export interface StartupDeps { runExtensionCliCommandImpl?: typeof import("../extensions/cliCommandRuntime").runExtensionCliCommand; env?: NodeJS.ProcessEnv; bunVersion?: string; + /** Override process cwd for an embedded review bootstrap. */ + cwd?: string; + /** Reuse the owning renderer's detected terminal mode. */ + terminalThemeMode?: "dark" | "light"; + /** Cancel provider-backed startup for an abandoned embedded surface. */ + signal?: AbortSignal; + /** Borrow the owning history session's already-loaded extension authority. */ + borrowedExtensionLoad?: import("../extensions/types").ExtensionLoadResult; } /** Carry the invocation's authoritative extension paths into a delegated review input. */ @@ -136,6 +154,13 @@ function applyDelegatedExtensionFlags( input: ParsedCliInput, invocation: ExtensionCliInvocationInput, ): ParsedCliInput { + if (input.kind === "history") { + return { + ...input, + extensionsEnabled: invocation.extensionsEnabled, + extensionPaths: [...invocation.extensionPaths], + }; + } if (!("options" in input)) return input; return { ...input, @@ -148,6 +173,19 @@ function applyDelegatedExtensionFlags( } as ParsedCliInput; } +/** Choose the history surface from terminal ownership unless static output was forced. */ +export function shouldUseInteractiveHistory({ + forceStatic, + stdinIsTTY, + stdoutIsTTY, +}: { + forceStatic: boolean; + stdinIsTTY: boolean; + stdoutIsTTY: boolean; +}) { + return !forceStatic && stdinIsTTY && stdoutIsTTY; +} + /** Normalize startup work so help, pager, and app-bootstrap paths can be tested directly. */ export async function prepareStartupPlan( argv: string[] = process.argv, @@ -170,17 +208,20 @@ export async function prepareStartupPlan( const env = deps.env ?? process.env; const bunVersion = deps.bunVersion ?? Bun.version; const loadBaseVcsCatalog = createBundledVcsCatalogLoader(); - const startupCwd = process.cwd(); + const startupCwd = deps.cwd ?? process.cwd(); + deps.signal?.throwIfAborted(); let parsedCliInput = await parseCliImpl(argv); let controllingTerminal: ControllingTerminal | null = null; - let preloadedExtensions: import("../extensions/types").ExtensionLoadResult | undefined; + let preloadedExtensions: import("../extensions/types").ExtensionLoadResult | undefined = + deps.borrowedExtensionLoad; + const ownsPreloadedExtensions = !deps.borrowedExtensionLoad; let delegatedDiscoveryCatalog: VcsCatalog | undefined; let delegatedReview: ExtensionReviewDescriptor | undefined; /** Retire startup-owned extension state before returning a non-app plan. */ const retirePreloadedExtensions = async () => { - if (!preloadedExtensions) return; + if (!preloadedExtensions || !ownsPreloadedExtensions) return; await (await import("../extensions/events")).retireExtensionLoadResult(preloadedExtensions); preloadedExtensions = undefined; }; @@ -373,6 +414,31 @@ export async function prepareStartupPlan( }); } + if (parsedCliInput.kind === "history") { + const baseVcsCatalog = await loadBaseVcsCatalog(); + const { loadHistoryBootstrap } = await import("./historyBootstrap"); + const bootstrap = await loadHistoryBootstrap({ + input: parsedCliInput, + cwd: startupCwd, + env, + baseVcsCatalog, + previousLoad: preloadedExtensions, + }); + // The runner owns source/extension retirement; unlike ordinary headless plans, history must + // retain its provider cursor until every page has been consumed. + preloadedExtensions = undefined; + const useInteractiveHistory = shouldUseInteractiveHistory({ + forceStatic: parsedCliInput.static, + stdinIsTTY, + stdoutIsTTY, + }); + return { + kind: useInteractiveHistory ? "history-interactive" : "history-static", + bootstrap, + input: parsedCliInput, + }; + } + if (parsedCliInput.kind === "pager") { const stdinText = await whileStartupOwnsExtensions(readStdinText); const pagerOptions = parsedCliInput.options; @@ -473,6 +539,11 @@ export async function prepareStartupPlan( if (cliInput.options.watch) { await whileStartupOwnsExtensions(() => { + if (!stdoutIsTTY) { + throw new HunkUserError("`--watch` requires an interactive output terminal.", [ + "Remove `--watch` when redirecting or piping Hunk's output.", + ]); + } assertReliableWatchRuntime(bunVersion); if (!canReloadInput(cliInput)) { throw new HunkUserError( @@ -492,8 +563,10 @@ export async function prepareStartupPlan( controllingTerminal = openControllingTerminalImpl(); } - let initialThemeMode: AppBootstrap["initialThemeMode"]; - if (cliInput.options.theme === "auto" && stdoutIsTTY) { + // Embedded reviews inherit their owner's detected mode so bootstrap never queries a terminal + // whose input and renderer are already exclusively owned. + let initialThemeMode: AppBootstrap["initialThemeMode"] = deps.terminalThemeMode; + if (!initialThemeMode && cliInput.options.theme === "auto" && stdoutIsTTY) { const themeInput = controllingTerminal?.stdin ?? (stdinIsTTY ? process.stdin : null); if (themeInput) { initialThemeMode = @@ -532,7 +605,9 @@ export async function prepareStartupPlan( env, baseVcsCatalog, discoveryCatalog: delegatedDiscoveryCatalog, - previousLoad: preloadedExtensions, + previousLoad: deps.borrowedExtensionLoad ? undefined : preloadedExtensions, + borrowedLoad: deps.borrowedExtensionLoad, + assertActive: () => deps.signal?.throwIfAborted(), }, { resolveConfiguredCliInputImpl, loadStartupExtensionsImpl }, ); @@ -540,6 +615,10 @@ export async function prepareStartupPlan( cliInput = configured.input; const extensionResult = resolvedExtensions.extensions; preloadedExtensions = extensionResult; + if (deps.signal?.aborted) { + await retirePreloadedExtensions(); + deps.signal.throwIfAborted(); + } let preparedSession: SessionBootstrapResult; try { @@ -550,6 +629,7 @@ export async function prepareStartupPlan( initialThemeMode, loadAppBootstrapImpl, baseVcsCatalog, + signal: deps.signal, }); } catch (error) { controllingTerminal?.close(); @@ -590,10 +670,18 @@ export async function prepareStartupPlan( : configured.startupNotices, extensionResult, ); - controllingTerminal ??= usesPipedPatchInputImpl(cliInput) ? openControllingTerminalImpl() : null; + controllingTerminal ??= + stdoutIsTTY && usesPipedPatchInputImpl(cliInput) ? openControllingTerminalImpl() : null; - // The mounted app now owns the registry and performs its one eventual shutdown. + // The selected runner now owns the registry and performs its one eventual shutdown. preloadedExtensions = undefined; + if (!stdoutIsTTY) { + return { + kind: "static-diff", + bootstrap, + }; + } + return { kind: "app", bootstrap, diff --git a/src/app/startup.vcsExtensions.test.ts b/packages/hunk/src/app/startup.vcsExtensions.test.ts similarity index 97% rename from src/app/startup.vcsExtensions.test.ts rename to packages/hunk/src/app/startup.vcsExtensions.test.ts index 099b2e0d1..b3161b1d2 100644 --- a/src/app/startup.vcsExtensions.test.ts +++ b/packages/hunk/src/app/startup.vcsExtensions.test.ts @@ -88,8 +88,8 @@ describe("external VCS startup bootstrap", () => { env: { HOME: createTempDir("hunk-external-vcs-home-") }, }); - expect(plan.kind).toBe("app"); - if (plan.kind !== "app") { + expect(plan.kind).toBe("static-diff"); + if (plan.kind !== "static-diff") { return; } expect(resolvedProjectRoots).toEqual([undefined, repo]); diff --git a/src/app/types.ts b/packages/hunk/src/app/types.ts similarity index 100% rename from src/app/types.ts rename to packages/hunk/src/app/types.ts diff --git a/src/app/vcsCatalog.test.ts b/packages/hunk/src/app/vcsCatalog.test.ts similarity index 100% rename from src/app/vcsCatalog.test.ts rename to packages/hunk/src/app/vcsCatalog.test.ts diff --git a/src/app/vcsCatalog.ts b/packages/hunk/src/app/vcsCatalog.ts similarity index 100% rename from src/app/vcsCatalog.ts rename to packages/hunk/src/app/vcsCatalog.ts diff --git a/src/core/bootstrap.ts b/packages/hunk/src/core/bootstrap.ts similarity index 93% rename from src/core/bootstrap.ts rename to packages/hunk/src/core/bootstrap.ts index 4860fa1b8..8e5288180 100644 --- a/src/core/bootstrap.ts +++ b/packages/hunk/src/core/bootstrap.ts @@ -6,7 +6,7 @@ * This module names shapes the tiers below it already own — the changeset, the * parsed command input, the resolved view and keybinding preferences, the * detected theme mode — and composes them into the one value that crosses from - * startup into the UI. The module directories under `src/core` do not import it + * startup into the UI. The module directories under `packages/hunk/src/core` do not import it * back — composition sits above the leaves it composes — with one exception the * boundary rules name: `changeset/loaders.ts` assembles this value in * `loadAppBootstrap`, so it has to name the shape it returns. @@ -55,7 +55,7 @@ export interface AppBootstrap { initialCopyDecorations?: boolean; initialCursorLine?: CursorLine; startupNotices?: readonly StartupNotice[]; - /** Validated metadata attached only by an extension-delegated patch review. */ + /** Validated metadata describing a delegated or history-selected review source. */ review?: ExtensionReviewDescriptor; viewPreferencesConfigPath?: string; /** The user's `[keybindings]` table, resolved against command defaults in App. */ diff --git a/src/core/changeset/binary.test.ts b/packages/hunk/src/core/changeset/binary.test.ts similarity index 100% rename from src/core/changeset/binary.test.ts rename to packages/hunk/src/core/changeset/binary.test.ts diff --git a/src/core/changeset/binary.ts b/packages/hunk/src/core/changeset/binary.ts similarity index 100% rename from src/core/changeset/binary.ts rename to packages/hunk/src/core/changeset/binary.ts diff --git a/src/core/changeset/diffFile.test.ts b/packages/hunk/src/core/changeset/diffFile.test.ts similarity index 100% rename from src/core/changeset/diffFile.test.ts rename to packages/hunk/src/core/changeset/diffFile.test.ts diff --git a/src/core/changeset/diffFile.ts b/packages/hunk/src/core/changeset/diffFile.ts similarity index 100% rename from src/core/changeset/diffFile.ts rename to packages/hunk/src/core/changeset/diffFile.ts diff --git a/src/core/changeset/diffPaths.ts b/packages/hunk/src/core/changeset/diffPaths.ts similarity index 100% rename from src/core/changeset/diffPaths.ts rename to packages/hunk/src/core/changeset/diffPaths.ts diff --git a/src/core/changeset/fileLanguage.test.ts b/packages/hunk/src/core/changeset/fileLanguage.test.ts similarity index 100% rename from src/core/changeset/fileLanguage.test.ts rename to packages/hunk/src/core/changeset/fileLanguage.test.ts diff --git a/src/core/changeset/fileLanguage.ts b/packages/hunk/src/core/changeset/fileLanguage.ts similarity index 100% rename from src/core/changeset/fileLanguage.ts rename to packages/hunk/src/core/changeset/fileLanguage.ts diff --git a/src/core/changeset/fileLanguageLookup.ts b/packages/hunk/src/core/changeset/fileLanguageLookup.ts similarity index 100% rename from src/core/changeset/fileLanguageLookup.ts rename to packages/hunk/src/core/changeset/fileLanguageLookup.ts diff --git a/src/core/changeset/fileSource.test.ts b/packages/hunk/src/core/changeset/fileSource.test.ts similarity index 100% rename from src/core/changeset/fileSource.test.ts rename to packages/hunk/src/core/changeset/fileSource.test.ts diff --git a/src/core/changeset/fileSource.ts b/packages/hunk/src/core/changeset/fileSource.ts similarity index 96% rename from src/core/changeset/fileSource.ts rename to packages/hunk/src/core/changeset/fileSource.ts index b654fdfd8..acda20868 100644 --- a/src/core/changeset/fileSource.ts +++ b/packages/hunk/src/core/changeset/fileSource.ts @@ -1,6 +1,6 @@ -import { DEFAULT_SOURCE_TEXT_MAX_BYTES, readFileTextWithLimit } from "../../lib/sourceText"; +import { DEFAULT_SOURCE_TEXT_MAX_BYTES, readFileTextWithLimit } from "@hunk/vcs/source"; -export { DEFAULT_SOURCE_TEXT_MAX_BYTES } from "../../lib/sourceText"; +export { DEFAULT_SOURCE_TEXT_MAX_BYTES } from "@hunk/vcs/source"; /** * Generic full-file source fetcher primitives used by input loaders and VCS adapters. diff --git a/src/core/changeset/fromPatch.ts b/packages/hunk/src/core/changeset/fromPatch.ts similarity index 100% rename from src/core/changeset/fromPatch.ts rename to packages/hunk/src/core/changeset/fromPatch.ts diff --git a/src/core/changeset/hunkHeader.test.ts b/packages/hunk/src/core/changeset/hunkHeader.test.ts similarity index 100% rename from src/core/changeset/hunkHeader.test.ts rename to packages/hunk/src/core/changeset/hunkHeader.test.ts diff --git a/src/core/changeset/hunkHeader.ts b/packages/hunk/src/core/changeset/hunkHeader.ts similarity index 100% rename from src/core/changeset/hunkHeader.ts rename to packages/hunk/src/core/changeset/hunkHeader.ts diff --git a/src/core/changeset/hunkSummary.test.ts b/packages/hunk/src/core/changeset/hunkSummary.test.ts similarity index 95% rename from src/core/changeset/hunkSummary.test.ts rename to packages/hunk/src/core/changeset/hunkSummary.test.ts index 322985aea..7240d3743 100644 --- a/src/core/changeset/hunkSummary.test.ts +++ b/packages/hunk/src/core/changeset/hunkSummary.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { Hunk } from "@pierre/diffs"; -import { createJsxFileViewLayout } from "../../../examples/extensions/jsx-file-view"; -import { createTestDiffFile } from "../../../test/helpers/diff-helpers"; +import { createJsxFileViewLayout } from "../../../../../examples/extensions/jsx-file-view"; +import { createTestDiffFile } from "../../../../../test/helpers/diff-helpers"; import { createFileViewInput } from "../../ui/fileViews/host"; import { validateFileViewLayout } from "../../ui/fileViews/layout"; import { formatHunkHeader } from "./hunkHeader"; diff --git a/src/core/changeset/hunkSummary.ts b/packages/hunk/src/core/changeset/hunkSummary.ts similarity index 100% rename from src/core/changeset/hunkSummary.ts rename to packages/hunk/src/core/changeset/hunkSummary.ts diff --git a/src/core/changeset/loaders.ordering.test.ts b/packages/hunk/src/core/changeset/loaders.ordering.test.ts similarity index 96% rename from src/core/changeset/loaders.ordering.test.ts rename to packages/hunk/src/core/changeset/loaders.ordering.test.ts index 3143077b4..9651255d4 100644 --- a/src/core/changeset/loaders.ordering.test.ts +++ b/packages/hunk/src/core/changeset/loaders.ordering.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { SidecarContext } from "./model"; import { orderDiffFiles } from "./loaders"; -import { createTestDiffFile } from "../../../test/helpers/diff-helpers"; +import { createTestDiffFile } from "../../../../../test/helpers/diff-helpers"; function sidecar(...paths: string[]): SidecarContext { return { diff --git a/src/core/changeset/loaders.test.ts b/packages/hunk/src/core/changeset/loaders.test.ts similarity index 99% rename from src/core/changeset/loaders.test.ts rename to packages/hunk/src/core/changeset/loaders.test.ts index 9950f19da..380ac4ba4 100644 --- a/src/core/changeset/loaders.test.ts +++ b/packages/hunk/src/core/changeset/loaders.test.ts @@ -13,7 +13,7 @@ import { join } from "node:path"; import { replaceExtensionFileLanguages } from "./fileLanguage"; import { SourceTextTooLargeError } from "./fileSource"; import { getBundledVcsCatalog } from "../../app/vcsCatalog"; -import { createGitVcsAdapter } from "../../extensions/default/vcs/git"; +import { createGitVcsAdapter } from "@hunk/git"; import { toInternalVcsAdapter } from "../../extensions/runExtension"; import { createVcsCatalog } from "../vcs"; import { loadAppBootstrap as loadCoreAppBootstrap, type LoadAppBootstrapOptions } from "./loaders"; @@ -198,6 +198,14 @@ afterEach(() => { }); describe("loadAppBootstrap", () => { + test("refuses an already-cancelled embedded review load", async () => { + const abort = new AbortController(); + abort.abort(); + await expect( + loadAppBootstrap({ kind: "show", ref: "opaque", options: {} }, { signal: abort.signal }), + ).rejects.toThrow(); + }); + test("synthesizes untracked file diffs an adapter reported by path", async () => { const dir = createTempDir("hunk-adapter-untracked-"); writeFileSync(join(dir, "note.txt"), "hello\n"); @@ -1932,8 +1940,9 @@ describe("loadAppBootstrap source fetcher attachment", () => { mutableBun.spawn = originalSpawn; } - expect(syncCalls.some((call) => call.includes("rev-parse"))).toBe(true); - expect(syncCalls.some((call) => call.includes("diff"))).toBe(true); + expect(syncCalls).toEqual([]); + expect(asyncCalls.some((call) => call.includes("rev-parse"))).toBe(true); + expect(asyncCalls.some((call) => call.includes("diff"))).toBe(true); expect(asyncCalls).toContainEqual([gitExecutable, "show", ":value.txt"]); }); diff --git a/src/core/changeset/loaders.ts b/packages/hunk/src/core/changeset/loaders.ts similarity index 96% rename from src/core/changeset/loaders.ts rename to packages/hunk/src/core/changeset/loaders.ts index aece09bc4..ff17a33d4 100644 --- a/src/core/changeset/loaders.ts +++ b/packages/hunk/src/core/changeset/loaders.ts @@ -45,6 +45,8 @@ export interface LoadAppBootstrapOptions { customThemes?: readonly NamedCustomThemeConfig[]; /** Complete adapter catalog composed by the app for this session. */ vcsCatalog?: VcsCatalog; + /** Cancel provider-backed review loading before it mutates mounted state. */ + signal?: AbortSignal; } /** Return the final path segment for display-oriented labels. */ @@ -216,10 +218,11 @@ async function loadVcsChangeset( sidecar: SidecarContext | null, cwd: string, vcsCatalog: VcsCatalog, + signal?: AbortSignal, ) { const adapter = getConfiguredVcsAdapter(input.options.vcs, vcsCatalog); const operation = operationFromInput(input); - const result = await loadVcsReview(adapter, operation, { cwd }, vcsCatalog); + const result = await loadVcsReview(adapter, operation, { cwd, signal }, vcsCatalog); const parsedChangeset = changesetFromPatch( result.patchText, result.title, @@ -274,8 +277,9 @@ async function loadPatchChangeset( /** Resolve CLI input into the fully loaded app bootstrap state. */ export async function loadAppBootstrap( input: CliInput, - { cwd = process.cwd(), customThemes, vcsCatalog }: LoadAppBootstrapOptions = {}, + { cwd = process.cwd(), customThemes, vcsCatalog, signal }: LoadAppBootstrapOptions = {}, ): Promise { + signal?.throwIfAborted(); // Capture before loading content so watch mode can detect mutations that race initial loading. let initialWatchSignature: string | undefined; if (input.options.watch) { @@ -289,6 +293,7 @@ export async function loadAppBootstrap( } const sidecar = await loadSidecarContext(input.options.agentContext, { cwd }); + signal?.throwIfAborted(); let changeset: Changeset; let repoRoot: string | undefined; @@ -301,7 +306,7 @@ export async function loadAppBootstrap( if (!vcsCatalog) { throw new Error("VCS-backed reviews require a composed VCS catalog."); } - const result = await loadVcsChangeset(input, sidecar, cwd, vcsCatalog); + const result = await loadVcsChangeset(input, sidecar, cwd, vcsCatalog, signal); changeset = result.changeset; repoRoot = result.repoRoot; } @@ -317,6 +322,7 @@ export async function loadAppBootstrap( break; } + signal?.throwIfAborted(); changeset = { ...changeset, files: orderDiffFiles(changeset.files, sidecar), diff --git a/src/core/changeset/model.ts b/packages/hunk/src/core/changeset/model.ts similarity index 100% rename from src/core/changeset/model.ts rename to packages/hunk/src/core/changeset/model.ts diff --git a/src/core/changeset/sidecar.test.ts b/packages/hunk/src/core/changeset/sidecar.test.ts similarity index 100% rename from src/core/changeset/sidecar.test.ts rename to packages/hunk/src/core/changeset/sidecar.test.ts diff --git a/src/core/changeset/sidecar.ts b/packages/hunk/src/core/changeset/sidecar.ts similarity index 98% rename from src/core/changeset/sidecar.ts rename to packages/hunk/src/core/changeset/sidecar.ts index 2540bd077..bb8c9ebb4 100644 --- a/src/core/changeset/sidecar.ts +++ b/packages/hunk/src/core/changeset/sidecar.ts @@ -4,7 +4,7 @@ * "Sidecar" names the file and its loader; "agent" names what the file carries. The notes * inside it stay `AgentAnnotation` / `AgentFileContext` because that is both the published * extension contract and the term the UI shows. Keeping the two apart leaves `agent` free to - * mean the coding-agent command surface in `src/session/agent/`. + * mean the coding-agent command surface in `packages/hunk/src/session/agent/`. */ import { resolve as resolvePath } from "node:path"; import type { AgentAnnotation, AgentFileContext } from "../../extension-api/types"; diff --git a/packages/hunk/src/core/history/lanePlanner.test.ts b/packages/hunk/src/core/history/lanePlanner.test.ts new file mode 100644 index 000000000..95ed565d8 --- /dev/null +++ b/packages/hunk/src/core/history/lanePlanner.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "bun:test"; +import { createHistoryLaneCheckpoint, planHistoryPage } from "./lanePlanner"; +import type { HistoryCommit } from "./types"; + +/** Build one deterministic history commit for graph tests. */ +function commit(revisionId: string, parentRevisionIds: string[] = []): HistoryCommit { + return { + revisionId, + displayId: revisionId, + parentRevisionIds, + subject: revisionId, + authorName: "Test", + authoredAt: "2026-01-01T00:00:00Z", + decorations: [], + }; +} + +describe("history lane planning", () => { + test("preserves first-parent continuity and ordered merge parents", () => { + const planned = planHistoryPage([ + commit("merge", ["main", "side"]), + commit("side", ["base"]), + commit("main", ["base"]), + commit("base"), + ]); + + expect(planned.rows.map((row) => [row.commit.revisionId, row.lane])).toEqual([ + ["merge", 0], + ["side", 1], + ["main", 0], + ["base", 0], + ]); + expect(planned.checkpoint.lanes).toEqual([]); + }); + + test("draws the side branch converging into an already-active first parent", () => { + const planned = planHistoryPage([ + commit("merge", ["main", "side"]), + commit("side", ["main"]), + commit("main"), + ]); + + expect(planned.rows[1]!.convergences).toEqual([{ from: 1, to: 0 }]); + expect(planned.checkpoint.lanes).toEqual([]); + }); + + test("uses explicit graph parents without changing review parents", () => { + const filtered = { ...commit("match", ["omitted"]), graphParentRevisionIds: [] }; + const olderFiltered = { + ...commit("older-match", ["another-omitted"]), + graphParentRevisionIds: [], + }; + const planned = planHistoryPage([filtered, olderFiltered]); + + expect(planned.rows.map((row) => row.lanesAfter)).toEqual([[], []]); + expect(filtered.parentRevisionIds).toEqual(["omitted"]); + }); + + test("produces identical rows across every page partition", () => { + const commits = [ + commit("merge", ["main", "side"]), + commit("side", ["base"]), + commit("main", ["base"]), + commit("base"), + ]; + const whole = planHistoryPage(commits); + // Every bit chooses whether a page ends after that commit, covering all 2^(n-1) partitions. + for (let boundaries = 0; boundaries < 1 << (commits.length - 1); boundaries += 1) { + let checkpoint = createHistoryLaneCheckpoint(); + const rows = []; + let start = 0; + for (let index = 0; index < commits.length; index += 1) { + if (index < commits.length - 1 && (boundaries & (1 << index)) === 0) continue; + const page = planHistoryPage(commits.slice(start, index + 1), checkpoint); + rows.push(...page.rows); + checkpoint = page.checkpoint; + start = index + 1; + } + expect(rows).toEqual(whole.rows); + expect(checkpoint).toEqual(whole.checkpoint); + } + }); + + test("rejects duplicate revisions within one page", () => { + expect(() => planHistoryPage([commit("same"), commit("same")])).toThrow("duplicate revision"); + }); +}); diff --git a/packages/hunk/src/core/history/lanePlanner.ts b/packages/hunk/src/core/history/lanePlanner.ts new file mode 100644 index 000000000..237b9095e --- /dev/null +++ b/packages/hunk/src/core/history/lanePlanner.ts @@ -0,0 +1,100 @@ +import type { + HistoryCommit, + HistoryGraphCell, + HistoryLaneCheckpoint, + PlannedHistoryPage, +} from "./types"; + +/** Create the empty graph state used before the first history page. */ +export function createHistoryLaneCheckpoint(): HistoryLaneCheckpoint { + return { lanes: [] }; +} + +/** Return unique graph parent ids in the provider's declared order. */ +function orderedUniqueParents(commit: HistoryCommit) { + const seen = new Set(); + return (commit.graphParentRevisionIds ?? commit.parentRevisionIds).filter((parent) => { + if (seen.has(parent)) return false; + seen.add(parent); + return true; + }); +} + +/** Plan one newest-first page while preserving enough lane state for continuation. */ +export function planHistoryPage( + commits: readonly HistoryCommit[], + checkpoint: HistoryLaneCheckpoint = createHistoryLaneCheckpoint(), +): PlannedHistoryPage { + const lanes = [...checkpoint.lanes]; + const pageRevisionIds = new Set(); + const rows: PlannedHistoryPage["rows"] = []; + + for (const commit of commits) { + if (pageRevisionIds.has(commit.revisionId)) { + throw new Error(`History contains duplicate revision ${commit.revisionId}.`); + } + pageRevisionIds.add(commit.revisionId); + + let lane = lanes.indexOf(commit.revisionId); + if (lane < 0) { + // Independent tips (for example `--all`) enter at the left edge. Missing shallow parents + // may also produce a new tip; neither case invents ancestry. + lane = 0; + lanes.unshift(commit.revisionId); + } + + const lanesBefore = [...lanes]; + const parents = orderedUniqueParents(commit); + const existingParents = parents.filter((parent) => { + const index = lanesBefore.indexOf(parent); + return index >= 0 && index !== lane; + }); + lanes.splice(lane, 1, ...parents); + + // A merge parent can already be active through another child. Keep its leftmost lane and + // collapse duplicates so topology state stays bounded by the active frontier. + const deduplicated: string[] = []; + for (const revisionId of lanes) { + if (!deduplicated.includes(revisionId)) deduplicated.push(revisionId); + } + lanes.splice(0, lanes.length, ...deduplicated); + + const convergences = lanesBefore.flatMap((revisionId, from) => { + if (from === lane) return []; + const to = lanes.indexOf(revisionId); + return to >= 0 && to !== from ? [{ from, to }] : []; + }); + for (const parent of existingParents) { + const to = lanes.indexOf(parent); + if ( + to >= 0 && + lane !== to && + !convergences.some((edge) => edge.from === lane && edge.to === to) + ) { + convergences.push({ from: lane, to }); + } + } + const width = Math.max(lanesBefore.length, lanes.length, lane + 1); + const cells: HistoryGraphCell[] = Array.from({ length: width }, (_, index) => ({ + kind: index === lane ? "node" : index < lanesBefore.length ? "vertical" : "empty", + })); + + rows.push({ + commit, + lane, + cells, + lanesBefore, + lanesAfter: [...lanes], + parentLanes: parents.flatMap((parent) => { + const index = lanes.indexOf(parent); + return index < 0 ? [] : [index]; + }), + convergences, + }); + } + + return { + rows, + checkpoint: { lanes: [...lanes] }, + }; +} diff --git a/packages/hunk/src/core/history/types.ts b/packages/hunk/src/core/history/types.ts new file mode 100644 index 000000000..2e1205bed --- /dev/null +++ b/packages/hunk/src/core/history/types.ts @@ -0,0 +1,32 @@ +import type { ExtensionVcsHistoryCommit } from "../../extension-api/types"; + +/** One normalized commit in newest-first provider order. */ +export type HistoryCommit = ExtensionVcsHistoryCommit; + +/** JSON-safe state required to continue planning graph lanes on a later page. */ +export interface HistoryLaneCheckpoint { + lanes: string[]; +} + +/** One symbolic lane shown on a commit row. */ +export interface HistoryGraphCell { + kind: "vertical" | "node" | "empty"; +} + +/** One symbolic graph row plus the lane mapping after its commit. */ +export interface HistoryGraphRow { + commit: HistoryCommit; + lane: number; + cells: HistoryGraphCell[]; + lanesBefore: string[]; + lanesAfter: string[]; + parentLanes: number[]; + /** Existing active lanes that collapse into a parent lane after this commit. */ + convergences: Array<{ from: number; to: number }>; +} + +/** A planned page and the checkpoint used by the following page. */ +export interface PlannedHistoryPage { + rows: HistoryGraphRow[]; + checkpoint: HistoryLaneCheckpoint; +} diff --git a/src/core/install/installSource.test.ts b/packages/hunk/src/core/install/installSource.test.ts similarity index 99% rename from src/core/install/installSource.test.ts rename to packages/hunk/src/core/install/installSource.test.ts index 7ec220aeb..7cc0d7fa5 100644 --- a/src/core/install/installSource.test.ts +++ b/packages/hunk/src/core/install/installSource.test.ts @@ -156,7 +156,7 @@ describe("install source detection", () => { }); test("detects local source builds installed into the default install directory", () => { - // Built from this platform's own default so the check tracks `scripts/install-bin.ts`. + // Built from this platform's own default so the check tracks `scripts/build/install-bin.ts`. const installDir = resolveDevInstallDir({}, HOME_DIR); expect(installDir).toBeDefined(); expect( diff --git a/src/core/install/installSource.ts b/packages/hunk/src/core/install/installSource.ts similarity index 98% rename from src/core/install/installSource.ts rename to packages/hunk/src/core/install/installSource.ts index 72a5a5dc9..5cac017a8 100644 --- a/src/core/install/installSource.ts +++ b/packages/hunk/src/core/install/installSource.ts @@ -122,7 +122,7 @@ function isHomebrewExecutablePath(executablePath: string) { /** * Resolve the directory `bun run install:bin` copies local builds into. * - * Mirrors `scripts/install-bin.ts`: an explicit `HUNK_INSTALL_DIR` wins, Windows installs land in + * Mirrors `scripts/build/install-bin.ts`: an explicit `HUNK_INSTALL_DIR` wins, Windows installs land in * the per-user Programs directory, and everything else uses `~/.local/bin`. */ export function resolveDevInstallDir(env: NodeJS.ProcessEnv, homeDir: string | undefined) { diff --git a/src/core/install/latestRelease.test.ts b/packages/hunk/src/core/install/latestRelease.test.ts similarity index 100% rename from src/core/install/latestRelease.test.ts rename to packages/hunk/src/core/install/latestRelease.test.ts diff --git a/src/core/install/latestRelease.ts b/packages/hunk/src/core/install/latestRelease.ts similarity index 100% rename from src/core/install/latestRelease.ts rename to packages/hunk/src/core/install/latestRelease.ts diff --git a/src/core/install/selfUpdate.test.ts b/packages/hunk/src/core/install/selfUpdate.test.ts similarity index 100% rename from src/core/install/selfUpdate.test.ts rename to packages/hunk/src/core/install/selfUpdate.test.ts diff --git a/src/core/install/selfUpdate.ts b/packages/hunk/src/core/install/selfUpdate.ts similarity index 100% rename from src/core/install/selfUpdate.ts rename to packages/hunk/src/core/install/selfUpdate.ts diff --git a/src/core/liveComments.test.ts b/packages/hunk/src/core/liveComments.test.ts similarity index 98% rename from src/core/liveComments.test.ts rename to packages/hunk/src/core/liveComments.test.ts index c1a3a789b..3e1b1a129 100644 --- a/src/core/liveComments.test.ts +++ b/packages/hunk/src/core/liveComments.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createTestDiffFile, lines } from "../../test/helpers/diff-helpers"; +import { createTestDiffFile, lines } from "../../../../test/helpers/diff-helpers"; import { buildLiveComment, findDiffFileByPath, diff --git a/src/core/liveComments.ts b/packages/hunk/src/core/liveComments.ts similarity index 74% rename from src/core/liveComments.ts rename to packages/hunk/src/core/liveComments.ts index b4d431167..1e3aea102 100644 --- a/src/core/liveComments.ts +++ b/packages/hunk/src/core/liveComments.ts @@ -14,20 +14,43 @@ export interface UserNoteLineTarget { line: number; } -export interface CommentTargetInput { - filePath: string; - hunkIndex?: number; - side?: DiffSide; - line?: number; +export interface CommentBodyInput { summary: string; rationale?: string; - /** Optional STML markup rendered as the note body (see src/ui/lib/stml). */ + /** Optional STML markup rendered as the note body (see packages/hunk/src/ui/lib/stml). */ markup?: string; author?: string; } +export type CommentTargetInput = CommentBodyInput & + ( + | { + filePath: string; + hunkIndex: number; + side?: DiffSide; + line?: number; + replyTo?: never; + } + | { + filePath: string; + hunkIndex?: never; + side: DiffSide; + line: number; + replyTo?: never; + } + | { + filePath?: never; + hunkIndex?: never; + side?: never; + line?: never; + /** Existing semantic note whose file and anchor this reply inherits. */ + replyTo: string; + } + ); + export interface LiveComment extends AgentAnnotation { id: string; + parentId?: string; source: "mcp"; author?: string; createdAt: string; @@ -56,7 +79,12 @@ export function findHunkIndexForLine(file: DiffFile, side: DiffSide, line: numbe /** Resolve either a hunk-wide or line-specific target against one visible diff file. */ export function resolveCommentTarget( file: DiffFile, - input: CommentTargetInput, + input: CommentBodyInput & { + filePath: string; + hunkIndex?: number; + side?: DiffSide; + line?: number; + }, ): ResolvedCommentTarget { if (input.hunkIndex !== undefined) { const hunk = file.metadata.hunks[input.hunkIndex]; @@ -88,13 +116,19 @@ export function resolveCommentTarget( /** Convert one incoming session-daemon comment command into a live annotation. */ export function buildLiveComment( - input: CommentTargetInput & { side: DiffSide; line: number }, + input: CommentBodyInput & { + filePath: string; + side: DiffSide; + line: number; + replyTo?: string; + }, commentId: string, createdAt: string, hunkIndex: number, ): LiveComment { return { id: commentId, + ...(input.replyTo ? { parentId: input.replyTo } : {}), source: "mcp", author: input.author, createdAt, diff --git a/src/core/patch/chunks.test.ts b/packages/hunk/src/core/patch/chunks.test.ts similarity index 100% rename from src/core/patch/chunks.test.ts rename to packages/hunk/src/core/patch/chunks.test.ts diff --git a/src/core/patch/chunks.ts b/packages/hunk/src/core/patch/chunks.ts similarity index 100% rename from src/core/patch/chunks.ts rename to packages/hunk/src/core/patch/chunks.ts diff --git a/src/core/patch/gitFormat.test.ts b/packages/hunk/src/core/patch/gitFormat.test.ts similarity index 100% rename from src/core/patch/gitFormat.test.ts rename to packages/hunk/src/core/patch/gitFormat.test.ts diff --git a/src/core/patch/gitFormat.ts b/packages/hunk/src/core/patch/gitFormat.ts similarity index 100% rename from src/core/patch/gitFormat.ts rename to packages/hunk/src/core/patch/gitFormat.ts diff --git a/src/core/patch/gitLog.test.ts b/packages/hunk/src/core/patch/gitLog.test.ts similarity index 100% rename from src/core/patch/gitLog.test.ts rename to packages/hunk/src/core/patch/gitLog.test.ts diff --git a/src/core/patch/gitLog.ts b/packages/hunk/src/core/patch/gitLog.ts similarity index 100% rename from src/core/patch/gitLog.ts rename to packages/hunk/src/core/patch/gitLog.ts diff --git a/src/core/patch/sanitize.test.ts b/packages/hunk/src/core/patch/sanitize.test.ts similarity index 100% rename from src/core/patch/sanitize.test.ts rename to packages/hunk/src/core/patch/sanitize.test.ts diff --git a/src/core/patch/sanitize.ts b/packages/hunk/src/core/patch/sanitize.ts similarity index 100% rename from src/core/patch/sanitize.ts rename to packages/hunk/src/core/patch/sanitize.ts diff --git a/src/core/patch/singleFile.ts b/packages/hunk/src/core/patch/singleFile.ts similarity index 100% rename from src/core/patch/singleFile.ts rename to packages/hunk/src/core/patch/singleFile.ts diff --git a/src/core/process/appStateFile.test.ts b/packages/hunk/src/core/process/appStateFile.test.ts similarity index 100% rename from src/core/process/appStateFile.test.ts rename to packages/hunk/src/core/process/appStateFile.test.ts diff --git a/src/core/process/appStateFile.ts b/packages/hunk/src/core/process/appStateFile.ts similarity index 100% rename from src/core/process/appStateFile.ts rename to packages/hunk/src/core/process/appStateFile.ts diff --git a/src/core/process/jobControl.test.ts b/packages/hunk/src/core/process/jobControl.test.ts similarity index 70% rename from src/core/process/jobControl.test.ts rename to packages/hunk/src/core/process/jobControl.test.ts index 3a0170306..eb12c3a35 100644 --- a/src/core/process/jobControl.test.ts +++ b/packages/hunk/src/core/process/jobControl.test.ts @@ -57,54 +57,6 @@ function createMockRenderer() { }; } -function createSignalHarness() { - const listeners = new Map void>>(); - const onceWrappers = new Map<() => void, () => void>(); - const removed: NodeJS.Signals[] = []; - - return { - emit(signal: NodeJS.Signals) { - const signalListeners = listeners.get(signal); - if (!signalListeners) { - return; - } - - const snapshot = Array.from(signalListeners); - for (const listener of snapshot) { - listener(); - } - }, - listenerCount(signal: NodeJS.Signals) { - return listeners.get(signal)?.size ?? 0; - }, - off(signal: NodeJS.Signals, listener: () => void) { - removed.push(signal); - listeners.get(signal)?.delete(listener); - const wrapped = onceWrappers.get(listener); - if (wrapped) { - listeners.get(signal)?.delete(wrapped); - onceWrappers.delete(listener); - } - }, - once(signal: NodeJS.Signals, listener: () => void) { - const wrapped = () => { - listeners.get(signal)?.delete(wrapped); - onceWrappers.delete(listener); - listener(); - }; - onceWrappers.set(listener, wrapped); - - let signalListeners = listeners.get(signal); - if (!signalListeners) { - signalListeners = new Set(); - listeners.set(signal, signalListeners); - } - signalListeners.add(wrapped); - }, - removed, - }; -} - describe("installJobControlInterruptSupport", () => { test("routes Ctrl-C through the provided shutdown callback", () => { const renderer = createMockRenderer(); @@ -185,45 +137,40 @@ describe("installJobControlSuspendSupport", () => { expect(sentSignals).toEqual([]); }); - test("suspends the foreground process group on Ctrl-Z and resumes on SIGCONT", () => { + test("suspends the foreground process group on Ctrl-Z and resumes once the job continues", () => { const renderer = createMockRenderer(); - const signals = createSignalHarness(); const sentSignals: Array<{ pid: number; signal: NodeJS.Signals }> = []; installJobControlSuspendSupport(renderer, { - kill: (pid, signal) => sentSignals.push({ pid, signal }), - off: signals.off, - once: signals.once, + kill: (pid, signal) => { + // Stands in for the stopped process: the renderer stays suspended until kill returns. + sentSignals.push({ pid, signal }); + expect(renderer.suspendCalls).toBe(1); + expect(renderer.resumeCalls).toBe(0); + }, platform: "linux", }); const ctrlZ = createTestKey({ ctrl: true, name: "z" }); renderer.emitKeypress(ctrlZ); + expect(ctrlZ.defaultPrevented).toBe(true); expect(ctrlZ.propagationStopped).toBe(true); - expect(renderer.suspendCalls).toBe(1); - expect(signals.listenerCount("SIGCONT")).toBe(1); expect(sentSignals).toEqual([{ pid: 0, signal: "SIGTSTP" }]); - - signals.emit("SIGCONT"); expect(renderer.resumeCalls).toBe(1); - expect(signals.listenerCount("SIGCONT")).toBe(0); }); - test("does not resume a destroyed renderer after SIGCONT", () => { + test("does not resume a destroyed renderer", () => { const renderer = createMockRenderer(); - const signals = createSignalHarness(); installJobControlSuspendSupport(renderer, { - kill: () => undefined, - off: signals.off, - once: signals.once, + kill: () => { + renderer.isDestroyed = true; + }, platform: "linux", }); renderer.emitKeypress(createTestKey({ ctrl: true, name: "z" })); - renderer.isDestroyed = true; - signals.emit("SIGCONT"); expect(renderer.suspendCalls).toBe(1); expect(renderer.resumeCalls).toBe(0); @@ -231,31 +178,24 @@ describe("installJobControlSuspendSupport", () => { test("restores the renderer if SIGTSTP cannot be sent", () => { const renderer = createMockRenderer(); - const signals = createSignalHarness(); installJobControlSuspendSupport(renderer, { kill: () => { throw new Error("unsupported signal"); }, - off: signals.off, - once: signals.once, platform: "linux", }); renderer.emitKeypress(createTestKey({ ctrl: true, name: "z" })); expect(renderer.suspendCalls).toBe(1); expect(renderer.resumeCalls).toBe(1); - expect(signals.listenerCount("SIGCONT")).toBe(0); }); - test("dispose removes the keypress listener and pending SIGCONT listener", () => { + test("dispose removes the keypress listener", () => { const renderer = createMockRenderer(); - const signals = createSignalHarness(); const support = installJobControlSuspendSupport(renderer, { kill: () => undefined, - off: signals.off, - once: signals.once, platform: "linux", }); @@ -263,7 +203,6 @@ describe("installJobControlSuspendSupport", () => { support.dispose(); expect(renderer.keypressListeners.size).toBe(0); - expect(signals.listenerCount("SIGCONT")).toBe(0); renderer.emitKeypress(createTestKey({ ctrl: true, name: "z" })); expect(renderer.suspendCalls).toBe(1); diff --git a/src/core/process/jobControl.ts b/packages/hunk/src/core/process/jobControl.ts similarity index 77% rename from src/core/process/jobControl.ts rename to packages/hunk/src/core/process/jobControl.ts index e513c6463..97afd856b 100644 --- a/src/core/process/jobControl.ts +++ b/packages/hunk/src/core/process/jobControl.ts @@ -1,6 +1,5 @@ import type { CliRenderer, KeyEvent } from "@opentui/core"; -type SignalListener = () => void; type KeypressListener = (key: KeyEvent) => void; type JobControlRenderer = Pick & { @@ -13,8 +12,6 @@ type JobControlRenderer = Pick unknown; - off?: (signal: NodeJS.Signals, listener: SignalListener) => unknown; - once?: (signal: NodeJS.Signals, listener: SignalListener) => unknown; platform?: NodeJS.Platform | string; /** Signal target passed to process.kill; defaults to 0 for the foreground process group. */ pid?: number; @@ -73,7 +70,15 @@ export function installJobControlInterruptSupport( * OpenTUI receives Ctrl-Z as a parsed keypress instead of letting the terminal driver turn it into * SIGTSTP. Match the common TUI pattern used by apps like opencode: treat Ctrl-Z as an app command, * ask OpenTUI to restore the terminal, then send SIGTSTP to the foreground process group so the - * shell can manage Hunk as a normal suspended job. SIGCONT resumes the renderer after `fg`. + * shell can manage Hunk as a normal suspended job. + * + * For a normal shell-managed job, the stop takes effect inside `kill`, so the statement after it + * runs only once the shell continues the job with `fg`. Suspend and resume are therefore one + * straight line. Staying on that call stack is also what keeps the job alive, since OpenTUI's + * suspend drops its keep-alive timer and stops reading stdin, so a runtime that reached an idle + * event loop here could exit before being continued. A `kill` that returns without stopping — a + * runtime that refuses SIGTSTP, or an orphaned process group that discards it — reaches the same + * restore instead of waiting for a SIGCONT nobody will send. */ export function installJobControlSuspendSupport( renderer: JobControlRenderer, @@ -85,38 +90,21 @@ export function installJobControlSuspendSupport( } const kill = deps.kill ?? process.kill.bind(process); - const off = deps.off ?? process.off.bind(process); - const once = deps.once ?? process.once.bind(process); const pid = deps.pid ?? 0; let disposed = false; - let resumeOnContinue: SignalListener | null = null; - - const clearPendingContinue = () => { - if (resumeOnContinue) { - off("SIGCONT", resumeOnContinue); - resumeOnContinue = null; - } - }; const suspend = () => { - resumeOnContinue = () => { - resumeOnContinue = null; - if (!renderer.isDestroyed) { - renderer.resume(); - } - }; - renderer.suspend(); - once("SIGCONT", resumeOnContinue); try { + // Blocks until the shell continues this job; see the note above. kill(pid, "SIGTSTP"); } catch { - // If the platform/runtime refuses SIGTSTP, leave the app usable instead of half-suspended. - clearPendingContinue(); - if (!renderer.isDestroyed) { - renderer.resume(); - } + // A runtime that refuses SIGTSTP leaves the app usable instead of half-suspended. + } + + if (!renderer.isDestroyed) { + renderer.resume(); } }; @@ -135,7 +123,6 @@ export function installJobControlSuspendSupport( return { dispose: () => { disposed = true; - clearPendingContinue(); renderer.keyInput.off("keypress", keypressListener); }, }; diff --git a/src/core/process/pager.test.ts b/packages/hunk/src/core/process/pager.test.ts similarity index 89% rename from src/core/process/pager.test.ts rename to packages/hunk/src/core/process/pager.test.ts index 84c7d2960..ec4d2449e 100644 --- a/src/core/process/pager.test.ts +++ b/packages/hunk/src/core/process/pager.test.ts @@ -20,6 +20,7 @@ function createClosingPager(code = 0) { const OSC52_CLIPBOARD = "\x1b]52;c;SGVsbG8=\x07"; const CSI_CLEAR_SCREEN = "\x1b[2J"; const DCS_PAYLOAD = "\x1bPqpayload\x1b\\"; +const DEFAULT_TEXT_PAGER = process.platform === "win32" ? "more" : "less -R"; function expectNoUnsafeTerminalControls(text: string) { expect(text).not.toContain(OSC52_CLIPBOARD); @@ -116,17 +117,17 @@ describe("general pager detection", () => { describe("plain text pager fallback", () => { test("falls back to less when no pager is configured", () => { - expect(resolveTextPagerCommand({})).toBe("less -R"); + expect(resolveTextPagerCommand({})).toBe(DEFAULT_TEXT_PAGER); }); test("prefers HUNK_TEXT_PAGER and avoids recursive hunk launches", () => { expect(resolveTextPagerCommand({ HUNK_TEXT_PAGER: "bat --paging=always" })).toBe( "bat --paging=always", ); - expect(resolveTextPagerCommand({ HUNK_TEXT_PAGER: "hunk pager" })).toBe("less -R"); - expect(resolveTextPagerCommand({ PAGER: "env FOO=1 hunk pager" })).toBe("less -R"); + expect(resolveTextPagerCommand({ HUNK_TEXT_PAGER: "hunk pager" })).toBe(DEFAULT_TEXT_PAGER); + expect(resolveTextPagerCommand({ PAGER: "env FOO=1 hunk pager" })).toBe(DEFAULT_TEXT_PAGER); expect(resolveTextPagerCommand({ PAGER: String.raw`"C:\tools\hunk.exe" pager` })).toBe( - "less -R", + DEFAULT_TEXT_PAGER, ); }); @@ -342,7 +343,7 @@ describe("plain text pager fallback", () => { }); test("supports simple env wrappers while still blocking recursive hunk pagers", async () => { - expect(resolveTextPagerCommand({ PAGER: "env LESS=FRX hunk pager" })).toBe("less -R"); + expect(resolveTextPagerCommand({ PAGER: "env LESS=FRX hunk pager" })).toBe(DEFAULT_TEXT_PAGER); await pagePlainText( "plain text", @@ -358,6 +359,44 @@ describe("plain text pager fallback", () => { ); }); + test("accepts EPIPE only when an early-closing pager exits successfully", async () => { + const pager = new EventEmitter() as EventEmitter & { stdin: PassThrough }; + pager.stdin = new PassThrough(); + await pagePlainText( + "long output", + { PAGER: "less -R" }, + createPagerDeps({ + spawnImpl() { + queueMicrotask(() => { + const error = Object.assign(new Error("broken pipe"), { code: "EPIPE" }); + pager.stdin.emit("error", error); + pager.emit("close", 0); + }); + return pager as never; + }, + }), + ); + }); + + test("rejects EPIPE when the pager itself reports failure", async () => { + const pager = new EventEmitter() as EventEmitter & { stdin: PassThrough }; + pager.stdin = new PassThrough(); + const promise = pagePlainText( + "long output", + { PAGER: "less -R" }, + createPagerDeps({ + spawnImpl() { + queueMicrotask(() => { + pager.stdin.emit("error", Object.assign(new Error("broken pipe"), { code: "EPIPE" })); + pager.emit("close", 1); + }); + return pager as never; + }, + }), + ); + await expect(promise).rejects.toThrow("Pager command failed"); + }); + test("throws when the pager exits with a non-zero status", async () => { const pager = new EventEmitter() as EventEmitter & { stdin: PassThrough }; pager.stdin = new PassThrough(); diff --git a/src/core/process/pager.ts b/packages/hunk/src/core/process/pager.ts similarity index 72% rename from src/core/process/pager.ts rename to packages/hunk/src/core/process/pager.ts index a946ca1d1..a874646e8 100644 --- a/src/core/process/pager.ts +++ b/packages/hunk/src/core/process/pager.ts @@ -15,7 +15,7 @@ export function looksLikePatchInput(text: string) { ); } -const DEFAULT_TEXT_PAGER_COMMAND = "less -R"; +const DEFAULT_TEXT_PAGER_COMMAND = process.platform === "win32" ? "more" : "less -R"; interface ResolvedPagerCommand { command: string; @@ -142,9 +142,14 @@ export interface PlainTextPagerDeps { spawnImpl: (command: string, args: string[], options: SpawnOptions) => ChildProcess; } -/** Stream plain text through a normal pager, or write directly when not attached to a terminal. */ -export async function pagePlainText( - text: string, +export interface PlainTextPagerWriter { + /** Return false when the pager closed early and the producer should stop reading. */ + write(text: string): Promise; + close(): Promise; +} + +/** Open one pager writer so callers can stream bounded chunks after deciding output will overflow. */ +export function openPlainTextPager( env: NodeJS.ProcessEnv = process.env, deps: PlainTextPagerDeps = { // Write through the descriptor rather than `process.stdout`: a piped consumer takes one @@ -158,45 +163,95 @@ export async function pagePlainText( }, spawnImpl: spawn, }, -) { +): PlainTextPagerWriter { if (!deps.stdout.isTTY) { - deps.stdout.write(sanitizeTerminalText(text)); - return; + return { + async write(text) { + deps.stdout.write(sanitizeTerminalText(text)); + }, + async close() {}, + }; } - const safeText = sanitizeTerminalText(text, { preserveAnsiStyle: true }); - const pagerSpec = resolveTextPagerSpec(env); const pagerCommand = pagerSpec.displayCommand; - let pager: ChildProcess; try { pager = deps.spawnImpl(pagerSpec.command, pagerSpec.args, { shell: false, stdio: ["pipe", "inherit", "inherit"], - env: { - ...env, - ...pagerSpec.env, - }, + env: { ...env, ...pagerSpec.env }, }); } catch (error) { throw new Error(`Pager command failed: ${pagerCommand}`, { cause: error }); } let spawnError: unknown; + let stdinError: NodeJS.ErrnoException | undefined; + let closed = false; + let pagerExited = false; const closeCode = new Promise((resolve) => { pager.once("error", (error) => { spawnError = error; }); pager.once("close", (code) => { + pagerExited = true; resolve(typeof code === "number" ? code : null); }); }); + pager.stdin?.once("error", (error: NodeJS.ErrnoException) => { + stdinError = error; + }); - pager.stdin?.end(safeText); - const code = await closeCode; + return { + async write(text) { + if (closed || pagerExited || stdinError?.code === "EPIPE") return false; + const safeText = sanitizeTerminalText(text, { preserveAnsiStyle: true }); + if (!pager.stdin?.write(safeText)) { + await new Promise((resolve) => { + const finish = () => { + pager.stdin?.off("drain", finish); + pager.stdin?.off("error", finish); + pager.off("close", finish); + resolve(); + }; + pager.stdin?.once("drain", finish); + pager.stdin?.once("error", finish); + pager.once("close", finish); + }); + } + return !pagerExited && stdinError?.code !== "EPIPE"; + }, + async close() { + if (closed) return; + closed = true; + if (!pager.stdin?.destroyed) pager.stdin?.end(); + const code = await closeCode; + const normalEarlyQuit = stdinError?.code === "EPIPE" && code === 0; + if ( + spawnError || + (stdinError && !normalEarlyQuit) || + (typeof code === "number" && code !== 0) + ) { + throw new Error(`Pager command failed: ${pagerCommand}`, { + cause: spawnError ?? stdinError, + }); + } + }, + }; +} - if (spawnError || (typeof code === "number" && code !== 0)) { - throw new Error(`Pager command failed: ${pagerCommand}`, { cause: spawnError }); +/** Stream plain text through a normal pager, or write directly when not attached to a terminal. */ +export async function pagePlainText( + text: string, + env: NodeJS.ProcessEnv = process.env, + deps: PlainTextPagerDeps = { stdout: process.stdout, spawnImpl: spawn }, +) { + if (!deps.stdout.isTTY) { + deps.stdout.write(sanitizeTerminalText(text)); + return; } + const pager = openPlainTextPager(env, deps); + await pager.write(text); + await pager.close(); } diff --git a/src/core/process/projectRoot.test.ts b/packages/hunk/src/core/process/projectRoot.test.ts similarity index 100% rename from src/core/process/projectRoot.test.ts rename to packages/hunk/src/core/process/projectRoot.test.ts diff --git a/src/core/process/projectRoot.ts b/packages/hunk/src/core/process/projectRoot.ts similarity index 100% rename from src/core/process/projectRoot.ts rename to packages/hunk/src/core/process/projectRoot.ts diff --git a/packages/hunk/src/core/process/relaunch.test.ts b/packages/hunk/src/core/process/relaunch.test.ts new file mode 100644 index 000000000..4241f0f57 --- /dev/null +++ b/packages/hunk/src/core/process/relaunch.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test"; +import { resolveCurrentHunkCommand } from "./relaunch"; + +describe("current Hunk relaunch resolution", () => { + test("keeps source and wrapper entrypoints", () => { + expect(resolveCurrentHunkCommand(["bun", "/repo/src/main.tsx"], "/bin/bun")).toEqual({ + command: "/bin/bun", + args: ["/repo/src/main.tsx"], + }); + expect(resolveCurrentHunkCommand(["node", "C:\\pkg\\bin\\hunk.cjs"], "node.exe")).toEqual({ + command: "node.exe", + args: ["C:\\pkg\\bin\\hunk.cjs"], + }); + }); + + test("uses the real executable for compiled Bun virtual paths", () => { + expect(resolveCurrentHunkCommand(["bun", "/$bunfs/root/hunk"], "/usr/bin/hunk")).toEqual({ + command: "/usr/bin/hunk", + args: [], + }); + expect(resolveCurrentHunkCommand(["bun", "B:\\~BUN\\root\\hunk.exe"], "C:\\hunk.exe")).toEqual({ + command: "C:\\hunk.exe", + args: [], + }); + }); +}); diff --git a/packages/hunk/src/core/process/relaunch.ts b/packages/hunk/src/core/process/relaunch.ts new file mode 100644 index 000000000..c8744851c --- /dev/null +++ b/packages/hunk/src/core/process/relaunch.ts @@ -0,0 +1,31 @@ +const SCRIPT_ENTRYPOINT_PATTERN = /[\\/]|\.(?:[cm]?js|tsx?)$/; +const BUNFS_PREFIX = "/$bunfs/"; +const BUNFS_WINDOWS_PREFIX = "b:/~bun/"; + +export interface HunkLaunchCommand { + command: string; + args: string[]; +} + +/** Return whether an entrypoint is Bun's virtual compiled-executable path. */ +function isBunfsEntrypoint(entrypoint: string) { + return ( + entrypoint.startsWith(BUNFS_PREFIX) || + entrypoint.replaceAll("\\", "/").toLowerCase().startsWith(BUNFS_WINDOWS_PREFIX) + ); +} + +/** Resolve the executable and stable prefix needed to launch this Hunk installation again. */ +export function resolveCurrentHunkCommand( + argv = process.argv, + execPath = process.execPath, +): HunkLaunchCommand { + const entrypoint = argv[1]; + if (entrypoint && isBunfsEntrypoint(entrypoint)) { + return { command: execPath, args: [] }; + } + if (entrypoint && !entrypoint.startsWith("-") && SCRIPT_ENTRYPOINT_PATTERN.test(entrypoint)) { + return { command: execPath, args: [entrypoint] }; + } + return { command: execPath, args: [] }; +} diff --git a/src/core/process/startupNotice.ts b/packages/hunk/src/core/process/startupNotice.ts similarity index 100% rename from src/core/process/startupNotice.ts rename to packages/hunk/src/core/process/startupNotice.ts diff --git a/src/core/process/stdout.test.ts b/packages/hunk/src/core/process/stdout.test.ts similarity index 100% rename from src/core/process/stdout.test.ts rename to packages/hunk/src/core/process/stdout.test.ts diff --git a/src/core/process/stdout.ts b/packages/hunk/src/core/process/stdout.ts similarity index 100% rename from src/core/process/stdout.ts rename to packages/hunk/src/core/process/stdout.ts diff --git a/src/core/process/terminal.test.ts b/packages/hunk/src/core/process/terminal.test.ts similarity index 100% rename from src/core/process/terminal.test.ts rename to packages/hunk/src/core/process/terminal.test.ts diff --git a/src/core/process/terminal.ts b/packages/hunk/src/core/process/terminal.ts similarity index 100% rename from src/core/process/terminal.ts rename to packages/hunk/src/core/process/terminal.ts diff --git a/src/core/process/updateNotice.test.ts b/packages/hunk/src/core/process/updateNotice.test.ts similarity index 100% rename from src/core/process/updateNotice.test.ts rename to packages/hunk/src/core/process/updateNotice.test.ts diff --git a/src/core/process/updateNotice.ts b/packages/hunk/src/core/process/updateNotice.ts similarity index 100% rename from src/core/process/updateNotice.ts rename to packages/hunk/src/core/process/updateNotice.ts diff --git a/src/core/review/actions.ts b/packages/hunk/src/core/review/actions.ts similarity index 100% rename from src/core/review/actions.ts rename to packages/hunk/src/core/review/actions.ts diff --git a/src/core/review/anchors.test.ts b/packages/hunk/src/core/review/anchors.test.ts similarity index 100% rename from src/core/review/anchors.test.ts rename to packages/hunk/src/core/review/anchors.test.ts diff --git a/src/core/review/anchors.ts b/packages/hunk/src/core/review/anchors.ts similarity index 100% rename from src/core/review/anchors.ts rename to packages/hunk/src/core/review/anchors.ts diff --git a/src/core/review/annotations.ts b/packages/hunk/src/core/review/annotations.ts similarity index 100% rename from src/core/review/annotations.ts rename to packages/hunk/src/core/review/annotations.ts diff --git a/src/core/review/canonicalFile.test.ts b/packages/hunk/src/core/review/canonicalFile.test.ts similarity index 97% rename from src/core/review/canonicalFile.test.ts rename to packages/hunk/src/core/review/canonicalFile.test.ts index d526b21d0..d40530db6 100644 --- a/src/core/review/canonicalFile.test.ts +++ b/packages/hunk/src/core/review/canonicalFile.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createTestReviewFile } from "../../../test/helpers/review-store-helpers"; +import { createTestReviewFile } from "../../../../../test/helpers/review-store-helpers"; import { buildReviewContentManifestFile } from "./contentManifest"; import { assertCanonicalFileMatchesManifest, diff --git a/src/core/review/canonicalFile.ts b/packages/hunk/src/core/review/canonicalFile.ts similarity index 100% rename from src/core/review/canonicalFile.ts rename to packages/hunk/src/core/review/canonicalFile.ts diff --git a/src/core/review/contentManifest.test.ts b/packages/hunk/src/core/review/contentManifest.test.ts similarity index 96% rename from src/core/review/contentManifest.test.ts rename to packages/hunk/src/core/review/contentManifest.test.ts index e6433367e..3a9291052 100644 --- a/src/core/review/contentManifest.test.ts +++ b/packages/hunk/src/core/review/contentManifest.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createTestDiffFile, lines } from "../../../test/helpers/diff-helpers"; +import { createTestDiffFile, lines } from "../../../../../test/helpers/diff-helpers"; import { buildReviewContentManifest } from "./contentManifest"; import { projectReviewDocument } from "./document"; diff --git a/src/core/review/contentManifest.ts b/packages/hunk/src/core/review/contentManifest.ts similarity index 100% rename from src/core/review/contentManifest.ts rename to packages/hunk/src/core/review/contentManifest.ts diff --git a/src/core/review/document.test.ts b/packages/hunk/src/core/review/document.test.ts similarity index 99% rename from src/core/review/document.test.ts rename to packages/hunk/src/core/review/document.test.ts index 003afd4d9..a78cab72b 100644 --- a/src/core/review/document.test.ts +++ b/packages/hunk/src/core/review/document.test.ts @@ -3,7 +3,7 @@ import { createTestDiffFile, createTestSourceFetcher, lines, -} from "../../../test/helpers/diff-helpers"; +} from "../../../../../test/helpers/diff-helpers"; import { projectReviewDocument, reviewEmptyDiffReason } from "./document"; import type { DiffFile } from "../changeset/model"; diff --git a/src/core/review/document.ts b/packages/hunk/src/core/review/document.ts similarity index 100% rename from src/core/review/document.ts rename to packages/hunk/src/core/review/document.ts diff --git a/src/core/review/expansion.test.ts b/packages/hunk/src/core/review/expansion.test.ts similarity index 99% rename from src/core/review/expansion.test.ts rename to packages/hunk/src/core/review/expansion.test.ts index 4f262bd9d..c238d8a9d 100644 --- a/src/core/review/expansion.test.ts +++ b/packages/hunk/src/core/review/expansion.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createTestReviewFile } from "../../../test/helpers/review-store-helpers"; +import { createTestReviewFile } from "../../../../../test/helpers/review-store-helpers"; import { parseReviewGapId, resolveReviewExpandedLine, diff --git a/src/core/review/expansion.ts b/packages/hunk/src/core/review/expansion.ts similarity index 100% rename from src/core/review/expansion.ts rename to packages/hunk/src/core/review/expansion.ts diff --git a/src/core/review/generationOrder.test.ts b/packages/hunk/src/core/review/generationOrder.test.ts similarity index 100% rename from src/core/review/generationOrder.test.ts rename to packages/hunk/src/core/review/generationOrder.test.ts diff --git a/src/core/review/generationOrder.ts b/packages/hunk/src/core/review/generationOrder.ts similarity index 100% rename from src/core/review/generationOrder.ts rename to packages/hunk/src/core/review/generationOrder.ts diff --git a/src/core/review/geometry.test.ts b/packages/hunk/src/core/review/geometry.test.ts similarity index 100% rename from src/core/review/geometry.test.ts rename to packages/hunk/src/core/review/geometry.test.ts diff --git a/src/core/review/geometry.ts b/packages/hunk/src/core/review/geometry.ts similarity index 100% rename from src/core/review/geometry.ts rename to packages/hunk/src/core/review/geometry.ts diff --git a/src/core/review/identity.test.ts b/packages/hunk/src/core/review/identity.test.ts similarity index 100% rename from src/core/review/identity.test.ts rename to packages/hunk/src/core/review/identity.test.ts diff --git a/src/core/review/identity.ts b/packages/hunk/src/core/review/identity.ts similarity index 100% rename from src/core/review/identity.ts rename to packages/hunk/src/core/review/identity.ts diff --git a/src/core/review/intents.test.ts b/packages/hunk/src/core/review/intents.test.ts similarity index 99% rename from src/core/review/intents.test.ts rename to packages/hunk/src/core/review/intents.test.ts index 05737999c..3ff752b75 100644 --- a/src/core/review/intents.test.ts +++ b/packages/hunk/src/core/review/intents.test.ts @@ -3,7 +3,7 @@ import { createTestReviewDocument, createTestReviewState, createTestStoredNote, -} from "../../../test/helpers/review-store-helpers"; +} from "../../../../../test/helpers/review-store-helpers"; import { applyReviewIntent, isBlankReviewNoteBody, diff --git a/src/core/review/intents.ts b/packages/hunk/src/core/review/intents.ts similarity index 97% rename from src/core/review/intents.ts rename to packages/hunk/src/core/review/intents.ts index 1c9da0bb8..d5e6527d3 100644 --- a/src/core/review/intents.ts +++ b/packages/hunk/src/core/review/intents.ts @@ -120,7 +120,7 @@ export type ReviewIntent = * The wire vocabulary is derived from this list instead of restated beside it * (`docs/browser-review-seam-audit.md`, B12). The assertion below makes the list total — * adding a member to `ReviewIntent` without naming it here fails to typecheck — and - * `src/session/reviewProtocol.ts` subtracts a named exclusion list from it rather than + * `packages/hunk/src/session/reviewProtocol.ts` subtracts a named exclusion list from it rather than * writing its own. */ export const REVIEW_INTENT_TYPES = [ @@ -546,6 +546,21 @@ function planExpansionToggle( }; } +/** Resolve an active or stale note that may parent a new reply. */ +export function requireReviewReplyParent( + state: Pick, + parentId: string, +): ReviewStoredNote { + const parent = selectStoredReviewNoteById(state, parentId); + if (!parent || parent.resolution === "orphaned") { + throw new ReviewIntentPlanningError( + "invalid-note-parent", + `Review note ${parentId} is no longer available as a reply parent.`, + ); + } + return parent; +} + /** Plan persistence of an active create/reply draft as one user note. */ function planUserNoteCreation(state: ReviewState, facts: ReviewIntentFacts): ReviewIntentPlan { const draft = state.draftNote; @@ -573,13 +588,7 @@ function planUserNoteCreation(state: ReviewState, facts: ReviewIntentFacts): Rev } const parent = - draft.kind === "reply" ? selectStoredReviewNoteById(state, draft.parentId) : undefined; - if (draft.kind === "reply" && (!parent || parent.resolution === "orphaned")) { - throw new ReviewIntentPlanningError( - "invalid-note-parent", - `Review note ${draft.parentId} is no longer available as a reply parent.`, - ); - } + draft.kind === "reply" ? requireReviewReplyParent(state, draft.parentId) : undefined; if (parent && parent.note.fileKey !== file.key) { throw new ReviewIntentPlanningError( "invalid-note-parent", diff --git a/src/core/review/navigation.test.ts b/packages/hunk/src/core/review/navigation.test.ts similarity index 100% rename from src/core/review/navigation.test.ts rename to packages/hunk/src/core/review/navigation.test.ts diff --git a/src/core/review/navigation.ts b/packages/hunk/src/core/review/navigation.ts similarity index 100% rename from src/core/review/navigation.ts rename to packages/hunk/src/core/review/navigation.ts diff --git a/src/core/review/noteSize.test.ts b/packages/hunk/src/core/review/noteSize.test.ts similarity index 100% rename from src/core/review/noteSize.test.ts rename to packages/hunk/src/core/review/noteSize.test.ts diff --git a/src/core/review/noteSize.ts b/packages/hunk/src/core/review/noteSize.ts similarity index 100% rename from src/core/review/noteSize.ts rename to packages/hunk/src/core/review/noteSize.ts diff --git a/src/core/review/reducer.test.ts b/packages/hunk/src/core/review/reducer.test.ts similarity index 99% rename from src/core/review/reducer.test.ts rename to packages/hunk/src/core/review/reducer.test.ts index 37439f151..15f2b9ce4 100644 --- a/src/core/review/reducer.test.ts +++ b/packages/hunk/src/core/review/reducer.test.ts @@ -3,7 +3,7 @@ import { createTestReviewDocument, createTestReviewState, createTestStoredNote, -} from "../../../test/helpers/review-store-helpers"; +} from "../../../../../test/helpers/review-store-helpers"; import { reduceReviewState } from "./reducer"; import type { ReviewState } from "./state"; diff --git a/src/core/review/reducer.ts b/packages/hunk/src/core/review/reducer.ts similarity index 100% rename from src/core/review/reducer.ts rename to packages/hunk/src/core/review/reducer.ts diff --git a/src/core/review/resourceAssembly.test.ts b/packages/hunk/src/core/review/resourceAssembly.test.ts similarity index 100% rename from src/core/review/resourceAssembly.test.ts rename to packages/hunk/src/core/review/resourceAssembly.test.ts diff --git a/src/core/review/resourceAssembly.ts b/packages/hunk/src/core/review/resourceAssembly.ts similarity index 100% rename from src/core/review/resourceAssembly.ts rename to packages/hunk/src/core/review/resourceAssembly.ts diff --git a/src/core/review/resources.test.ts b/packages/hunk/src/core/review/resources.test.ts similarity index 100% rename from src/core/review/resources.test.ts rename to packages/hunk/src/core/review/resources.test.ts diff --git a/src/core/review/resources.ts b/packages/hunk/src/core/review/resources.ts similarity index 100% rename from src/core/review/resources.ts rename to packages/hunk/src/core/review/resources.ts diff --git a/src/core/review/selectors.test.ts b/packages/hunk/src/core/review/selectors.test.ts similarity index 98% rename from src/core/review/selectors.test.ts rename to packages/hunk/src/core/review/selectors.test.ts index dd6abd164..cda5d45ef 100644 --- a/src/core/review/selectors.test.ts +++ b/packages/hunk/src/core/review/selectors.test.ts @@ -2,8 +2,8 @@ import { describe, expect, test } from "bun:test"; import { createTestReviewState, createTestStoredNote, -} from "../../../test/helpers/review-store-helpers"; -import { createTestReviewFile } from "../../../test/helpers/review-store-helpers"; +} from "../../../../../test/helpers/review-store-helpers"; +import { createTestReviewFile } from "../../../../../test/helpers/review-store-helpers"; import { reduceReviewState } from "./reducer"; import { isReviewGapExpanded, diff --git a/src/core/review/selectors.ts b/packages/hunk/src/core/review/selectors.ts similarity index 100% rename from src/core/review/selectors.ts rename to packages/hunk/src/core/review/selectors.ts diff --git a/src/core/review/state.test.ts b/packages/hunk/src/core/review/state.test.ts similarity index 96% rename from src/core/review/state.test.ts rename to packages/hunk/src/core/review/state.test.ts index c09f7247f..ed7f05112 100644 --- a/src/core/review/state.test.ts +++ b/packages/hunk/src/core/review/state.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createTestReviewDocument } from "../../../test/helpers/review-store-helpers"; +import { createTestReviewDocument } from "../../../../../test/helpers/review-store-helpers"; import { createInitialReviewState, isRenderableStoredReviewNote, diff --git a/src/core/review/state.ts b/packages/hunk/src/core/review/state.ts similarity index 100% rename from src/core/review/state.ts rename to packages/hunk/src/core/review/state.ts diff --git a/src/core/review/stml.test.ts b/packages/hunk/src/core/review/stml.test.ts similarity index 100% rename from src/core/review/stml.test.ts rename to packages/hunk/src/core/review/stml.test.ts diff --git a/src/core/review/stml.ts b/packages/hunk/src/core/review/stml.ts similarity index 100% rename from src/core/review/stml.ts rename to packages/hunk/src/core/review/stml.ts diff --git a/src/core/review/store.test.ts b/packages/hunk/src/core/review/store.test.ts similarity index 97% rename from src/core/review/store.test.ts rename to packages/hunk/src/core/review/store.test.ts index 19a70242d..0e518c708 100644 --- a/src/core/review/store.test.ts +++ b/packages/hunk/src/core/review/store.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { createTestReviewDocument, createTestStoredNote, -} from "../../../test/helpers/review-store-helpers"; +} from "../../../../../test/helpers/review-store-helpers"; import { createReviewStore } from "./store"; describe("createReviewStore", () => { diff --git a/src/core/review/store.ts b/packages/hunk/src/core/review/store.ts similarity index 100% rename from src/core/review/store.ts rename to packages/hunk/src/core/review/store.ts diff --git a/src/core/review/types.ts b/packages/hunk/src/core/review/types.ts similarity index 100% rename from src/core/review/types.ts rename to packages/hunk/src/core/review/types.ts diff --git a/src/core/review/validation.test.ts b/packages/hunk/src/core/review/validation.test.ts similarity index 100% rename from src/core/review/validation.test.ts rename to packages/hunk/src/core/review/validation.test.ts diff --git a/src/core/review/validation.ts b/packages/hunk/src/core/review/validation.ts similarity index 100% rename from src/core/review/validation.ts rename to packages/hunk/src/core/review/validation.ts diff --git a/src/core/reviewDescriptor.test.ts b/packages/hunk/src/core/reviewDescriptor.test.ts similarity index 84% rename from src/core/reviewDescriptor.test.ts rename to packages/hunk/src/core/reviewDescriptor.test.ts index 3d46a51d3..8d1b47344 100644 --- a/src/core/reviewDescriptor.test.ts +++ b/packages/hunk/src/core/reviewDescriptor.test.ts @@ -24,8 +24,13 @@ describe("delegated review descriptor validation", () => { provider: "GitHub", title: "Commit", revision: "abc1234", + authoredAt: "2026-01-01T00:00:00Z", }), - ).toMatchObject({ kind: "commit", revision: "abc1234" }); + ).toMatchObject({ + kind: "commit", + revision: "abc1234", + authoredAt: "2026-01-01T00:00:00Z", + }); expect( validateExtensionReviewDescriptor({ kind: "comparison", @@ -44,6 +49,13 @@ describe("delegated review descriptor validation", () => { { ...review, url: "http://github.com/modem-dev/hunk/pull/123" }, { ...review, title: "é".repeat(1025) }, { ...review, repository: "x".repeat(513) }, + { + kind: "commit", + provider: "GitHub", + title: "Commit", + revision: "abc1234", + authoredAt: "yesterday", + }, { ...review, provider: "p".repeat(256), diff --git a/src/core/reviewDescriptor.ts b/packages/hunk/src/core/reviewDescriptor.ts similarity index 94% rename from src/core/reviewDescriptor.ts rename to packages/hunk/src/core/reviewDescriptor.ts index 2f8c170ff..b6f942e83 100644 --- a/src/core/reviewDescriptor.ts +++ b/packages/hunk/src/core/reviewDescriptor.ts @@ -11,6 +11,7 @@ const REVIEW_DESCRIPTOR_FIELD_LIMITS = Object.freeze({ base: 512, head: 512, revision: 512, + authoredAt: 128, }); /** Measure a public descriptor string in transport bytes rather than UTF-16 code units. */ @@ -92,7 +93,7 @@ export function validateExtensionReviewDescriptor(value: unknown): ExtensionRevi kind === "change-request" ? ["id", "repository", "author", "base", "head", "state", "draft"] : kind === "commit" - ? ["revision", "author"] + ? ["revision", "author", "authoredAt"] : ["base", "head"]; const allowed = new Set([...common, ...kindFields]); const ownKeys = Reflect.ownKeys(value); @@ -130,6 +131,10 @@ export function validateExtensionReviewDescriptor(value: unknown): ExtensionRevi ...(draft === undefined ? {} : { draft }), }; } else if (kind === "commit") { + const authoredAt = validateDescriptorString(candidate, "authoredAt", false); + if (authoredAt !== undefined && Number.isNaN(Date.parse(authoredAt))) { + throw new Error("delegate review authoredAt must be a valid timestamp"); + } descriptor = { kind, provider, @@ -137,6 +142,7 @@ export function validateExtensionReviewDescriptor(value: unknown): ExtensionRevi ...(url === undefined ? {} : { url }), revision: validateDescriptorString(candidate, "revision", true)!, ...copyOptionalDescriptorFields(candidate, ["author"]), + ...(authoredAt === undefined ? {} : { authoredAt }), }; } else { descriptor = { diff --git a/src/core/reviewDigest.ts b/packages/hunk/src/core/reviewDigest.ts similarity index 100% rename from src/core/reviewDigest.ts rename to packages/hunk/src/core/reviewDigest.ts diff --git a/src/core/run/cliCommandNames.ts b/packages/hunk/src/core/run/cliCommandNames.ts similarity index 98% rename from src/core/run/cliCommandNames.ts rename to packages/hunk/src/core/run/cliCommandNames.ts index 0ec53b717..a0baa3659 100644 --- a/src/core/run/cliCommandNames.ts +++ b/packages/hunk/src/core/run/cliCommandNames.ts @@ -2,6 +2,7 @@ export const BUILT_IN_CLI_COMMAND_NAMES = new Set([ "diff", "show", + "log", "patch", "pager", "difftool", diff --git a/src/core/run/commandCatalog.test.ts b/packages/hunk/src/core/run/commandCatalog.test.ts similarity index 99% rename from src/core/run/commandCatalog.test.ts rename to packages/hunk/src/core/run/commandCatalog.test.ts index 2b04000d4..a0be55309 100644 --- a/src/core/run/commandCatalog.test.ts +++ b/packages/hunk/src/core/run/commandCatalog.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { createTestReviewState, createTestStoredNote, -} from "../../../test/helpers/review-store-helpers"; +} from "../../../../../test/helpers/review-store-helpers"; import { APP_COMMAND_CATALOG, appCommandCatalogEntry, diff --git a/src/core/run/commandCatalog.ts b/packages/hunk/src/core/run/commandCatalog.ts similarity index 99% rename from src/core/run/commandCatalog.ts rename to packages/hunk/src/core/run/commandCatalog.ts index 473151191..acfd9cf5d 100644 --- a/src/core/run/commandCatalog.ts +++ b/packages/hunk/src/core/run/commandCatalog.ts @@ -21,7 +21,7 @@ * (audit F4). * * This module is renderer-neutral and dependency-light: no OpenTUI, no React, no Node - * builtins, chords as plain strings. It is not part of `src/core/review` because it + * builtins, chords as plain strings. It is not part of `packages/hunk/src/core/review` because it * describes UI vocabulary rather than review semantics, and that module stays purely about * what a review *is*. */ diff --git a/src/core/run/commandInputs.ts b/packages/hunk/src/core/run/commandInputs.ts similarity index 85% rename from src/core/run/commandInputs.ts rename to packages/hunk/src/core/run/commandInputs.ts index 8dca48432..5713d571a 100644 --- a/src/core/run/commandInputs.ts +++ b/packages/hunk/src/core/run/commandInputs.ts @@ -110,6 +110,32 @@ export type CliInput = export type ReviewNoteSource = "ai" | "agent" | "user"; export type SessionCommentListType = "live" | "all" | ReviewNoteSource; +export type HistoryColorMode = "auto" | "always" | "never"; +export type HistoryFormat = "medium" | "compact"; + +/** Auto-responsive VCS history invocation, deliberately separate from review view options. */ +export interface HistoryCommandInput { + kind: "history"; + revision?: string; + all?: boolean; + firstParent?: boolean; + maxCount?: number; + author?: string; + grep?: string; + since?: string; + until?: string; + pathspecs?: string[]; + color: HistoryColorMode; + format: HistoryFormat; + ascii: boolean; + /** Force scrollback output even when stdin and stdout are terminals. */ + static: boolean; + theme?: string; + vcs?: string; + extensionsEnabled: boolean; + extensionPaths: string[]; +} + export interface HelpCommandInput { kind: "help"; text: string; @@ -178,31 +204,60 @@ export interface SessionReloadCommandInput { sourcePath?: string; } -export interface SessionCommentAddCommandInput { - kind: "session"; - action: "comment-add"; - output: SessionCommandOutput; - selector: SessionSelectorInput; - filePath: string; - side: "old" | "new"; - line: number; +interface SessionCommentBodyInput { summary: string; rationale?: string; markup?: string; author?: string; - reveal: boolean; } -export interface SessionCommentApplyItemInput { - filePath: string; - hunkNumber?: number; - side?: "old" | "new"; - line?: number; - summary: string; - rationale?: string; - markup?: string; - author?: string; -} +export type SessionCommentAddTargetInput = + | { + filePath: string; + side: "old" | "new"; + line: number; + replyTo?: never; + } + | { + filePath?: never; + side?: never; + line?: never; + replyTo: string; + }; + +export type SessionCommentAddCommandInput = { + kind: "session"; + action: "comment-add"; + output: SessionCommandOutput; + selector: SessionSelectorInput; + reveal: boolean; +} & SessionCommentBodyInput & + SessionCommentAddTargetInput; + +export type SessionCommentApplyTargetInput = + | { + filePath: string; + hunkNumber: number; + side?: "old" | "new"; + line?: number; + replyTo?: never; + } + | { + filePath: string; + hunkNumber?: never; + side: "old" | "new"; + line: number; + replyTo?: never; + } + | { + filePath?: never; + hunkNumber?: never; + side?: never; + line?: never; + replyTo: string; + }; + +export type SessionCommentApplyItemInput = SessionCommentBodyInput & SessionCommentApplyTargetInput; export interface SessionCommentApplyCommandInput { kind: "session"; @@ -350,6 +405,7 @@ export type ExtensionManageCommandInput = export type ParsedCliInput = | CliInput + | HistoryCommandInput | HelpCommandInput | PagerCommandInput | DaemonServeCommandInput diff --git a/src/core/run/config.test.ts b/packages/hunk/src/core/run/config.test.ts similarity index 100% rename from src/core/run/config.test.ts rename to packages/hunk/src/core/run/config.test.ts diff --git a/src/core/run/config.ts b/packages/hunk/src/core/run/config.ts similarity index 98% rename from src/core/run/config.ts rename to packages/hunk/src/core/run/config.ts index 68ebc02b2..940d26f87 100644 --- a/src/core/run/config.ts +++ b/packages/hunk/src/core/run/config.ts @@ -67,7 +67,7 @@ export interface ExtensionsConfig { * Command ids are the ones the dispatch table declares — `"hunk.app.quit"`, * `"hunk.review.nextHunk"`, or `"."` for an extension * command. Resolution against each command's defaults lives in - * `src/ui/lib/keymap.ts`. + * `packages/hunk/src/ui/lib/keymap.ts`. */ export type UserKeyBinding = string | readonly string[] | false; @@ -99,6 +99,23 @@ const DEFAULT_VIEW_PREFERENCES: PersistedViewPreferences = { cursorLine: "row", }; +/** Project resolved launch options into the complete preference shape used by persistence. */ +export function persistedViewPreferencesFromOptions( + options: CommonOptions, +): PersistedViewPreferences { + return { + mode: options.mode ?? DEFAULT_VIEW_PREFERENCES.mode, + ...(options.theme === undefined ? {} : { theme: options.theme }), + showLineNumbers: options.lineNumbers ?? DEFAULT_VIEW_PREFERENCES.showLineNumbers, + wrapLines: options.wrapLines ?? DEFAULT_VIEW_PREFERENCES.wrapLines, + showHunkHeaders: options.hunkHeaders ?? DEFAULT_VIEW_PREFERENCES.showHunkHeaders, + showMenuBar: options.menuBar ?? DEFAULT_VIEW_PREFERENCES.showMenuBar, + showAgentNotes: options.agentNotes ?? DEFAULT_VIEW_PREFERENCES.showAgentNotes, + copyDecorations: options.copyDecorations ?? DEFAULT_VIEW_PREFERENCES.copyDecorations, + cursorLine: options.cursorLine ?? DEFAULT_VIEW_PREFERENCES.cursorLine, + }; +} + const VIEW_PREFERENCES_PROMPT_CONFIG_KEY = "prompt_save_view_preferences"; const PERSISTED_VIEW_PREFERENCE_KEYS: Array<{ configKey: string; diff --git a/src/core/run/errors.test.ts b/packages/hunk/src/core/run/errors.test.ts similarity index 100% rename from src/core/run/errors.test.ts rename to packages/hunk/src/core/run/errors.test.ts diff --git a/src/core/run/errors.ts b/packages/hunk/src/core/run/errors.ts similarity index 100% rename from src/core/run/errors.ts rename to packages/hunk/src/core/run/errors.ts diff --git a/src/core/run/experimental.test.ts b/packages/hunk/src/core/run/experimental.test.ts similarity index 92% rename from src/core/run/experimental.test.ts rename to packages/hunk/src/core/run/experimental.test.ts index d16c51ac3..59a12b5e5 100644 --- a/src/core/run/experimental.test.ts +++ b/packages/hunk/src/core/run/experimental.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { createTestAgentFileContext, createTestDiffFile } from "../../../test/helpers/diff-helpers"; +import { + createTestAgentFileContext, + createTestDiffFile, +} from "../../../../../test/helpers/diff-helpers"; import { resolveExperimentalDiffFiles, resolveExperimentalFeatures } from "./experimental"; describe("experimental review features", () => { diff --git a/src/core/run/experimental.ts b/packages/hunk/src/core/run/experimental.ts similarity index 100% rename from src/core/run/experimental.ts rename to packages/hunk/src/core/run/experimental.ts diff --git a/src/core/run/inputReload.ts b/packages/hunk/src/core/run/inputReload.ts similarity index 100% rename from src/core/run/inputReload.ts rename to packages/hunk/src/core/run/inputReload.ts diff --git a/src/core/run/paths.test.ts b/packages/hunk/src/core/run/paths.test.ts similarity index 100% rename from src/core/run/paths.test.ts rename to packages/hunk/src/core/run/paths.test.ts diff --git a/src/core/run/paths.ts b/packages/hunk/src/core/run/paths.ts similarity index 96% rename from src/core/run/paths.ts rename to packages/hunk/src/core/run/paths.ts index 7ded9f11c..33f61f850 100644 --- a/src/core/run/paths.ts +++ b/packages/hunk/src/core/run/paths.ts @@ -5,8 +5,8 @@ import { basename, dirname, join, resolve } from "node:path"; * Skills Hunk ships, in the order `hunk skill path` lists them. * * A skill is bundled only if it is in `package.json`'s `files` allowlist and the - * prebuilt artifact staging; `skills/` also holds maintainer-only documents that - * never ship, and naming them here would resolve paths users cannot have. + * prebuilt artifact staging. The repository-root `skills/` directory separately holds + * maintainer-only documents that never ship, and naming them here would resolve paths users cannot have. */ export const BUNDLED_SKILL_NAMES = ["hunk-review", "hunk-extensions"] as const; export type BundledSkillName = (typeof BUNDLED_SKILL_NAMES)[number]; diff --git a/src/core/run/reviewGap.test.ts b/packages/hunk/src/core/run/reviewGap.test.ts similarity index 100% rename from src/core/run/reviewGap.test.ts rename to packages/hunk/src/core/run/reviewGap.test.ts diff --git a/src/core/run/reviewGap.ts b/packages/hunk/src/core/run/reviewGap.ts similarity index 100% rename from src/core/run/reviewGap.ts rename to packages/hunk/src/core/run/reviewGap.ts diff --git a/src/core/run/tabWidth.ts b/packages/hunk/src/core/run/tabWidth.ts similarity index 100% rename from src/core/run/tabWidth.ts rename to packages/hunk/src/core/run/tabWidth.ts diff --git a/src/core/run/version.ts b/packages/hunk/src/core/run/version.ts similarity index 100% rename from src/core/run/version.ts rename to packages/hunk/src/core/run/version.ts diff --git a/src/core/theme/catalog.test.ts b/packages/hunk/src/core/theme/catalog.test.ts similarity index 100% rename from src/core/theme/catalog.test.ts rename to packages/hunk/src/core/theme/catalog.test.ts diff --git a/src/core/theme/catalog.ts b/packages/hunk/src/core/theme/catalog.ts similarity index 100% rename from src/core/theme/catalog.ts rename to packages/hunk/src/core/theme/catalog.ts diff --git a/src/core/theme/customThemes.test.ts b/packages/hunk/src/core/theme/customThemes.test.ts similarity index 100% rename from src/core/theme/customThemes.test.ts rename to packages/hunk/src/core/theme/customThemes.test.ts diff --git a/src/core/theme/customThemes.ts b/packages/hunk/src/core/theme/customThemes.ts similarity index 100% rename from src/core/theme/customThemes.ts rename to packages/hunk/src/core/theme/customThemes.ts diff --git a/src/core/theme/detection.test.ts b/packages/hunk/src/core/theme/detection.test.ts similarity index 100% rename from src/core/theme/detection.test.ts rename to packages/hunk/src/core/theme/detection.test.ts diff --git a/src/core/theme/detection.ts b/packages/hunk/src/core/theme/detection.ts similarity index 100% rename from src/core/theme/detection.ts rename to packages/hunk/src/core/theme/detection.ts diff --git a/src/core/theme/legacySyntaxScopes.test.ts b/packages/hunk/src/core/theme/legacySyntaxScopes.test.ts similarity index 100% rename from src/core/theme/legacySyntaxScopes.test.ts rename to packages/hunk/src/core/theme/legacySyntaxScopes.test.ts diff --git a/src/core/theme/legacySyntaxScopes.ts b/packages/hunk/src/core/theme/legacySyntaxScopes.ts similarity index 100% rename from src/core/theme/legacySyntaxScopes.ts rename to packages/hunk/src/core/theme/legacySyntaxScopes.ts diff --git a/src/core/vcs/index.test.ts b/packages/hunk/src/core/vcs/index.test.ts similarity index 100% rename from src/core/vcs/index.test.ts rename to packages/hunk/src/core/vcs/index.test.ts diff --git a/src/core/vcs/index.ts b/packages/hunk/src/core/vcs/index.ts similarity index 82% rename from src/core/vcs/index.ts rename to packages/hunk/src/core/vcs/index.ts index 53113bc91..386b320f3 100644 --- a/src/core/vcs/index.ts +++ b/packages/hunk/src/core/vcs/index.ts @@ -1,6 +1,12 @@ import { relative, resolve } from "node:path"; import { HUNK_DEFAULT_VCS_DETECTION_PRIORITY } from "../../extension-api/types"; import { HunkUserError } from "../run/errors"; +import type { + ExtensionVcsHistoryCommit, + ExtensionVcsHistoryInput, + ExtensionVcsHistoryReviewAction, + ExtensionVcsHistoryReviewOptions, +} from "../../extension-api/types"; import type { CliInput } from "../run/commandInputs"; import type { VcsAdapter, @@ -8,6 +14,7 @@ import type { VcsDetection, VcsId, VcsLoadContext, + VcsHistorySource, VcsOperation, VcsPatchResult, VcsReviewInput, @@ -152,6 +159,40 @@ export async function loadVcsReview( return await handler.load(operation.input, context); } +/** Open a provider-neutral history source or report that the selected backend lacks one. */ +export async function openVcsHistory( + adapter: VcsAdapter, + input: ExtensionVcsHistoryInput, + context: VcsLoadContext, + catalog: VcsCatalog, +): Promise { + if (!adapter.history) { + const supportingAdapter = catalog.adapters.find((candidate) => candidate.history); + throw new HunkUserError(`\`hunk log\` is not supported by ${adapter.name}.`, [ + ...(supportingAdapter + ? [`Use \`--vcs ${supportingAdapter.id}\` in a compatible repository.`] + : []), + "Use a VCS adapter that implements history browsing.", + ]); + } + return await adapter.history.open(input, context); +} + +/** Ask the selected provider how one opaque history item should open in review. */ +export async function planVcsHistoryReview( + adapter: VcsAdapter, + commit: ExtensionVcsHistoryCommit, + context: VcsLoadContext, + options?: ExtensionVcsHistoryReviewOptions, +): Promise { + if (!adapter.history) { + throw new HunkUserError(`\`hunk log\` is not supported by ${adapter.name}.`, [ + "Use a VCS adapter that implements history browsing.", + ]); + } + return await adapter.history.planReview(commit, context, options); +} + /** Build an adapter event plan, falling back to signature polling. */ export function createVcsWatchPlan( adapter: VcsAdapter, diff --git a/src/core/vcs/types.ts b/packages/hunk/src/core/vcs/types.ts similarity index 74% rename from src/core/vcs/types.ts rename to packages/hunk/src/core/vcs/types.ts index bb056911c..7a110e22a 100644 --- a/src/core/vcs/types.ts +++ b/packages/hunk/src/core/vcs/types.ts @@ -1,4 +1,11 @@ -import type { ExtensionVcsWatchPlan } from "../../extension-api/types"; +import type { + ExtensionVcsHistoryCommit, + ExtensionVcsHistoryInput, + ExtensionVcsHistoryPage, + ExtensionVcsHistoryReviewAction, + ExtensionVcsHistoryReviewOptions, + ExtensionVcsWatchPlan, +} from "../../extension-api/types"; import type { DiffFile } from "../changeset/model"; import type { VcsDiffCommandInput, @@ -16,6 +23,7 @@ export interface VcsDetection { export interface VcsLoadContext { cwd: string; + signal?: AbortSignal; } export type VcsReviewInput = VcsDiffCommandInput | VcsShowCommandInput | VcsStashShowCommandInput; @@ -39,6 +47,22 @@ export interface VcsOperations { "stash-show"?: VcsOperation; } +/** Internal history cursor after extension-boundary validation. */ +export interface VcsHistorySource { + read(options: { limit: number; signal?: AbortSignal }): Promise; + close(): Promise; +} + +/** Optional provider-neutral read-only history and review-planning capability. */ +export interface VcsHistoryCapability { + open(input: ExtensionVcsHistoryInput, context: VcsLoadContext): Promise; + planReview( + commit: ExtensionVcsHistoryCommit, + context: VcsLoadContext, + options?: ExtensionVcsHistoryReviewOptions, + ): Promise; +} + /** * One adapter operation's result, after the conversion boundary. * @@ -73,6 +97,7 @@ export interface VcsAdapter { name: string; detect(cwd: string): VcsDetection | null; operations: VcsOperations; + history?: VcsHistoryCapability; /** Detection order weight; higher is consulted first. See the public contract. */ detectionPriority?: number; } diff --git a/src/core/vcs/untracked.test.ts b/packages/hunk/src/core/vcs/untracked.test.ts similarity index 100% rename from src/core/vcs/untracked.test.ts rename to packages/hunk/src/core/vcs/untracked.test.ts diff --git a/src/core/vcs/untracked.ts b/packages/hunk/src/core/vcs/untracked.ts similarity index 97% rename from src/core/vcs/untracked.ts rename to packages/hunk/src/core/vcs/untracked.ts index ea5187aa7..ee73aebd9 100644 --- a/src/core/vcs/untracked.ts +++ b/packages/hunk/src/core/vcs/untracked.ts @@ -3,10 +3,9 @@ import { join } from "node:path"; import { createSkippedBinaryMetadata, isProbablyBinaryFile } from "../changeset/binary"; import { buildDiffFile, createSkippedLargeMetadata } from "../changeset/diffFile"; import { createFileSourceFetcher } from "../changeset/fileSource"; -import { inspectLargeUntrackedFile } from "../../lib/largeFile"; +import { inspectLargeUntrackedFile, type LargeFileCheck } from "@hunk/vcs/large-file"; import { escapeUntrackedPatchPath } from "../../lib/patchPath"; import { parseSingleFilePatch } from "../patch/singleFile"; -import type { LargeFileCheck } from "../../lib/largeFile"; /** * Host-side synthesis of untracked files into reviewable diffs. diff --git a/src/core/watch/controller.test.ts b/packages/hunk/src/core/watch/controller.test.ts similarity index 100% rename from src/core/watch/controller.test.ts rename to packages/hunk/src/core/watch/controller.test.ts diff --git a/src/core/watch/controller.ts b/packages/hunk/src/core/watch/controller.ts similarity index 100% rename from src/core/watch/controller.ts rename to packages/hunk/src/core/watch/controller.ts diff --git a/src/core/watch/observer.fs.test.ts b/packages/hunk/src/core/watch/observer.fs.test.ts similarity index 100% rename from src/core/watch/observer.fs.test.ts rename to packages/hunk/src/core/watch/observer.fs.test.ts diff --git a/src/core/watch/observer.test.ts b/packages/hunk/src/core/watch/observer.test.ts similarity index 98% rename from src/core/watch/observer.test.ts rename to packages/hunk/src/core/watch/observer.test.ts index 8372b040f..f909062b4 100644 --- a/src/core/watch/observer.test.ts +++ b/packages/hunk/src/core/watch/observer.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { join } from "node:path"; -import { createWatchTestClock } from "../../../test/helpers/watchTest"; +import { createWatchTestClock } from "../../../../../test/helpers/watchTest"; import { createWatchController, WATCH_EVENT_SOURCE_STARTUP_TIMEOUT_CODE } from "./controller"; import { createNativeTreeWatcher, diff --git a/src/core/watch/observer.ts b/packages/hunk/src/core/watch/observer.ts similarity index 100% rename from src/core/watch/observer.ts rename to packages/hunk/src/core/watch/observer.ts diff --git a/src/core/watch/plan.test.ts b/packages/hunk/src/core/watch/plan.test.ts similarity index 100% rename from src/core/watch/plan.test.ts rename to packages/hunk/src/core/watch/plan.test.ts diff --git a/src/core/watch/plan.ts b/packages/hunk/src/core/watch/plan.ts similarity index 98% rename from src/core/watch/plan.ts rename to packages/hunk/src/core/watch/plan.ts index 902dd869c..cb3d0a8ae 100644 --- a/src/core/watch/plan.ts +++ b/packages/hunk/src/core/watch/plan.ts @@ -6,7 +6,7 @@ import type { ExtensionVcsWatchTarget, ExtensionVcsWatchTargetSource, } from "../../extension-api/types"; -import { normalizePathForOS } from "../../lib/osPath"; +import { normalizePathForOS } from "@hunk/vcs/path"; import type { CliInput } from "../run/commandInputs"; import { createVcsWatchPlan, getConfiguredVcsAdapter, operationFromInput } from "../vcs"; import type { VcsCatalog } from "../vcs/types"; diff --git a/src/core/watch/runtime.test.ts b/packages/hunk/src/core/watch/runtime.test.ts similarity index 100% rename from src/core/watch/runtime.test.ts rename to packages/hunk/src/core/watch/runtime.test.ts diff --git a/src/core/watch/runtime.ts b/packages/hunk/src/core/watch/runtime.ts similarity index 100% rename from src/core/watch/runtime.ts rename to packages/hunk/src/core/watch/runtime.ts diff --git a/src/core/watch/signature.test.ts b/packages/hunk/src/core/watch/signature.test.ts similarity index 100% rename from src/core/watch/signature.test.ts rename to packages/hunk/src/core/watch/signature.test.ts diff --git a/src/core/watch/signature.ts b/packages/hunk/src/core/watch/signature.ts similarity index 100% rename from src/core/watch/signature.ts rename to packages/hunk/src/core/watch/signature.ts diff --git a/src/extension-api/index.ts b/packages/hunk/src/extension-api/index.ts similarity index 95% rename from src/extension-api/index.ts rename to packages/hunk/src/extension-api/index.ts index bf6057743..225ee7261 100644 --- a/src/extension-api/index.ts +++ b/packages/hunk/src/extension-api/index.ts @@ -148,6 +148,14 @@ export type { ExtensionVcsFileSourceResult, ExtensionVcsFileSourceTooLarge, ExtensionVcsFileStats, + ExtensionVcsHistoryCapability, + ExtensionVcsHistoryCommit, + ExtensionVcsHistoryDecoration, + ExtensionVcsHistoryInput, + ExtensionVcsHistoryPage, + ExtensionVcsHistoryReviewAction, + ExtensionVcsHistoryReviewOptions, + ExtensionVcsHistorySource, ExtensionVcsLoadContext, ExtensionVcsOperation, ExtensionVcsOperations, diff --git a/src/extension-api/keys.test.ts b/packages/hunk/src/extension-api/keys.test.ts similarity index 88% rename from src/extension-api/keys.test.ts rename to packages/hunk/src/extension-api/keys.test.ts index 0d8812c6a..b991d6199 100644 --- a/src/extension-api/keys.test.ts +++ b/packages/hunk/src/extension-api/keys.test.ts @@ -10,7 +10,7 @@ import { /** * The published chord grammar. * - * These cover the grammar itself; `src/lib/commandKeys.test.ts` covers the + * These cover the grammar itself; `packages/hunk/src/lib/commandKeys.test.ts` covers the * internal-only pieces built on top of it. */ @@ -131,6 +131,31 @@ describe("matchesKeyChord", () => { expect(matchesKeyChord(parsed("r"), keyEvent({ name: "r", ctrl: true }))).toBe(false); }); + test("alt matches explicit option and legacy raw meta events", () => { + const alt = parsed("alt+n"); + expect(matchesKeyChord(alt, keyEvent({ name: "n", option: true }))).toBe(true); + expect( + matchesKeyChord(alt, keyEvent({ name: "n", meta: true, option: true, source: "kitty" })), + ).toBe(true); + expect(matchesKeyChord(alt, keyEvent({ name: "n", meta: true, source: "raw" }))).toBe(true); + expect(matchesKeyChord(alt, keyEvent({ name: "n", meta: true }))).toBe(true); + }); + + test("kitty keeps alt and meta distinct", () => { + const kittyAlt = keyEvent({ + name: "n", + meta: true, + option: true, + source: "kitty", + }); + const kittyMeta = keyEvent({ name: "n", meta: true, source: "kitty" }); + + expect(matchesKeyChord(parsed("alt+n"), kittyAlt)).toBe(true); + expect(matchesKeyChord(parsed("alt+n"), kittyMeta)).toBe(false); + expect(matchesKeyChord(parsed("meta+n"), kittyAlt)).toBe(false); + expect(matchesKeyChord(parsed("meta+n"), kittyMeta)).toBe(true); + }); + test("symbols match by sequence and ignore the shift flag", () => { const chord = parsed("{"); expect(matchesKeyChord(chord, keyEvent({ name: "[", sequence: "{", shift: true }))).toBe(true); diff --git a/src/extension-api/keys.ts b/packages/hunk/src/extension-api/keys.ts similarity index 93% rename from src/extension-api/keys.ts rename to packages/hunk/src/extension-api/keys.ts index 50c56adcb..10afa208c 100644 --- a/src/extension-api/keys.ts +++ b/packages/hunk/src/extension-api/keys.ts @@ -192,6 +192,24 @@ function matchesNamedKey(base: string, key: ExtensionKeyEvent) { return base === "space" && (name === " " || key.sequence === " "); } +/** Report whether an event carries the chord's modifiers across terminal protocols. */ +function matchesModifiers(parsed: ParsedKeyChord, key: ExtensionKeyEvent) { + if (Boolean(key.ctrl) !== parsed.ctrl) { + return false; + } + + const meta = Boolean(key.meta); + const option = Boolean(key.option); + + // Legacy terminals encode Alt as an Escape-prefixed key and can only report + // `meta`. Kitty distinguishes that legacy shape from a true Meta key. + if (parsed.option && !parsed.meta) { + return option || (meta && key.source !== "kitty"); + } + + return meta === parsed.meta && option === parsed.option; +} + /** * Report whether one key event is the parsed chord. * @@ -216,11 +234,7 @@ export function matchesKeyChord(parsed: ParsedKeyChord, key: ExtensionKeyEvent): return true; } - if (Boolean(key.ctrl) !== parsed.ctrl || Boolean(key.meta) !== parsed.meta) { - return false; - } - - if (Boolean(key.option) !== parsed.option) { + if (!matchesModifiers(parsed, key)) { return false; } diff --git a/src/extension-api/types.ts b/packages/hunk/src/extension-api/types.ts similarity index 95% rename from src/extension-api/types.ts rename to packages/hunk/src/extension-api/types.ts index 37e84fde8..fb1b4c0be 100644 --- a/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 = 18; +export const HUNK_EXTENSION_API_VERSION = 20; export type HunkExtensionApiVersion = typeof HUNK_EXTENSION_API_VERSION; export type ExtensionNotifyType = "info" | "warning" | "error"; @@ -240,6 +240,8 @@ export interface ExtensionKeyEvent { /** The alt/option modifier. */ option?: boolean; shift?: boolean; + /** The terminal protocol that decoded the key, when the host exposes it. */ + source?: "raw" | "kitty"; } /* -------------------------------------------------------------------------- */ @@ -667,6 +669,8 @@ export interface ExtensionVcsDetection { /** Ambient information an operation may need to shell out. */ export interface ExtensionVcsLoadContext { cwd: string; + /** Abort provider setup or work when the owning host generation ends. */ + signal?: AbortSignal; } /** @@ -727,6 +731,115 @@ export interface ExtensionVcsShowInput { options: ExtensionVcsReviewOptions; } +/** One structured ref decorating a history commit. */ +export type ExtensionVcsHistoryDecoration = + | { + kind: "head"; + /** Display label for detached HEAD; normally `HEAD`. */ + label: string; + /** Local branch HEAD is attached to, without display punctuation. */ + attachedLocalBranch?: string; + } + | { + kind: "local-branch" | "remote-branch" | "tag" | "ref"; + label: string; + }; + +/** One immutable commit summary returned by a VCS history provider. */ +export interface ExtensionVcsHistoryCommit { + revisionId: string; + displayId: string; + parentRevisionIds: string[]; + /** + * Parent ids used only for graph topology when traversal filters omit intermediate commits. + * Omit this field when graph parents are identical to `parentRevisionIds`. + */ + graphParentRevisionIds?: string[]; + subject: string; + /** Commit message content after the subject, preserving paragraph breaks. */ + body?: string; + authorName: string; + authorEmail?: string; + authoredAt: string; + decorations: ExtensionVcsHistoryDecoration[]; + /** + * Optional logical identity that remains stable when the provider rewrites a revision. + * + * A Jujutsu change id is the canonical example. This is metadata only: Hunk + * continues to key graph and review operations by the immutable `revisionId`. + */ + logicalId?: string; +} + +/** Provider-neutral history traversal accepted by `hunk log`. */ +export interface ExtensionVcsHistoryInput { + revision?: string; + all?: boolean; + firstParent?: boolean; + maxCount?: number; + author?: string; + grep?: string; + since?: string; + until?: string; + pathspecs?: string[]; +} + +/** + * One bounded history read in child-before-parent topological order. + * + * Across every page from one source, a commit must appear before any of its + * parents that the source emits. Page boundaries never reset that invariant. + * `done` distinguishes EOF from a page boundary. + */ +export interface ExtensionVcsHistoryPage { + commits: ExtensionVcsHistoryCommit[]; + done: boolean; +} + +/** A cancellable history cursor owned by its provider. */ +export interface ExtensionVcsHistorySource { + /** Read the next page while preserving the source-wide topological order. */ + read(options: { limit: number; signal?: AbortSignal }): Promise; + close(): void | Promise; +} + +/** Optional provider-neutral selection facts for reviewing one history item. */ +export interface ExtensionVcsHistoryReviewOptions { + /** One ordered parent id returned on the commit, when the caller chooses a specific parent. */ + parentRevisionId?: string; +} + +/** A provider-owned declaration of how Hunk should review one history item. */ +export type ExtensionVcsHistoryReviewAction = + | { + kind: "revision-show"; + revisionId: string; + } + | { + kind: "revision-range"; + fromRevisionId: string; + toRevisionId: string; + }; + +/** Optional read-only history capability implemented independently of review operations. */ +export interface ExtensionVcsHistoryCapability { + open( + input: ExtensionVcsHistoryInput, + context: ExtensionVcsLoadContext, + ): ExtensionVcsHistorySource | Promise; + /** + * Declare how to open one returned commit in Hunk's ordinary review surface. + * + * Providers own root and merge semantics. Revision ids are opaque to the + * host; the returned action is passed to this adapter's review operation. + */ + planReview( + commit: ExtensionVcsHistoryCommit, + context: ExtensionVcsLoadContext, + options?: ExtensionVcsHistoryReviewOptions, + ): ExtensionVcsHistoryReviewAction | Promise; +} + /** Stash review request, as extension adapters receive it. */ export interface ExtensionVcsStashShowInput { kind: "stash-show"; @@ -972,6 +1085,8 @@ export interface ExtensionVcsAdapter { name: string; detect(cwd: string): ExtensionVcsDetection | null; operations?: ExtensionVcsOperations; + /** Optional static/interactive history enumeration capability. */ + history?: ExtensionVcsHistoryCapability; /** * Where this adapter sits in detection order; higher is consulted first. * @@ -1004,6 +1119,8 @@ export interface ExtensionPaintTheme { border: string; accent: string; accentMuted: string; + /** Bright foreground for clickable copy affordances. */ + copyAction: string; text: string; muted: string; /** Background highlighting the selected row or hunk. */ @@ -1067,6 +1184,8 @@ export interface ExtensionReviewNavigation { * Actions stay valid for as long as the component is mounted. */ export interface ExtensionPaneActions extends ExtensionReviewNavigation { + /** Copy text through the terminal clipboard integration, returning false when unavailable. */ + copyText(text: string): boolean; /** Show one toast, attributed to the owning extension. */ notify(message: string, type?: ExtensionNotifyType): void; } @@ -1144,7 +1263,7 @@ export interface ExtensionCurrentLinePaint { /** Immutable state used to decide whether an open pane is meaningful this frame. */ export interface ExtensionPaneAvailabilityContext { readonly placement: ExtensionPanePlacement; - /** Immutable delegated review metadata, or null for ordinary reviews. */ + /** Immutable review-source metadata, or null for ordinary reviews. */ readonly review: ExtensionReviewDescriptor | null; readonly files: readonly ExtensionDiffFile[]; readonly selectedFileId: string | null; @@ -1154,7 +1273,7 @@ export interface ExtensionPaneAvailabilityContext { /** Everything a custom pane component receives, refreshed as the app changes. */ export interface ExtensionPaneProps { - /** Immutable delegated review metadata, or null for ordinary reviews. */ + /** Immutable review-source metadata, or null for ordinary reviews. */ readonly review: ExtensionReviewDescriptor | null; readonly files: readonly ExtensionDiffFile[]; readonly selectedFileId: string | null; @@ -1313,6 +1432,8 @@ export interface ExtensionCommitReviewDescriptor extends ExtensionReviewDescript /** Provider revision identifier. */ readonly revision: string; readonly author?: string; + /** Date-time string used for relative commit time when available. */ + readonly authoredAt?: string; } /** Metadata for one comparison between two provider refs. */ @@ -1322,7 +1443,7 @@ export interface ExtensionComparisonReviewDescriptor extends ExtensionReviewDesc readonly head: string; } -/** Bounded provider-neutral metadata attached to an extension-delegated patch review. */ +/** Bounded provider-neutral metadata describing a delegated or history-selected review. */ export type ExtensionReviewDescriptor = | ExtensionChangeRequestReviewDescriptor | ExtensionCommitReviewDescriptor diff --git a/src/extensions/apply.test.ts b/packages/hunk/src/extensions/apply.test.ts similarity index 99% rename from src/extensions/apply.test.ts rename to packages/hunk/src/extensions/apply.test.ts index bff56e08e..74178273a 100644 --- a/src/extensions/apply.test.ts +++ b/packages/hunk/src/extensions/apply.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; -import { createTestDiffFile } from "../../test/helpers/diff-helpers"; +import { createTestDiffFile } from "../../../../test/helpers/diff-helpers"; import { HUNK_CORE_VCS_DETECTION_PRIORITY, HUNK_DEFAULT_VCS_DETECTION_PRIORITY, diff --git a/src/extensions/apply.ts b/packages/hunk/src/extensions/apply.ts similarity index 100% rename from src/extensions/apply.ts rename to packages/hunk/src/extensions/apply.ts diff --git a/src/extensions/cliCommandRuntime.test.ts b/packages/hunk/src/extensions/cliCommandRuntime.test.ts similarity index 100% rename from src/extensions/cliCommandRuntime.test.ts rename to packages/hunk/src/extensions/cliCommandRuntime.test.ts diff --git a/src/extensions/cliCommandRuntime.ts b/packages/hunk/src/extensions/cliCommandRuntime.ts similarity index 100% rename from src/extensions/cliCommandRuntime.ts rename to packages/hunk/src/extensions/cliCommandRuntime.ts diff --git a/src/extensions/cliCommands.test.ts b/packages/hunk/src/extensions/cliCommands.test.ts similarity index 100% rename from src/extensions/cliCommands.test.ts rename to packages/hunk/src/extensions/cliCommands.test.ts diff --git a/src/extensions/cliCommands.ts b/packages/hunk/src/extensions/cliCommands.ts similarity index 100% rename from src/extensions/cliCommands.ts rename to packages/hunk/src/extensions/cliCommands.ts diff --git a/src/extensions/default/ui/index.test.ts b/packages/hunk/src/extensions/default/ui/index.test.ts similarity index 76% rename from src/extensions/default/ui/index.test.ts rename to packages/hunk/src/extensions/default/ui/index.test.ts index 1d925701d..c02761d46 100644 --- a/src/extensions/default/ui/index.test.ts +++ b/packages/hunk/src/extensions/default/ui/index.test.ts @@ -27,6 +27,21 @@ describe("bundled UI registry", () => { currentLine: null, }), ).toBeTrue(); + expect( + reviewInfo.available?.({ + review: { + kind: "commit", + provider: "GitHub", + title: "Commit title", + revision: "abc1234", + }, + placement: "top", + files: [], + selectedFileId: null, + selectedHunkIndex: null, + currentLine: null, + }), + ).toBeTrue(); expect( reviewInfo.available?.({ review: null, diff --git a/src/extensions/default/ui/index.ts b/packages/hunk/src/extensions/default/ui/index.ts similarity index 100% rename from src/extensions/default/ui/index.ts rename to packages/hunk/src/extensions/default/ui/index.ts diff --git a/packages/hunk/src/extensions/default/ui/reviewInfo/index.test.tsx b/packages/hunk/src/extensions/default/ui/reviewInfo/index.test.tsx new file mode 100644 index 000000000..d4f60c1f6 --- /dev/null +++ b/packages/hunk/src/extensions/default/ui/reviewInfo/index.test.tsx @@ -0,0 +1,168 @@ +import { describe, expect, mock, test } from "bun:test"; +import { testRender } from "@opentui/react/test-utils"; +import { act } from "react"; +import { capturedTestColorToHex } from "../../../../../../../test/helpers/test-color-helpers"; +import type { ExtensionPaneProps } from "../../../../extension-api/types"; +import { toExtensionPaintTheme } from "../../../../ui/lib/extensionPaintTheme"; +import { resolveTheme } from "../../../../ui/themes"; +import { ReviewInfoPane } from "."; +import { reviewInfoLines } from "./presentation"; + +const review = { + kind: "change-request" as const, + provider: "GitHub", + title: "A deliberately long delegated review title", + id: "#123", + repository: "modem-dev/hunk", + author: "octocat", + base: "main", + head: "feature/review-info", + state: "open" as const, +}; + +/** Return the background painted at one terminal column on every captured row. */ +function backgroundsAtColumn( + setup: Awaited>, + column: number, +): Array { + return setup.captureSpans().lines.map((line) => { + let spanStart = 0; + for (const span of line.spans) { + const spanEnd = spanStart + span.width; + if (spanStart <= column && column < spanEnd) return capturedTestColorToHex(span.bg); + spanStart = spanEnd; + } + return null; + }); +} + +describe("ReviewInfoPane", () => { + test("separates review chrome with the diff's thin accent rail and panel background", async () => { + const appTheme = resolveTheme("github-dark-default", null); + const theme = toExtensionPaintTheme(appTheme); + const width = 30; + const setup = await testRender( + , + { width, height: 3 }, + ); + + try { + await act(async () => { + await setup.renderOnce(); + }); + expect(backgroundsAtColumn(setup, 0)).toEqual([ + theme.panel.toLowerCase(), + theme.panel.toLowerCase(), + theme.panel.toLowerCase(), + ]); + for (const line of setup.captureSpans().lines.slice(1, 3)) { + const rail = line.spans.find((span) => span.text === "▌"); + expect(capturedTestColorToHex(rail?.fg)).toBe(theme.accent.toLowerCase()); + } + expect(backgroundsAtColumn(setup, 1)).toEqual([ + theme.panel.toLowerCase(), + theme.panel.toLowerCase(), + theme.panel.toLowerCase(), + ]); + expect(backgroundsAtColumn(setup, width - 1)).toEqual([ + theme.panel.toLowerCase(), + theme.panel.toLowerCase(), + theme.panel.toLowerCase(), + ]); + expect(backgroundsAtColumn(setup, 1)).not.toContain(theme.panelAlt.toLowerCase()); + + const [primary, secondary] = reviewInfoLines(review, width - 3); + const frame = setup.captureCharFrame(); + expect(frame.split("\n")[0]).toBe("─".repeat(width)); + const borderSpan = setup.captureSpans().lines[0]?.spans.find((span) => span.width > 0); + expect(capturedTestColorToHex(borderSpan?.fg)).toBe(theme.border.toLowerCase()); + expect(frame).toContain(` ${primary}`); + expect(frame).toContain(` ${secondary}`); + } finally { + setup.renderer.destroy(); + } + }); + + test("renders commit metadata with the same panel chrome and copies its full revision", async () => { + const fullRevision = "0123456789abcdef0123456789abcdef01234567"; + const appTheme = resolveTheme("github-dark-default", null); + const theme = toExtensionPaintTheme(appTheme); + const width = 60; + const copyText = mock(() => true); + const setup = await testRender( + , + { width, height: 3 }, + ); + + try { + await act(async () => { + await setup.renderOnce(); + }); + const frame = setup.captureCharFrame(); + expect(frame).toContain("Render selected commit metadata"); + expect(frame).toContain("octocat · 10 hours ago"); + expect(frame.split("\n")[1]?.trimEnd()).toEndWith("0123456789abcdef01… ⧉"); + expect(frame).not.toContain("GitHub"); + const revisionSpan = setup + .captureSpans() + .lines[1]?.spans.find((span) => span.text.includes("0123456789abcdef01…")); + expect(capturedTestColorToHex(revisionSpan?.fg)).toBe(theme.fileRenamed.toLowerCase()); + const copySpan = setup.captureSpans().lines[1]?.spans.find((span) => span.text === "⧉"); + expect(capturedTestColorToHex(copySpan?.fg)).toBe(appTheme.lineNumberFg.toLowerCase()); + await act(async () => setup.mockMouse.click(width - 2, 1)); + expect(copyText).toHaveBeenCalledWith(fullRevision); + expect(backgroundsAtColumn(setup, 0).slice(1)).toEqual([ + theme.panel.toLowerCase(), + theme.panel.toLowerCase(), + ]); + } finally { + setup.renderer.destroy(); + } + }); + + test("keeps the border deterministic when no metadata text fits", async () => { + const theme = toExtensionPaintTheme(resolveTheme("github-dark-default", null)); + const setup = await testRender( + , + { width: 3, height: 3 }, + ); + + try { + await act(async () => { + await setup.renderOnce(); + }); + expect(setup.captureCharFrame().split("\n").slice(0, 3)).toEqual(["───", "▌ ", "▌ "]); + } finally { + setup.renderer.destroy(); + } + }); +}); diff --git a/packages/hunk/src/extensions/default/ui/reviewInfo/index.tsx b/packages/hunk/src/extensions/default/ui/reviewInfo/index.tsx new file mode 100644 index 000000000..61b77bbfa --- /dev/null +++ b/packages/hunk/src/extensions/default/ui/reviewInfo/index.tsx @@ -0,0 +1,123 @@ +import type { MouseEvent as TuiMouseEvent } from "@opentui/core"; +import { useEffect, useState, type ReactNode } from "react"; +import type { ExtensionFactory } from "../../../types"; +import type { + ExtensionPaneProps, + ExtensionReviewDescriptor, +} from "../../../../extension-api/types"; +import { diffRailMarker } from "../../../../ui/diff/rowStyle"; +import { reviewInfoContent } from "./presentation"; + +export const BUNDLED_REVIEW_INFO_VIEW_ID = "review-info"; + +/** Report whether the bundled pane has a concise projection for this review kind. */ +function supportsReviewInfo( + review: ExtensionReviewDescriptor | null, +): review is Extract { + return review?.kind === "change-request" || review?.kind === "commit"; +} + +/** Render change-request or commit identity above the review without duplicating diff facts. */ +export function ReviewInfoPane({ actions, review, theme, width }: ExtensionPaneProps): ReactNode { + const [now, setNow] = useState(() => Date.now()); + const commitAuthoredAt = review?.kind === "commit" ? review.authoredAt : undefined; + useEffect(() => { + if (!commitAuthoredAt) return; + setNow(Date.now()); + const timer = setInterval(() => setNow(Date.now()), 60_000); + return () => clearInterval(timer); + }, [commitAuthoredAt]); + + if (!supportsReviewInfo(review)) return null; + const { primary, secondary, trailing } = reviewInfoContent(review, Math.max(0, width - 3), now); + if (width <= 1) { + return ( + + + ─ + + + {diffRailMarker()} + + + {diffRailMarker()} + + + ); + } + return ( + + + {"─".repeat(Math.max(0, width))} + + + + + {diffRailMarker()} + + + {diffRailMarker()} + + + {width > 1 ? ( + = 2 ? 1 : 0, + paddingRight: width >= 3 ? 1 : 0, + flexDirection: "column", + backgroundColor: theme.panel, + }} + > + + {primary} + {trailing && review.kind === "commit" ? ( + { + event.stopPropagation(); + actions.copyText(review.revision); + }} + > + {trailing} + ⧉ + + ) : null} + + {secondary} + + ) : null} + + + ); +} + +/** Register the provider-neutral review summary pane. */ +const registerBundledReviewInfo: ExtensionFactory = (hunk) => { + hunk.registerPane({ + id: BUNDLED_REVIEW_INFO_VIEW_ID, + title: "Review info", + placement: "top", + height: { preferred: 3, min: 3, max: 3 }, + defaultOpen: true, + available: ({ review }) => supportsReviewInfo(review), + component: ReviewInfoPane, + }); +}; + +export default registerBundledReviewInfo; diff --git a/src/extensions/default/ui/reviewInfo/presentation.test.ts b/packages/hunk/src/extensions/default/ui/reviewInfo/presentation.test.ts similarity index 55% rename from src/extensions/default/ui/reviewInfo/presentation.test.ts rename to packages/hunk/src/extensions/default/ui/reviewInfo/presentation.test.ts index d7e526c98..b5d3f1c70 100644 --- a/src/extensions/default/ui/reviewInfo/presentation.test.ts +++ b/packages/hunk/src/extensions/default/ui/reviewInfo/presentation.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { fitReviewInfoText, reviewInfoLines, sanitizeReviewInfoText } from "./presentation"; +import { + fitReviewInfoText, + reviewInfoContent, + reviewInfoLines, + sanitizeReviewInfoText, +} from "./presentation"; const review = { kind: "change-request" as const, @@ -24,6 +29,60 @@ describe("review info presentation", () => { ); }); + test("formats commit identity and provider facts into two lines", () => { + const content = reviewInfoContent( + { + kind: "commit", + provider: "GitHub", + title: "Render commit review metadata", + revision: "abc1234", + author: "octocat", + authoredAt: "2026-01-01T00:00:00Z", + }, + 200, + Date.parse("2026-01-01T10:00:00Z"), + ); + expect(content).toEqual({ + primary: "Render commit review metadata", + secondary: "octocat · 10 hours ago", + trailing: "abc1234", + }); + }); + + test("caps a long commit id so narrow layouts retain the title", () => { + expect( + reviewInfoContent( + { + kind: "commit", + provider: "Git", + title: "Visible title", + revision: "1234567890abcdef", + author: "ada", + }, + 20, + ), + ).toEqual({ + primary: "Visible ti…", + secondary: "ada", + trailing: "123456…", + }); + }); + + test("keeps the title instead of showing an unusable revision at tiny widths", () => { + const commit = { + kind: "commit" as const, + provider: "Git", + title: "Title", + revision: "1234567890abcdef", + }; + expect(reviewInfoContent(commit, 6)).toEqual({ primary: "Title", secondary: "" }); + expect(reviewInfoContent(commit, 12)).toEqual({ + primary: "Title", + secondary: "", + trailing: "123…", + }); + }); + test("omits unknown state while preserving explicit draft identity", () => { const { state: _state, ...withoutState } = review; expect(reviewInfoLines(withoutState, 200)[0]).toBe("#123 · Add delegated review metadata"); diff --git a/packages/hunk/src/extensions/default/ui/reviewInfo/presentation.ts b/packages/hunk/src/extensions/default/ui/reviewInfo/presentation.ts new file mode 100644 index 000000000..151f3a787 --- /dev/null +++ b/packages/hunk/src/extensions/default/ui/reviewInfo/presentation.ts @@ -0,0 +1,106 @@ +import type { + ExtensionChangeRequestReviewDescriptor, + ExtensionCommitReviewDescriptor, +} from "../../../../extension-api/types"; +import { formatHistoryRelativeTime } from "../../../../ui/log/formatting"; +import { measureClusterWidth, textClusters } from "../../../../ui/lib/text"; + +const MIN_COMMIT_REVISION_DISPLAY_WIDTH = 4; + +/** Collapse unsafe or layout-changing provider text into one deterministic terminal line. */ +export function sanitizeReviewInfoText(value: string): string { + return value + .replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ") + .replace(/\s+/gu, " ") + .trim(); +} + +/** Fit sanitized text to an exact terminal-cell budget with one ellipsis when clipped. */ +export function fitReviewInfoText(value: string, width: number): string { + const safe = sanitizeReviewInfoText(value); + if (width <= 0) return ""; + const clusters = textClusters(safe); + if (clusters.reduce((sum, cluster) => sum + measureClusterWidth(cluster), 0) <= width) + return safe; + if (width === 1) return "…"; + + let used = 0; + let fitted = ""; + for (const cluster of clusters) { + const clusterWidth = measureClusterWidth(cluster); + if (used + clusterWidth > width - 1) break; + fitted += cluster; + used += clusterWidth; + } + return `${fitted}…`; +} + +type ReviewInfoDescriptor = + | ExtensionChangeRequestReviewDescriptor + | ExtensionCommitReviewDescriptor; + +/** Join one metadata row after sanitizing optional provider fields. */ +function reviewInfoRow(values: readonly (string | undefined)[]) { + return values + .filter((value): value is string => typeof value === "string" && value.length > 0) + .map(sanitizeReviewInfoText) + .filter(Boolean) + .join(" · "); +} + +export interface ReviewInfoContent { + primary: string; + secondary: string; + /** Right-aligned identity on the primary row. */ + trailing?: string; +} + +/** Measure sanitized display text in terminal cells. */ +function reviewInfoTextWidth(value: string) { + return textClusters(value).reduce((sum, cluster) => sum + measureClusterWidth(cluster), 0); +} + +/** Derive the concise rows and optional right edge rendered by the review-info pane. */ +export function reviewInfoContent( + review: ReviewInfoDescriptor, + width: number, + now = Date.now(), +): ReviewInfoContent { + if (review.kind === "commit") { + const trailingWidth = Math.max(0, Math.min(width, Math.floor(width * 0.35))); + const trailing = + trailingWidth >= MIN_COMMIT_REVISION_DISPLAY_WIDTH && width - trailingWidth - 2 >= 1 + ? fitReviewInfoText(review.revision, trailingWidth) + : ""; + // Reserve one cell each for the gap before the id and its adjacent copy action. + const primaryWidth = Math.max(0, width - reviewInfoTextWidth(trailing) - (trailing ? 2 : 0)); + const relativeTime = review.authoredAt + ? formatHistoryRelativeTime(review.authoredAt, now) + : undefined; + return { + primary: fitReviewInfoText(review.title, primaryWidth), + secondary: fitReviewInfoText(reviewInfoRow([review.author, relativeTime]), width), + ...(trailing ? { trailing } : {}), + }; + } + + const state = review.draft ? "DRAFT" : review.state?.toUpperCase(); + const refs = review.base && review.head ? `${review.base} ← ${review.head}` : undefined; + return { + primary: fitReviewInfoText(reviewInfoRow([state, review.id, review.title]), width), + secondary: fitReviewInfoText( + reviewInfoRow([review.author, review.provider, review.repository, refs]), + width, + ), + }; +} + +/** Return only the two left-aligned rows for callers that do not paint the trailing identity. */ +export function reviewInfoLines( + review: ReviewInfoDescriptor, + width: number, + now = Date.now(), +): readonly [string, string] { + const content = reviewInfoContent(review, width, now); + return [content.primary, content.secondary]; +} diff --git a/src/extensions/default/ui/sidebar/FileSidebars.tsx b/packages/hunk/src/extensions/default/ui/sidebar/FileSidebars.tsx similarity index 72% rename from src/extensions/default/ui/sidebar/FileSidebars.tsx rename to packages/hunk/src/extensions/default/ui/sidebar/FileSidebars.tsx index 44c2681a4..19ae651ea 100644 --- a/src/extensions/default/ui/sidebar/FileSidebars.tsx +++ b/packages/hunk/src/extensions/default/ui/sidebar/FileSidebars.tsx @@ -5,8 +5,12 @@ import type { ExtensionPaneProps } from "../../../../extension-api/types"; import { buildFlatSidebarEntries, buildTreeSidebarEntries, + collapseTreeSidebarEntries, + expandCollapsedDirectoryPaths, resolveFileSidebarMode, + sidebarDirectoryPaths, sidebarEntryStatsWidth, + toggleCollapsedDirectoryPath, type SidebarEntry, } from "../../../../ui/lib/files"; import { fileRowId } from "../../../../ui/lib/ids"; @@ -33,16 +37,23 @@ type FileSidebarVariantProps = Pick< viewportHeight: number; }; +/** Ignore directory toggles for projections that cannot contain directory rows. */ +function ignoreDirectoryToggle() {} + interface VirtualizedFileSidebarRowsProps extends Omit { + collapsedDirectoryPaths?: ReadonlySet; entries: SidebarEntry[]; + onToggleDirectory?: (path: string) => void; paddingLeft?: number; } /** Render one windowed sidebar projection with shared file selection and stats lanes. */ export function VirtualizedFileSidebarRows({ actions, + collapsedDirectoryPaths, entries, estimatedViewportRows, + onToggleDirectory, paddingLeft = 1, scrollTop, selectedFileId, @@ -93,7 +104,9 @@ export function VirtualizedFileSidebarRows({ return ( ; } -/** Render the fully expanded ordered hierarchy for a wide file sidebar. */ -export function TreeFileSidebar({ files, ...props }: FileSidebarVariantProps): ReactNode { - const entries = useMemo(() => buildTreeSidebarEntries(files), [files]); - return ; +/** Render the ordered hierarchy after applying the sidebar's collapsed directory paths. */ +export function TreeFileSidebar({ + collapsedDirectoryPaths, + files, + onToggleDirectory, + ...props +}: FileSidebarVariantProps & { + collapsedDirectoryPaths: ReadonlySet; + onToggleDirectory: (path: string) => void; +}): ReactNode { + const entries = useMemo( + () => collapseTreeSidebarEntries(buildTreeSidebarEntries(files), collapsedDirectoryPaths), + [collapsedDirectoryPaths, files], + ); + return ( + + ); } /** @@ -146,6 +178,11 @@ export function FlexFileSidebar({ actions, }: BuiltInSidebarProps): ReactNode { const scrollRef = useRef(null); + const previousSelectedFileIdRef = useRef(selectedFileId); + const skipSelectedFileRevealRef = useRef(false); + const [collapsedDirectoryPaths, setCollapsedDirectoryPaths] = useState>( + () => new Set(), + ); const [scrollViewport, setScrollViewport] = useState({ top: 0, height: 0 }); const terminal = useTerminalDimensions(); // Mirrors the host layout: one column of row highlight plus row padding. @@ -162,6 +199,29 @@ export function FlexFileSidebar({ viewportHeight: scrollViewport.height, }; + /** Toggle one logical directory everywhere it appears in the ordered tree projection. */ + const toggleDirectory = (path: string) => { + skipSelectedFileRevealRef.current = true; + setCollapsedDirectoryPaths((current) => toggleCollapsedDirectoryPath(current, path)); + }; + + useEffect(() => { + const previousSelectedFileId = previousSelectedFileIdRef.current; + previousSelectedFileIdRef.current = selectedFileId; + if (!selectedFileId || selectedFileId === previousSelectedFileId) { + return; + } + + const selectedFile = files.find((file) => file.id === selectedFileId); + if (!selectedFile) { + return; + } + + setCollapsedDirectoryPaths((current) => + expandCollapsedDirectoryPaths(current, sidebarDirectoryPaths(selectedFile.path)), + ); + }, [files, selectedFileId]); + useEffect(() => { const scrollBox = scrollRef.current; if (!scrollBox) { @@ -216,12 +276,16 @@ export function FlexFileSidebar({ // Selection and projection changes can both move the target row, so follow // the stable file id after either event instead of only after navigation. useEffect(() => { + if (skipSelectedFileRevealRef.current) { + skipSelectedFileRevealRef.current = false; + return; + } if (!selectedFileId) { return; } scrollRef.current?.scrollChildIntoView(fileRowId(selectedFileId)); - }, [files, mode, selectedFileId]); + }, [collapsedDirectoryPaths, files, mode, selectedFileId]); return ( {mode === "tree" ? ( - + ) : ( )} diff --git a/src/extensions/default/ui/sidebar/index.test.tsx b/packages/hunk/src/extensions/default/ui/sidebar/index.test.tsx similarity index 100% rename from src/extensions/default/ui/sidebar/index.test.tsx rename to packages/hunk/src/extensions/default/ui/sidebar/index.test.tsx diff --git a/src/extensions/default/ui/sidebar/index.tsx b/packages/hunk/src/extensions/default/ui/sidebar/index.tsx similarity index 100% rename from src/extensions/default/ui/sidebar/index.tsx rename to packages/hunk/src/extensions/default/ui/sidebar/index.tsx diff --git a/src/extensions/default/vcs/index.test.ts b/packages/hunk/src/extensions/default/vcs/index.test.ts similarity index 100% rename from src/extensions/default/vcs/index.test.ts rename to packages/hunk/src/extensions/default/vcs/index.test.ts diff --git a/src/extensions/default/vcs/index.ts b/packages/hunk/src/extensions/default/vcs/index.ts similarity index 97% rename from src/extensions/default/vcs/index.ts rename to packages/hunk/src/extensions/default/vcs/index.ts index 7cdc221ce..62f7ea6f9 100644 --- a/src/extensions/default/vcs/index.ts +++ b/packages/hunk/src/extensions/default/vcs/index.ts @@ -1,6 +1,6 @@ -import gitExtension from "./git"; -import jjExtension from "./jujutsu"; -import slExtension from "./sapling"; +import gitExtension from "@hunk/git"; +import jjExtension from "@hunk/jj"; +import slExtension from "@hunk/sapling"; import { runExtensionFactory } from "../../runExtension"; import { createEmptyExtensionRegistry, diff --git a/src/extensions/discovery.test.ts b/packages/hunk/src/extensions/discovery.test.ts similarity index 100% rename from src/extensions/discovery.test.ts rename to packages/hunk/src/extensions/discovery.test.ts diff --git a/src/extensions/discovery.ts b/packages/hunk/src/extensions/discovery.ts similarity index 100% rename from src/extensions/discovery.ts rename to packages/hunk/src/extensions/discovery.ts diff --git a/src/extensions/events.test.ts b/packages/hunk/src/extensions/events.test.ts similarity index 99% rename from src/extensions/events.test.ts rename to packages/hunk/src/extensions/events.test.ts index 8957295a9..b5086c644 100644 --- a/src/extensions/events.test.ts +++ b/packages/hunk/src/extensions/events.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createTestDiffFile } from "../../test/helpers/diff-helpers"; +import { createTestDiffFile } from "../../../../test/helpers/diff-helpers"; import { bindExtensionEventBus, emitExtensionCustomEvent, diff --git a/src/extensions/events.ts b/packages/hunk/src/extensions/events.ts similarity index 100% rename from src/extensions/events.ts rename to packages/hunk/src/extensions/events.ts diff --git a/src/extensions/extensionIds.ts b/packages/hunk/src/extensions/extensionIds.ts similarity index 95% rename from src/extensions/extensionIds.ts rename to packages/hunk/src/extensions/extensionIds.ts index cec899f29..65ba7898f 100644 --- a/src/extensions/extensionIds.ts +++ b/packages/hunk/src/extensions/extensionIds.ts @@ -15,7 +15,7 @@ * colon; no leading separator, so ids read as names. * * The rules are enforced once, where candidates become loadable extensions - * (`src/extensions/host.ts`); this module only states them, so keymap code can + * (`packages/hunk/src/extensions/host.ts`); this module only states them, so keymap code can * ask about the vendor namespace without pulling the loader in behind it. */ diff --git a/src/extensions/host.test.ts b/packages/hunk/src/extensions/host.test.ts similarity index 100% rename from src/extensions/host.test.ts rename to packages/hunk/src/extensions/host.test.ts diff --git a/src/extensions/host.ts b/packages/hunk/src/extensions/host.ts similarity index 100% rename from src/extensions/host.ts rename to packages/hunk/src/extensions/host.ts diff --git a/src/extensions/hostRuntimeModules.test.ts b/packages/hunk/src/extensions/hostRuntimeModules.test.ts similarity index 100% rename from src/extensions/hostRuntimeModules.test.ts rename to packages/hunk/src/extensions/hostRuntimeModules.test.ts diff --git a/src/extensions/hostRuntimeModules.ts b/packages/hunk/src/extensions/hostRuntimeModules.ts similarity index 100% rename from src/extensions/hostRuntimeModules.ts rename to packages/hunk/src/extensions/hostRuntimeModules.ts diff --git a/src/extensions/manage/cli.ts b/packages/hunk/src/extensions/manage/cli.ts similarity index 100% rename from src/extensions/manage/cli.ts rename to packages/hunk/src/extensions/manage/cli.ts diff --git a/src/extensions/manage/install.test.ts b/packages/hunk/src/extensions/manage/install.test.ts similarity index 100% rename from src/extensions/manage/install.test.ts rename to packages/hunk/src/extensions/manage/install.test.ts diff --git a/src/extensions/manage/install.ts b/packages/hunk/src/extensions/manage/install.ts similarity index 100% rename from src/extensions/manage/install.ts rename to packages/hunk/src/extensions/manage/install.ts diff --git a/src/extensions/manage/records.ts b/packages/hunk/src/extensions/manage/records.ts similarity index 100% rename from src/extensions/manage/records.ts rename to packages/hunk/src/extensions/manage/records.ts diff --git a/src/extensions/manage/source.test.ts b/packages/hunk/src/extensions/manage/source.test.ts similarity index 100% rename from src/extensions/manage/source.test.ts rename to packages/hunk/src/extensions/manage/source.test.ts diff --git a/src/extensions/manage/source.ts b/packages/hunk/src/extensions/manage/source.ts similarity index 100% rename from src/extensions/manage/source.ts rename to packages/hunk/src/extensions/manage/source.ts diff --git a/src/extensions/notifications.test.ts b/packages/hunk/src/extensions/notifications.test.ts similarity index 100% rename from src/extensions/notifications.test.ts rename to packages/hunk/src/extensions/notifications.test.ts diff --git a/src/extensions/notifications.ts b/packages/hunk/src/extensions/notifications.ts similarity index 100% rename from src/extensions/notifications.ts rename to packages/hunk/src/extensions/notifications.ts diff --git a/src/extensions/panes.ts b/packages/hunk/src/extensions/panes.ts similarity index 100% rename from src/extensions/panes.ts rename to packages/hunk/src/extensions/panes.ts diff --git a/src/extensions/publicApiRobustness.test.ts b/packages/hunk/src/extensions/publicApiRobustness.test.ts similarity index 99% rename from src/extensions/publicApiRobustness.test.ts rename to packages/hunk/src/extensions/publicApiRobustness.test.ts index 7ff7b09ce..961e51643 100644 --- a/src/extensions/publicApiRobustness.test.ts +++ b/packages/hunk/src/extensions/publicApiRobustness.test.ts @@ -3,7 +3,7 @@ import { collectSessionCustomThemes } from "../core/theme/customThemes"; import type { Changeset } from "../core/changeset/model"; import { detectVcs, extendVcsCatalog } from "../core/vcs"; import { getBundledVcsCatalog } from "../app/vcsCatalog"; -import { createTestDiffFile } from "../../test/helpers/diff-helpers"; +import { createTestDiffFile } from "../../../../test/helpers/diff-helpers"; import { applyExtensionChangesetTransforms, applyExtensionFileLanguages, diff --git a/src/extensions/reviewSnapshot.test.ts b/packages/hunk/src/extensions/reviewSnapshot.test.ts similarity index 99% rename from src/extensions/reviewSnapshot.test.ts rename to packages/hunk/src/extensions/reviewSnapshot.test.ts index a698069a0..512ea45e0 100644 --- a/src/extensions/reviewSnapshot.test.ts +++ b/packages/hunk/src/extensions/reviewSnapshot.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { createTestReviewState, createTestStoredNote, -} from "../../test/helpers/review-store-helpers"; +} from "../../../../test/helpers/review-store-helpers"; import { buildExtensionReviewSnapshot, diffExtensionReviewNotes, diff --git a/src/extensions/reviewSnapshot.ts b/packages/hunk/src/extensions/reviewSnapshot.ts similarity index 100% rename from src/extensions/reviewSnapshot.ts rename to packages/hunk/src/extensions/reviewSnapshot.ts diff --git a/src/extensions/runExtension.test.ts b/packages/hunk/src/extensions/runExtension.test.ts similarity index 74% rename from src/extensions/runExtension.test.ts rename to packages/hunk/src/extensions/runExtension.test.ts index 86d546818..0e8031d25 100644 --- a/src/extensions/runExtension.test.ts +++ b/packages/hunk/src/extensions/runExtension.test.ts @@ -825,6 +825,17 @@ describe("toInternalVcsAdapter detection ids", () => { expect(mismatches).toEqual(["mercurial"]); }); + test("preserves terminal-safe adapter ids that begin with a dash", () => { + const adapter = toInternalVcsAdapter({ + id: "-custom", + name: "Custom", + detect: () => ({ id: "-custom", repoRoot: "/repo" }), + }); + + expect(adapter.id).toBe("-custom"); + expect(adapter.detect("/repo")).toEqual({ id: "-custom", repoRoot: "/repo" }); + }); + test("passes a matching detection through untouched, with no diagnostic", () => { const mismatches: string[] = []; const detection = { id: "hg", repoRoot: "/repo" }; @@ -833,10 +844,24 @@ describe("toInternalVcsAdapter detection ids", () => { (returnedId) => mismatches.push(returnedId), ); - expect(adapter.detect("/repo")).toBe(detection); + expect(adapter.detect("/repo")).toEqual(detection); expect(mismatches).toEqual([]); }); + test("snapshots and sanitizes adapter metadata before it reaches diagnostics", () => { + let nameReads = 0; + const adapter = toInternalVcsAdapter({ + id: "demo", + get name() { + nameReads += 1; + return nameReads === 1 ? "Demo\x1b[2J" : "Changed"; + }, + detect: () => null, + }); + expect(adapter.name).toBe("Demo"); + expect(nameReads).toBe(1); + }); + test("treats a detection without a usable repoRoot as no detection", () => { // `detectVcs` measures distance with `path.relative(detected.repoRoot, cwd)`, // and does it outside its own per-adapter try/catch — so a missing repoRoot @@ -947,3 +972,313 @@ describe("toInternalVcsAdapter detection ids", () => { ]); }); }); + +describe("toInternalVcsAdapter history boundary", () => { + test("requires providers to own selected-item review planning", () => { + expect(() => + toInternalVcsAdapter({ + id: "incomplete", + name: "Incomplete", + detect: () => null, + history: { + open: () => ({ + read: async () => ({ commits: [], done: true }), + close() {}, + }), + } as never, + }), + ).toThrow("history must provide open() and planReview() functions"); + }); + + test("copies and sanitizes bounded history pages", async () => { + const commit = { + revisionId: "a".repeat(40), + displayId: "aaaaaaaa", + parentRevisionIds: [] as string[], + subject: "safe\x1b]52;c;cHdu\x07\nspoof", + authorName: "Ada\rLovelace", + authoredAt: "2026-01-01T00:00:00Z", + decorations: [{ kind: "head" as const, label: "HEAD\x1b[2J" }], + }; + let closed = 0; + const adapter = toInternalVcsAdapter({ + id: "demo", + name: "Demo", + detect: () => null, + history: { + open: () => ({ + read: async () => ({ commits: [commit], done: true }), + close: () => { + closed += 1; + }, + }), + planReview: (selected) => ({ + kind: "revision-show", + revisionId: selected.revisionId, + }), + }, + }); + const source = await adapter.history!.open({}, { cwd: "/repo" }); + const page = await source.read({ limit: 1 }); + + expect(page.commits[0]?.subject).toBe("safespoof"); + expect(page.commits[0]?.authorName).toBe("AdaLovelace"); + expect(page.commits[0]?.decorations[0]?.label).toBe("HEAD"); + expect(page.commits[0]).not.toBe(commit); + expect(closed).toBe(1); + }); + + test("rejects unsafe revision ids and required display fields erased by sanitization", async () => { + const validCommit = { + revisionId: "a".repeat(40), + displayId: "aaaaaaaa", + parentRevisionIds: [] as string[], + subject: "Safe subject", + authorName: "Ada", + authoredAt: "2026-01-01T00:00:00Z", + decorations: [], + }; + const readCommit = async (commit: typeof validCommit) => { + const adapter = toInternalVcsAdapter({ + id: "adversarial", + name: "Adversarial", + detect: () => null, + history: { + open: () => ({ + read: async () => ({ commits: [commit], done: true }), + close() {}, + }), + planReview: (selected) => ({ + kind: "revision-show", + revisionId: selected.revisionId, + }), + }, + }); + return adapter + .history!.open({}, { cwd: "/repo" }) + .then((source) => source.read({ limit: 1 })); + }; + + await expect(readCommit({ ...validCommit, revisionId: `unsafe\tid` })).rejects.toThrow( + "terminal-safe immutable revision id", + ); + for (const field of ["displayId", "subject", "authorName"] as const) { + await expect(readCommit({ ...validCommit, [field]: "\x1b[2J" })).rejects.toThrow( + `${field} must remain non-empty after sanitization`, + ); + } + }); + + test("rejects tabs in provider-owned review action revision ids", async () => { + const commit = { + revisionId: "a".repeat(40), + displayId: "aaaaaaaa", + parentRevisionIds: [] as string[], + subject: "Safe subject", + authorName: "Ada", + authoredAt: "2026-01-01T00:00:00Z", + decorations: [], + }; + const adapter = toInternalVcsAdapter({ + id: "adversarial-plan", + name: "Adversarial plan", + detect: () => null, + history: { + open: () => ({ + read: async () => ({ commits: [], done: true }), + close() {}, + }), + planReview: () => ({ kind: "revision-show", revisionId: "unsafe\tid" }), + }, + }); + + await expect(adapter.history!.planReview(commit, { cwd: "/repo" })).rejects.toThrow( + "terminal-safe immutable revision id", + ); + }); + + test("snapshots source, page, commit, and decoration accessors exactly once", async () => { + const reads = { sourceRead: 0, commits: 0, subject: 0, label: 0 }; + const commit = { + revisionId: "a".repeat(40), + displayId: "aaaaaaaa", + parentRevisionIds: [], + get subject() { + reads.subject += 1; + return reads.subject === 1 ? "Stable" : "Changed"; + }, + authorName: "Ada", + authoredAt: "2026-01-01T00:00:00Z", + decorations: [ + { + kind: "tag", + get label() { + reads.label += 1; + return reads.label === 1 ? "v1" : "changed"; + }, + }, + ], + }; + const page = { + get commits() { + reads.commits += 1; + return reads.commits === 1 ? [commit] : [{}, {}]; + }, + done: true, + }; + const publicSource = { + get read() { + reads.sourceRead += 1; + return async () => page; + }, + close() {}, + }; + const adapter = toInternalVcsAdapter({ + id: "demo", + name: "Demo", + detect: () => null, + history: { + open: () => publicSource as never, + planReview: (selected) => ({ + kind: "revision-show", + revisionId: selected.revisionId, + }), + }, + }); + const source = await adapter.history!.open({}, { cwd: "/repo" }); + const result = await source.read({ limit: 1 }); + expect(result.commits[0]?.subject).toBe("Stable"); + expect(result.commits[0]?.decorations[0]?.label).toBe("v1"); + expect(reads).toEqual({ sourceRead: 1, commits: 1, subject: 1, label: 1 }); + }); + + test("closes malformed and over-limit sources once", async () => { + let closed = 0; + const adapter = toInternalVcsAdapter({ + id: "demo", + name: "Demo", + detect: () => null, + history: { + open: () => ({ + read: async () => ({ commits: [{}, {}], done: false }) as never, + close: () => { + closed += 1; + }, + }), + planReview: (selected) => ({ + kind: "revision-show", + revisionId: selected.revisionId, + }), + }, + }); + const source = await adapter.history!.open({}, { cwd: "/repo" }); + await expect(source.read({ limit: 1 })).rejects.toThrow("more commits"); + await source.close(); + expect(closed).toBe(1); + }); + + test("preserves custom-provider paging and provider-owned opaque review plans", async () => { + const root = { + revisionId: "opaque:root/revision", + displayId: "root", + parentRevisionIds: [], + subject: "Root", + authorName: "Ada", + authoredAt: "2026-01-01T00:00:00Z", + decorations: [], + }; + const child = { + ...root, + revisionId: "opaque:child/revision", + displayId: "child", + parentRevisionIds: [root.revisionId], + subject: "Child", + }; + let page = 0; + let plannedParent: string | undefined; + const adapter = toInternalVcsAdapter({ + id: "opaque", + name: "Opaque VCS", + detect: (cwd) => ({ id: "opaque", repoRoot: cwd }), + history: { + open: () => ({ + read: async () => + page++ === 0 ? { commits: [child], done: false } : { commits: [root], done: true }, + close() {}, + }), + planReview: (selected, _context, options) => { + plannedParent = options?.parentRevisionId; + return selected.parentRevisionIds.length + ? { + kind: "revision-range", + fromRevisionId: + options?.parentRevisionId ?? `opaque:base-for/${selected.revisionId}`, + toRevisionId: selected.revisionId, + } + : { kind: "revision-show", revisionId: `opaque:root-view/${selected.revisionId}` }; + }, + }, + }); + + const source = await adapter.history!.open({}, { cwd: "/repo" }); + expect((await source.read({ limit: 1 })).commits.map((commit) => commit.revisionId)).toEqual([ + child.revisionId, + ]); + expect((await source.read({ limit: 1 })).commits.map((commit) => commit.revisionId)).toEqual([ + root.revisionId, + ]); + await expect(adapter.history!.planReview(child, { cwd: "/repo" })).resolves.toEqual({ + kind: "revision-range", + fromRevisionId: `opaque:base-for/${child.revisionId}`, + toRevisionId: child.revisionId, + }); + await expect( + adapter.history!.planReview(child, { cwd: "/repo" }, { parentRevisionId: root.revisionId }), + ).resolves.toEqual({ + kind: "revision-range", + fromRevisionId: root.revisionId, + toRevisionId: child.revisionId, + }); + expect(plannedParent).toBe(root.revisionId); + await expect(adapter.history!.planReview(root, { cwd: "/repo" })).resolves.toEqual({ + kind: "revision-show", + revisionId: `opaque:root-view/${root.revisionId}`, + }); + }); + + test("rejects parent-before-child ordering across page boundaries", async () => { + const commit = (revisionId: string, parentRevisionIds: string[]) => ({ + revisionId, + displayId: revisionId, + parentRevisionIds, + subject: revisionId, + authorName: "Ada", + authoredAt: "2026-01-01T00:00:00Z", + decorations: [], + }); + let page = 0; + const adapter = toInternalVcsAdapter({ + id: "misordered", + name: "Misordered", + detect: () => null, + history: { + open: () => ({ + read: async () => + page++ === 0 + ? { commits: [commit("parent", [])], done: false } + : { commits: [commit("child", ["parent"])], done: true }, + close() {}, + }), + planReview: (selected) => ({ + kind: "revision-show", + revisionId: selected.revisionId, + }), + }, + }); + const source = await adapter.history!.open({}, { cwd: "/repo" }); + await source.read({ limit: 1 }); + await expect(source.read({ limit: 1 })).rejects.toThrow( + "VCS history returned parent parent before child child.", + ); + }); +}); diff --git a/src/extensions/runExtension.ts b/packages/hunk/src/extensions/runExtension.ts similarity index 67% rename from src/extensions/runExtension.ts rename to packages/hunk/src/extensions/runExtension.ts index 6345da02b..8f2e05d8a 100644 --- a/src/extensions/runExtension.ts +++ b/packages/hunk/src/extensions/runExtension.ts @@ -27,8 +27,14 @@ import { import { parseKeyChord, toKeyChordList } from "../lib/commandKeys"; import { toUserFacingError } from "../core/run/errors"; import { toInternalVcsPatchResult } from "./vcsPatchResult"; -import type { ExtensionVcsOperation } from "../extension-api/types"; -import type { VcsAdapter, VcsOperation, VcsReviewInput } from "../core/vcs/types"; +import type { + ExtensionVcsHistoryCommit, + ExtensionVcsHistoryReviewAction, + ExtensionVcsHistorySource, + ExtensionVcsOperation, +} from "../extension-api/types"; +import type { VcsAdapter, VcsHistorySource, VcsOperation, VcsReviewInput } from "../core/vcs/types"; +import { sanitizeTerminalLine, sanitizeTerminalText } from "../lib/terminalText"; import { defaultExtensionPaneSize, extensionPaneSize, isVerticalPanePlacement } from "./panes"; import { isReservedExtensionCliCommandName, @@ -39,7 +45,7 @@ import { copyExtensionCliCommand } from "./cliCommands"; /** * Running one extension factory into the shared registry. * - * This module deliberately imports nothing from `src/core/vcs` beyond its + * This module deliberately imports nothing from `packages/hunk/src/core/vcs` beyond its * types: it is what the bundled tier (`./bundled`) uses, and that tier is * loaded *from* VCS adapter resolution. Keeping the dependency one-way means * bundled loading cannot deadlock on a half-initialized module. @@ -170,6 +176,263 @@ function toInternalVcsOperation( }; } +/** Snapshot named properties once so accessors cannot change values after validation. */ +function snapshotProperties(value: Record, keys: readonly string[]) { + const snapshot: Record = {}; + for (const key of keys) snapshot[key] = value[key]; + return snapshot; +} + +/** Snapshot an array's length and each accepted element once, including for proxied arrays. */ +function snapshotArray(value: unknown[], maximum = Number.MAX_SAFE_INTEGER) { + const length = value.length; + if (!Number.isSafeInteger(length) || length < 0 || length > maximum) { + throw new Error("VCS history returned more values than allowed."); + } + const snapshot: unknown[] = []; + for (let index = 0; index < length; index += 1) snapshot.push(value[index]); + return snapshot; +} + +/** Copy and validate one extension-provided history commit before it reaches core or UI. */ +function normalizeHistoryCommit(value: unknown): ExtensionVcsHistoryCommit { + if (!isPlainObject(value)) { + throw new Error("VCS history returned a commit that is not an object."); + } + const snapshot = snapshotProperties(value, [ + "revisionId", + "displayId", + "parentRevisionIds", + "graphParentRevisionIds", + "subject", + "body", + "authorName", + "authorEmail", + "authoredAt", + "decorations", + "logicalId", + ]); + const required = (key: string) => + assertNonEmptyString(snapshot[key], `VCS history commit ${key} must be a non-empty string.`); + const safeDisplay = (key: "displayId" | "subject" | "authorName") => { + const text = sanitizeTerminalLine(required(key)).replaceAll("\t", " "); + if (text.trim().length === 0) { + throw new Error(`VCS history commit ${key} must remain non-empty after sanitization.`); + } + return text; + }; + const safeRevision = (revision: unknown, label: string) => { + const text = assertNonEmptyString(revision, `${label} must be a non-empty string.`); + if (text.includes("\t") || sanitizeTerminalLine(text) !== text) { + throw new Error(`${label} must be a terminal-safe immutable revision id.`); + } + return text; + }; + const revisionId = safeRevision(snapshot.revisionId, "VCS history commit revisionId"); + const displayId = safeDisplay("displayId"); + const authoredAt = required("authoredAt"); + if ( + !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(authoredAt) || + Number.isNaN(Date.parse(authoredAt)) + ) { + throw new Error("VCS history commit authoredAt must be an ISO timestamp."); + } + if (!Array.isArray(snapshot.parentRevisionIds)) { + throw new Error("VCS history commit parentRevisionIds must be an array."); + } + if ( + snapshot.graphParentRevisionIds !== undefined && + !Array.isArray(snapshot.graphParentRevisionIds) + ) { + throw new Error("VCS history commit graphParentRevisionIds must be an array when present."); + } + if (!Array.isArray(snapshot.decorations)) { + throw new Error("VCS history commit decorations must be an array."); + } + + const parentValues = snapshotArray(snapshot.parentRevisionIds, 256); + const graphParentValues = Array.isArray(snapshot.graphParentRevisionIds) + ? snapshotArray(snapshot.graphParentRevisionIds, 256) + : undefined; + const decorationValues = snapshotArray(snapshot.decorations, 256); + const parentRevisionIds = parentValues.map((parent) => + safeRevision(parent, "VCS history parent revision id"), + ); + const graphParentRevisionIds = graphParentValues?.map((parent) => + safeRevision(parent, "VCS history graph parent revision id"), + ); + const decorationKinds = new Set(["head", "local-branch", "remote-branch", "tag", "ref"]); + const decorations = decorationValues.map((decoration) => { + if (!isPlainObject(decoration)) { + throw new Error("VCS history returned an invalid decoration."); + } + const fields = snapshotProperties(decoration, ["kind", "label", "attachedLocalBranch"]); + if (typeof fields.kind !== "string" || !decorationKinds.has(fields.kind)) { + throw new Error("VCS history returned an invalid decoration."); + } + const kind = fields.kind as ExtensionVcsHistoryCommit["decorations"][number]["kind"]; + const label = sanitizeTerminalLine( + assertNonEmptyString(fields.label, "VCS history decoration labels must be non-empty."), + ).replaceAll("\t", " "); + if (kind === "head") { + const attachedLocalBranch = + fields.attachedLocalBranch === undefined + ? undefined + : sanitizeTerminalLine( + assertNonEmptyString( + fields.attachedLocalBranch, + "VCS history attached local branch must be non-empty.", + ), + ).replaceAll("\t", " "); + return { kind, label, ...(attachedLocalBranch ? { attachedLocalBranch } : {}) }; + } + if (fields.attachedLocalBranch !== undefined) { + throw new Error("Only a VCS history HEAD decoration may name an attached local branch."); + } + return { kind, label }; + }); + + return { + revisionId, + displayId, + parentRevisionIds, + ...(graphParentRevisionIds ? { graphParentRevisionIds } : {}), + subject: safeDisplay("subject"), + ...(typeof snapshot.body === "string" + ? { + body: sanitizeTerminalText(snapshot.body, { + preserveNewlines: true, + preserveTabs: false, + }), + } + : {}), + authorName: safeDisplay("authorName"), + ...(typeof snapshot.authorEmail === "string" + ? { authorEmail: sanitizeTerminalLine(snapshot.authorEmail).replaceAll("\t", " ") } + : {}), + authoredAt, + decorations, + ...(typeof snapshot.logicalId === "string" + ? { logicalId: sanitizeTerminalLine(snapshot.logicalId).replaceAll("\t", " ") } + : {}), + }; +} + +/** Copy and validate a provider-owned plan for opening one opaque history item. */ +function normalizeHistoryReviewAction(value: unknown): ExtensionVcsHistoryReviewAction { + if (!isPlainObject(value)) { + throw new Error("VCS history planReview() must return an action object."); + } + const fields = snapshotProperties(value, [ + "kind", + "revisionId", + "fromRevisionId", + "toRevisionId", + ]); + const revision = (candidate: unknown, label: string) => { + const text = assertNonEmptyString(candidate, `${label} must be a non-empty string.`); + if (text.includes("\t") || sanitizeTerminalLine(text) !== text) { + throw new Error(`${label} must be a terminal-safe immutable revision id.`); + } + return text; + }; + if (fields.kind === "revision-show") { + return { kind: "revision-show", revisionId: revision(fields.revisionId, "revisionId") }; + } + if (fields.kind === "revision-range") { + return { + kind: "revision-range", + fromRevisionId: revision(fields.fromRevisionId, "fromRevisionId"), + toRevisionId: revision(fields.toRevisionId, "toRevisionId"), + }; + } + throw new Error("VCS history planReview() returned an unsupported action kind."); +} + +/** Wrap an extension history source with bounded reads, copying, cleanup, and error translation. */ +async function toInternalHistorySource( + source: ExtensionVcsHistorySource, +): Promise { + if (!isPlainObject(source)) { + throw new Error("VCS history open() must return a source object."); + } + const sourceFields = snapshotProperties(source, ["read", "close"]); + const sourceRead = sourceFields.read; + const sourceClose = sourceFields.close; + if (typeof sourceRead !== "function" || typeof sourceClose !== "function") { + if (typeof sourceClose === "function") { + try { + await sourceClose.call(source); + } catch { + // The malformed shape remains the primary failure. + } + } + throw new Error("VCS history open() must return a source with read() and close()."); + } + let closed = false; + let done = false; + const acceptedIds = new Set(); + const close = async () => { + if (closed) return; + closed = true; + try { + await sourceClose.call(source); + } catch (error) { + throw toUserFacingError(error); + } + }; + + return { + async read({ limit, signal }) { + if (closed || done) return { commits: [], done: true }; + if (!Number.isSafeInteger(limit) || limit <= 0) { + throw new Error("VCS history reads require a positive integer limit."); + } + try { + if (signal?.aborted) throw signal.reason ?? new Error("History read aborted."); + const page = await sourceRead.call(source, { limit, signal }); + if (!isPlainObject(page)) { + throw new Error("VCS history returned an invalid page."); + } + const pageFields = snapshotProperties(page, ["commits", "done"]); + if (!Array.isArray(pageFields.commits) || typeof pageFields.done !== "boolean") { + throw new Error("VCS history returned an invalid page."); + } + let commitValues: unknown[]; + try { + commitValues = snapshotArray(pageFields.commits, limit); + } catch { + throw new Error("VCS history returned more commits than the requested page limit."); + } + const commits = commitValues.map(normalizeHistoryCommit); + for (const commit of commits) { + if (acceptedIds.has(commit.revisionId)) { + throw new Error(`VCS history returned duplicate revision ${commit.revisionId}.`); + } + const emittedParent = commit.parentRevisionIds.find((parent) => acceptedIds.has(parent)); + if (emittedParent) { + throw new Error( + `VCS history returned parent ${emittedParent} before child ${commit.revisionId}.`, + ); + } + acceptedIds.add(commit.revisionId); + } + done = pageFields.done; + if (done) await close(); + return { commits, done }; + } catch (error) { + try { + await close(); + } catch { + // Preserve the read/validation failure as the primary error. + } + throw toUserFacingError(error); + } + }, + close, + }; +} + /** * Accept the public adapter shape as the internal one. * @@ -199,7 +462,22 @@ export function toInternalVcsAdapter( /** Diagnostic sink for a `detect()` result whose id was rewritten. */ reportDetectionIdMismatch?: (returnedId: string) => void, ): VcsAdapter { - const operations = adapter.operations; + const adapterFields = snapshotProperties(adapter as unknown as Record, [ + "id", + "name", + "operations", + "history", + "detect", + "detectionPriority", + ]); + const adapterId = assertNonEmptyString(adapterFields.id, "registerVcsAdapter requires an id."); + if (sanitizeTerminalLine(adapterId) !== adapterId) { + throw new Error("registerVcsAdapter requires a terminal-safe id."); + } + const adapterName = sanitizeTerminalLine( + assertNonEmptyString(adapterFields.name, "registerVcsAdapter requires a name."), + ).replaceAll("\t", " "); + const operations = adapterFields.operations; if (operations !== undefined && !isPlainObject(operations)) { throw new Error("registerVcsAdapter requires operations to be an object of review operations."); } @@ -213,39 +491,103 @@ export function toInternalVcsAdapter( } } - const detect = adapter.detect; + const history = adapterFields.history; + const historyFields = isPlainObject(history) + ? snapshotProperties(history, ["open", "planReview"]) + : undefined; + const historyOpen = historyFields?.open; + const historyPlanReview = historyFields?.planReview; + if ( + history !== undefined && + (!isPlainObject(history) || + typeof historyOpen !== "function" || + typeof historyPlanReview !== "function") + ) { + throw new Error("registerVcsAdapter history must provide open() and planReview() functions."); + } + + const openHistory = historyOpen as NonNullable["open"]; + const planHistoryReview = historyPlanReview as NonNullable< + ExtensionVcsAdapter["history"] + >["planReview"]; + const detect = adapterFields.detect; + if (typeof detect !== "function") { + throw new Error("registerVcsAdapter requires a detect() function."); + } // Report once per adapter: detection runs on every session and reload, and a // repeated diagnostic for one authoring mistake is noise, not information. let reportedMismatch = false; return { - ...adapter, + id: adapterId, + name: adapterName, + detectionPriority: + typeof adapterFields.detectionPriority === "number" + ? adapterFields.detectionPriority + : undefined, detect(cwd: string) { const detected = detect(cwd); if (!detected || !isPlainObject(detected)) { return null; } - // `repoRoot` is a path every caller measures distance against, and the - // measurement happens outside detection's own error handling — so a - // detection missing it throws past `detectVcs` and aborts startup rather - // than being skipped. Treat it as "did not recognize this directory". - if (typeof detected.repoRoot !== "string" || detected.repoRoot.length === 0) { + // Snapshot detection metadata before validation so accessors cannot change it later. + const detectionFields = snapshotProperties(detected, ["id", "repoRoot"]); + if (typeof detectionFields.repoRoot !== "string" || detectionFields.repoRoot.length === 0) { return null; } - if (detected.id === adapter.id) { - return detected; - } - - if (!reportedMismatch) { + if (detectionFields.id !== adapterId && !reportedMismatch) { reportedMismatch = true; - reportDetectionIdMismatch?.(String(detected.id)); + reportDetectionIdMismatch?.(sanitizeTerminalLine(String(detectionFields.id))); } - return { ...detected, id: adapter.id }; + return { id: adapterId, repoRoot: detectionFields.repoRoot }; }, operations: internalOperations, + ...(history && { + history: { + async open(input, context) { + try { + const source = await openHistory.call( + history, + { + ...input, + ...(input.pathspecs ? { pathspecs: [...input.pathspecs] } : {}), + }, + context, + ); + return await toInternalHistorySource(source); + } catch (error) { + throw toUserFacingError(error); + } + }, + async planReview(commit, context, options) { + try { + const normalizedCommit = normalizeHistoryCommit(commit); + const parentRevisionId = options?.parentRevisionId; + if ( + parentRevisionId !== undefined && + !normalizedCommit.parentRevisionIds.includes(parentRevisionId) + ) { + throw new Error( + "VCS history parent selection must name one of the commit's parents.", + ); + } + return normalizeHistoryReviewAction( + await planHistoryReview.call( + history, + normalizedCommit, + context, + parentRevisionId === undefined ? undefined : { parentRevisionId }, + ), + ); + } catch (error) { + throw toUserFacingError(error); + } + }, + }, + }), }; } diff --git a/packages/hunk/src/extensions/session.test.ts b/packages/hunk/src/extensions/session.test.ts new file mode 100644 index 000000000..3a386df8e --- /dev/null +++ b/packages/hunk/src/extensions/session.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, test } from "bun:test"; +import { createExtensionNotificationHub } from "./notifications"; +import { createExtensionSession } from "./session"; +import { + createEmptyExtensionLoadResult, + type ExtensionEventHandler, + type ExtensionLoadResult, +} from "./types"; + +/** Build a registry with observable startup and shutdown handlers. */ +function loadResult( + id: string, + events: string[], + shutdown?: () => void | Promise, +): ExtensionLoadResult { + const result = createEmptyExtensionLoadResult("/repo", createExtensionNotificationHub()); + result.registry.eventHandlers.startup.push({ + extensionId: id, + handler: ((payload: { cwd: string }) => { + events.push(`${id}:start:${payload.cwd}`); + }) as ExtensionEventHandler, + }); + result.registry.eventHandlers.shutdown.push({ + extensionId: id, + handler: async () => { + events.push(`${id}:stop`); + await shutdown?.(); + }, + }); + return result; +} + +describe("ExtensionSession", () => { + test("starts each adopted registry once and revokes its predecessor before exposing replacement", async () => { + const events: string[] = []; + let release!: () => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + const initial = loadResult("initial", events, () => pending); + const replacement = loadResult("replacement", events); + const session = createExtensionSession(initial, "/one"); + + expect(session.startCurrent()).toBe(true); + expect(session.startCurrent()).toBe(false); + session.trackPrepared(replacement); + const retirement = session.adoptPrepared(replacement, "/two"); + + expect(initial.registry.eventBusPhase).toBe("closed"); + expect(session.current).toBe(replacement); + expect(session.cwd).toBe("/two"); + expect(session.startCurrent()).toBe(true); + expect(events).toEqual(["initial:start:/one", "initial:stop", "replacement:start:/two"]); + release(); + await retirement; + await session.shutdown(); + expect(events).toEqual([ + "initial:start:/one", + "initial:stop", + "replacement:start:/two", + "replacement:stop", + ]); + }); + + test("owns aliases by registry identity and retires multiple provisional registries once", async () => { + const events: string[] = []; + const initial = loadResult("initial", events); + const first = loadResult("first", events); + const alias = { ...first }; + const second = loadResult("second", events); + const session = createExtensionSession(initial, "/repo"); + + session.trackPrepared(first); + session.trackPrepared(alias); + session.trackPrepared(second); + await session.retirePrepared(); + await session.retirePrepared(first); + await session.shutdown(); + await session.shutdown(); + + expect(events.filter((event) => event === "first:stop")).toHaveLength(1); + expect(events.filter((event) => event === "second:stop")).toHaveLength(1); + expect(events.filter((event) => event === "initial:stop")).toHaveLength(1); + }); + + test("closes synchronously and drains provisional authority published during shutdown", async () => { + const events: string[] = []; + let releaseInitial!: () => void; + let releaseLate!: () => void; + const initialWait = new Promise((resolve) => { + releaseInitial = resolve; + }); + const lateWait = new Promise((resolve) => { + releaseLate = resolve; + }); + const initial = loadResult("initial", events, () => initialWait); + const late = loadResult("late", events, () => lateWait); + const session = createExtensionSession(initial, "/repo"); + + const shutdown = session.shutdown(); + expect(session.closing).toBe(true); + expect(session.startCurrent()).toBe(false); + session.trackPrepared(late); + expect(late.registry.eventBusPhase).toBe("closed"); + + let settled = false; + void shutdown.then(() => { + settled = true; + }); + releaseInitial(); + await Promise.resolve(); + expect(settled).toBe(false); + releaseLate(); + await shutdown; + expect(events).toEqual(["initial:stop", "late:stop"]); + }); + + test("a later shutdown call drains provisional work tracked after shutdown settled", async () => { + const events: string[] = []; + let releaseLate!: () => void; + const lateWait = new Promise((resolve) => { + releaseLate = resolve; + }); + const session = createExtensionSession(loadResult("initial", events), "/repo"); + await session.shutdown(); + + const late = loadResult("late", events, () => lateWait); + session.trackPrepared(late); + let settled = false; + const barrier = session.shutdown().then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + expect(late.registry.eventBusPhase).toBe("closed"); + + releaseLate(); + await barrier; + expect(events).toEqual(["initial:stop", "late:stop"]); + }); + + test("rejects adoption after closing and registries not staged by the session", async () => { + const events: string[] = []; + const session = createExtensionSession(loadResult("initial", events), "/repo"); + const foreign = loadResult("foreign", events); + expect(() => session.adoptPrepared(foreign, "/other")).toThrow("not prepared"); + await session.shutdown(); + expect(() => session.adoptPrepared(foreign, "/other")).toThrow("shutting down"); + }); +}); diff --git a/packages/hunk/src/extensions/session.ts b/packages/hunk/src/extensions/session.ts new file mode 100644 index 000000000..25504de84 --- /dev/null +++ b/packages/hunk/src/extensions/session.ts @@ -0,0 +1,135 @@ +import { emitExtensionEvent, retireExtensionLoadResult } from "./events"; +import type { ExtensionLoadResult, ExtensionRegistry } from "./types"; + +/** Owns extension registries for one command or interactive Hunk session. */ +export interface ExtensionSession { + readonly current: ExtensionLoadResult; + readonly cwd: string; + readonly closing: boolean; + startCurrent(cwd?: string): boolean; + trackPrepared(result: ExtensionLoadResult): void; + retirePrepared(result?: ExtensionLoadResult): Promise; + adoptPrepared(result: ExtensionLoadResult, cwd: string): Promise; + shutdown(): Promise; +} + +/** Coordinates active, provisional, and retiring extension registries by registry identity. */ +class ExtensionSessionImpl implements ExtensionSession { + #current: ExtensionLoadResult; + #cwd: string; + #closing = false; + #prepared = new Map(); + #started = new WeakSet(); + #retirements = new Set>(); + #shutdownPromise: Promise | undefined; + + constructor(initial: ExtensionLoadResult, cwd: string) { + this.#current = initial; + this.#cwd = cwd; + } + + get current() { + return this.#current; + } + + get cwd() { + return this.#cwd; + } + + get closing() { + return this.#closing; + } + + /** Emit startup once for each registry that reaches active authority. */ + startCurrent(cwd = this.#cwd) { + if (this.#closing) return false; + const { registry } = this.#current; + if (this.#started.has(registry)) return false; + this.#started.add(registry); + emitExtensionEvent(this.#current, "startup", { cwd }); + return true; + } + + /** Own a provisional registry before asynchronous loading can suspend. */ + trackPrepared(result: ExtensionLoadResult) { + if (result.registry === this.#current.registry) return; + if (this.#closing) { + void this.#retire(result); + return; + } + this.#prepared.set(result.registry, result); + } + + /** Retire one staged registry, or every registry that has not been adopted. */ + async retirePrepared(result?: ExtensionLoadResult) { + if (result) { + if (result.registry === this.#current.registry) return; + this.#prepared.delete(result.registry); + await this.#retire(result); + return; + } + const prepared = [...this.#prepared.values()]; + this.#prepared.clear(); + await Promise.allSettled(prepared.map((entry) => this.#retire(entry))); + } + + /** Adopt a staged registry at the caller's content commit gate and retire its predecessor. */ + adoptPrepared(result: ExtensionLoadResult, cwd: string) { + if (this.#closing) throw new Error("The extension session is shutting down."); + if (result.registry !== this.#current.registry && !this.#prepared.has(result.registry)) { + throw new Error("The extension registry was not prepared by this session."); + } + if (result.registry === this.#current.registry) { + this.#cwd = cwd; + return Promise.resolve(); + } + + const previous = this.#current; + this.#prepared.delete(result.registry); + // Retirement revokes the old event/control authority synchronously before the swap is visible. + const retirement = this.#retire(previous); + this.#current = result; + this.#cwd = cwd; + return retirement; + } + + /** Revoke every owned registry and wait for all retirement work known to this call. */ + shutdown() { + if (!this.#closing) { + this.#closing = true; + void this.#retire(this.#current); + const prepared = [...this.#prepared.values()]; + this.#prepared.clear(); + for (const result of prepared) void this.#retire(result); + } + + const previous = this.#shutdownPromise; + const shutdown = (async () => { + await previous; + while (this.#retirements.size > 0) { + await Promise.allSettled(this.#retirements); + } + })(); + this.#shutdownPromise = shutdown; + return shutdown; + } + + /** Track one registry's shared retirement completion without duplicating shutdown. */ + #retire(result: ExtensionLoadResult) { + const retirement = retireExtensionLoadResult(result); + this.#retirements.add(retirement); + void retirement.then( + () => this.#retirements.delete(retirement), + () => this.#retirements.delete(retirement), + ); + return retirement; + } +} + +/** Create one explicit owner for a loaded extension registry. */ +export function createExtensionSession( + initial: ExtensionLoadResult, + cwd: string, +): ExtensionSession { + return new ExtensionSessionImpl(initial, cwd); +} diff --git a/src/extensions/startup.test.ts b/packages/hunk/src/extensions/startup.test.ts similarity index 100% rename from src/extensions/startup.test.ts rename to packages/hunk/src/extensions/startup.test.ts diff --git a/src/extensions/startup.ts b/packages/hunk/src/extensions/startup.ts similarity index 100% rename from src/extensions/startup.ts rename to packages/hunk/src/extensions/startup.ts diff --git a/src/extensions/trust.test.ts b/packages/hunk/src/extensions/trust.test.ts similarity index 100% rename from src/extensions/trust.test.ts rename to packages/hunk/src/extensions/trust.test.ts diff --git a/src/extensions/trust.ts b/packages/hunk/src/extensions/trust.ts similarity index 100% rename from src/extensions/trust.ts rename to packages/hunk/src/extensions/trust.ts diff --git a/src/extensions/types.ts b/packages/hunk/src/extensions/types.ts similarity index 96% rename from src/extensions/types.ts rename to packages/hunk/src/extensions/types.ts index 2fd466a62..dbd6be259 100644 --- a/src/extensions/types.ts +++ b/packages/hunk/src/extensions/types.ts @@ -22,7 +22,7 @@ import type { import { createExtensionNotificationHub, type ExtensionNotificationHub } from "./notifications"; /** - * The authoring contract lives in `src/extension-api/types.ts` and is re-exported + * The authoring contract lives in `packages/hunk/src/extension-api/types.ts` and is re-exported * here so host code keeps one import site for both halves of the system. That * module is self-contained because its declarations are published; this one is * free to reference Hunk internals. @@ -76,6 +76,14 @@ export type { ExtensionSessionOptions, ExtensionThemeConfig, ExtensionVcsAdapter, + ExtensionVcsHistoryCapability, + ExtensionVcsHistoryCommit, + ExtensionVcsHistoryDecoration, + ExtensionVcsHistoryInput, + ExtensionVcsHistoryPage, + ExtensionVcsHistoryReviewAction, + ExtensionVcsHistoryReviewOptions, + ExtensionVcsHistorySource, ExtensionWorkspace, ExtensionWorkspaceWriteRequest, ExtensionWorkspaceWriteResult, @@ -85,7 +93,7 @@ export type { /** * Where one extension came from, which decides its trust posture. * - * `bundled` is Hunk's own compiled-in tier (`src/extensions/default/`): no + * `bundled` is Hunk's own compiled-in tier (`packages/hunk/src/extensions/default/`): no * discovery, no trust prompt, and never disabled by `--no-extensions`. Every * other origin is a user extension loaded from disk. */ diff --git a/src/extensions/vcsPatchResult.test.ts b/packages/hunk/src/extensions/vcsPatchResult.test.ts similarity index 100% rename from src/extensions/vcsPatchResult.test.ts rename to packages/hunk/src/extensions/vcsPatchResult.test.ts diff --git a/src/extensions/vcsPatchResult.ts b/packages/hunk/src/extensions/vcsPatchResult.ts similarity index 100% rename from src/extensions/vcsPatchResult.ts rename to packages/hunk/src/extensions/vcsPatchResult.ts diff --git a/src/highlightWorkerClient.ts b/packages/hunk/src/highlightWorkerClient.ts similarity index 100% rename from src/highlightWorkerClient.ts rename to packages/hunk/src/highlightWorkerClient.ts diff --git a/src/highlightWorkerEntry.ts b/packages/hunk/src/highlightWorkerEntry.ts similarity index 100% rename from src/highlightWorkerEntry.ts rename to packages/hunk/src/highlightWorkerEntry.ts diff --git a/src/hunk-review/skillDocument.test.ts b/packages/hunk/src/hunk-review/skillDocument.test.ts similarity index 90% rename from src/hunk-review/skillDocument.test.ts rename to packages/hunk/src/hunk-review/skillDocument.test.ts index e45f4175e..ac5eded55 100644 --- a/src/hunk-review/skillDocument.test.ts +++ b/packages/hunk/src/hunk-review/skillDocument.test.ts @@ -9,7 +9,15 @@ import { import { renderHunkReviewSkill } from "./skillDocument"; const SKILL_PATH = join(import.meta.dir, "..", "..", "skills", "hunk-review", "SKILL.md"); -const AGENT_WORKFLOWS_PATH = join(import.meta.dir, "..", "..", "docs", "agent-workflows.md"); +const AGENT_WORKFLOWS_PATH = join( + import.meta.dir, + "..", + "..", + "..", + "..", + "docs", + "agent-workflows.md", +); /** Every flag the agent-facing docs may reference: the session surface plus auxiliary options. */ const DOCUMENTED_AGENT_FLAGS = new Set([ @@ -32,7 +40,7 @@ describe("hunk-review skill document", () => { if (checkedIn !== rendered) { throw new Error( - "skills/hunk-review/SKILL.md is out of date. Run `bun run generate:skill` and commit the result.", + "packages/hunk/skills/hunk-review/SKILL.md is out of date. Run `bun run generate:skill` and commit the result.", ); } diff --git a/src/hunk-review/skillDocument.ts b/packages/hunk/src/hunk-review/skillDocument.ts similarity index 96% rename from src/hunk-review/skillDocument.ts rename to packages/hunk/src/hunk-review/skillDocument.ts index 79694d612..55d0b5069 100644 --- a/src/hunk-review/skillDocument.ts +++ b/packages/hunk/src/hunk-review/skillDocument.ts @@ -2,7 +2,7 @@ import { AGENT_ERROR_DOCS } from "../session/agent/errors"; import { SESSION_AGENT_COMMANDS, type AgentCommandSpec } from "../session/agent/surface"; /** - * Deterministic renderer for `skills/hunk-review/SKILL.md`. + * Deterministic renderer for `packages/hunk/skills/hunk-review/SKILL.md`. * * The command reference blocks and the "Common errors" section are derived from * `agentSurface.ts` and `agentErrors.ts` so the skill can never disagree with the parser or the @@ -160,8 +160,8 @@ const COMMENTS_SECTION = [ "", "- `comment list --type user` shows human-authored inline notes; without `--type`, `comment list` preserves the legacy live-agent-comment view", "- `comment add` is best for one note; `comment apply` is best when an agent already has several notes ready", - "- `comment add` requires `--file`, `--summary`, and exactly one of `--old-line` or `--new-line`", - "- `comment apply` payload items require `filePath`, `summary`, and exactly one target such as `hunk`, `hunkNumber`, `oldLine`, or `newLine`", + "- Root `comment add` notes require `--file`, `--summary`, and exactly one of `--old-line` or `--new-line`; replies use `--reply-to ` with `--summary` and inherit the parent's anchor", + "- `comment apply` items require `summary` plus either `replyTo` by itself or `filePath` with exactly one target such as `hunk`, `hunkNumber`, `oldLine`, or `newLine`", "- `comment apply` reads a JSON batch from stdin and validates the full batch before mutating the live session", "- Pass `--focus` when you want to jump to the new note or the first note in a batch", "- `comment list` and `comment clear` accept optional `--file`", diff --git a/src/lib/commandKeys.test.ts b/packages/hunk/src/lib/commandKeys.test.ts similarity index 56% rename from src/lib/commandKeys.test.ts rename to packages/hunk/src/lib/commandKeys.test.ts index 54c451581..018658309 100644 --- a/src/lib/commandKeys.test.ts +++ b/packages/hunk/src/lib/commandKeys.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { KeyEvent } from "@opentui/core"; +import { KeyEvent, parseKeypress } from "@opentui/core"; import { matchesKeyChord, parseKeyChord, toKeyChordList } from "./commandKeys"; import { synthesizeKeyEvent } from "../ui/lib/syntheticKeyEvent"; @@ -7,7 +7,7 @@ import { synthesizeKeyEvent } from "../ui/lib/syntheticKeyEvent"; * The internal-only pieces of chord handling. * * The grammar itself is published as `hunkdiff/extension` and covered by - * `src/extension-api/keys.test.ts`; what lives here is what only Hunk needs. + * `packages/hunk/src/extension-api/keys.test.ts`; what lives here is what only Hunk needs. */ function parsed(chord: string) { @@ -19,6 +19,35 @@ function parsed(chord: string) { return result; } +/** Decode a real terminal sequence or fail the test when OpenTUI rejects it. */ +function decodedKey(sequence: string, useKittyKeyboard: boolean) { + const key = parseKeypress(sequence, { useKittyKeyboard }); + if (!key) { + throw new Error(`Could not decode terminal key ${JSON.stringify(sequence)}`); + } + + return key; +} + +describe("terminal modifier protocols", () => { + test("matches alt in legacy, explicit ANSI, and Kitty encodings", () => { + const alt = parsed("alt+n"); + + expect(matchesKeyChord(alt, decodedKey("\u001bn", false))).toBe(true); + expect(matchesKeyChord(alt, decodedKey("\u001b[27;3;110~", false))).toBe(true); + expect(matchesKeyChord(alt, decodedKey("\u001b[110;3u", true))).toBe(true); + }); + + test("distinguishes Kitty meta from Kitty alt", () => { + const kittyAlt = decodedKey("\u001b[110;3u", true); + const kittyMeta = decodedKey("\u001b[110;33u", true); + + expect(matchesKeyChord(parsed("alt+n"), kittyMeta)).toBe(false); + expect(matchesKeyChord(parsed("meta+n"), kittyAlt)).toBe(false); + expect(matchesKeyChord(parsed("meta+n"), kittyMeta)).toBe(true); + }); +}); + describe("synthesizeKeyEvent", () => { test("round-trips through the matcher for every chord form", () => { for (const chord of ["y", "G", "ctrl+shift+m", "f10", "{", "alt+left", ".", "space"]) { diff --git a/src/lib/commandKeys.ts b/packages/hunk/src/lib/commandKeys.ts similarity index 96% rename from src/lib/commandKeys.ts rename to packages/hunk/src/lib/commandKeys.ts index f9de7322f..15ece1cf7 100644 --- a/src/lib/commandKeys.ts +++ b/packages/hunk/src/lib/commandKeys.ts @@ -4,7 +4,7 @@ import { matchesKeyChord, parseKeyChord, type ParsedKeyChord } from "../extensio /** * Key-chord parsing and matching for the command registry. * - * The grammar itself lives in `src/extension-api/keys.ts` because extensions + * The grammar itself lives in `packages/hunk/src/extension-api/keys.ts` because extensions * need it too — it is published as part of `hunkdiff/extension` — and is * re-exported here so internal code keeps importing key handling from one * place. What stays local is what extensions have no use for: synthesizing the diff --git a/src/lib/patchPath.ts b/packages/hunk/src/lib/patchPath.ts similarity index 100% rename from src/lib/patchPath.ts rename to packages/hunk/src/lib/patchPath.ts diff --git a/src/lib/terminalText.test.ts b/packages/hunk/src/lib/terminalText.test.ts similarity index 100% rename from src/lib/terminalText.test.ts rename to packages/hunk/src/lib/terminalText.test.ts diff --git a/src/lib/terminalText.ts b/packages/hunk/src/lib/terminalText.ts similarity index 100% rename from src/lib/terminalText.ts rename to packages/hunk/src/lib/terminalText.ts diff --git a/src/main.tsx b/packages/hunk/src/main.tsx similarity index 70% rename from src/main.tsx rename to packages/hunk/src/main.tsx index 2e923b5cb..80a6caff0 100644 --- a/src/main.tsx +++ b/packages/hunk/src/main.tsx @@ -4,7 +4,7 @@ import { formatCliError } from "./core/run/errors"; import { pagePlainText } from "./core/process/pager"; import { writeStdout } from "./core/process/stdout"; import { prepareStartupPlan } from "./app/startup"; -import { sanitizeTerminalText } from "./lib/terminalText"; +import { sanitizeTerminalLine, sanitizeTerminalText } from "./lib/terminalText"; import { serveSessionBrokerDaemon } from "./session/broker/brokerServer"; import { runSessionCommand } from "./session/agent/commands"; @@ -88,6 +88,18 @@ async function main() { ); } + if (startupPlan.kind === "history-static") { + const { runStaticHistory } = await import("./ui/history/runStaticHistory"); + await runStaticHistory(startupPlan.bootstrap); + return; + } + + if (startupPlan.kind === "history-interactive") { + const { runInteractiveHistory } = await import("./ui/history/runInteractiveHistory"); + await runInteractiveHistory(startupPlan.bootstrap); + return; + } + if (startupPlan.kind === "plain-text-pager") { await pagePlainText(startupPlan.text); process.exit(0); @@ -111,27 +123,44 @@ async function main() { process.exit(0); } + if (startupPlan.kind === "static-diff") { + const [{ renderStaticDiff }, { retireExtensionLoadResult }] = await Promise.all([ + import("./ui/staticDiffPager"), + import("./extensions/events"), + ]); + try { + for (const notice of startupPlan.bootstrap.startupNotices ?? []) { + process.stderr.write(`hunk: warning: ${sanitizeTerminalLine(notice.message)}\n`); + } + writeStdout( + await renderStaticDiff( + startupPlan.bootstrap.changeset, + startupPlan.bootstrap.input.options, + { + customThemes: startupPlan.bootstrap.customThemes, + color: false, + preserveFullLines: true, + }, + ), + ); + } finally { + await retireExtensionLoadResult(startupPlan.bootstrap.extensions); + } + process.exit(0); + } + if (startupPlan.kind !== "app") { throw new Error("Unreachable startup plan."); } // OpenTUI stays behind the interactive plan so headless commands never materialize its embedded - // native library. The highlighting client starts the compiled worker only when an opted-in, - // eligible diff needs it, so normal sessions do not pay its startup cost. The interactive - // app owns that worker's disposal: this call returns once the app is mounted, not once it exits. - try { - const { runInteractiveApp } = await import("./ui/runInteractiveApp"); - await runInteractiveApp(startupPlan); - } catch (error) { - startupPlan.controllingTerminal?.close(); - await ( - await import("./extensions/events") - ).retireExtensionLoadResult(startupPlan.bootstrap.extensions); - throw error; - } + // native library. The shared interactive runner owns the highlighting worker and terminal until + // the mounted surface acknowledges graceful shutdown. + const { runInteractiveApp } = await import("./ui/runInteractiveApp"); + await runInteractiveApp(startupPlan); } await main().catch((error) => { process.stderr.write(formatCliError(error)); - process.exit(1); + process.exitCode = 1; }); diff --git a/src/opentui/HunkDiffBody.tsx b/packages/hunk/src/opentui/HunkDiffBody.tsx similarity index 100% rename from src/opentui/HunkDiffBody.tsx rename to packages/hunk/src/opentui/HunkDiffBody.tsx diff --git a/src/opentui/HunkDiffFileHeader.tsx b/packages/hunk/src/opentui/HunkDiffFileHeader.tsx similarity index 100% rename from src/opentui/HunkDiffFileHeader.tsx rename to packages/hunk/src/opentui/HunkDiffFileHeader.tsx diff --git a/src/opentui/HunkDiffView.test.tsx b/packages/hunk/src/opentui/HunkDiffView.test.tsx similarity index 98% rename from src/opentui/HunkDiffView.test.tsx rename to packages/hunk/src/opentui/HunkDiffView.test.tsx index df9511c8b..dad55e7c1 100644 --- a/src/opentui/HunkDiffView.test.tsx +++ b/packages/hunk/src/opentui/HunkDiffView.test.tsx @@ -220,11 +220,11 @@ describe("OpenTUI public components", () => { expect(narrowFrame).toContain("src/ui/"); expect(wideFrame).not.toContain("src/ui/"); - expect(wideFrame).toContain("src/"); - expect(wideFrame).toContain("ui/"); + expect(wideFrame).toContain("⌄ src/"); + expect(wideFrame).toContain("⌄ ui/"); const wideLines = wideFrame.split("\n"); - expect(wideLines.find((line) => line.includes("src/"))?.indexOf("src/")).toBe(1); - expect(wideLines.find((line) => line.includes("ui/"))?.indexOf("ui/")).toBe(3); + expect(wideLines.find((line) => line.includes("src/"))?.indexOf("src/")).toBe(3); + expect(wideLines.find((line) => line.includes("ui/"))?.indexOf("ui/")).toBe(5); expect(wideFrame).toContain("alpha.ts"); expect(wideFrame).toContain("beta.ts"); }); diff --git a/src/opentui/HunkDiffView.tsx b/packages/hunk/src/opentui/HunkDiffView.tsx similarity index 100% rename from src/opentui/HunkDiffView.tsx rename to packages/hunk/src/opentui/HunkDiffView.tsx diff --git a/src/opentui/HunkFileNav.tsx b/packages/hunk/src/opentui/HunkFileNav.tsx similarity index 58% rename from src/opentui/HunkFileNav.tsx rename to packages/hunk/src/opentui/HunkFileNav.tsx index 1472afcc0..7acf56575 100644 --- a/src/opentui/HunkFileNav.tsx +++ b/packages/hunk/src/opentui/HunkFileNav.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { FileDirectoryRow, FileGroupHeader, @@ -7,8 +7,12 @@ import { import { buildFlatSidebarEntries, buildTreeSidebarEntries, + collapseTreeSidebarEntries, + expandCollapsedDirectoryPaths, resolveFileSidebarMode, + sidebarDirectoryPaths, sidebarEntryStatsWidth, + toggleCollapsedDirectoryPath, } from "../ui/lib/files"; import { resolveTheme } from "../ui/themes"; import { toInternalDiffFiles } from "./model"; @@ -24,15 +28,44 @@ export function HunkFileNav({ }: HunkFileNavProps) { const resolvedTheme = resolveTheme(theme, null); const internalFiles = useMemo(() => toInternalDiffFiles(files), [files]); + const previousSelectedFileIdRef = useRef(selectedFileId); + const [collapsedDirectoryPaths, setCollapsedDirectoryPaths] = useState>( + () => new Set(), + ); const textWidth = Math.max(1, width - 1); const mode = resolveFileSidebarMode(textWidth); - const entries = useMemo( - () => - mode === "tree" - ? buildTreeSidebarEntries(internalFiles) - : buildFlatSidebarEntries(internalFiles), - [internalFiles, mode], - ); + const entries = useMemo(() => { + if (mode === "flat") { + return buildFlatSidebarEntries(internalFiles); + } + return collapseTreeSidebarEntries( + buildTreeSidebarEntries(internalFiles), + collapsedDirectoryPaths, + ); + }, [collapsedDirectoryPaths, internalFiles, mode]); + + /** Toggle one logical directory everywhere it appears in the ordered tree projection. */ + const toggleDirectory = (path: string) => { + setCollapsedDirectoryPaths((current) => toggleCollapsedDirectoryPath(current, path)); + }; + + useEffect(() => { + const previousSelectedFileId = previousSelectedFileIdRef.current; + previousSelectedFileIdRef.current = selectedFileId; + if (!selectedFileId || selectedFileId === previousSelectedFileId) { + return; + } + + const selectedFile = internalFiles.find((file) => file.id === selectedFileId); + if (!selectedFile) { + return; + } + + setCollapsedDirectoryPaths((current) => + expandCollapsedDirectoryPaths(current, sidebarDirectoryPaths(selectedFile.path)), + ); + }, [internalFiles, selectedFileId]); + const fileEntries = entries.filter((entry) => entry.kind === "file"); const statsWidth = Math.max(0, ...fileEntries.map((entry) => sidebarEntryStatsWidth(entry))); @@ -54,7 +87,9 @@ export function HunkFileNav({ return ( (); /** Count visible additions and deletions from Pierre metadata. */ -export const countHunkDiffStats = countDiffStats; +export function countHunkDiffStats(metadata: FileDiffMetadata): HunkDiffStats { + return countDiffStats(metadata); +} /** Build one public file while optionally preserving paths decoded exactly from Git quoting. */ function buildHunkDiffFile(input: HunkDiffFileInput, pathsAreExact: boolean): HunkDiffFile { @@ -48,7 +50,7 @@ function resolveHunkDiffFile(input: HunkDiffFileInput) { return createHunkDiffFile(input); } -/** Adapt the public OpenTUI file shape into Hunk's internal review file model. */ +/** @internal Adapt the public OpenTUI file shape into Hunk's internal review file model. */ export function toInternalDiffFile(diff: HunkDiffFileInput): DiffFile { const normalized = resolveHunkDiffFile(diff); const patch = normalized.patch ?? ""; @@ -93,7 +95,7 @@ export function createHunkDiffFilesFromPatch(patchText: string, sourceId = "patc }); } -/** Adapt a list of public OpenTUI files into Hunk's internal review file model. */ +/** @internal Adapt a list of public OpenTUI files into Hunk's internal review file model. */ export function toInternalDiffFiles(files: HunkDiffFileInput[]) { return files.map(toInternalDiffFile); } diff --git a/src/opentui/themes.ts b/packages/hunk/src/opentui/themes.ts similarity index 100% rename from src/opentui/themes.ts rename to packages/hunk/src/opentui/themes.ts diff --git a/src/opentui/types.ts b/packages/hunk/src/opentui/types.ts similarity index 100% rename from src/opentui/types.ts rename to packages/hunk/src/opentui/types.ts diff --git a/src/session/agent/cliClient.test.ts b/packages/hunk/src/session/agent/cliClient.test.ts similarity index 98% rename from src/session/agent/cliClient.test.ts rename to packages/hunk/src/session/agent/cliClient.test.ts index b58b0a9f9..b2eb87b2f 100644 --- a/src/session/agent/cliClient.test.ts +++ b/packages/hunk/src/session/agent/cliClient.test.ts @@ -8,7 +8,7 @@ import { createTestSessionReviewFile, createTestSessionReviewHunk, createTestSessionSnapshot, -} from "../../../test/helpers/session-daemon-fixtures"; +} from "../../../../../test/helpers/session-daemon-fixtures"; import type { SessionSelectorInput } from "../../core/run/commandInputs"; import { HUNK_SESSION_API_PATH, @@ -204,7 +204,7 @@ describe("HTTP Hunk session CLI client", () => { kind: "session", action: "comment-apply", selector, - comments: [{ filePath: "src/app.ts", summary: "Check this" }], + comments: [{ filePath: "src/app.ts", hunkNumber: 1, summary: "Check this" }], revealMode: "first", output: "json", }), @@ -301,7 +301,7 @@ describe("HTTP Hunk session CLI client", () => { { action: "comment-apply", selector, - comments: [{ filePath: "src/app.ts", summary: "Check this" }], + comments: [{ filePath: "src/app.ts", hunkNumber: 1, summary: "Check this" }], revealMode: "first", }, { action: "comment-list", selector, filePath: "src/app.ts" }, diff --git a/src/session/agent/cliClient.ts b/packages/hunk/src/session/agent/cliClient.ts similarity index 99% rename from src/session/agent/cliClient.ts rename to packages/hunk/src/session/agent/cliClient.ts index b6ab01072..3e536f87e 100644 --- a/src/session/agent/cliClient.ts +++ b/packages/hunk/src/session/agent/cliClient.ts @@ -224,6 +224,7 @@ class HttpHunkSessionCliClient implements HunkSessionCliClient { filePath: input.filePath, side: input.side, line: input.line, + ...(input.replyTo !== undefined ? { replyTo: input.replyTo } : {}), summary: input.summary, rationale: input.rationale, markup: input.markup, diff --git a/src/session/agent/commands.daemon.test.ts b/packages/hunk/src/session/agent/commands.daemon.test.ts similarity index 98% rename from src/session/agent/commands.daemon.test.ts rename to packages/hunk/src/session/agent/commands.daemon.test.ts index dbb4c7f62..93f59b68d 100644 --- a/src/session/agent/commands.daemon.test.ts +++ b/packages/hunk/src/session/agent/commands.daemon.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { createServer } from "node:net"; import { platform } from "node:os"; import type { SessionCommandInput } from "../../core/run/commandInputs"; -import { createTestListedSession } from "../../../test/helpers/session-daemon-fixtures"; +import { createTestListedSession } from "../../../../../test/helpers/session-daemon-fixtures"; import { runSessionCommand, setSessionCommandTestHooks, diff --git a/src/session/agent/commands.test.ts b/packages/hunk/src/session/agent/commands.test.ts similarity index 99% rename from src/session/agent/commands.test.ts rename to packages/hunk/src/session/agent/commands.test.ts index 8b7d9e7b7..8470c8f5d 100644 --- a/src/session/agent/commands.test.ts +++ b/packages/hunk/src/session/agent/commands.test.ts @@ -6,7 +6,7 @@ import { createTestSessionFileSummary, createTestSessionReview as buildTestSessionReview, createTestSessionSnapshot, -} from "../../../test/helpers/session-daemon-fixtures"; +} from "../../../../../test/helpers/session-daemon-fixtures"; import type { SessionCommandInput, SessionSelectorInput } from "../../core/run/commandInputs"; import { runSessionCommand, diff --git a/src/session/agent/commands.ts b/packages/hunk/src/session/agent/commands.ts similarity index 100% rename from src/session/agent/commands.ts rename to packages/hunk/src/session/agent/commands.ts diff --git a/src/session/agent/errors.test.ts b/packages/hunk/src/session/agent/errors.test.ts similarity index 100% rename from src/session/agent/errors.test.ts rename to packages/hunk/src/session/agent/errors.test.ts diff --git a/src/session/agent/errors.ts b/packages/hunk/src/session/agent/errors.ts similarity index 97% rename from src/session/agent/errors.ts rename to packages/hunk/src/session/agent/errors.ts index dcba9dad4..b8db1f392 100644 --- a/src/session/agent/errors.ts +++ b/packages/hunk/src/session/agent/errors.ts @@ -1,7 +1,7 @@ /** * Agent-facing error messages for the `hunk session` surface. * - * Every message quoted by the generated `skills/hunk-review/SKILL.md` "Common errors" section is + * Every message quoted by the generated `packages/hunk/skills/hunk-review/SKILL.md` "Common errors" section is * defined (or contract-tested) here, so the skill can never quote wording the CLI no longer * throws. Throw sites import these builders instead of repeating string literals. */ diff --git a/src/session/agent/surface.test.ts b/packages/hunk/src/session/agent/surface.test.ts similarity index 94% rename from src/session/agent/surface.test.ts rename to packages/hunk/src/session/agent/surface.test.ts index 033048297..184dade6e 100644 --- a/src/session/agent/surface.test.ts +++ b/packages/hunk/src/session/agent/surface.test.ts @@ -88,17 +88,21 @@ describe("session agent command surface", () => { const navigate: SessionCommandOptions<"navigate"> = { repo: ".", hunk: 2, json: true }; // @ts-expect-error --hunk parses to a number, not a string const badHunk: SessionCommandOptions<"navigate"> = { hunk: "2" }; - // @ts-expect-error comment add requires --file and --summary + // @ts-expect-error comment add always requires --summary const missingRequired: SessionCommandOptions<"comment-add"> = { newLine: 3 }; const add: SessionCommandOptions<"comment-add"> = { file: "a.ts", summary: "note", newLine: 3, }; + const reply: SessionCommandOptions<"comment-add"> = { + replyTo: "user:parent", + summary: "reply", + }; // @ts-expect-error unknown flags are rejected const unknownFlag: SessionCommandOptions<"list"> = { repo: "." }; - expect([navigate, badHunk, missingRequired, add, unknownFlag]).toBeTruthy(); + expect([navigate, badHunk, missingRequired, add, reply, unknownFlag]).toBeTruthy(); }); test("marks navigation and comment targets with positive-int parsing", () => { diff --git a/src/session/agent/surface.ts b/packages/hunk/src/session/agent/surface.ts similarity index 94% rename from src/session/agent/surface.ts rename to packages/hunk/src/session/agent/surface.ts index 80007bc85..aa5601bb3 100644 --- a/src/session/agent/surface.ts +++ b/packages/hunk/src/session/agent/surface.ts @@ -4,8 +4,8 @@ import type { SessionDaemonAction } from "../protocol"; * Declarative description of the agent-facing `hunk session` command surface. * * This module is the single source of truth for what agents can invoke: the Commander commands in - * `src/app/cli.ts`, the `hunk session --help` usage text, and the generated - * `skills/hunk-review/SKILL.md` reference sections are all derived from these specs, so the parser + * `packages/hunk/src/app/cli.ts`, the `hunk session --help` usage text, and the generated + * `packages/hunk/skills/hunk-review/SKILL.md` reference sections are all derived from these specs, so the parser * and the docs cannot drift apart. Keep it pure data with no runtime dependencies. */ @@ -67,7 +67,7 @@ type OptionValue