diff --git a/.changeset/four-edge-extension-panes.md b/.changeset/four-edge-extension-panes.md new file mode 100644 index 000000000..876997c84 --- /dev/null +++ b/.changeset/four-edge-extension-panes.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Generalize extension sidebars into dockable panes on all four review edges. diff --git a/docs/extension-architecture.md b/docs/extension-architecture.md index d58e5361a..ffde8f232 100644 --- a/docs/extension-architecture.md +++ b/docs/extension-architecture.md @@ -19,22 +19,18 @@ object and registry collection (`src/extensions/runExtension.ts`): into the binary. `default/vcs/{git,jujutsu,sapling}` is statically imported and loaded synchronously _from VCS adapter resolution_ (`default/vcs/index.ts`), so backends exist during config resolution — that - load path must stay renderer-free. `default/ui/sidebar/` is deliberately not - part of that list: it is UI code, loaded through `getBundledSidebarView` - where the app resolves its sidebar views. + load path must stay renderer-free. `default/ui/index.ts` is deliberately not + part of that list: it synchronously loads the bundled files pane through + `runExtensionFactory` only where the app resolves UI panes. -There are zero core-registered VCS adapters and no private sidebar: Git and -the built-in file navigation register through the public `registerVcsAdapter` -and `registerSidebarView` like any extension. That dogfooding is the honesty -mechanism — Git exercises every VCS integration point, the bundled sidebar -consumes exactly the public sidebar props, so a gap in the published contract -breaks Hunk's own code first. +Git and the built-in file navigation use the public `registerVcsAdapter` and +`registerPane` paths. The current-line lens remains an installable example. Bundled extensions are implicitly trusted and stay loaded under `--no-extensions`, which governs user extensions only. An extension id is a file stem the user chose, and it is the namespace that id -owns for commands (`.`), sidebar views +owns for commands (`.`), panes (`:`), and config (`[extension.]`). `host.ts` is the one place those ids are vetted — discovery stays a pure filesystem walk, and every way an id can be derived arrives there as `candidate.id`. It refuses @@ -47,7 +43,7 @@ load issue and costs only that extension. The rules themselves are stated in ## One registry, one apply path Registrations (themes, file languages, VCS adapters, changeset transforms, -sidebar views, commands, lifecycle/UI events, and inter-extension bus listeners) collect into one +panes, commands, lifecycle/UI events, and bus listeners) collect into one `ExtensionRegistry` (`src/extensions/types.ts`) and are resolved/applied through `src/extensions/apply.ts` on both startup and reload. A factory that throws is rolled back to its pre-run registration counts @@ -59,25 +55,23 @@ Extension files import `react`, `@opentui/*`, and `hunkdiff/extension` as host-served runtime modules (`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 `registerSidebarView` components +own instances. That identity is what lets `registerPane` components render inside the app's React tree with working hooks. The module header documents why the obvious alternatives don't work (process-wide specifier claims break the host's lazy imports; the loaders resolve lazily so headless commands never pay OpenTUI's native-library extraction). -## Sidebar system +## Four-edge pane system -Sidebar registration is additive: any number of views, placed left or right -of the review stream, open/closed per view, `replacesDefault` to stand in -for the bundled file navigation. `src/ui/lib/sidebarPanes.ts` is the pane -model — session view list, open-state reconciliation across reloads, and the -layout plan deciding which open panes fit at what width. -`src/ui/components/panes/ExtensionSidebarPane.tsx` mounts one view: frozen -file views in, guarded actions out, error boundary scoped to the -registration identity. The frozen views fill `changeType` and the public -`hunks` summaries from the opaque metadata at the view boundary -(`src/extensions/events.ts`, deriving through `src/core/hunkSummary.ts` — the -same helper the agent session surface reports hunks with). +`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. + +`src/ui/components/panes/ExtensionPane.tsx` mounts panes with guarded actions and +failure containment. `DiffPane` exposes optional current-line paint without +publishing Pierre rows, plans, cursor keys, or caches. Deprecated sidebar APIs +normalize into this same registry and layout path. ## File-view system @@ -138,13 +132,9 @@ commands. Extension `registerCommand` entries join the same table via `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 sidebar open/close -controls, which is how a registered key opens an extension's sidebar, plus a -`selection` snapshot resolved by `src/ui/lib/extensionSelection.ts` from the -same frozen file views the sidebar panes render — one conversion feeding both -surfaces, so a command and a sidebar can never disagree about what is selected. -App reads the snapshot through a ref when a command fires, keeping the dispatch -table stable as the review moves. +(`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 +panes render. App reads it through a ref so the dispatch table stays stable. `ctx.dialogs` is the one place extension code can interrupt the user, so its ordering and settlement live outside React in diff --git a/docs/extensions.md b/docs/extensions.md index 29b28ba0e..b0a962bed 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -86,7 +86,7 @@ a folder of the same name — or later giving that folder a manifest — keeps i config working. The id is also the namespace your extension owns: its commands are -`.` and its sidebar views `:`. So the id has to be +`.` and its panes `:`. So the id has to be spelled like a name — starting with a letter or digit, then letters, digits, `-`, or `_`. A dot or a colon would make those composed ids ambiguous, and `hunk`, `git`, `jj`, and `sl` are reserved for what Hunk ships. An extension @@ -106,11 +106,10 @@ repository's own README. ## Bundled extensions Every VCS backend Hunk ships — **Git, Jujutsu, and Sapling** — is an extension, -and so is the **built-in file-navigation sidebar**. They live in +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.registerSidebarView` this guide -documents. There is no core-registered backend left, no private sidebar, and no -private path into the review pipeline. +the same `hunk.registerVcsAdapter` and `hunk.registerPane` this guide +documents. 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, @@ -131,7 +130,7 @@ being Hunk's own code: 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 sidebar and every built-in command are named under. +id the bundled files pane and every built-in command are named under. ## Trust @@ -192,9 +191,8 @@ cannot mutate the registry mid-session. ### `hunk.apiVersion` -The API generation this Hunk speaks (currently `4`). Branch on it if you want -one file to support several Hunk versions. Version 4 adds session-scoped -keyboard modes; version 3 added public semantic command execution. +The API generation this Hunk speaks (currently `4`). Version 4 adds keyboard +modes and docked panes; API-v3 sidebar names remain as deprecated aliases. ### `hunk.registerTheme(theme)` @@ -471,19 +469,17 @@ treated the same way. `HUNK_EXTENSION_USER_ERROR_NAME` is exported if you would rather not hard-code the string. Hunk's own bundled Git, Jujutsu, and Sapling backends raise their failures exactly this way. -### `hunk.registerSidebarView(view)` +### `hunk.registerPane(pane)` -Contribute a sidebar view — your own React component, rendered inside Hunk's -OpenTUI tree. Registration is additive: your view exists beside the built-in -file navigation, on either side of the review stream, and any number of views -can be open at once. Pair it with `registerCommand` so a key opens it: +Render a React component on the `left`, `right`, `top`, or `bottom` edge of the +review. Pair it with `registerCommand` so a key opens it: ```tsx -// ~/.config/hunk/extensions/flat-sidebar.tsx +// ~/.config/hunk/extensions/flat-pane.tsx import { useMemo } from "react"; -import type { ExtensionSidebarViewProps, HunkExtensionAPI } from "hunkdiff/extension"; +import type { ExtensionPaneProps, HunkExtensionAPI } from "hunkdiff/extension"; -function FlatSidebar({ files, selectedFileId, theme, actions }: ExtensionSidebarViewProps) { +function FlatPane({ files, selectedFileId, theme, actions }: ExtensionPaneProps) { const ordered = useMemo(() => [...files].sort((a, b) => a.path.localeCompare(b.path)), [files]); return ( @@ -504,26 +500,31 @@ function FlatSidebar({ files, selectedFileId, theme, actions }: ExtensionSidebar } export default function (hunk: HunkExtensionAPI) { - hunk.registerSidebarView({ + hunk.registerPane({ id: "flat", title: "Flat files", placement: "right", - component: FlatSidebar, + component: FlatPane, + }); + hunk.registerCommand({ id: "toggle-flat", title: "Toggle flat pane", key: "ctrl+f" }, (ctx) => { + ctx.panes.toggle("flat"); }); - hunk.registerCommand( - { id: "toggle-flat", title: "Toggle flat sidebar", key: "ctrl+f" }, - (ctx) => { - ctx.sidebars.toggle("flat"); - }, - ); } ``` -Beyond `id` and `component`, a view may declare a `title` (for diagnostics and -future menu listings), a `placement` of `"left"` (default) or `"right"`, -`defaultOpen: true` to start open, or `replacesDefault: true` to start open -_in place of_ the built-in file navigation — which stays available, just -closed, so a command can reopen it. +`placement` defaults to `"left"`. Left/right panes use `width`; top/bottom panes +use `height`. Both accept `{ preferred, min?, max? }`; equal bounds make a fixed +pane. Defaults are `{ preferred: 34, min: 22 }` columns and +`{ preferred: 8, min: 3 }` rows. + +Use `defaultOpen` to open a pane initially, `replaces: "hunk:files"` to replace +the initial files pane (and override `defaultOpen`), and `available(context)` to +hide it conditionally. + +`currentLine: true` opts into the opaque `currentLine.render(side, width)` +painter. The installable +[`current-line-lens`](../examples/extensions/current-line-lens/) example uses +this API; it is not bundled Hunk UI. Import `react` normally — Hunk serves its own React instance to extension files at import time, so hooks, context, and JSX all run on the reconciler drawing the @@ -539,13 +540,19 @@ The component receives fresh props as the app changes: | `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` | -| `width` | terminal columns the sidebar pane occupies | +| `placement` | the accepted terminal edge | +| `width` | exact terminal columns in the host-owned rectangle | +| `height` | exact terminal rows in the host-owned rectangle | +| `currentLine` | opaque selected-row painter 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 the sidebar may trigger | +| `actions` | navigation 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. `actions.selectFile(fileId)` and `actions.selectHunk(fileId, hunkIndex)` route -through the same review controller as the built-in sidebar and the keyboard +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, type?)` shows a toast attributed to your @@ -566,9 +573,9 @@ chord. Like Pi's injected `KeybindingsManager`, this keeps local component behavior synchronized with the user's remaps and unbindings: ```ts -import type { ExtensionKeyEvent, ExtensionSidebarViewProps } from "hunkdiff/extension"; +import type { ExtensionKeyEvent, ExtensionPaneProps } from "hunkdiff/extension"; -export function handleSidebarKey(props: ExtensionSidebarViewProps, key: ExtensionKeyEvent) { +export function handlePaneKey(props: ExtensionPaneProps, key: ExtensionKeyEvent) { const nextFile = props.files[1]; if (nextFile && props.keybindings.matches(key, "hunk.review.nextFile")) { // The user may have remapped this from `.` to another chord. @@ -587,30 +594,24 @@ event argument is structural — OpenTUI's `KeyEvent` works directly. extension-local keys that intentionally are not commands. Prefer a named command whenever a shortcut should be user-remappable. -Hunk keeps owning pane arrangement — widths, resize dividers, responsive -show/hide, and dropping panes that no longer fit a narrow terminal — and your -component fills the pane it is given. A component that throws while rendering -costs you the pane, not the user the session: the failure is reported as a -toast naming your extension, the pane closes, and the built-in file navigation -reopens if nothing else is showing. +Hunk owns pane geometry, dividers, and responsive omission. Render failures are +contained to that pane; a failed `hunk:files` replacement restores file +navigation. -Props carry the pane's `width` but not its height: the pane is a flex cell, so -give your root element `height="100%"` and let layout size it. Everything else -about scrolling — pane viewport height, scroll position, keeping a row visible -— goes through the `` itself, via a plain React ref. Hunk serves -its own `@opentui/core` to extension files, so the renderable a ref hands you -is the very instance the host renders with. +Props carry the pane's exact `width` and `height`. Use a `` ref for +scroll position and selection following; Hunk serves the matching +`@opentui/core` instance to extensions. #### Scrolling: the scrollbox ref contract -The one behavior a list sidebar always ends up needing is following the +The one behavior a list pane always ends up needing is following the selection. Give your rows stable `id` props, hold a ref to the scrollbox, and scroll the selected row into view from an effect: ```tsx import { useEffect, useRef } from "react"; import type { ScrollBoxRenderable } from "@opentui/core"; -import type { ExtensionSidebarViewProps } from "hunkdiff/extension"; +import type { ExtensionPaneProps } from "hunkdiff/extension"; function HunkList({ files, @@ -618,7 +619,7 @@ function HunkList({ selectedHunkIndex, theme, actions, -}: ExtensionSidebarViewProps) { +}: ExtensionPaneProps) { const scrollRef = useRef(null); // Follow policy is deliberately yours: the host never scrolls a pane it @@ -654,21 +655,21 @@ function HunkList({ } ``` -The ref surface this recipe stands on is the exact one the built-in sidebar +The ref surface this recipe stands on is the exact one the built-in files pane runs on: - **`scrollChildIntoView(id)`** scrolls the descendant with that `id` prop into view. - **`scrollTop`** and **`viewport.height`** read the current scroll offset and - the pane's viewport rows — the pane-height number the props do not carry. - A read before the first layout pass reports `0`, so viewport-dependent code - belongs behind the events below rather than a bare mount effect. + the scrollbox's live viewport rows. A read before the first layout pass + reports `0`, so viewport-dependent code belongs behind the events below + rather than a bare mount effect. - **`verticalScrollBar.on("change", handler)`**, **`viewport.on("layout-changed", handler)`**, and **`viewport.on("resized", handler)`** report scrolling and pane resizes; unsubscribe with the matching `.off` in your effect's cleanup. -That is enough to window a long list yourself: the built-in sidebar renders +That is enough to window a long list yourself: the built-in files pane renders only the rows near the viewport, plus spacer boxes sized from those same reads (its render-window helper is host code, but nothing it computes needs anything beyond this surface — `useTerminalDimensions` from `@opentui/react` @@ -676,20 +677,15 @@ serves as its pre-first-layout viewport estimate). One honest caveat: this contract rides on OpenTUI's renderable API, served at whatever version Hunk pins — a wider surface than `hunkdiff/extension` itself. -The built-in sidebar exercising the exact same calls is the compatibility -guarantee: a change that breaks your scroll code breaks Hunk's own sidebar -first. Still, keep scroll handling small and behind your own helpers. +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. -The built-in sidebar is itself a bundled extension -(`src/extensions/default/ui/sidebar/`): it registers through this exact call, -its component consumes exactly the props documented above, and its windowing -and selection follow run on exactly the ref contract above — so it doubles as -the reference implementation for everything a third-party sidebar can build, -from grouping and stat badges down to scroll behavior. +Its implementation lives in `src/extensions/default/ui/sidebar/` and serves as +the reference for third-party panes. -#### Sidebar state from events +#### Pane state from events -Lifecycle handlers run outside React, but a sidebar component only rerenders +Lifecycle handlers run outside React, but a pane component only rerenders when React sees a change. The recipe that connects them is a module-local store read through `useSyncExternalStore`: the event handler updates the store, and any mounted component subscribed to it rerenders — while the store keeps @@ -725,7 +721,7 @@ function ViewedCount() { export default function (hunk: HunkExtensionAPI) { hunk.on("file_viewed", ({ file }) => markViewed(file.path)); - hunk.registerSidebarView({ id: "progress", component: ViewedCount }); + hunk.registerPane({ id: "progress", component: ViewedCount }); } ``` @@ -786,7 +782,7 @@ export default function (hunk: HunkExtensionAPI) { `layout` receives one readonly input containing `file`, `width`, `signal`, `changes`, and `readDocument`. `input.file` is the same frozen public -`ExtensionDiffFile` sidebars receive. `input.changes` exposes typed added and +`ExtensionDiffFile` panes receive. `input.changes` exposes typed added and removed ranges without Pierre metadata; complete old/new hunk ranges remain available through `input.file.hunks`. `readDocument("old" | "new")` is lazy and cached by Hunk; it resolves exact @@ -819,7 +815,7 @@ warning per concrete extension registration and fall back to raw diff. Rapid wid geometry measured for a stale width. An experimental custom row keeps symbolic fallback spans and declares its fixed painter atomically as `component: { height, render }`. Painter props include the same -curated semantic `theme` palette as custom sidebars. It updates live at paint +curated semantic `theme` palette as custom panes. It updates live at paint time without entering `layout` or changing deterministic geometry. If painting fails, the fallback spans are clipped to that same declared height rather than changing stream geometry. Custom rows are non-focusable @@ -1040,9 +1036,8 @@ atomically. See the dependency-free ### `hunk.registerCommand(command, handler)` -Register a named command, optionally bound to a key. Commands are not a -sidebar one-off: they are the same mechanism Hunk's own shortcuts dispatch -through — one table, one loop, built-ins first. +Register a named command, optionally bound to a key. Commands share Hunk's +built-in dispatch table, with built-ins taking precedence. ```ts import type { HunkExtensionAPI } from "hunkdiff/extension"; @@ -1092,17 +1087,18 @@ refused, is still reachable with the mouse. The handler fires when the key is pressed outside modal UI — dialogs, menus, and focused text inputs own their keys first. It receives the standard context -plus `ctx.sidebars`, the controls for opening sidebar views: +plus `ctx.panes`, the controls for opening panes: -- `ctx.sidebars.open(viewId)` / `close(viewId)` / `toggle(viewId)` — a bare id - names your own extension's view, `"files"` names the built-in file - navigation, and `":"` addresses any registered view. - Opening a view also reveals the sidebar area when the user has hidden it - with `s`, so the open is never silent. -- `ctx.sidebars.isOpen(viewId)` reports current state. +- `ctx.panes.open(paneId)` / `close(paneId)` / `toggle(paneId)` — a bare id + names your own extension's pane, `"files"` names the built-in file + navigation, and `":"` addresses any registered pane. + Opening a left/right pane also reveals the sidebar area when the user has + hidden it with `s`; top/bottom pane state is independent of that area. +- `ctx.panes.isOpen(paneId)` reports the logical open preference, including + while availability or terminal bounds temporarily omit the pane. `ctx.selection` is where the review was pointing when the command fired — the -same selection a sidebar component sees in its props, so a command never has to +same selection a pane component sees in its props, so a command never has to track `selection_changed` itself to know what the user is looking at: ```ts @@ -1121,7 +1117,7 @@ hunk.registerCommand( ``` `selection.file` is a frozen read-only view, identical to the entries in a -sidebar's `files` prop. Hunk keeps the selection inside the visible files, so +pane's `files` prop. Hunk keeps the selection inside the visible files, so it is `null` only when nothing is visible at all — a filter that matches no files. `selection.hunkIndex` is that file's selected hunk, and `null` whenever `file` is — or when the file has no hunks to @@ -1160,10 +1156,10 @@ extension registry. Controls retained across an extension-registry reload or App session keyboard modes. See [Session keyboard modes](#session-keyboard-modes). `ctx.navigation` moves the review stream: `selectFile(fileId)` and -`selectHunk(fileId, hunkIndex)`, the same guarded navigation a sidebar's +`selectHunk(fileId, hunkIndex)`, the same guarded navigation a pane's `actions` carry, routed through the same review controller — the stream scrolls, selection updates, and `selection_changed` fires exactly as if the -user had clicked a sidebar row. Unlike `selection` it is live, not a snapshot: +user had clicked a pane row. Unlike `selection` it is live, not a snapshot: a call acts on the review as it is at that moment, so a handler that awaits a dialog and then navigates still works. A file id the stream cannot currently show is refused with a warning rather than corrupting the selection, and a @@ -1323,7 +1319,7 @@ hunk.transformChangeset((changeset) => ({ ``` The function may be async. Filtering and reordering `files` is fully supported — -the sidebar and the review stream both follow whatever you return. +the panes and review stream both follow whatever you return. Each file carries an opaque `metadata` field: it is the parsed diff the renderer draws from, so pass it through untouched (spreading a file preserves it). What @@ -1334,7 +1330,7 @@ is skipped: the previous changeset carries forward and you get a warning naming your extension and the problem. You never need to reach into `metadata` to know what a file's hunks are: the -read-only views Hunk hands outward (event payloads, sidebar props, a command's +read-only views Hunk hands outward (event payloads, pane props, a command's selection) carry a `hunks` list of public summaries — `index`, the `@@` header, and the inclusive old/new line spans, in render order. Like `changeType`, it is derived from the metadata at that boundary, so a transform neither receives nor @@ -1345,8 +1341,8 @@ the metadata actually parses to. Subscribe to a lifecycle or UI event. Handlers may be async; Hunk never blocks the UI waiting for one. Alongside `cwd` and `notify`, every handler receives -`ctx.sidebars`, the same open/close/toggle controls command handlers receive. -That means a `changeset_loaded` handler can reveal its extension's sidebar when +`ctx.panes`, the same open/close/toggle controls command handlers receive. +That means a `changeset_loaded` handler can reveal its extension's pane when it finds something worth showing — no keypress required. | Event | Payload | When | @@ -1387,7 +1383,7 @@ anyway, so treat it as best-effort flushing rather than guaranteed cleanup. `hunk.events` is a small bus shared by every loaded extension. Use it to coordinate extensions without coupling them through a command or global state. Names are open-ended, so namespace them with your extension id. Listeners get -the same `ctx.sidebars` controls as lifecycle handlers; delivery is fire-and-forget +the same `ctx.panes` controls as lifecycle handlers; delivery is fire-and-forget and one listener's failure is reported without stopping the others. Events an extension emits while factories are loading are queued until every extension has had a chance to subscribe. @@ -1397,12 +1393,12 @@ import type { HunkExtensionAPI } from "hunkdiff/extension"; export default function (hunk: HunkExtensionAPI) { hunk.events.on<{ fileCount: number }>("summary:ready", (payload, ctx) => { - if (payload.fileCount > 100) ctx.sidebars.open("summary"); + if (payload.fileCount > 100) ctx.panes.open("summary"); }); hunk.on("changeset_loaded", ({ changeset }, ctx) => { hunk.events.emit("summary:ready", { fileCount: changeset.files.length }); - ctx.sidebars.open("summary"); + ctx.panes.open("summary"); }); } ``` @@ -1438,8 +1434,9 @@ const patterns = (hunk.config.patterns as string[] | undefined) ?? ["*.lock"]; ### `ctx.notify(message, type?)` Every handler and transform receives a context object with `cwd` and `notify`. -Event and bus handlers additionally receive `sidebars` and `events.emit`; command -handlers receive `sidebars`, `selection`, `navigation`, and `dialogs`. `notify` +Event and bus handlers additionally receive `panes` and `events.emit`; command +handlers receive `panes`, `selection`, `navigation`, and `dialogs`. The deprecated +`sidebars` alias remains available during the API-v4 compatibility window. `notify` shows a single unobtrusive line at the bottom of the app that clears itself after a few seconds; queued messages appear in turn. `type` is `"info"` (default), `"warning"`, or `"error"`, which selects the color. Notifications @@ -1453,13 +1450,13 @@ to the terminal, because the TUI owns the screen. ## A complete example -The examples directory contains two user-installable folder extensions: +Installable examples include: -- [`examples/extensions/review-triage/`](../examples/extensions/review-triage/) - is a session-local hunk triage board combining a sidebar, commands, dialogs, - lifecycle listeners, and the extension event bus. Its API evaluation and - follow-up opportunities are recorded in - [Extension API field notes](extension-api-evaluation.md). +- [`pane-layout`](../examples/extensions/pane-layout/) for all four placements. +- [`current-line-lens`](../examples/extensions/current-line-lens/) for opaque + selected-row paint. +- [`review-triage`](../examples/extensions/review-triage/) for panes, commands, + dialogs, lifecycle events, and the event bus. - [`examples/extensions/rendered-markdown/`](../examples/extensions/rendered-markdown/) parses Markdown into generic host-owned file-view rows. Its README shows how to run it from the checkout or copy it into the global extensions directory. diff --git a/examples/extensions/current-line-lens/README.md b/examples/extensions/current-line-lens/README.md new file mode 100644 index 000000000..35ec29cd3 --- /dev/null +++ b/examples/extensions/current-line-lens/README.md @@ -0,0 +1,13 @@ +# Current-line lens extension + +Pins the selected split-diff row at the bottom of the review, old version above new. + +Run it from this checkout: + +```bash +bun run src/main.tsx -- diff --mode split --extension ./examples/extensions/current-line-lens +``` + +The pane opens on load. Toggle it from the **Extensions** menu. It uses the public `currentLine` pane API and hides itself when that paint is unavailable. + +Copy this directory to your Hunk extensions directory to install it. diff --git a/examples/extensions/current-line-lens/index.tsx b/examples/extensions/current-line-lens/index.tsx new file mode 100644 index 000000000..112a97a7a --- /dev/null +++ b/examples/extensions/current-line-lens/index.tsx @@ -0,0 +1,41 @@ +import type { ReactNode } from "react"; +import type { ExtensionPaneProps, HunkExtensionAPI } from "hunkdiff/extension"; + +const LENS_LABEL = "─ Current line · old above, new below "; + +/** Build the lens rule from terminal-width ASCII without private UI helpers. */ +function lineLensRule(width: number) { + const label = LENS_LABEL.slice(0, Math.max(0, width)); + return label + "─".repeat(Math.max(0, width - label.length)); +} + +/** Render the selected split row's old/new sides in a fixed bottom pane. */ +export function CurrentLineLens({ currentLine, theme, width }: ExtensionPaneProps): ReactNode { + if (!currentLine) return null; + const rule = lineLensRule(width); + return ( + + {rule} + {currentLine.render("old", width) as ReactNode} + {currentLine.render("new", width) as ReactNode} + + ); +} + +/** Register an optional old-above-new current-line lens as a public extension. */ +export default function registerCurrentLineLens(hunk: HunkExtensionAPI) { + hunk.registerPane({ + id: "line-lens", + title: "Current-line lens example", + placement: "bottom", + height: { preferred: 3, min: 3, max: 3 }, + defaultOpen: true, + currentLine: true, + available: ({ currentLine }) => currentLine !== null, + component: CurrentLineLens, + }); + + hunk.registerCommand({ id: "toggle", title: "Toggle current-line lens example" }, (ctx) => + ctx.panes.toggle("line-lens"), + ); +} diff --git a/examples/extensions/current-line-lens/package.json b/examples/extensions/current-line-lens/package.json new file mode 100644 index 000000000..289f7c393 --- /dev/null +++ b/examples/extensions/current-line-lens/package.json @@ -0,0 +1,9 @@ +{ + "name": "hunk-current-line-lens-extension", + "private": true, + "hunk": { + "extensions": [ + "./index.tsx" + ] + } +} diff --git a/examples/extensions/pane-layout/README.md b/examples/extensions/pane-layout/README.md new file mode 100644 index 000000000..f11d28be3 --- /dev/null +++ b/examples/extensions/pane-layout/README.md @@ -0,0 +1,13 @@ +# Pane layout extension + +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 +``` + +Press `ctrl+p` or use the **Extensions** menu. Drag the right divider to resize. + +Copy this directory to your Hunk extensions directory to install it. diff --git a/examples/extensions/pane-layout/index.tsx b/examples/extensions/pane-layout/index.tsx new file mode 100644 index 000000000..ce2604769 --- /dev/null +++ b/examples/extensions/pane-layout/index.tsx @@ -0,0 +1,60 @@ +import type { ReactNode } from "react"; +import type { ExtensionPaneProps, HunkExtensionAPI } from "hunkdiff/extension"; + +/** Render one bounded edge pane using only the public geometry and review props. */ +function EdgePane({ + files, + height, + placement, + selectedFileId, + theme, + width, +}: ExtensionPaneProps): ReactNode { + const selected = files.find((file) => file.id === selectedFileId); + return ( + + {`${placement.toUpperCase()} PANE · ${width}×${height}`} + {selected?.path ?? `${files.length} visible files`} + + ); +} + +/** Register resizable side content plus fixed top and bottom status strips. */ +export default function registerPaneLayout(hunk: HunkExtensionAPI) { + hunk.registerPane({ + id: "side", + title: "Pane example · side", + placement: "right", + width: { preferred: 28, min: 18, max: 44 }, + component: EdgePane, + }); + for (const placement of ["top", "bottom"] as const) { + hunk.registerPane({ + id: placement, + title: `Pane example · ${placement}`, + placement, + height: { preferred: 2, min: 2, max: 2 }, + component: EdgePane, + }); + } + + hunk.registerCommand( + { id: "toggle", title: "Toggle pane layout example", key: "ctrl+p" }, + (ctx) => { + const ids = ["side", "top", "bottom"] as const; + const close = ids.some((id) => ctx.panes.isOpen(id)); + for (const id of ids) { + if (close) ctx.panes.close(id); + else ctx.panes.open(id); + } + }, + ); +} diff --git a/examples/extensions/pane-layout/package.json b/examples/extensions/pane-layout/package.json new file mode 100644 index 000000000..5c979cebe --- /dev/null +++ b/examples/extensions/pane-layout/package.json @@ -0,0 +1,9 @@ +{ + "name": "hunk-pane-layout-extension", + "private": true, + "hunk": { + "extensions": [ + "./index.tsx" + ] + } +} diff --git a/examples/extensions/review-triage/README.md b/examples/extensions/review-triage/README.md index 474308697..31d4f606b 100644 --- a/examples/extensions/review-triage/README.md +++ b/examples/extensions/review-triage/README.md @@ -12,13 +12,13 @@ Or copy the directory to your Hunk extensions directory and keep its `package.js ## Use -Open **Extensions → Toggle review triage** (`y`). The right sidebar lists each visible file's hunks; click a hunk to navigate the review stream. Use **Extensions → Mark selected hunk…** (`x`) to choose a status and enter an optional rationale. **Center current review line**, **Set review focus…**, and **Clear triage decisions** are menu-only commands. +Open **Extensions → Toggle review triage** (`y`). The right pane lists each visible file's hunks; click a hunk to navigate the review stream. Use **Extensions → Mark selected hunk…** (`x`) to choose a status and enter an optional rationale. **Center current review line**, **Set review focus…**, and **Clear triage decisions** are menu-only commands. The board intentionally keeps state only for the running Hunk session. Reloading reconciles decisions against the newly parsed hunks and drops entries that no longer match, rather than silently transferring a decision to changed code. ## API surface exercised -- `registerSidebarView` renders public file/hunk summaries and navigates with sidebar actions. +- `registerPane` renders public file/hunk summaries and navigates with pane actions. - `registerCommand` supplies the Extensions-menu items and user-remappable defaults; `ctx.commands.execute` delegates the dedicated centering action to Hunk's public semantic command. - `dialogs.select`, `dialogs.input`, and `dialogs.confirm` implement the review decision and clear flows. - Lifecycle handlers track changeset loads, reloads, selection, viewed hunks, Hunk notes, filters, and pending watch reloads through a `useSyncExternalStore` bridge. diff --git a/examples/extensions/review-triage/index.tsx b/examples/extensions/review-triage/index.tsx index b7bd6b1ee..d7c725d0c 100644 --- a/examples/extensions/review-triage/index.tsx +++ b/examples/extensions/review-triage/index.tsx @@ -3,7 +3,7 @@ import type { ExtensionChangeset, ExtensionDiffFile, ExtensionReviewNote, - ExtensionSidebarViewProps, + ExtensionPaneProps, HunkExtensionAPI, } from "hunkdiff/extension"; @@ -42,7 +42,7 @@ function hunkKey(fileId: string, hunkIndex: number) { return `${fileId}:${hunkIndex}`; } -/** Publish an immutable store snapshot so a closed sidebar never loses its state. */ +/** Publish an immutable store snapshot so a closed pane never loses its state. */ function updateSnapshot(update: (current: TriageSnapshot) => TriageSnapshot) { const next = update(snapshot); if (next === snapshot) { @@ -55,7 +55,7 @@ function updateSnapshot(update: (current: TriageSnapshot) => TriageSnapshot) { } } -/** Subscribe a mounted sidebar to lifecycle state gathered outside React. */ +/** Subscribe a mounted pane to lifecycle state gathered outside React. */ function useTriageSnapshot() { return useSyncExternalStore( (listener) => { @@ -125,14 +125,14 @@ function clearDecisions() { updateSnapshot((current) => ({ ...current, decisions: new Map() })); } -/** Render a compact, clickable hunk triage board from public sidebar props. */ -function ReviewTriageSidebar({ +/** Render a compact, clickable hunk triage board from public pane props. */ +function ReviewTriagePane({ files, selectedFileId, selectedHunkIndex, theme, actions, -}: ExtensionSidebarViewProps): ReactNode { +}: ExtensionPaneProps): ReactNode { const state = useTriageSnapshot(); const summary = useMemo(() => { const total = files.reduce((count, file) => count + (file.hunks?.length ?? 0), 0); @@ -225,15 +225,15 @@ function ReviewTriageSidebar({ /** Register a session-local review board that drives only documented Hunk extension APIs. */ export default function registerReviewTriage(hunk: HunkExtensionAPI) { - hunk.registerSidebarView({ + hunk.registerPane({ id: "triage", title: "Review triage", placement: "right", - component: ReviewTriageSidebar, + component: ReviewTriagePane, }); hunk.registerCommand({ id: "toggle", title: "Toggle review triage", key: "y" }, (ctx) => - ctx.sidebars.toggle("triage"), + ctx.panes.toggle("triage"), ); hunk.registerCommand({ id: "center", title: "Center current review line" }, (ctx) => { @@ -288,7 +288,7 @@ export default function registerReviewTriage(hunk: HunkExtensionAPI) { return; } updateSnapshot((current) => ({ ...current, focus: focus.trim() })); - ctx.sidebars.open("triage"); + ctx.panes.open("triage"); }); hunk.registerCommand({ id: "clear", title: "Clear triage decisions" }, async (ctx) => { @@ -323,5 +323,5 @@ export default function registerReviewTriage(hunk: HunkExtensionAPI) { }); // Lets another extension reveal this board without importing its module state. - hunk.events.on("review-triage:open", (_payload, ctx) => ctx.sidebars.open("triage")); + hunk.events.on("review-triage:open", (_payload, ctx) => ctx.panes.open("triage")); } diff --git a/scripts/check-pack.ts b/scripts/check-pack.ts index 2a64192a7..b50474de5 100644 --- a/scripts/check-pack.ts +++ b/scripts/check-pack.ts @@ -31,7 +31,11 @@ import type { ExtensionKeyboardModeControls, ExtensionKeyboardModeKeyResult, ExtensionPaintTheme, + ExtensionHorizontalPane, + ExtensionPaneProps, + ExtensionPaneSize, ExtensionReviewSelection, + ExtensionVerticalPane, ExtensionVcsAdapter, ExtensionVcsDiffInput, ExtensionVcsLoadContext, @@ -55,6 +59,40 @@ export default function (hunk: HunkExtensionAPI) { hunk.registerTheme(theme); hunk.registerFileLanguage(".zig", "zig"); + const pane = (props: ExtensionPaneProps) => { + hunk.log(\`\${props.placement}:\${props.width}x\${props.height}\`); + props.currentLine?.render("new", props.width); + return null; + }; + const paneSize: ExtensionPaneSize = { preferred: 3, min: 2, max: 4 }; + for (const placement of ["left", "right"] as const) { + const verticalPane: ExtensionVerticalPane = { + id: placement, + placement, + width: paneSize, + component: pane, + }; + hunk.registerPane(verticalPane); + } + for (const placement of ["top", "bottom"] as const) { + const horizontalPane: ExtensionHorizontalPane = { + id: placement, + placement, + height: paneSize, + currentLine: placement === "bottom", + component: pane, + }; + hunk.registerPane(horizontalPane); + } + hunk.registerSidebarView({ + id: "legacy", + placement: "right", + component: ({ files, width }) => { + hunk.log(\`legacy:\${files.length}:\${width}\`); + return null; + }, + }); + const renderRow = (props: ExtensionFileViewRowComponentProps) => { const paintTheme: ExtensionPaintTheme = props.theme; hunk.log(paintTheme.text); @@ -132,6 +170,8 @@ export default function (hunk: HunkExtensionAPI) { modeControls.enterMode("review-keys"); } modeControls.exitMode(); + ctx.panes.toggle("bottom"); + if (ctx.sidebars.isOpen("legacy")) ctx.sidebars.close("legacy"); }); hunk.registerCommand({ id: "rewrite", title: "Rewrite the selection" }, async (ctx) => { diff --git a/skills/hunk-extensions/SKILL.md b/skills/hunk-extensions/SKILL.md index 378f9f37a..901d6b5ae 100644 --- a/skills/hunk-extensions/SKILL.md +++ b/skills/hunk-extensions/SKILL.md @@ -1,6 +1,6 @@ --- name: hunk-extensions -description: Maps the `hunkdiff/extension` authoring surface for Hunk, the terminal diff viewer — hiding or reordering reviewed files, sidebar panes, alternate file views, commands and key bindings, dialogs, workspace writes, themes, syntax languages, VCS backends, lifecycle events. Use when writing, debugging, or installing a Hunk extension, or when a request asks Hunk itself to behave differently. Not for reviewing a diff in a live session — that is hunk-review. +description: Maps the `hunkdiff/extension` authoring surface for Hunk, the terminal diff viewer — hiding or reordering reviewed files, docked panes, alternate file views, commands and key bindings, dialogs, workspace writes, themes, syntax languages, VCS backends, lifecycle events. Use when writing, debugging, or installing a Hunk extension, or when a request asks Hunk itself to behave differently. Not for reviewing a diff in a live session — that is hunk-review. --- # Building Hunk extensions @@ -34,12 +34,12 @@ material before writing code. Outside a Hunk checkout the guide is split across (discovery, trust, config) and its -companion pages — extension-api, file-previews, vcs-adapters, custom-sidebars — +companion pages — extension-api, file-previews, vcs-adapters, custom-panes — and the contract ships as `node_modules/hunkdiff/dist/npm/extension/index.d.ts`. The examples, by what they demonstrate: -- `review-triage/` — sidebar + commands + all three dialog shapes + lifecycle +- `review-triage/` — pane + commands + all three dialog shapes + lifecycle events + the extension event bus + a `useSyncExternalStore` bridge. - `inline-edit/` — an interactive file-view `mode` driving `ctx.workspace` writes; its README explains the async lifetime rules better than anything else in tree. @@ -75,8 +75,8 @@ dependency-free. The **id** is the file stem, or the folder name for a folder extension — unless its manifest declares several entries, in which case each entry is its own extension named by its own stem (numeric suffix on collision). The id is the -namespace it owns: commands are `.`, sidebar views and keyboard -modes are `:`, config `[extension.]`. Ids match +namespace it owns: commands are `.`, panes and keyboard modes +are `:`, config `[extension.]`. Ids match `/^[A-Za-z0-9][A-Za-z0-9_-]*$/`; `hunk`, `git`, `jj`, and `sl` are reserved. A bad or duplicate id is skipped with a startup notice. @@ -87,7 +87,7 @@ bad or duplicate id is skipped with a startup notice. | Add a selectable color theme | `hunk.registerTheme(theme)` | | Highlight an unrecognized file extension | `hunk.registerFileLanguage(ext, lang)` | | Support another VCS (`git`/`jj`/`sl` are reserved) | `hunk.registerVcsAdapter(adapter)` | -| Add a navigation/list/status pane beside the review | `hunk.registerSidebarView(view)` | +| Add a navigation/list/status pane beside the review | `hunk.registerPane(pane)` | | Present a file as something other than a raw diff | `hunk.registerFileView(view)` (experimental) | | Interpret review keys as a temporary global mode | `hunk.registerKeyboardMode(mode)` | | Bind a key / add an Extensions-menu entry | `hunk.registerCommand(command, handler)` | @@ -106,20 +106,18 @@ Every event, bus, command, and file-view mode handler — plus every changeset transform — gets `ctx.cwd` and `ctx.notify(message, type?)`. A file view's `matches` and `layout` get no context at all. Beyond that: -- **Event and bus handlers** also get `ctx.sidebars` (open/close/toggle/isOpen on - any view) and `ctx.events.emit`. -- **Command handlers** get `ctx.sidebars`, `ctx.fileViews` (select/toggle/isActive/ +- **Event and bus handlers** also get `ctx.panes` (open/close/toggle/isOpen on + any pane) and `ctx.events.emit`. +- **Command handlers** get `ctx.panes`, `ctx.fileViews` (select/toggle/isActive/ refresh/enterMode/exitMode), `ctx.selection` (a snapshot of file + hunk index), `ctx.navigation` (live, guarded `selectFile`/`selectHunk`), `ctx.commands` (`isEnabled`/`execute` for public semantic `hunk.*` commands), `ctx.keyboardModes` (enter/exit/probe this extension's session modes), `ctx.dialogs` (`confirm`/`select`/`input`, queued and attributed), and `ctx.workspace` (`readDocument`, `canWriteDocument`, `writeDocument` with consent). -- **Sidebar components** get props: `files` (frozen, filtered, review order, each - with `hunks` summaries), `selectedFileId`, `selectedHunkIndex`, `width`, - `theme` (hex tokens plus an `appearance` flag — see `ExtensionPaintTheme`), - `keybindings` (ask by command id, never hard-code a chord), and `actions` - (`selectFile`, `selectHunk`, `notify`). +- **Pane components** get frozen `files`, selection, placement, exact dimensions, + optional `currentLine` paint, semantic `theme`, resolved `keybindings`, and + guarded navigation/notification `actions`. - **File-view `layout`** gets `file`, `width`, `signal`, `changes`, and a lazy `readDocument(side)`. - **File-view `mode` handlers** get `ctx.file` and `ctx.fileViews`. `onKey`, @@ -135,7 +133,7 @@ transform — gets `ctx.cwd` and `ctx.notify(message, type?)`. A file view's session mode owns input, Escape exits it; the status badge and Extensions menu are unconditional host-owned exits. -Event payloads, sidebar props, and a command's selection all hand you frozen +Event payloads, pane props, and a command's selection all hand you frozen `ExtensionDiffFile` / `ExtensionDiffHunk` views. A changeset transform is the exception: it receives the live changeset and is expected to return a new one. `metadata` is unfrozen either way — it is the renderer's parsed diff, so pass it @@ -145,11 +143,9 @@ through untouched. Most extension bugs are one of these: -- **Registering a surface does not show it.** A sidebar view starts closed unless - it declares `defaultOpen` (or `replacesDefault`, which starts open in place of - the built-in file list). A file view never activates itself — raw diff is the - default and the user picks the view from the **View** menu. Ship a command that - toggles it and say which key, or correct code looks like it did nothing. +- **Registering a surface does not show it.** Panes need `defaultOpen`, + `replaces: "hunk:files"`, or a command that opens them. File views remain raw + until selected from the **View** menu. - **A rejected file-view layout silently becomes raw diff.** `hunkRows` needs one in-bounds, inclusive entry per parsed hunk at the same array index, and `sourceRanges` may not overlap on a side; invalid, oversized, cancelled, and @@ -231,7 +227,7 @@ Practical checks, in order of cost: loads immediately with no trust prompt, so it is the iteration path. Ask them what the footer notices and toasts said. 5. **Triage with `--no-extensions`** to confirm a symptom belongs to an extension - (bundled VCS backends and the built-in sidebar stay loaded either way). + (bundled VCS backends and the built-in files pane stay loaded either way). ## If it does not load @@ -242,9 +238,9 @@ Practical checks, in order of cost: claimed), import failure, missing default export, or a throwing factory. - Repo-local extension silently absent → the trust prompt was dismissed or denied; decisions are stored per repo root in `~/.config/hunk/state.json`. -- Sidebar pane closes with a toast → the component threw; a second React copy is +- Pane closes with a toast → the component threw; a second React copy is the usual cause. -- Sidebar or file view never appears → nothing opened it (no `defaultOpen`, no +- Pane or file view never appears → nothing opened it (no `defaultOpen`, no command), `matches` returned false, or the layout was rejected. - Command never fires → its chord lost to a built-in or an earlier extension (a warning says so); it is still reachable from the **Extensions** menu and @@ -254,7 +250,7 @@ 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 sidebar are **bundled extensions** in +- Shipped VCS backends and the built-in files pane are **bundled extensions** in `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 diff --git a/src/extension-api/index.ts b/src/extension-api/index.ts index 423632067..3d8da1f6f 100644 --- a/src/extension-api/index.ts +++ b/src/extension-api/index.ts @@ -79,6 +79,19 @@ export type { ExtensionSelectOptions, ExtensionNotifyType, ExtensionPaintTheme, + ExtensionPane, + ExtensionPaneActions, + ExtensionPaneAvailabilityContext, + ExtensionPaneComponent, + ExtensionPaneControls, + ExtensionPaneKeybindings, + ExtensionPanePlacement, + ExtensionPaneProps, + ExtensionPaneTheme, + ExtensionPaneSize, + ExtensionHorizontalPane, + ExtensionVerticalPane, + ExtensionCurrentLinePaint, ExtensionLayoutMode, ExtensionResolvedLayout, ExtensionReviewNavigation, diff --git a/src/extension-api/types.ts b/src/extension-api/types.ts index 70903f988..ed9bc10c8 100644 --- a/src/extension-api/types.ts +++ b/src/extension-api/types.ts @@ -162,7 +162,7 @@ export interface ExtensionDiffFile { * is what the renderer draws from. Carry it through untouched — spreading a * file (`{ ...file, path }`) preserves it. A file returned without usable * metadata is rejected, and the previous changeset is kept. On the read-only - * views Hunk hands outward (event payloads, sidebar props, a command's + * views Hunk hands outward (event payloads, pane props, a command's * selection) it is guarded like the rest of the view: reads pass through, * writes into it are refused. */ @@ -170,7 +170,7 @@ export interface ExtensionDiffFile { /** * How this file changed, using the same vocabulary VCS adapters report. * - * Present on the read-only views Hunk hands outward (event payloads, sidebar + * Present on the read-only views Hunk hands outward (event payloads, pane * props); a transform that synthesizes a file may omit it, and the file is * treated as an ordinary `"change"`. */ @@ -182,7 +182,7 @@ export interface ExtensionDiffFile { * order — empty for a file with nothing to select (binary, skipped). * * Like `changeType`, this is filled on the read-only views Hunk hands - * outward (event payloads, sidebar props, a command's selection). It is + * outward (event payloads, pane props, a command's selection). It is * derived from `metadata` at that boundary, so a transform neither receives * nor needs to produce it — a `hunks` value on a transform's returned file * is ignored in favor of what the metadata actually parses to. @@ -839,7 +839,7 @@ export interface ExtensionVcsAdapter { } /* -------------------------------------------------------------------------- */ -/* Sidebar views */ +/* Docked panes */ /* -------------------------------------------------------------------------- */ /** @@ -874,8 +874,10 @@ export interface ExtensionPaintTheme { noteBorder: string; } -/** Backward-compatible name for the shared extension painter theme. */ -export type ExtensionSidebarTheme = ExtensionPaintTheme; +/** Theme exposed to extension-owned pane painters. */ +export type ExtensionPaneTheme = ExtensionPaintTheme; +/** @deprecated Use ExtensionPaneTheme. */ +export type ExtensionSidebarTheme = ExtensionPaneTheme; /** * Navigation any extension surface can trigger, exactly as the built-in @@ -898,25 +900,27 @@ export interface ExtensionReviewNavigation { } /** - * What a custom sidebar component can trigger: review navigation plus a toast. + * What a custom pane component can trigger: review navigation plus a toast. * * Actions stay valid for as long as the component is mounted. */ -export interface ExtensionSidebarActions extends ExtensionReviewNavigation { +export interface ExtensionPaneActions extends ExtensionReviewNavigation { /** Show one toast, attributed to the owning extension. */ notify(message: string, type?: ExtensionNotifyType): void; } +/** @deprecated Use ExtensionPaneActions. */ +export type ExtensionSidebarActions = ExtensionPaneActions; /** - * The resolved command bindings available to a custom sidebar. + * The resolved command bindings available to a custom pane. * - * This mirrors Pi's injected keybindings manager: sidebar components name a + * This mirrors Pi's injected keybindings manager: pane components name a * command instead of repeating its default chord, so their local key handling * follows the user's `[keybindings]` configuration. The command ids are the * same ids documented by Hunk (`"hunk.review.nextFile"`) and extensions * (`"."`). */ -export interface ExtensionSidebarKeybindings { +export interface ExtensionPaneKeybindings { /** Report whether one terminal key event matches the command's current binding. */ matches( key: { @@ -933,64 +937,110 @@ export interface ExtensionSidebarKeybindings { getKeys(commandId: string): readonly string[]; } -/** Everything a custom sidebar component receives, refreshed as the app changes. */ -export interface ExtensionSidebarViewProps { +/** A terminal edge where a host-owned pane can be docked. */ +export type ExtensionPanePlacement = "left" | "right" | "top" | "bottom"; + +/** Requested pane width or height along its docked edge. */ +export interface ExtensionPaneSize { + preferred: number; + min?: number; + max?: number; +} + +/** Opaque host renderer for the selected split row. */ +export interface ExtensionCurrentLinePaint { + /** Paint one side as a clipped, no-wrap terminal row. */ + render(side: "old" | "new", width: number): unknown; +} + +/** Immutable state used to decide whether an open pane is meaningful this frame. */ +export interface ExtensionPaneAvailabilityContext { + readonly placement: ExtensionPanePlacement; + readonly files: readonly ExtensionDiffFile[]; + readonly selectedFileId: string | null; + readonly selectedHunkIndex: number | null; + readonly currentLine: ExtensionCurrentLinePaint | null; +} + +/** Everything a custom pane component receives, refreshed as the app changes. */ +export interface ExtensionPaneProps { + readonly files: readonly ExtensionDiffFile[]; + readonly selectedFileId: string | null; + readonly selectedHunkIndex: number | null; + readonly placement: ExtensionPanePlacement; + /** Exact host-owned component rectangle. */ + readonly width: number; + readonly height: number; + readonly theme: ExtensionPaneTheme; + readonly keybindings: ExtensionPaneKeybindings; + readonly actions: ExtensionPaneActions; + /** Non-null only when the registration explicitly requested current-line paint. */ + readonly currentLine: ExtensionCurrentLinePaint | null; +} + +/** A React/OpenTUI component mounted inside an exact host-owned rectangle. */ +export type ExtensionPaneComponent = (props: ExtensionPaneProps) => unknown; + +/** Fields shared by panes on every terminal edge. */ +interface ExtensionPaneBase { + /** Identifies the pane within its extension; `:` globally. */ + id: string; + title?: string; + defaultOpen?: boolean; /** - * The reviewed files currently visible, in review-stream order. - * - * Read-only frozen views, filtered the way the built-in sidebar is: the - * app's file filter applies before the list reaches the component. + * Start open in place of this pane, which starts closed. + * Replacement initial defaults take precedence over `defaultOpen`. */ + replaces?: string; + /** Opt into live current-line paint; unrelated panes receive stable null. */ + currentLine?: boolean; + /** Synchronous frame-availability policy. */ + available?(context: ExtensionPaneAvailabilityContext): boolean; + component: ExtensionPaneComponent; +} + +/** A left/right pane sized explicitly in terminal columns. */ +export interface ExtensionVerticalPane extends ExtensionPaneBase { + /** Defaults to `"left"`. */ + placement?: "left" | "right"; + /** Defaults to 34 preferred and 22 minimum columns. */ + width?: ExtensionPaneSize; + height?: never; +} + +/** A top/bottom pane sized explicitly in terminal rows. */ +export interface ExtensionHorizontalPane extends ExtensionPaneBase { + placement: "top" | "bottom"; + /** Defaults to 8 preferred and 3 minimum rows. */ + height?: ExtensionPaneSize; + width?: never; +} + +/** A docked pane contributed by an extension. */ +export type ExtensionPane = ExtensionVerticalPane | ExtensionHorizontalPane; + +/** @deprecated Use ExtensionPaneKeybindings. */ +export type ExtensionSidebarKeybindings = ExtensionPaneKeybindings; +/** @deprecated Use ExtensionPanePlacement. */ +export type ExtensionSidebarPlacement = Extract; +/** @deprecated Use ExtensionPaneProps. */ +export interface ExtensionSidebarViewProps { files: ExtensionDiffFile[]; selectedFileId: string | null; selectedHunkIndex: number | null; - /** Terminal columns the sidebar pane occupies; height comes from flex layout. */ width: number; theme: ExtensionSidebarTheme; - /** Resolved command bindings; use these instead of hard-coding sidebar chords. */ keybindings: ExtensionSidebarKeybindings; actions: ExtensionSidebarActions; } - -/** - * A custom sidebar component. - * - * This is a plain React function component rendered inside Hunk's own tree — - * import `react` normally (Hunk serves its own instance to extension files, so - * hooks work; never bundle a copy of React into an extension) and return - * OpenTUI elements (`box`, `text`, `scrollbox`, ...). The return type is - * opaque here only because this module publishes no React types; annotate the - * component with your own `@types/react` and it satisfies this shape. - */ +/** @deprecated Use ExtensionPaneComponent. */ export type ExtensionSidebarComponent = (props: ExtensionSidebarViewProps) => unknown; - -/** Which side of the review stream a sidebar pane sits on. */ -export type ExtensionSidebarPlacement = "left" | "right"; - -/** - * A sidebar view contributed by an extension. - * - * Registration is additive: every registered view exists alongside the - * built-in file navigation, and any number can be open at once. A view opens - * when `defaultOpen` asks for it, or when extension code opens it through the - * sidebar controls — typically from a `registerCommand` handler bound to a - * key. - */ +/** @deprecated Use ExtensionPane. */ export interface ExtensionSidebarView { - /** Identifies the view within its extension; `:` globally. */ id: string; - /** Human-readable name, for diagnostics and future menu listings. */ title?: string; - /** Which side of the review stream the pane sits on. Defaults to `"left"`. */ placement?: ExtensionSidebarPlacement; - /** Open this view when the session starts. Defaults to closed. */ defaultOpen?: boolean; - /** - * Stand in for the built-in file navigation instead of joining it. - * - * Implies `defaultOpen`: the view starts open and the built-in `files` - * sidebar starts closed (the user or an extension can still reopen it). - */ replacesDefault?: boolean; component: ExtensionSidebarComponent; } @@ -1074,21 +1124,23 @@ export interface ExtensionKeyboardModeControls { isActive(modeId?: string): boolean; } -/** Open, close, and inspect sidebar views from a command handler. */ -export interface ExtensionSidebarControls { +/** Open, close, and inspect panes from a command handler. */ +export interface ExtensionPaneControls { /** - * Resolve one view: a bare id names this extension's own view, `"files"` - * names the built-in file navigation, and `":"` - * addresses any registered view explicitly. + * Resolve one pane: a bare id names this extension's own pane, `"files"` + * names the built-in file navigation, and `":"` + * addresses any registered pane explicitly. * - * Opening a view (here, or via `toggle`) also reveals the sidebar area when - * the user has hidden it, so the open is never silent. + * Opening a left/right pane (here, or via `toggle`) also reveals the sidebar + * area when the user has hidden it, so the open is never silent. */ open(viewId: string): void; close(viewId: string): void; toggle(viewId: string): void; isOpen(viewId: string): boolean; } +/** @deprecated Use ExtensionPaneControls. */ +export type ExtensionSidebarControls = ExtensionPaneControls; /** Select or inspect the active file presentation from an extension command. */ export interface ExtensionFileViewControls { @@ -1182,7 +1234,7 @@ export interface ExtensionReviewSelection { /** * The selected file among the currently visible (filtered) files, or `null`. * - * The same frozen read-only view a sidebar component receives in its `files` + * The same frozen read-only view a pane component receives in its `files` * prop, so holding or mutating it cannot reach the review model. Hunk keeps * the selection inside the visible list — filtering away the selected file * immediately reselects the first visible one — so in practice this is @@ -1389,7 +1441,10 @@ export interface ExtensionCommandContext extends ExtensionContext { readonly commands: ExtensionCommandControls; /** Session keyboard modes registered by this command's owning extension. */ readonly keyboardModes: ExtensionKeyboardModeControls; - sidebars: ExtensionSidebarControls; + /** Session panes registered by this command's owning extension. */ + readonly panes: ExtensionPaneControls; + /** @deprecated Use panes. */ + readonly sidebars: ExtensionSidebarControls; /** Host-owned selection controls for alternate file presentations. */ fileViews: ExtensionFileViewControls; /** @@ -1400,7 +1455,7 @@ export interface ExtensionCommandContext extends ExtensionContext { */ readonly selection: ExtensionReviewSelection; /** - * Navigate the review stream, exactly as a sidebar's actions do. + * Navigate the review stream, exactly as a pane's actions do. * * Live rather than snapshot, the opposite of `selection`: a call acts on the * review as it is at that moment, validated against the currently visible @@ -1448,8 +1503,10 @@ export interface ExtensionEventBus { emit(event: string, payload: Payload): void; } -/** Context lifecycle and bus listeners receive, including live sidebar controls. */ +/** Context lifecycle and bus listeners receive, including live pane controls. */ export interface ExtensionEventContext extends ExtensionContext { + panes: ExtensionPaneControls; + /** @deprecated Use panes. */ sidebars: ExtensionSidebarControls; events: Pick; } @@ -1533,13 +1590,13 @@ export interface HunkExtensionAPI { /** Contribute one additional VCS backend. */ registerVcsAdapter(adapter: ExtensionVcsAdapter): void; /** - * Contribute a sidebar view beside (or in place of) the built-in one. + * Register a docked pane on any terminal edge. * - * Any number of views can be registered and open simultaneously, on either - * side of the review stream. A view that throws while rendering is closed - * with a warning naming the extension; the built-in file navigation is - * restored if nothing else is showing files. + * Any number can be open simultaneously. Hunk owns their exact rectangles, + * minimum review bounds, availability, and render-failure containment. */ + registerPane(pane: ExtensionPane): void; + /** @deprecated Use registerPane. */ registerSidebarView(view: ExtensionSidebarView): void; /** * Register a host-rendered alternative presentation for matching files. @@ -1561,13 +1618,13 @@ export interface HunkExtensionAPI { * * The handler runs when the key fires outside modal UI (dialogs, menus, * focused inputs own their keys first). Handlers receive the standard - * context plus sidebar controls, so a command can open the sidebar view its - * extension registered. + * context plus pane controls, so a command can open the pane its extension + * registered. */ registerCommand(command: ExtensionCommand, handler: ExtensionCommandHandler): void; /** Rewrite every loaded changeset before review. */ transformChangeset(fn: ChangesetTransform): void; - /** Subscribe to one Hunk lifecycle or UI event. Handlers receive sidebar controls. */ + /** Subscribe to one Hunk lifecycle or UI event. Handlers receive pane controls. */ on(event: Event, handler: ExtensionEventHandler): void; /** Publish or subscribe to a namespaced event shared with other loaded extensions. */ readonly events: ExtensionEventBus; diff --git a/src/extensions/apply.test.ts b/src/extensions/apply.test.ts index dd11ba1d9..76432df8d 100644 --- a/src/extensions/apply.test.ts +++ b/src/extensions/apply.test.ts @@ -19,7 +19,7 @@ import { resolveExtensionCommands, resolveExtensionFileViews, resolveExtensionKeyboardModes, - resolveExtensionSidebarViews, + resolveExtensionPanes, resolveExtensionVcsAdapters, resolveSessionVcsId, } from "./apply"; @@ -142,39 +142,39 @@ describe("extension VCS adapters", () => { }); }); -describe("extension sidebar views", () => { - test("resolves no views from an empty registry", () => { +describe("extension panes", () => { + test("resolves no panes from an empty registry", () => { const result = createEmptyExtensionLoadResult(); - const { views, issues } = resolveExtensionSidebarViews(result.registry); + const { panes, issues } = resolveExtensionPanes(result.registry); - expect(views).toEqual([]); + expect(panes).toEqual([]); expect(issues).toEqual([]); }); - test("keeps every distinct view and reports duplicate keys", () => { + test("keeps every distinct pane and reports duplicate keys", () => { const result = createEmptyExtensionLoadResult(); const tree = { id: "tree", component: () => null }; const flat = { id: "flat", component: () => null }; const treeAgain = { id: "tree", component: () => null }; - result.registry.sidebarViews.push( - { extensionId: "alpha", view: tree }, - { extensionId: "beta", view: flat }, - { extensionId: "alpha", view: treeAgain }, + result.registry.panes.push( + { extensionId: "alpha", pane: tree }, + { extensionId: "beta", pane: flat }, + { extensionId: "alpha", pane: treeAgain }, ); - const { views, issues } = resolveExtensionSidebarViews(result.registry); + const { panes, issues } = resolveExtensionPanes(result.registry); - // Registration is additive: distinct views from any extension coexist, + // Registration is additive: distinct panes from any extension coexist, // and only an identity collision is refused. - expect(views).toEqual([ - { extensionId: "alpha", view: tree }, - { extensionId: "beta", view: flat }, + expect(panes).toEqual([ + { extensionId: "alpha", pane: tree }, + { extensionId: "beta", pane: flat }, ]); expect(issues).toEqual([ { extensionId: "alpha", - message: 'Skipped duplicate sidebar view "alpha:tree" from extension alpha', + message: 'Skipped duplicate pane "alpha:tree" from extension alpha', }, ]); }); diff --git a/src/extensions/apply.ts b/src/extensions/apply.ts index 8e12ef368..a1718d350 100644 --- a/src/extensions/apply.ts +++ b/src/extensions/apply.ts @@ -11,7 +11,7 @@ import type { RegisteredCommand, RegisteredFileView, RegisteredKeyboardMode, - RegisteredSidebarView, + RegisteredPane, } from "./types"; /** @@ -122,47 +122,36 @@ export function registeredViewKey(registered: { extensionId: string; view: { id: return qualifiedViewKey(registered.extensionId, registered.view.id); } -/** Derive the key one sidebar view is addressed by everywhere in the app. */ -export function sidebarViewKey(registered: RegisteredSidebarView) { - return registeredViewKey(registered); +/** Derive the key one pane is addressed by everywhere in the app. */ +export function paneKey(registered: RegisteredPane) { + return qualifiedViewKey(registered.extensionId, registered.pane.id); } -/** The sidebar views one session offers, plus the registrations skipped as duplicates. */ -export interface ResolvedExtensionSidebarViews { - views: RegisteredSidebarView[]; +/** The panes one session offers, plus registrations skipped as duplicates. */ +export interface ResolvedExtensionPanes { + panes: RegisteredPane[]; issues: ExtensionApplyIssue[]; } -/** - * Collect every sidebar view a session offers. - * - * Registration is additive — any number of views coexist beside the built-in - * file navigation — so the only thing resolved here is identity: two - * registrations sharing one `:` key would make open/close - * state ambiguous, so the first wins and the duplicate is reported. - */ -export function resolveExtensionSidebarViews( - registry: ExtensionRegistry, -): ResolvedExtensionSidebarViews { - const views: RegisteredSidebarView[] = []; +/** Resolve pane identities while retaining registration order as priority. */ +export function resolveExtensionPanes(registry: ExtensionRegistry): ResolvedExtensionPanes { + const panes: RegisteredPane[] = []; const issues: ExtensionApplyIssue[] = []; const claimed = new Set(); - for (const registered of registry.sidebarViews) { - const key = sidebarViewKey(registered); + for (const registered of registry.panes) { + const key = paneKey(registered); if (claimed.has(key)) { issues.push({ extensionId: registered.extensionId, - message: `Skipped duplicate sidebar view "${key}" from extension ${registered.extensionId}`, + message: `Skipped duplicate pane "${key}" from extension ${registered.extensionId}`, }); continue; } - claimed.add(key); - views.push(registered); + panes.push(registered); } - - return { views, issues }; + return { panes, issues }; } /** Derive the key one file view is addressed by everywhere in the app. */ @@ -298,7 +287,7 @@ export function applyExtensionRegistrations( // Resolved again where the UI consumes them; consulted here so skipped // duplicate registrations surface through the same notice path as every // other refusal. - const sidebars = resolveExtensionSidebarViews(result.registry); + const panes = resolveExtensionPanes(result.registry); const fileViews = resolveExtensionFileViews(result.registry); const keyboardModes = resolveExtensionKeyboardModes(result.registry); const commands = resolveExtensionCommands(result.registry); @@ -307,7 +296,7 @@ export function applyExtensionRegistrations( issues: [ ...languageIssues, ...vcs.issues, - ...sidebars.issues, + ...panes.issues, ...fileViews.issues, ...keyboardModes.issues, ...commands.issues, diff --git a/src/extensions/default/ui/index.test.ts b/src/extensions/default/ui/index.test.ts new file mode 100644 index 000000000..c3472717f --- /dev/null +++ b/src/extensions/default/ui/index.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, test } from "bun:test"; +import { getBundledUIRegistry } from "."; +import { paneKey } from "../../apply"; + +describe("bundled UI registry", () => { + test("registers only the built-in files pane", () => { + const panes = getBundledUIRegistry().panes; + expect(panes.map(paneKey)).toEqual(["hunk:files"]); + }); +}); diff --git a/src/extensions/default/ui/index.ts b/src/extensions/default/ui/index.ts new file mode 100644 index 000000000..b5575104b --- /dev/null +++ b/src/extensions/default/ui/index.ts @@ -0,0 +1,36 @@ +import { HUNK_VENDOR_EXTENSION_ID } from "../../extensionIds"; +import { runExtensionFactory } from "../../runExtension"; +import { + createEmptyExtensionRegistry, + type ExtensionFactory, + type ExtensionLoadIssue, + type ExtensionRegistry, +} from "../../types"; +import registerBundledSidebar from "./sidebar"; + +const factories: readonly [string, ExtensionFactory][] = [["files", registerBundledSidebar]]; +let cachedRegistry: ExtensionRegistry | undefined; + +/** Load bundled UI registrations through the public factory path, once per process. */ +export function getBundledUIRegistry(): ExtensionRegistry { + if (cachedRegistry) return cachedRegistry; + const registry = createEmptyExtensionRegistry(); + const issues: ExtensionLoadIssue[] = []; + for (const [id, factory] of factories) { + runExtensionFactory({ + metadata: { + id: HUNK_VENDOR_EXTENSION_ID, + sourcePath: `hunk:bundled/ui/${id}`, + origin: "bundled", + }, + registry, + issues, + factory, + }); + } + if (issues.length > 0 || registry.panes.length !== factories.length) { + throw new Error(`Bundled UI failed to register: ${issues[0]?.message ?? "missing pane"}`); + } + cachedRegistry = registry; + return registry; +} diff --git a/src/extensions/default/ui/sidebar/index.test.tsx b/src/extensions/default/ui/sidebar/index.test.tsx index 6f64dff89..507b9faad 100644 --- a/src/extensions/default/ui/sidebar/index.test.tsx +++ b/src/extensions/default/ui/sidebar/index.test.tsx @@ -1,31 +1,29 @@ import { describe, expect, test } from "bun:test"; -import { sidebarViewKey } from "../../../apply"; -import { - BUNDLED_SIDEBAR_EXTENSION_ID, - BUNDLED_SIDEBAR_VIEW_ID, - BuiltInSidebarView, - getBundledSidebarView, -} from "."; +import { paneKey } from "../../../apply"; +import { BUNDLED_SIDEBAR_EXTENSION_ID, BUNDLED_SIDEBAR_VIEW_ID, BuiltInSidebarView } from "."; +import { getBundledUIRegistry } from ".."; + +const getBundledFilesPane = () => getBundledUIRegistry().panes[0]!; describe("bundled sidebar extension", () => { test("registers the built-in view through the public factory path", () => { - const registered = getBundledSidebarView(); + const registered = getBundledFilesPane(); expect(registered.extensionId).toBe(BUNDLED_SIDEBAR_EXTENSION_ID); - expect(registered.view.id).toBe(BUNDLED_SIDEBAR_VIEW_ID); + expect(registered.pane.id).toBe(BUNDLED_SIDEBAR_VIEW_ID); // The registration carries the exact component the app renders and the // extension pipeline falls back to, so there is one built-in sidebar. - expect(registered.view.component).toBe(BuiltInSidebarView); + expect(registered.pane.component).toBe(BuiltInSidebarView); }); test("owns the reserved vendor id, so no extension can mint its view key", () => { // `hunk` is refused as an extension id at load, which is what makes this // key unreachable from disk — a `sidebar.ts` extension used to collide. expect(BUNDLED_SIDEBAR_EXTENSION_ID).toBe("hunk"); - expect(sidebarViewKey(getBundledSidebarView())).toBe("hunk:files"); + expect(paneKey(getBundledFilesPane())).toBe("hunk:files"); }); test("loads once and hands back the same registration afterwards", () => { - expect(getBundledSidebarView()).toBe(getBundledSidebarView()); + expect(getBundledFilesPane()).toBe(getBundledFilesPane()); }); }); diff --git a/src/extensions/default/ui/sidebar/index.tsx b/src/extensions/default/ui/sidebar/index.tsx index 2cdff6ceb..da41c5086 100644 --- a/src/extensions/default/ui/sidebar/index.tsx +++ b/src/extensions/default/ui/sidebar/index.tsx @@ -1,7 +1,7 @@ import type { ScrollBoxRenderable } from "@opentui/core"; import { useTerminalDimensions } from "@opentui/react"; import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; -import type { ExtensionSidebarViewProps } from "../../../../extension-api/types"; +import type { ExtensionPaneProps } from "../../../../extension-api/types"; import { buildSidebarEntries, sidebarEntryStatsWidth, @@ -11,29 +11,23 @@ import { fileRowId } from "../../../../ui/lib/ids"; import { buildSidebarRenderWindow } from "../../../../ui/lib/sidebarRenderWindow"; import { FileGroupHeader, FileListItem } from "../../../../ui/components/panes/FileListItem"; import { HUNK_VENDOR_EXTENSION_ID } from "../../../extensionIds"; -import { runExtensionFactory } from "../../../runExtension"; -import { - createEmptyExtensionRegistry, - type ExtensionFactory, - type ExtensionLoadIssue, - type RegisteredSidebarView, -} from "../../../types"; +import type { ExtensionFactory } from "../../../types"; /** * Hunk's file-navigation sidebar, shipped as a bundled extension. * * Like the Git backend, the built-in sidebar registers through the public API — - * `registerSidebarView` — and its component consumes exactly the published - * `ExtensionSidebarViewProps`: the frozen file views for its entries, the theme + * `registerPane` — and its component consumes exactly the published + * `ExtensionPaneProps`: the frozen file views for its entries, the theme * token slice for its colors, `actions.selectFile` for navigation, and the * host-served `@opentui/react` for its hooks. That is what keeps the sidebar * contract honest: anything this pane needs that the props cannot express is a - * real gap in what third-party sidebars can build. + * real gap in what third-party panes can build. * * Unlike the VCS tier this module is UI code, so it is deliberately *not* part * of `loadBundledExtensions` — that list is imported from VCS adapter - * resolution, which must stay renderer-free. The sidebar instead loads through - * `getBundledSidebarView` at the one place the app resolves its active sidebar. + * resolution, which must stay renderer-free. The pane instead loads through + * `getBundledUIRegistry` at the one place the app resolves its active panes. * Rendering helpers (row components, the render window) are imported from Hunk * directly: this is host code, and the dogfooding boundary is the data, * actions, and theme crossing the props — not utility code. @@ -41,7 +35,7 @@ import { * The scrollbox usage below is itself part of the published contract: the ref * reads (`scrollTop`, `viewport.height`), the scrollbar/viewport change * events, and `scrollChildIntoView` over child `id` props are documented in - * docs/extensions.md as the supported way third-party sidebars scroll and + * docs/extensions.md as the supported way third-party panes scroll and * follow the selection. Changing how this component talks to its scrollbox * means updating that contract — same honesty mechanism as the props. */ @@ -58,14 +52,17 @@ import { export const BUNDLED_SIDEBAR_EXTENSION_ID = HUNK_VENDOR_EXTENSION_ID; export const BUNDLED_SIDEBAR_VIEW_ID = "files"; -/** Render the built-in file navigation sidebar from the public sidebar props. */ +type BuiltInSidebarProps = Omit & + Partial>; + +/** Render the built-in file navigation pane from public pane props. */ export function BuiltInSidebarView({ files, selectedFileId, theme, width, actions, -}: ExtensionSidebarViewProps): ReactNode { +}: BuiltInSidebarProps): ReactNode { const scrollRef = useRef(null); const [scrollViewport, setScrollViewport] = useState({ top: 0, height: 0 }); const terminal = useTerminalDimensions(); @@ -197,47 +194,14 @@ export function BuiltInSidebarView({ /** The factory the bundled sidebar registers through, same as any extension. */ const registerBundledSidebar: ExtensionFactory = (hunk) => { - hunk.registerSidebarView({ id: BUNDLED_SIDEBAR_VIEW_ID, component: BuiltInSidebarView }); + hunk.registerPane({ + id: BUNDLED_SIDEBAR_VIEW_ID, + title: "Files", + placement: "left", + width: { preferred: 34, min: 22 }, + defaultOpen: true, + component: BuiltInSidebarView, + }); }; export default registerBundledSidebar; - -let cachedView: RegisteredSidebarView | undefined; - -/** - * Load the bundled sidebar registration, once per process. - * - * Runs the factory through `runExtensionFactory` — the same seal, validation, - * and registry path user extensions take — and hands back the one registration - * it produced. The app uses it as the default a user-registered sidebar view - * overrides. A failure here is a Hunk bug, not an extension author's, so it - * throws instead of degrading. - */ -export function getBundledSidebarView(): RegisteredSidebarView { - if (cachedView) { - return cachedView; - } - - const registry = createEmptyExtensionRegistry(); - const issues: ExtensionLoadIssue[] = []; - runExtensionFactory({ - metadata: { - id: BUNDLED_SIDEBAR_EXTENSION_ID, - sourcePath: "hunk:bundled/sidebar", - origin: "bundled", - }, - registry, - issues, - factory: registerBundledSidebar, - }); - - const view = registry.sidebarViews[0]; - if (issues.length > 0 || !view) { - throw new Error( - `Bundled sidebar failed to register: ${issues[0]?.message ?? "no view registered"}`, - ); - } - - cachedView = view; - return cachedView; -} diff --git a/src/extensions/events.test.ts b/src/extensions/events.test.ts index 9d8531ee1..1568aae7d 100644 --- a/src/extensions/events.test.ts +++ b/src/extensions/events.test.ts @@ -146,32 +146,42 @@ describe("extension event dispatch", () => { expect(Date.now() - started).toBeLessThan(1_000); }); - test("gives lifecycle handlers live sidebar controls for their owning extension", () => { + test("gives lifecycle handlers live pane controls and one deprecated alias", () => { const opened: string[] = []; + let aliasesSame = false; const { result } = createTestLoadResult([ { extensionId: "summary", event: "changeset_loaded", - handler: (_payload, ctx) => ctx.sidebars.open("summary"), + handler: (_payload, ctx) => { + aliasesSame = ctx.panes === ctx.sidebars; + ctx.panes.open("summary"); + ctx.sidebars.open("legacy"); + }, }, ]); - result.eventContextProvider = (extensionId) => ({ - cwd: "/repo", - notify: () => {}, - sidebars: { - open: (viewId) => opened.push(`${extensionId}:${viewId}`), + result.eventContextProvider = (extensionId) => { + const panes = { + open: (viewId: string) => opened.push(`${extensionId}:${viewId}`), close: () => {}, toggle: () => {}, isOpen: () => false, - }, - events: { emit: () => {} }, - }); + }; + return { + cwd: "/repo", + notify: () => {}, + panes, + sidebars: panes, + events: { emit: () => {} }, + }; + }; emitExtensionEvent(result, "changeset_loaded", { changeset: { id: "c", sourceLabel: "repo", title: "t", files: [] }, }); - expect(opened).toEqual(["summary:summary"]); + expect(aliasesSame).toBe(true); + expect(opened).toEqual(["summary:summary", "summary:legacy"]); }); test("is a no-op when the session has no extensions", () => { diff --git a/src/extensions/events.ts b/src/extensions/events.ts index dd37f8bb0..d9cbf55e3 100644 --- a/src/extensions/events.ts +++ b/src/extensions/events.ts @@ -9,7 +9,7 @@ import type { Hunk } from "@pierre/diffs"; import type { ExtensionDiffHunk, ExtensionEventContext, - ExtensionSidebarControls, + ExtensionPaneControls, ExtensionVcsFileChangeType, } from "../extension-api/types"; import { summarizeHunk } from "../core/hunkSummary"; @@ -288,14 +288,14 @@ function toHandlerPayload( }) as ExtensionEventPayloads[Event]; } -/** Sidebar controls used only before the mounted app has installed live controls. */ -function unavailableSidebarControls( +/** Pane controls used only before the mounted app has installed live controls. */ +function unavailablePaneControls( result: ExtensionLoadResult, extensionId: string, -): ExtensionSidebarControls { +): ExtensionPaneControls { const unavailable = (method: string, viewId: string) => { result.context.notify( - `Extension ${extensionId} cannot ${method} sidebar view "${viewId}" before the app is ready`, + `Extension ${extensionId} cannot ${method} pane "${viewId}" before the app is ready`, "warning", ); }; @@ -313,17 +313,23 @@ function createEventContext( result: ExtensionLoadResult, extensionId: string, ): ExtensionEventContext { - return ( - result.eventContextProvider?.(extensionId) ?? { - ...result.context, - sidebars: unavailableSidebarControls(result, extensionId), - events: { - emit(event, payload) { - emitExtensionCustomEvent(result, event, payload); - }, + const provided = result.eventContextProvider?.(extensionId); + if (provided) { + return provided; + } + + // The deprecated name is an alias, not another control path. + const panes = unavailablePaneControls(result, extensionId); + return { + ...result.context, + panes, + sidebars: panes, + events: { + emit(event, payload) { + emitExtensionCustomEvent(result, event, payload); }, - } - ); + }, + }; } /** diff --git a/src/extensions/extensionIds.ts b/src/extensions/extensionIds.ts index 00d163f4e..cec899f29 100644 --- a/src/extensions/extensionIds.ts +++ b/src/extensions/extensionIds.ts @@ -3,7 +3,7 @@ * * An extension id is a file stem the user chose, and it is the namespace that * id space owns everywhere else: `.` for commands, - * `:` for sidebar views, `[extension.]` for config. + * `:` for panes, `[extension.]` for config. * Two structural rules keep that from breaking down: * * - **One vendor namespace.** Everything Hunk itself owns lives under `hunk`, @@ -11,7 +11,7 @@ * built-in groupings can be added forever without colliding with an id * somebody already installed. * - **A parseable charset.** No dots, so `.` splits at - * the first dot; no colons, so `:` splits at the first + * the first dot; no colons, so `:` splits at the first * colon; no leading separator, so ids read as names. * * The rules are enforced once, where candidates become loadable extensions @@ -19,8 +19,10 @@ * ask about the vendor namespace without pulling the loader in behind it. */ -/** The id Hunk reserves for itself: built-in commands, views, and bundled UI. */ +/** The id Hunk reserves for itself: built-in commands, panes, and bundled UI. */ export const HUNK_VENDOR_EXTENSION_ID = "hunk"; +/** Stable key of the bundled files pane. */ +export const HUNK_FILES_PANE_KEY = "hunk:files"; /** * Characters an extension id may be spelled with. diff --git a/src/extensions/host.test.ts b/src/extensions/host.test.ts index 38a650f69..915e73096 100644 --- a/src/extensions/host.test.ts +++ b/src/extensions/host.test.ts @@ -241,9 +241,9 @@ export default function (hunk: { registerSidebarView: (view: unknown) => void }) { id: "flat-sidebar", sourcePath: entryPath, origin: "global" }, ]); expect( - result.registry.sidebarViews.map((entry) => ({ + result.registry.panes.map((entry) => ({ extensionId: entry.extensionId, - viewId: entry.view.id, + viewId: entry.pane.id, })), ).toEqual([{ extensionId: "flat-sidebar", viewId: "flat" }]); }); diff --git a/src/extensions/hostRuntimeModules.ts b/src/extensions/hostRuntimeModules.ts index 981bab943..e7f2ac343 100644 --- a/src/extensions/hostRuntimeModules.ts +++ b/src/extensions/hostRuntimeModules.ts @@ -9,7 +9,7 @@ import { dirname } from "node:path"; * repo-local extension inside a JavaScript project) would resolve to a *second* * React whose hooks dispatcher is not the one Hunk renders with. That identity * is what makes extension-authored components (hooks included) mountable - * inside Hunk's own tree — see `registerSidebarView`. + * inside Hunk's own tree — see `registerPane`. * * The mechanism is deliberately scoped to extension source, because the obvious * one is not safe: claiming the bare `react` specifier process-wide with a diff --git a/src/extensions/index.ts b/src/extensions/index.ts index 8d26882fe..5640c6911 100644 --- a/src/extensions/index.ts +++ b/src/extensions/index.ts @@ -7,14 +7,14 @@ export { resolveDetectedVcsIdWithExtensions, resolveExtensionCommands, resolveExtensionKeyboardModes, - resolveExtensionSidebarViews, + resolveExtensionPanes, resolveExtensionVcsAdapters, - sidebarViewKey, + paneKey, type AppliedExtensionRegistrations, type ExtensionApplyIssue, type ResolvedExtensionCommands, type ResolvedExtensionKeyboardModes, - type ResolvedExtensionSidebarViews, + type ResolvedExtensionPanes, } from "./apply"; export { getBundledVcsAdapters, @@ -90,7 +90,7 @@ export type { RegisteredEventHandler, RegisteredFileLanguage, RegisteredKeyboardMode, - RegisteredSidebarView, + RegisteredPane, RegisteredTheme, RegisteredVcsAdapter, SessionReloadReason, diff --git a/src/extensions/panes.ts b/src/extensions/panes.ts new file mode 100644 index 000000000..5915915cb --- /dev/null +++ b/src/extensions/panes.ts @@ -0,0 +1,31 @@ +import type { + ExtensionPane, + ExtensionPanePlacement, + ExtensionPaneSize, +} from "../extension-api/types"; + +const DEFAULT_VERTICAL_PANE_WIDTH = Object.freeze({ preferred: 34, min: 22 }); +const DEFAULT_HORIZONTAL_PANE_HEIGHT = Object.freeze({ preferred: 8, min: 3 }); + +/** Report whether a pane occupies a vertical edge and is therefore width-sized. */ +export function isVerticalPanePlacement(placement: ExtensionPanePlacement) { + return placement === "left" || placement === "right"; +} + +/** Resolve the host default for the explicit dimension implied by placement. */ +export function defaultExtensionPaneSize(placement: ExtensionPanePlacement): ExtensionPaneSize { + return isVerticalPanePlacement(placement) + ? DEFAULT_VERTICAL_PANE_WIDTH + : DEFAULT_HORIZONTAL_PANE_HEIGHT; +} + +/** Read the width or height request matching one pane's accepted placement. */ +export function extensionPaneSize( + pane: ExtensionPane, + placement: ExtensionPanePlacement = pane.placement ?? "left", +): ExtensionPaneSize { + return ( + (isVerticalPanePlacement(placement) ? pane.width : pane.height) ?? + defaultExtensionPaneSize(placement) + ); +} diff --git a/src/extensions/runExtension.test.ts b/src/extensions/runExtension.test.ts index 9213cbab0..021537014 100644 --- a/src/extensions/runExtension.test.ts +++ b/src/extensions/runExtension.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { resolveExtensionPanes } from "./apply"; import { runExtensionFactory, toInternalVcsAdapter } from "./runExtension"; import { createEmptyExtensionRegistry, type ExtensionLoadIssue } from "./types"; @@ -100,6 +101,136 @@ describe("runExtensionFactory", () => { }); }); +describe("registerPane", () => { + test("collects every placement with normalized width or height", () => { + const registry = createEmptyExtensionRegistry(); + const issues: ExtensionLoadIssue[] = []; + runExtensionFactory({ + metadata: bundledMetadata("panes"), + registry, + issues, + factory: (hunk) => { + const size = { preferred: 3, min: 2, max: 4 }; + for (const placement of ["left", "right"] as const) { + hunk.registerPane({ id: placement, placement, width: size, component: () => null }); + } + for (const placement of ["top", "bottom"] as const) { + hunk.registerPane({ id: placement, placement, height: size, component: () => null }); + } + }, + }); + expect(issues).toEqual([]); + expect( + registry.panes.map(({ pane }) => [ + pane.id, + pane.placement, + pane.placement === "left" || pane.placement === "right" ? pane.width : pane.height, + ]), + ).toEqual([ + ["left", "left", { preferred: 3, min: 2, max: 4 }], + ["right", "right", { preferred: 3, min: 2, max: 4 }], + ["top", "top", { preferred: 3, min: 2, max: 4 }], + ["bottom", "bottom", { preferred: 3, min: 2, max: 4 }], + ]); + }); + + test("uses placement-aware defaults for width and height", () => { + const registry = createEmptyExtensionRegistry(); + const issues: ExtensionLoadIssue[] = []; + runExtensionFactory({ + metadata: bundledMetadata("pane-defaults"), + registry, + issues, + factory: (hunk) => { + hunk.registerPane({ id: "side", placement: "right", component: () => null }); + hunk.registerPane({ id: "vertical", placement: "bottom", component: () => null }); + }, + }); + + expect(issues).toEqual([]); + expect(registry.panes[0]?.pane.width).toEqual({ + preferred: 34, + min: 22, + max: Number.MAX_SAFE_INTEGER, + }); + expect(registry.panes[1]?.pane.height).toEqual({ + preferred: 8, + min: 3, + max: Number.MAX_SAFE_INTEGER, + }); + }); + + test("validates opt-ins, replacement keys, and synchronous availability callbacks", () => { + for (const pane of [ + { id: "paint", currentLine: "yes", component: () => null }, + { id: "replacement", replaces: "", component: () => null }, + { id: "self", replaces: "bad-pane:self", component: () => null }, + { id: "availability", available: true, component: () => null }, + ]) { + const registry = createEmptyExtensionRegistry(); + const issues: ExtensionLoadIssue[] = []; + runExtensionFactory({ + metadata: bundledMetadata("bad-pane"), + registry, + issues, + factory: (hunk) => hunk.registerPane(pane as never), + }); + expect(registry.panes).toEqual([]); + expect(issues).toHaveLength(1); + } + }); + + test("rejects invalid placements, dimensions, and bounds", () => { + const invalidPanes = [ + { id: "", component: () => null }, + { id: "component", component: null }, + { id: "placement", placement: "center", component: () => null }, + { id: "zero", width: { preferred: 0 }, component: () => null }, + { id: "fraction", width: { preferred: 1.5 }, component: () => null }, + { id: "infinite", width: { preferred: Number.POSITIVE_INFINITY }, component: () => null }, + { + id: "unsafe", + width: { preferred: Number.MAX_SAFE_INTEGER + 1 }, + component: () => null, + }, + { id: "bounds", width: { preferred: 3, min: 4 }, component: () => null }, + { id: "maximum", width: { preferred: 4, max: 3 }, component: () => null }, + { id: "side-height", placement: "right", height: { preferred: 4 }, component: () => null }, + { id: "top-width", placement: "top", width: { preferred: 4 }, component: () => null }, + ]; + + for (const pane of invalidPanes) { + const registry = createEmptyExtensionRegistry(); + const issues: ExtensionLoadIssue[] = []; + runExtensionFactory({ + metadata: bundledMetadata("bad-pane"), + registry, + issues, + factory: (hunk) => hunk.registerPane(pane as never), + }); + expect(registry.panes).toEqual([]); + expect(issues).toHaveLength(1); + } + }); + + test("rolls pane registrations back when their factory later throws", () => { + const registry = createEmptyExtensionRegistry(); + const issues: ExtensionLoadIssue[] = []; + runExtensionFactory({ + metadata: bundledMetadata("half-pane"), + registry, + issues, + factory: (hunk) => { + hunk.registerPane({ id: "tree", component: () => null }); + throw new Error("after registering"); + }, + }); + + expect(registry.panes).toEqual([]); + expect(issues.map((issue) => issue.extensionId)).toEqual(["half-pane"]); + }); +}); + describe("registerSidebarView", () => { test("collects a valid view tagged with the owning extension", () => { const registry = createEmptyExtensionRegistry(); @@ -116,8 +247,16 @@ describe("registerSidebarView", () => { }); expect(issues).toEqual([]); - expect(registry.sidebarViews).toEqual([ - { extensionId: "side", view: { id: "tree", component } }, + expect(registry.panes).toEqual([ + { + extensionId: "side", + pane: { + id: "tree", + placement: "left", + width: { preferred: 34, min: 22, max: Number.MAX_SAFE_INTEGER }, + component, + }, + }, ]); }); @@ -134,7 +273,7 @@ describe("registerSidebarView", () => { }, }); - expect(registry.sidebarViews).toEqual([]); + expect(registry.panes).toEqual([]); expect(issues.map((issue) => issue.extensionId)).toEqual(["broken-side"]); expect(issues[0]?.message).toContain("component function"); }); @@ -154,9 +293,37 @@ describe("registerSidebarView", () => { }); // A failed factory is not loaded, so its sidebar must not win the session. - expect(registry.sidebarViews).toEqual([]); + expect(registry.panes).toEqual([]); expect(issues.map((issue) => issue.extensionId)).toEqual(["half-side"]); }); + + test("collides with registerPane through one identity path and keeps the first", () => { + const registry = createEmptyExtensionRegistry(); + const issues: ExtensionLoadIssue[] = []; + const first = () => null; + const duplicate = () => null; + + runExtensionFactory({ + metadata: bundledMetadata("mixed"), + registry, + issues, + factory: (hunk) => { + hunk.registerSidebarView({ id: "tree", component: first }); + hunk.registerPane({ id: "tree", component: duplicate }); + }, + }); + + const resolved = resolveExtensionPanes(registry); + expect(issues).toEqual([]); + expect(resolved.panes).toHaveLength(1); + expect(resolved.panes[0]?.pane.component).toBe(first); + expect(resolved.issues).toEqual([ + { + extensionId: "mixed", + message: 'Skipped duplicate pane "mixed:tree" from extension mixed', + }, + ]); + }); }); describe("registerFileView", () => { diff --git a/src/extensions/runExtension.ts b/src/extensions/runExtension.ts index 5ede4faf3..e64409ccd 100644 --- a/src/extensions/runExtension.ts +++ b/src/extensions/runExtension.ts @@ -11,6 +11,7 @@ import { type ExtensionEventBus, type ExtensionMetadata, type ExtensionRegistry, + type ExtensionPane, type ExtensionSidebarView, type ExtensionFileView, type ExtensionKeyboardMode, @@ -23,6 +24,7 @@ import { toUserFacingError } from "../core/errors"; import { toInternalVcsPatchResult } from "./vcsPatchResult"; import type { ExtensionVcsOperation } from "../extension-api/types"; import type { VcsAdapter, VcsOperation, VcsReviewInput } from "../core/vcs/types"; +import { defaultExtensionPaneSize, extensionPaneSize, isVerticalPanePlacement } from "./panes"; /** * Running one extension factory into the shared registry. @@ -217,7 +219,7 @@ interface RegistrySnapshot { fileLanguages: number; vcsAdapters: number; changesetTransforms: number; - sidebarViews: number; + panes: number; fileViews: number; keyboardModes: number; commands: number; @@ -238,7 +240,7 @@ function snapshotRegistry(registry: ExtensionRegistry): RegistrySnapshot { fileLanguages: registry.fileLanguages.length, vcsAdapters: registry.vcsAdapters.length, changesetTransforms: registry.changesetTransforms.length, - sidebarViews: registry.sidebarViews.length, + panes: registry.panes.length, fileViews: registry.fileViews.length, keyboardModes: registry.keyboardModes.length, commands: registry.commands.length, @@ -259,7 +261,7 @@ function rollbackRegistry(registry: ExtensionRegistry, snapshot: RegistrySnapsho registry.fileLanguages.length = snapshot.fileLanguages; registry.vcsAdapters.length = snapshot.vcsAdapters; registry.changesetTransforms.length = snapshot.changesetTransforms; - registry.sidebarViews.length = snapshot.sidebarViews; + registry.panes.length = snapshot.panes; registry.fileViews.length = snapshot.fileViews; registry.keyboardModes.length = snapshot.keyboardModes; registry.commands.length = snapshot.commands; @@ -356,19 +358,76 @@ export function createExtensionApi( }), }); }, + registerPane(pane: ExtensionPane) { + assertOpen("registerPane"); + assertNonEmptyString(pane?.id, "registerPane requires a pane with a non-empty id."); + if (typeof pane.component !== "function") { + throw new Error("registerPane requires a pane with a component function."); + } + const placement = pane.placement ?? "left"; + if (!(["left", "right", "top", "bottom"] as const).includes(placement)) { + throw new Error( + `registerPane placement must be "left", "right", "top", or "bottom", got "${String(placement)}".`, + ); + } + const dimension = isVerticalPanePlacement(placement) ? "width" : "height"; + const wrongDimension = dimension === "width" ? "height" : "width"; + if (pane[wrongDimension] !== undefined) { + throw new Error(`registerPane ${placement} panes use ${dimension}, not ${wrongDimension}.`); + } + const size = extensionPaneSize(pane, placement); + const min = size.min ?? 1; + const max = size.max ?? Number.MAX_SAFE_INTEGER; + for (const [name, value] of Object.entries({ preferred: size.preferred, min, max })) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`registerPane ${dimension}.${name} must be a positive safe integer.`); + } + } + if (min > size.preferred || size.preferred > max) { + throw new Error(`registerPane ${dimension} must satisfy min <= preferred <= max.`); + } + if (pane.available !== undefined && typeof pane.available !== "function") { + throw new Error("registerPane available must be a function."); + } + if (pane.currentLine !== undefined && typeof pane.currentLine !== "boolean") { + throw new Error("registerPane currentLine must be a boolean."); + } + if (pane.replaces !== undefined) { + assertNonEmptyString(pane.replaces, "registerPane replaces must be a non-empty pane key."); + if (pane.replaces === `${metadata.id}:${pane.id}`) { + throw new Error("registerPane cannot replace itself."); + } + } + + const normalizedSize = { preferred: size.preferred, min, max }; + registry.panes.push({ + extensionId: metadata.id, + pane: { + ...pane, + placement, + ...(dimension === "width" + ? { width: normalizedSize, height: undefined } + : { height: normalizedSize, width: undefined }), + } as ExtensionPane, + }); + }, registerSidebarView(view: ExtensionSidebarView) { assertOpen("registerSidebarView"); assertNonEmptyString(view?.id, "registerSidebarView requires a view with a non-empty id."); - if (typeof view.component !== "function") { - throw new Error("registerSidebarView requires a view with a component function."); - } if (view.placement !== undefined && view.placement !== "left" && view.placement !== "right") { throw new Error( `registerSidebarView placement must be "left" or "right", got "${String(view.placement)}".`, ); } - - registry.sidebarViews.push({ extensionId: metadata.id, view }); + api.registerPane({ + id: view.id, + ...(view.title ? { title: view.title } : {}), + placement: view.placement ?? "left", + width: defaultExtensionPaneSize("left"), + defaultOpen: view.defaultOpen, + replaces: view.replacesDefault ? "hunk:files" : undefined, + component: view.component as unknown as ExtensionPane["component"], + }); }, registerFileView(view: ExtensionFileView) { assertOpen("registerFileView"); diff --git a/src/extensions/types.ts b/src/extensions/types.ts index dca65350a..56fa4e1c5 100644 --- a/src/extensions/types.ts +++ b/src/extensions/types.ts @@ -11,7 +11,7 @@ import type { ExtensionFileView, ExtensionKeyboardMode, ExtensionNotifyType, - ExtensionSidebarView, + ExtensionPane, ExtensionThemeConfig, } from "../extension-api/types"; import { createExtensionNotificationHub, type ExtensionNotificationHub } from "./notifications"; @@ -64,6 +64,19 @@ export type { ExtensionReviewNote, ExtensionNotifyType, ExtensionPaintTheme, + ExtensionPane, + ExtensionPaneActions, + ExtensionPaneAvailabilityContext, + ExtensionPaneComponent, + ExtensionPaneControls, + ExtensionPaneKeybindings, + ExtensionPanePlacement, + ExtensionPaneProps, + ExtensionPaneTheme, + ExtensionPaneSize, + ExtensionHorizontalPane, + ExtensionVerticalPane, + ExtensionCurrentLinePaint, ExtensionSelectOptions, ExtensionSidebarActions, ExtensionSidebarComponent, @@ -131,9 +144,9 @@ export interface RegisteredChangesetTransform { transform: ChangesetTransform; } -export interface RegisteredSidebarView { +export interface RegisteredPane { extensionId: string; - view: ExtensionSidebarView; + pane: ExtensionPane; } /** A host-rendered alternative file presentation registered by one extension. */ @@ -189,7 +202,7 @@ export interface ExtensionRegistry { fileLanguages: RegisteredFileLanguage[]; vcsAdapters: RegisteredVcsAdapter[]; changesetTransforms: RegisteredChangesetTransform[]; - sidebarViews: RegisteredSidebarView[]; + panes: RegisteredPane[]; fileViews: RegisteredFileView[]; keyboardModes: RegisteredKeyboardMode[]; commands: RegisteredCommand[]; @@ -264,7 +277,7 @@ export function createEmptyExtensionRegistry(): ExtensionRegistry { fileLanguages: [], vcsAdapters: [], changesetTransforms: [], - sidebarViews: [], + panes: [], fileViews: [], keyboardModes: [], commands: [], diff --git a/src/ui/App.tsx b/src/ui/App.tsx index e375416d0..4dbc3540f 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -6,7 +6,6 @@ import { import { useRenderer, useTerminalDimensions } from "@opentui/react"; import { writeFile } from "node:fs/promises"; import { - Fragment, Suspense, lazy, useCallback, @@ -50,11 +49,12 @@ import type { ExtensionFileSide, ExtensionNotifyType, ExtensionReviewNote, - ExtensionSidebarControls, + ExtensionPaneControls, ExtensionWorkspace, ExtensionWorkspaceWriteRequest, ExtensionWorkspaceWriteResult, RegisteredCommand, + RegisteredPane, } from "../extensions/types"; import type { HunkSessionBrokerClient, @@ -67,7 +67,7 @@ import { ExtensionDialog } from "./components/chrome/ExtensionDialog"; import { ExtensionToast } from "./components/chrome/ExtensionToast"; import { StatusBar } from "./components/chrome/StatusBar"; import { DiffPane } from "./components/panes/DiffPane"; -import { ExtensionSidebarPane } from "./components/panes/ExtensionSidebarPane"; +import { ExtensionPaneHost } from "./components/panes/ExtensionPane"; import { PaneDivider } from "./components/panes/PaneDivider"; import { findMaxLineNumber, @@ -92,6 +92,12 @@ import { import { buildAppMenus } from "./lib/appMenus"; import { buildExtensionAppCommands, extensionCommandKeyDefaults } from "./lib/extensionCommands"; import { createExtensionCommandControls } from "./lib/extensionCommandControls"; +import { + applyExtensionCurrentLinePaintUpdate, + extensionCurrentLinePaintMatchesCursor, + type ExtensionCurrentLinePaintState, + type ExtensionCurrentLinePaintUpdate, +} from "./lib/extensionCurrentLine"; import { createGuardedReviewNavigation } from "./lib/extensionNavigation"; import type { CurrentLineAlignment } from "./lib/hunkScroll"; import type { LineCursor } from "./lib/lineCursors"; @@ -99,17 +105,20 @@ import { buildExtensionReviewSelection } from "./lib/extensionSelection"; import { useFilePresentationController } from "./fileViews/useFilePresentationController"; import { useFilePresentationRendering } from "./fileViews/useFilePresentationRendering"; import { useKeyboardModeController } from "./keyboardModes/useKeyboardModeController"; -import { createExtensionSidebarKeybindings, resolveCommandKeys } from "./lib/keymap"; +import { createExtensionPaneKeybindings, resolveCommandKeys } from "./lib/keymap"; import { - buildSessionSidebarViews, - bundledSidebarViewKey, - initialSidebarOpenState, - planSidebarLayout, - reconcileSidebarOpenState, - resolveSidebarViewKey, - type SidebarPanePlan, - type SidebarPlacement, -} from "./lib/sidebarPanes"; + buildSessionPanes, + EXTENSION_PANE_DIVIDER_SIZE, + initialPaneOpenState, + MIN_EXTENSION_REVIEW_HEIGHT, + planExtensionPanes, + reconcilePaneOpenState, + resolvePaneKey, + type PlannedPane, +} from "./lib/extensionPanes"; +import type { ExtensionPanePlacement } from "../extension-api/types"; +import { HUNK_FILES_PANE_KEY } from "../extensions/extensionIds"; +import { extensionPaneSize } from "../extensions/panes"; import { nextExtensionTrustPromptRoot } from "./lib/extensionTrustPrompt"; import { normalizeWorkspaceWriteRequest, @@ -208,11 +217,8 @@ export function App({ watchRuntime?: WatchedInputRuntime; }) { const SIDEBAR_MIN_WIDTH = 22; - const SIDEBAR_DEFAULT_WIDTH = 34; const DIFF_MIN_WIDTH = 48; const BODY_PADDING = 2; - const DIVIDER_WIDTH = 1; - const DIVIDER_HIT_WIDTH = 5; const pagerMode = Boolean(bootstrap.input.options.pager); const tabWidth = bootstrap.initialTabWidth ?? DEFAULT_TAB_WIDTH; @@ -264,17 +270,45 @@ export function App({ const [saveConfigPromptOpen, setSaveConfigPromptOpen] = useState(false); const [focusArea, setFocusArea] = useState("files"); const [activeAddNoteTarget, setActiveAddNoteTarget] = useState(null); - const [sidebarWidths, setSidebarWidths] = useState>({}); - const [sidebarResize, setSidebarResize] = useState<{ + const [paneSizes, setPaneSizes] = useState>({}); + const [paneResize, setPaneResize] = useState<{ key: string; - placement: SidebarPlacement; - originX: number; - startWidth: number; - maxWidth: number; + registered: RegisteredPane; + placement: ExtensionPanePlacement; + origin: number; + startSize: number; + maxSize: number; + minSize: number; } | null>(null); const [sessionNoticeText, setSessionNoticeText] = useState(null); const sessionNoticeTimeoutRef = useRef | null>(null); const extensions = bootstrap.extensions; + const sessionPanes = useMemo(() => buildSessionPanes(extensions), [extensions]); + const [paneOpenState, setPaneOpenState] = useState(() => initialPaneOpenState(sessionPanes)); + useEffect( + () => setPaneOpenState((current) => reconcilePaneOpenState(sessionPanes, current)), + [sessionPanes], + ); + const sessionPanesRef = useRef(sessionPanes); + sessionPanesRef.current = sessionPanes; + const paneOpenStateRef = useRef(paneOpenState); + paneOpenStateRef.current = paneOpenState; + const currentLinePaintRequested = sessionPanes.some( + (pane) => paneOpenState.open.includes(pane.key) && pane.registered.pane.currentLine === true, + ); + const [currentLinePaintState, setCurrentLinePaintState] = + useState({ + status: "unavailable", + fileId: null, + cursorKey: null, + paint: null, + }); + const onCurrentLinePaintChange = useCallback((update: ExtensionCurrentLinePaintUpdate) => { + setCurrentLinePaintState((current) => applyExtensionCurrentLinePaintUpdate(current, update)); + }, []); + const retainedCurrentLinePaneKeysRef = useRef>(new Set()); + const [paneFailureEpoch, setPaneFailureEpoch] = useState(0); + const paneAvailabilityQuarantineRef = useRef(new WeakSet()); const pendingTrustRepoRoot = extensions?.pendingTrustRepoRoot; const extensionToast = useExtensionNotifications(extensions?.notifications); // Repo-local extensions were discovered but skipped for want of a trust @@ -373,6 +407,14 @@ export function App({ const selectedFile = review.selectedFile; const selectedHunkIndex = review.selectedHunkIndex; const selectedFileId = selectedFile?.id ?? null; + const currentLinePaintMatchesCursor = extensionCurrentLinePaintMatchesCursor( + currentLinePaintState, + review.lineCursor, + ); + const currentLinePaint = currentLinePaintMatchesCursor ? currentLinePaintState.paint : null; + const currentLinePaintPending = + currentLinePaintState.status === "pending" || + (currentLinePaintState.status === "ready" && !currentLinePaintMatchesCursor); const sessionFileViews = useMemo( () => (extensions ? resolveExtensionFileViews(extensions.registry).views : []), [extensions], @@ -561,32 +603,11 @@ export function App({ emitExtensionEvent(extensions, "changeset_loaded", { changeset: bootstrap.changeset }); }, [bootstrap.changeset, extensions]); - // Every sidebar view this session offers — the bundled file navigation plus - // each registered view — and which of them are open. Registration is - // additive; the built-in sidebar is itself a bundled extension, so every - // pane renders through the extension path. - const sessionSidebarViews = useMemo(() => buildSessionSidebarViews(extensions), [extensions]); - const [sidebarOpenState, setSidebarOpenState] = useState(() => - initialSidebarOpenState(sessionSidebarViews), - ); - useEffect(() => { - // Reloads may add or remove views; keep the user's open/closed choices for - // the ones that survived. - setSidebarOpenState((current) => reconcileSidebarOpenState(sessionSidebarViews, current)); - }, [sessionSidebarViews]); - const sessionSidebarViewsRef = useRef(sessionSidebarViews); - sessionSidebarViewsRef.current = sessionSidebarViews; - const sidebarOpenStateRef = useRef(sidebarOpenState); - sidebarOpenStateRef.current = sidebarOpenState; - - const setSidebarOpen = useCallback((key: string, nextOpen: boolean | "toggle") => { - setSidebarOpenState((current) => { + const setPaneOpen = useCallback((key: string, nextOpen: boolean | "toggle") => { + setPaneOpenState((current) => { const isOpen = current.open.includes(key); const resolved = nextOpen === "toggle" ? !isOpen : nextOpen; - if (resolved === isOpen) { - return current; - } - + if (resolved === isOpen) return current; return { known: current.known, open: resolved ? [...current.open, key] : current.open.filter((open) => open !== key), @@ -594,65 +615,50 @@ export function App({ }); }, []); - /** Close a sidebar view that failed rendering; never leave the area empty. */ - const handleSidebarViewFailure = useCallback((key: string) => { - setSidebarOpenState((current) => { - const open = current.open.filter((openKey) => openKey !== key); - return { - known: current.known, - open: open.length > 0 ? open : [bundledSidebarViewKey()], - }; - }); - }, []); - - /** Build the sidebar controls one extension's command handlers receive. */ - const createSidebarControls = useCallback( - (extensionId: string): ExtensionSidebarControls => { - const resolve = (method: string, viewId: string) => { - const key = resolveSidebarViewKey(sessionSidebarViewsRef.current, extensionId, viewId); - if (!key) { + /** Build the canonical pane controls; deprecated sidebar controls share this object. */ + const createPaneControls = useCallback( + (extensionId: string): ExtensionPaneControls => { + const resolve = (method: string, id: string) => { + const key = resolvePaneKey(sessionPanesRef.current, extensionId, id); + if (!key) extensions?.context.notify( - `Extension ${extensionId} ${method} targeted unknown sidebar view "${viewId}"`, + `Extension ${extensionId} ${method} targeted unknown pane "${id}"`, "warning", ); - } - return key; }; - + const revealIfSide = (key: string) => { + const pane = sessionPanesRef.current.find((entry) => entry.key === key); + if (pane?.placement === "left" || pane?.placement === "right") + revealSidebarAreaRef.current(); + }; return { - open(viewId: string) { - const key = resolve("sidebars.open", viewId); + open(id) { + const key = resolve("panes.open", id); if (key) { - setSidebarOpen(key, true); - // Opening a view is a request to *see* it: a sidebar area the - // user hid with `s` reveals again, or the open would be silent. - revealSidebarAreaRef.current(); + setPaneOpen(key, true); + revealIfSide(key); } }, - close(viewId: string) { - const key = resolve("sidebars.close", viewId); - if (key) { - setSidebarOpen(key, false); - } + close(id) { + const key = resolve("panes.close", id); + if (key) setPaneOpen(key, false); }, - toggle(viewId: string) { - const key = resolve("sidebars.toggle", viewId); + toggle(id) { + const key = resolve("panes.toggle", id); if (key) { - const willOpen = !sidebarOpenStateRef.current.open.includes(key); - setSidebarOpen(key, "toggle"); - if (willOpen) { - revealSidebarAreaRef.current(); - } + const opens = !paneOpenStateRef.current.open.includes(key); + setPaneOpen(key, "toggle"); + if (opens) revealIfSide(key); } }, - isOpen(viewId: string) { - const key = resolveSidebarViewKey(sessionSidebarViewsRef.current, extensionId, viewId); - return key !== undefined && sidebarOpenStateRef.current.open.includes(key); + isOpen(id) { + const key = resolvePaneKey(sessionPanesRef.current, extensionId, id); + return key !== undefined && paneOpenStateRef.current.open.includes(key); }, }; }, - [extensions, setSidebarOpen], + [extensions, setPaneOpen], ); /** @@ -782,19 +788,23 @@ export function App({ [createExtensionDialogs], ); - // Lifecycle and bus listeners receive the same sidebar controls as commands, + // Lifecycle and bus listeners receive the same pane controls as commands, // so an extension can react to loaded content by revealing its own pane. if (extensions) { - extensions.eventContextProvider = (extensionId): ExtensionEventContext => ({ - cwd: extensions.context.cwd, - notify: (message, type) => extensions.context.notify(message, type), - sidebars: createSidebarControls(extensionId), - events: { - emit(event, payload) { - emitExtensionCustomEvent(extensions, event, payload); + extensions.eventContextProvider = (extensionId): ExtensionEventContext => { + const panes = createPaneControls(extensionId); + return { + cwd: extensions.context.cwd, + notify: (message, type) => extensions.context.notify(message, type), + panes, + sidebars: panes, + events: { + emit(event, payload) { + emitExtensionCustomEvent(extensions, event, payload); + }, }, - }, - }); + }; + }; } /** Invoke one extension command with its context, containing any failure. */ @@ -807,12 +817,14 @@ export function App({ "warning", ); }; + const panes = createPaneControls(registered.extensionId); const ctx: ExtensionCommandContext = { cwd: extensions?.context.cwd ?? process.cwd(), commands: extensionCommandControls, keyboardModes: createKeyboardModeControls(registered.extensionId, extensions?.registry), notify: (message, type) => extensions?.context.notify(message, type), - sidebars: createSidebarControls(registered.extensionId), + panes, + sidebars: panes, fileViews: createFileViewControls(registered.extensionId), // Snapshot semantics: built when the key fires, so the handler sees // where the review was at that moment, even if it awaits and the user @@ -859,7 +871,7 @@ export function App({ createExtensionDialogs, createFileViewControls, createKeyboardModeControls, - createSidebarControls, + createPaneControls, extensionCommandControls, createWorkspaceControls, extensions, @@ -897,14 +909,14 @@ export function App({ }), [registeredExtensionCommands, resolvedCommandKeys, runExtensionCommand], ); - // Sidebar views receive the dispatcher’s effective keys, including command + // Pane views receive the dispatcher’s effective keys, including command // conflicts, rather than independently resolving their default bindings. - const sidebarKeybindings = useMemo(() => { + const paneKeybindings = useMemo(() => { const effectiveKeys = new Map(resolvedCommandKeys); for (const command of extensionAppCommands.commands) { effectiveKeys.set(command.id, command.keys); } - return createExtensionSidebarKeybindings(effectiveKeys); + return createExtensionPaneKeybindings(effectiveKeys); }, [extensionAppCommands.commands, resolvedCommandKeys]); const reportedCommandConflictsRef = useRef(new Set()); useEffect(() => { @@ -982,7 +994,8 @@ export function App({ const bodyPadding = pagerMode ? 0 : BODY_PADDING; const bodyWidth = Math.max(0, terminal.width - bodyPadding); const responsiveLayout = resolveResponsiveLayout(layoutMode, terminal.width); - const canForceShowSidebar = bodyWidth >= SIDEBAR_MIN_WIDTH + DIVIDER_WIDTH + DIFF_MIN_WIDTH; + const canForceShowSidebar = + bodyWidth >= SIDEBAR_MIN_WIDTH + EXTENSION_PANE_DIVIDER_SIZE + DIFF_MIN_WIDTH; const sidebarAreaVisible = sidebarVisible && (responsiveLayout.showSidebar || (forceSidebarOpen && canForceShowSidebar)); const resolvedLayout = responsiveLayout.layout; @@ -997,36 +1010,92 @@ export function App({ } reportedLayoutRef.current = signature; }, [extensions, layoutMode, resolvedLayout]); - const sidebarLayout = useMemo( + const statusBarVisible = + focusArea === "filter" || + Boolean(review.filter) || + Boolean( + sessionNoticeText ?? + transientNoticeText ?? + noticeText ?? + fileViewModeHint ?? + keyboardModeHint, + ); + const bodyHeight = Math.max( + 0, + terminal.height - (showMenuBar ? 1 : 0) - (extensionToast ? 1 : 0) - (statusBarVisible ? 1 : 0), + ); + const failedFilesReplacement = sessionPanes.some( + (pane) => + paneOpenState.open.includes(pane.key) && + pane.registered.pane.replaces === HUNK_FILES_PANE_KEY && + paneAvailabilityQuarantineRef.current.has(pane.registered), + ); + const effectiveOpenPaneKeys = paneOpenState.open.filter((key) => { + const pane = sessionPanes.find((entry) => entry.key === key); + return sidebarAreaVisible || (pane?.placement !== "left" && pane?.placement !== "right"); + }); + if ( + failedFilesReplacement && + sidebarAreaVisible && + !effectiveOpenPaneKeys.includes(HUNK_FILES_PANE_KEY) + ) { + effectiveOpenPaneKeys.push(HUNK_FILES_PANE_KEY); + } + const paneLayout = useMemo( () => - sidebarAreaVisible - ? planSidebarLayout({ - views: sessionSidebarViews, - openKeys: sidebarOpenState.open, - widths: sidebarWidths, - defaultWidth: SIDEBAR_DEFAULT_WIDTH, - minWidth: SIDEBAR_MIN_WIDTH, - dividerWidth: DIVIDER_WIDTH, - bodyWidth, - diffMinWidth: DIFF_MIN_WIDTH, - }) - : { left: [], right: [], totalWidth: 0, leftWidth: 0 }, + planExtensionPanes({ + panes: sessionPanes, + openKeys: effectiveOpenPaneKeys, + sizes: paneSizes, + bodyWidth, + bodyHeight, + minReviewWidth: DIFF_MIN_WIDTH, + minReviewHeight: MIN_EXTENSION_REVIEW_HEIGHT, + currentLine: currentLinePaint, + retainCurrentLineKeys: currentLinePaintPending + ? retainedCurrentLinePaneKeysRef.current + : undefined, + availabilityContext: { + files: getExtensionFileViews(), + selectedFileId, + selectedHunkIndex, + }, + quarantined: paneAvailabilityQuarantineRef.current, + onAvailabilityError: (pane, error) => + extensions?.context.notify( + `Extension ${pane.registered.extensionId} pane "${pane.registered.pane.id}" availability failed • ${error instanceof Error ? error.message : String(error)}`, + "warning", + ), + }), [ + bodyHeight, bodyWidth, - DIFF_MIN_WIDTH, - DIVIDER_WIDTH, - SIDEBAR_DEFAULT_WIDTH, - SIDEBAR_MIN_WIDTH, - sessionSidebarViews, - sidebarAreaVisible, - sidebarOpenState.open, - sidebarWidths, + currentLinePaint, + currentLinePaintPending, + effectiveOpenPaneKeys.join("\0"), + extensions, + filteredFiles, + getExtensionFileViews, + paneFailureEpoch, + paneSizes, + selectedFileId, + selectedHunkIndex, + sessionPanes, ], ); - const renderSidebar = sidebarLayout.left.length + sidebarLayout.right.length > 0; - // DIFF_MIN_WIDTH reserves room while planning sidebars; the pane itself must - // still fit terminals narrower than that preferred minimum. - const diffPaneWidth = Math.max(0, bodyWidth - sidebarLayout.totalWidth); + useLayoutEffect(() => { + if (currentLinePaintPending) return; + retainedCurrentLinePaneKeysRef.current = new Set( + paneLayout.panes + .filter(({ pane }) => pane.registered.pane.currentLine === true) + .map(({ pane }) => pane.key), + ); + }, [currentLinePaintPending, paneLayout]); + const renderSidebar = paneLayout.panes.some( + ({ pane }) => pane.placement === "left" || pane.placement === "right", + ); + const diffPaneWidth = paneLayout.reviewBounds.width; + const diffPaneHeight = paneLayout.reviewBounds.height; const diffContentWidth = Math.max(0, diffPaneWidth - 2); // Mirrors toggleSidebar's reveal half: visible again, forced open when the // responsive layout alone would keep it hidden and the terminal has room. @@ -1097,13 +1166,22 @@ export function App({ ), [diffContentWidth, maxLineNumberDigits, resolvedLayout, showLineNumbers], ); - const isResizingSidebar = sidebarResize !== null; + const isResizingPane = paneResize !== null; useEffect(() => { - if (!renderSidebar) { - setSidebarResize(null); + if ( + paneResize && + !paneLayout.panes.some( + (planned) => + planned.pane.key === paneResize.key && + planned.pane.registered === paneResize.registered && + planned.pane.placement === paneResize.placement && + planned.divider !== undefined, + ) + ) { + setPaneResize(null); } - }, [renderSidebar]); + }, [paneLayout.panes, paneResize]); useEffect(() => { // Force an intermediate redraw when app geometry or row-wrapping changes so pane relayout @@ -1873,53 +1951,53 @@ export function App({ themeSelectorOpen: themeSelectorState.open, }); - /** Start a mouse drag resize for one sidebar pane's divider. */ - const beginSidebarResize = - (key: string, placement: SidebarPlacement, currentWidth: number) => (event: TuiMouseEvent) => { - if (event.button !== MouseButton.LEFT) { - return; - } - - closeMenu(); - setSidebarResize({ - key, - placement, - originX: event.x, - startWidth: currentWidth, - // The pane may grow by whatever the review stream can give up. - maxWidth: currentWidth + Math.max(0, diffPaneWidth - DIFF_MIN_WIDTH), - }); - event.preventDefault(); - event.stopPropagation(); - }; - - /** Update the dragged pane's width while a resize is active. */ - const updateSidebarResize = (event: TuiMouseEvent) => { - if (!sidebarResize) { - return; - } - - const { key, placement, originX, startWidth, maxWidth } = sidebarResize; - // A right-side pane's divider is its left edge, so the drag delta inverts: - // swapping origin and current feeds the same clamp the mirrored motion. - const nextWidth = - placement === "right" - ? resizeSidebarWidth(startWidth, event.x, originX, SIDEBAR_MIN_WIDTH, maxWidth) - : resizeSidebarWidth(startWidth, originX, event.x, SIDEBAR_MIN_WIDTH, maxWidth); - setSidebarWidths((current) => - current[key] === nextWidth ? current : { ...current, [key]: nextWidth }, - ); + /** Start a mouse drag for one resizable pane. */ + const beginPaneResize = (planned: PlannedPane) => (event: TuiMouseEvent) => { + if (event.button !== MouseButton.LEFT) return; + const vertical = planned.pane.placement === "left" || planned.pane.placement === "right"; + const spec = extensionPaneSize(planned.pane.registered.pane, planned.pane.placement); + const currentSize = vertical ? planned.bounds.width : planned.bounds.height; + closeMenu(); + setPaneResize({ + key: planned.pane.key, + registered: planned.pane.registered, + placement: planned.pane.placement, + origin: vertical ? event.x : event.y, + startSize: currentSize, + maxSize: Math.min( + spec.max ?? Number.MAX_SAFE_INTEGER, + currentSize + + Math.max( + 0, + vertical + ? diffPaneWidth - DIFF_MIN_WIDTH + : diffPaneHeight - MIN_EXTENSION_REVIEW_HEIGHT, + ), + ), + minSize: spec.min ?? 1, + }); event.preventDefault(); event.stopPropagation(); }; - /** End the current sidebar resize interaction. */ - const endSidebarResize = (event?: TuiMouseEvent) => { - if (!isResizingSidebar) { - return; - } + /** Update the active pane drag on its placement axis. */ + const updatePaneResize = (event: TuiMouseEvent) => { + if (!paneResize) return; + const { key, placement, origin, startSize, maxSize, minSize } = paneResize; + const vertical = placement === "left" || placement === "right"; + const position = vertical ? event.x : event.y; + const inverted = placement === "right" || placement === "bottom"; + const next = inverted + ? resizeSidebarWidth(startSize, position, origin, minSize, maxSize) + : resizeSidebarWidth(startSize, origin, position, minSize, maxSize); + setPaneSizes((current) => (current[key] === next ? current : { ...current, [key]: next })); + event.preventDefault(); + event.stopPropagation(); + }; - setSidebarResize(null); + const endPaneResize = (event?: TuiMouseEvent) => { + if (!isResizingPane) return; + setPaneResize(null); event?.preventDefault(); event?.stopPropagation(); }; @@ -1938,48 +2016,88 @@ export function App({ const diffHeaderStatsWidth = maxFileHeaderStatsWidth(filteredFiles); const diffHeaderLabelWidth = Math.max(0, diffContentWidth - diffHeaderStatsWidth - 1); const diffSeparatorWidth = Math.max(0, diffContentWidth - 2); - // Mirror the App layout: bodyPadding/2 left-padding, then every left pane - // plus its divider. Keep this in lockstep with the body container's - // paddingLeft and the sidebar render branch below. - const diffPaneScreenLeft = bodyPadding / 2 + sidebarLayout.leftWidth; - const diffPaneScreenTop = showMenuBar ? 1 : 0; - - /** Render one open sidebar view at its planned width. */ - const renderSidebarPane = (pane: SidebarPanePlan) => { - // Resolved here so hidden sidebars never pay for the conversion; the - // per-source cache hands every pane (and command snapshots) one list. - const paneSelection = getExtensionSelection(); + const diffPaneScreenLeft = bodyPadding / 2 + paneLayout.reviewBounds.x; + const diffPaneScreenTop = (showMenuBar ? 1 : 0) + paneLayout.reviewBounds.y; + + /** Render one pane from the exact accepted host rectangle. */ + const renderPane = (planned: PlannedPane) => { + const selection = getExtensionSelection(); + const { bounds, pane } = planned; return ( - extensions?.context.notify(message, type)} - onSelectFile={(fileId) => { - focusFiles(); - jumpToFile(fileId, 0, { alignFileHeaderTop: true }); - }} - onSelectHunk={(fileId, hunkIndex) => { - focusFiles(); - review.selectHunk(fileId, hunkIndex); + handleSidebarViewFailure(pane.view.key) - } - /> + > + extensions?.context.notify(message, type)} + onSelectFile={(fileId) => { + focusFiles(); + jumpToFile(fileId, 0, { alignFileHeaderTop: true }); + }} + onSelectHunk={(fileId, hunkIndex) => { + focusFiles(); + review.selectHunk(fileId, hunkIndex); + }} + onRenderFailure={ + pane.key === HUNK_FILES_PANE_KEY + ? undefined + : () => { + paneAvailabilityQuarantineRef.current.add(pane.registered); + if (pane.registered.pane.replaces === HUNK_FILES_PANE_KEY) { + revealSidebarAreaRef.current(); + } + setPaneFailureEpoch((value) => value + 1); + } + } + /> + ); }; + const renderDivider = (planned: PlannedPane) => + planned.divider ? ( + + + + ) : null; + return ( { - endSidebarResize(event); + endPaneResize(event); cancelCopySelectionRef.current?.(); }} onMouseUp={(event) => { - endSidebarResize(event); + endPaneResize(event); closeMenu(); cancelCopySelectionRef.current?.(); }} > - {sidebarLayout.left.map((pane, index) => { - // Each left pane is followed by its own draggable divider; the hit - // zone tracks the divider's absolute column inside the body row. - const paneLeft = - bodyPadding / 2 + - sidebarLayout.left - .slice(0, index) - .reduce((sum, previous) => sum + previous.width + DIVIDER_WIDTH, 0); - const dividerX = paneLeft + pane.width; - return ( - - {renderSidebarPane(pane)} - - - ); - })} - - { - scrollCodeHorizontally(delta * FAST_CODE_HORIZONTAL_SCROLL_COLUMNS); + {paneLayout.panes.map(renderPane)} + {paneLayout.panes.map(renderDivider)} + - review.selectHunk(fileId, hunkIndex, { preserveViewport: true }) - } - onLineCursorsChange={setLineCursors} - onViewportLineCursorChange={review.anchorLineCursor} - /> - - {sidebarLayout.right.map((pane, index) => { - // Right panes sit after the review stream; each is preceded by its - // divider, and dragging that divider left grows the pane. - const dividerX = - bodyPadding / 2 + - sidebarLayout.leftWidth + - diffPaneWidth + - sidebarLayout.right - .slice(0, index) - .reduce((sum, previous) => sum + previous.width + DIVIDER_WIDTH, 0); - return ( - - - {renderSidebarPane(pane)} - - ); - })} + > + { + scrollCodeHorizontally(delta * FAST_CODE_HORIZONTAL_SCROLL_COLUMNS); + }} + onCopyFeedback={showTransientNotice} + onFileViewRowFailure={reportFileViewRowFailure} + onSelectFile={jumpToFile} + onToggleGap={review.toggleGap} + onViewportCenteredHunkChange={(fileId, hunkIndex) => + review.selectHunk(fileId, hunkIndex, { preserveViewport: true }) + } + onLineCursorsChange={setLineCursors} + currentLinePaintRequested={currentLinePaintRequested} + onCurrentLinePaintChange={onCurrentLinePaintChange} + onViewportLineCursorChange={review.anchorLineCursor} + /> + {extensionToast ? ( @@ -2156,15 +2228,7 @@ export function App({ /> ) : null} - {focusArea === "filter" || - Boolean(review.filter) || - Boolean( - sessionNoticeText ?? - transientNoticeText ?? - noticeText ?? - fileViewModeHint ?? - keyboardModeHint, - ) ? ( + {statusBarVisible ? ( { ); await flushUntil( setup, - () => setup.captureCharFrame().includes("alpha.txt"), + () => setup.captureCharFrame().includes("M alpha.txt"), "the built-in sidebar to reopen after the crash", ); }); }); + test("reevaluates pane availability when filtering changes visible files", async () => { + const repo = createTestRepo("hunk-ext-pane-availability-"); + const extPath = join(createTempDir("hunk-ext-pane-availability-ext-"), "ext.ts"); + writeFileSync( + extPath, + `import { createElement } from "react";\n` + + `export default function (hunk) {\n` + + ` hunk.registerPane({\n` + + ` id: "two-files",\n` + + ` placement: "bottom",\n` + + ` height: { preferred: 1, min: 1, max: 1 },\n` + + ` defaultOpen: true,\n` + + ` available: ({ files }) => files.length === 2,\n` + + ` component: () => createElement("text", { content: "TWO FILE PANE" }),\n` + + ` });\n` + + `}\n`, + ); + + const bootstrap = await launchWithExtension(repo, extPath); + await withAppHost(bootstrap, async (setup) => { + await flushUntil( + setup, + () => setup.captureCharFrame().includes("TWO FILE PANE"), + "the pane to be available for both visible files", + ); + + await act(async () => { + await setup.mockInput.typeText("/"); + }); + await flush(setup); + await act(async () => { + await setup.mockInput.typeText("alpha"); + }); + await flushUntil( + setup, + () => !setup.captureCharFrame().includes("TWO FILE PANE"), + "the pane availability policy to observe the filtered file list", + ); + }); + }); + test("the documented scrollbox ref contract follows the selection from a fixture sidebar", async () => { // Enough changed files that the fixture pane's list overflows its viewport: // the last row is only visible if `scrollChildIntoView` actually scrolled. diff --git a/src/ui/AppHost.sidebar-resize.test.tsx b/src/ui/AppHost.sidebar-resize.test.tsx index fc626f89e..d9df3532e 100644 --- a/src/ui/AppHost.sidebar-resize.test.tsx +++ b/src/ui/AppHost.sidebar-resize.test.tsx @@ -4,6 +4,7 @@ import { act } from "react"; import type { AppBootstrap } from "../core/types"; import { createTestVcsAppBootstrap } from "../../test/helpers/app-bootstrap"; import { createTestDiffFile as buildTestDiffFile, lines } from "../../test/helpers/diff-helpers"; +import { createEmptyExtensionLoadResult } from "../extensions/types"; const { AppHost } = await import("./AppHost"); @@ -40,6 +41,22 @@ function createResizeBootstrap(): AppBootstrap { }); } +/** Add one resizable top pane through the same registry user extensions populate. */ +function createTopPaneResizeBootstrap(): AppBootstrap { + const extensions = createEmptyExtensionLoadResult(); + extensions.registry.panes.push({ + extensionId: "resize-test", + pane: { + id: "top", + placement: "top", + defaultOpen: true, + height: { preferred: 4, min: 2, max: 8 }, + component: ({ width, height }) => , + }, + }); + return { ...createResizeBootstrap(), extensions }; +} + /** Drive one or two render passes so pending state commits land before assertions. */ async function flush(setup: Awaited>) { await act(async () => { @@ -79,6 +96,27 @@ async function dragDivider( await flush(setup); } +/** Drag a horizontal divider on its row axis. */ +async function dragHorizontalDivider( + setup: Awaited>, + fromY: number, + toY: number, +) { + const x = Math.floor(WIDE.width / 2); + await act(async () => { + await setup.mockMouse.pressDown(x, fromY); + }); + await flush(setup); + await act(async () => { + await setup.mockMouse.moveTo(x, toY); + }); + await flush(setup); + await act(async () => { + await setup.mockMouse.release(x, toY); + }); + await flush(setup); +} + let setup: Awaited> | null = null; beforeEach(() => { @@ -112,6 +150,17 @@ describe("AppHost sidebar resize", () => { expect(dividerColumn(setup)).toBe(23); }); + test("dragging a horizontal divider resizes a top pane on the row axis", async () => { + setup = await testRender(, WIDE); + await flush(setup); + expect(setup.captureCharFrame()).toContain("TOP PANE 203x4"); + + // Menu row 0, four pane rows 1-4, divider row 5. + await dragHorizontalDivider(setup, 5, 8); + + expect(setup.captureCharFrame()).toContain("TOP PANE 203x7"); + }); + test("a mouse release with no active drag leaves the layout unchanged", async () => { setup = await testRender(, WIDE); await flush(setup); diff --git a/src/ui/components/panes/DiffPane.tsx b/src/ui/components/panes/DiffPane.tsx index d7fe42e95..03afb8e58 100644 --- a/src/ui/components/panes/DiffPane.tsx +++ b/src/ui/components/panes/DiffPane.tsx @@ -53,6 +53,7 @@ import { measureDiffSectionGeometry, type DiffSectionGeometry, } from "../../diff/diffSectionGeometry"; +import type { DiffSectionRowPlan } from "../../diff/diffSectionRowPlan"; import { createReviewMouseWheelScrollAcceleration } from "../../lib/scrollAcceleration"; import { buildFileSectionLayouts, @@ -74,6 +75,10 @@ import type { AppTheme } from "../../themes"; import { DiffSection } from "./DiffSection"; import type { FileViewRowFailure } from "../../fileViews/types"; import { DiffFileHeaderRow } from "./DiffFileHeaderRow"; +import { + createExtensionCurrentLinePaint, + type ExtensionCurrentLinePaintUpdate, +} from "../../lib/extensionCurrentLine"; import { VerticalScrollbar, type VerticalScrollbarHandle } from "../scrollbar/VerticalScrollbar"; import type { VisibleBodyBounds } from "../../diff/rowWindowing"; import type { ResolvedFileViewLayout } from "../../fileViews/useFileViews"; @@ -237,6 +242,7 @@ export function DiffPane({ selectedHunkRevealRequestId, theme, width, + height, cancelCopySelectionRef, onActiveAddNoteAffordanceChange, onRemoveUserNote, @@ -253,6 +259,8 @@ export function DiffPane({ onSelectFile, onToggleGap = NOOP_TOGGLE_GAP, onLineCursorsChange, + currentLinePaintRequested = false, + onCurrentLinePaintChange, onViewportCenteredHunkChange, onViewportLineCursorChange, }: { @@ -294,6 +302,7 @@ export function DiffPane({ selectedHunkRevealRequestId?: number; theme: AppTheme; width: number; + height?: number; cancelCopySelectionRef?: RefObject<(() => void) | null>; onActiveAddNoteAffordanceChange?: ( affordance: (ActiveAddNoteAffordance & { fileId: string }) | null, @@ -312,6 +321,8 @@ export function DiffPane({ onSelectFile: (fileId: string) => void; onToggleGap?: (fileId: string, gapKey: string) => void; onLineCursorsChange?: (cursors: LineCursor[]) => void; + currentLinePaintRequested?: boolean; + onCurrentLinePaintChange?: (update: ExtensionCurrentLinePaintUpdate) => void; onViewportCenteredHunkChange?: (fileId: string, hunkIndex: number) => void; onViewportLineCursorChange?: (cursor: LineCursor) => void; }) { @@ -321,6 +332,11 @@ export function DiffPane({ () => createReviewMouseWheelScrollAcceleration(), [], ); + const [currentLineRowPlan, setCurrentLineRowPlan] = useState<{ + source: { file: DiffFile; theme: AppTheme; tabWidth: number }; + rowPlan: DiffSectionRowPlan; + highlighted: boolean; + } | null>(null); const [addNoteHoverClearSignal, setAddNoteHoverClearSignal] = useState(0); const [addNoteHoverClearFileId, setAddNoteHoverClearFileId] = useState(null); const hoveredFileIdRef = useRef(null); @@ -953,6 +969,99 @@ export function DiffPane({ [cursorLine, renderedLineCursor], ); + // Current-line paint closes over the exact accepted renderer plan. It remains opaque to + // extensions and never introduces another highlight request, cache, or cursor model. + const currentLinePaintFile = useMemo(() => { + if ( + !currentLinePaintRequested || + layout !== "split" || + cursorLine === "off" || + !renderedLineCursor || + pagerMode || + fileViewRenderPlans.has(renderedLineCursor.fileId) + ) + return undefined; + const sectionIndex = fileSectionIndexById.get(renderedLineCursor.fileId); + return sectionIndex === undefined ? undefined : files[sectionIndex]; + }, [ + currentLinePaintRequested, + cursorLine, + fileSectionIndexById, + fileViewRenderPlans, + files, + layout, + pagerMode, + renderedLineCursor, + ]); + + const currentLinePaintSource = useMemo( + () => (currentLinePaintFile ? { file: currentLinePaintFile, theme, tabWidth } : null), + [currentLinePaintFile, tabWidth, theme], + ); + + const currentLineRowPlanCallback = useMemo(() => { + if (!currentLinePaintSource) return undefined; + return (rowPlan: DiffSectionRowPlan, highlighted: boolean) => { + setCurrentLineRowPlan((current) => + current?.source === currentLinePaintSource && + current.rowPlan === rowPlan && + current.highlighted === highlighted + ? current + : { source: currentLinePaintSource, rowPlan, highlighted }, + ); + }; + }, [currentLinePaintSource]); + + const currentLinePaint = useMemo(() => { + if ( + !currentLinePaintSource || + !renderedLineCursor || + !currentLineRowPlan?.highlighted || + currentLineRowPlan.source !== currentLinePaintSource + ) + return null; + return createExtensionCurrentLinePaint({ + cursor: renderedLineCursor, + rowPlan: currentLineRowPlan.rowPlan, + showLineNumbers, + codeHorizontalOffset, + theme, + }); + }, [ + codeHorizontalOffset, + currentLinePaintSource, + currentLineRowPlan, + renderedLineCursor, + showLineNumbers, + theme, + ]); + + const currentLinePaintUpdate = useMemo(() => { + if (!currentLinePaintSource) return { status: "unavailable" }; + if ( + !renderedLineCursor || + !currentLineRowPlan?.highlighted || + currentLineRowPlan.source !== currentLinePaintSource + ) + return { status: "pending" }; + return currentLinePaint + ? { + status: "ready", + fileId: renderedLineCursor.fileId, + cursorKey: renderedLineCursor.stableKey, + paint: currentLinePaint, + } + : { status: "unavailable" }; + }, [currentLinePaint, currentLinePaintSource, currentLineRowPlan, renderedLineCursor]); + + useLayoutEffect(() => { + onCurrentLinePaintChange?.(currentLinePaintUpdate); + }, [currentLinePaintUpdate, onCurrentLinePaintChange]); + useLayoutEffect( + () => () => onCurrentLinePaintChange?.({ status: "unavailable" }), + [onCurrentLinePaintChange], + ); + const copySelectedRowKeysByFile = useMemo( () => buildCopySelectedRowKeys({ @@ -2098,6 +2207,7 @@ export function DiffPane({ ) : null} - - - + + - {fileRenderItems.map((item) => { - if (item.kind === "spacer") { + + {fileRenderItems.map((item) => { + if (item.kind === "spacer") { + return ( + + ); + } + + const { sectionIndex: index } = item; + const file = files[index]; + if (!file) { + return null; + } + return ( - 0} + showLineNumbers={showLineNumbers} + showHunkHeaders={showHunkHeaders} + sourceStatus={sourceStatusByFileId[file.id]} + tabWidth={tabWidth} + wrapLines={wrapLines} + theme={theme} + hoverActive={hoveredFileId === null || hoveredFileId === file.id} + hoverClearSignal={ + addNoteHoverClearFileId === file.id ? addNoteHoverClearSignal : 0 + } + viewWidth={diffContentWidth} + visibleAgentNotes={ + visibleAgentNotesByFile.get(file.id) ?? EMPTY_VISIBLE_AGENT_NOTES + } + visibleBodyBounds={visibleBodyBoundsByFile.get(file.id)} + onHover={() => setHoveredFileForRowActions(file.id)} + onMouseScroll={clearAddNoteHoverForScroll} + onFileViewRowFailure={onFileViewRowFailure} + onActiveAddNoteAffordanceChange={ + onActiveAddNoteAffordanceChange + ? activeAddNoteAffordanceCallback(file.id) + : undefined + } + onStartUserNoteAtHunk={ + reserveAddNoteColumn ? startUserNoteAtHunkCallback(file.id) : undefined + } + onRowPlanChange={ + file.id === currentLinePaintFile?.id + ? currentLineRowPlanCallback + : undefined + } + onSelect={selectFileCallback(file.id)} + onToggleGap={(gapKey) => onToggleGap(file.id, gapKey)} /> ); - } - - const { sectionIndex: index } = item; - const file = files[index]; - if (!file) { - return null; - } - - return ( - 0} - showLineNumbers={showLineNumbers} - showHunkHeaders={showHunkHeaders} - sourceStatus={sourceStatusByFileId[file.id]} - tabWidth={tabWidth} - wrapLines={wrapLines} - theme={theme} - hoverActive={hoveredFileId === null || hoveredFileId === file.id} - hoverClearSignal={ - addNoteHoverClearFileId === file.id ? addNoteHoverClearSignal : 0 - } - viewWidth={diffContentWidth} - visibleAgentNotes={ - visibleAgentNotesByFile.get(file.id) ?? EMPTY_VISIBLE_AGENT_NOTES - } - visibleBodyBounds={visibleBodyBoundsByFile.get(file.id)} - onHover={() => setHoveredFileForRowActions(file.id)} - onMouseScroll={clearAddNoteHoverForScroll} - onFileViewRowFailure={onFileViewRowFailure} - onActiveAddNoteAffordanceChange={ - onActiveAddNoteAffordanceChange - ? activeAddNoteAffordanceCallback(file.id) - : undefined - } - onStartUserNoteAtHunk={ - reserveAddNoteColumn ? startUserNoteAtHunkCallback(file.id) : undefined - } - onSelect={selectFileCallback(file.id)} - onToggleGap={(gapKey) => onToggleGap(file.id, gapKey)} - /> - ); - })} - - - + })} + + + + ) : ( diff --git a/src/ui/components/panes/DiffSection.tsx b/src/ui/components/panes/DiffSection.tsx index 87ae6c123..509d5790f 100644 --- a/src/ui/components/panes/DiffSection.tsx +++ b/src/ui/components/panes/DiffSection.tsx @@ -5,6 +5,7 @@ import { PierreDiffView, type ActiveAddNoteAffordance } from "../../diff/PierreD import type { CursorHighlight } from "../../diff/renderRows"; import type { VisibleBodyBounds } from "../../diff/rowWindowing"; import type { DiffSectionGeometry } from "../../diff/diffSectionGeometry"; +import type { DiffSectionRowPlan } from "../../diff/diffSectionRowPlan"; import type { VisibleAgentNote } from "../../lib/agentAnnotations"; import type { CopySelectedRowRange } from "./copySelection"; import { diffSectionId } from "../../lib/ids"; @@ -48,6 +49,7 @@ interface DiffSectionProps { onFileViewRowFailure?: (failure: FileViewRowFailure) => void; onActiveAddNoteAffordanceChange?: (affordance: ActiveAddNoteAffordance | null) => void; onStartUserNoteAtHunk?: (hunkIndex: number, target?: UserNoteLineTarget) => void; + onRowPlanChange?: (rowPlan: DiffSectionRowPlan, highlighted: boolean) => void; onSelect: () => void; onToggleGap: (gapKey: string) => void; } @@ -86,6 +88,7 @@ function DiffSectionComponent({ onFileViewRowFailure, onActiveAddNoteAffordanceChange, onStartUserNoteAtHunk, + onRowPlanChange, onSelect, onToggleGap, }: DiffSectionProps) { @@ -170,6 +173,7 @@ function DiffSectionComponent({ onHover={onHover} onActiveAddNoteAffordanceChange={onActiveAddNoteAffordanceChange} onStartUserNoteAtHunk={onStartUserNoteAtHunk} + onRowPlanChange={onRowPlanChange} onToggleGap={onToggleGap} selectedHunkIndex={selectedHunkIndex} sectionGeometry={sectionGeometry} @@ -216,6 +220,7 @@ export const DiffSection = memo(DiffSectionComponent, (previous, next) => { previous.onFileViewRowFailure === next.onFileViewRowFailure && previous.onActiveAddNoteAffordanceChange === next.onActiveAddNoteAffordanceChange && previous.onStartUserNoteAtHunk === next.onStartUserNoteAtHunk && + previous.onRowPlanChange === next.onRowPlanChange && previous.theme === next.theme && previous.visibleAgentNotes === next.visibleAgentNotes && previous.visibleBodyBounds === next.visibleBodyBounds && diff --git a/src/ui/components/panes/ExtensionSidebarPane.test.tsx b/src/ui/components/panes/ExtensionPane.test.tsx similarity index 69% rename from src/ui/components/panes/ExtensionSidebarPane.test.tsx rename to src/ui/components/panes/ExtensionPane.test.tsx index 01b7dc2bc..ededa92e0 100644 --- a/src/ui/components/panes/ExtensionSidebarPane.test.tsx +++ b/src/ui/components/panes/ExtensionPane.test.tsx @@ -3,24 +3,24 @@ import { testRender } from "@opentui/react/test-utils"; import { act, useState, type ReactNode } from "react"; import { createTestDiffFile } from "../../../../test/helpers/diff-helpers"; import type { - ExtensionSidebarActions, - ExtensionSidebarKeybindings, - ExtensionSidebarViewProps, + ExtensionPaneActions, + ExtensionPaneKeybindings, + ExtensionPaneProps, } from "../../../extension-api/types"; import { toReadOnlyFileViews } from "../../../extensions/events"; -import type { RegisteredSidebarView } from "../../../extensions/types"; +import type { RegisteredPane } from "../../../extensions/types"; import { resolveTheme } from "../../themes"; -import { ExtensionSidebarPane } from "./ExtensionSidebarPane"; +import { ExtensionPaneHost } from "./ExtensionPane"; /** One registration object, the way each extension load pass produces a fresh one. */ -function registeredView(component: (props: ExtensionSidebarViewProps) => ReactNode) { +function registeredView(component: (props: ExtensionPaneProps) => ReactNode) { return { extensionId: "probe", - view: { id: "probe-view", component }, - } as RegisteredSidebarView; + pane: { id: "probe-view", placement: "left", width: { preferred: 34, min: 22 }, component }, + } as unknown as RegisteredPane; } -const TEST_KEYBINDINGS: ExtensionSidebarKeybindings = { +const TEST_KEYBINDINGS: ExtensionPaneKeybindings = { matches: () => false, getKeys: () => [], }; @@ -61,16 +61,16 @@ async function withPane( } } -describe("ExtensionSidebarPane actions", () => { +describe("ExtensionPaneHost actions", () => { test("refuses garbage hunk indices and clamps the rest into the file's range", async () => { const files = createTestFiles(); const theme = resolveTheme("github-dark-default", null); const notifications: string[] = []; const hunkSelections: Array<[string, number]> = []; - let actions: ExtensionSidebarActions | undefined; + let actions: ExtensionPaneActions | undefined; await withPane( - { actions = props.actions; return ; @@ -82,6 +82,9 @@ describe("ExtensionSidebarPane actions", () => { showTopChrome={true} theme={theme} width={30} + height={100} + placement="left" + currentLine={null} keybindings={TEST_KEYBINDINGS} notify={(message) => notifications.push(message)} onSelectFile={() => {}} @@ -119,7 +122,51 @@ describe("ExtensionSidebarPane actions", () => { }); }); -describe("ExtensionSidebarPane failure recovery", () => { +describe("ExtensionPaneHost failure recovery", () => { + test("the bundled files pane has a renderer-independent safe fallback", async () => { + const files = createTestFiles(); + const theme = resolveTheme("github-dark-default", null); + const notifications: string[] = []; + const registered: RegisteredPane = { + extensionId: "hunk", + pane: { + id: "files", + placement: "left", + width: { preferred: 34, min: 22 }, + component: () => { + throw new Error("files exploded"); + }, + }, + }; + + await withPane( + notifications.push(message)} + onSelectFile={() => {}} + onSelectHunk={() => {}} + />, + async (setup) => { + expect(setup.captureCharFrame()).toContain("Files pane unavailable"); + expect(notifications.some((line) => line.includes("failed rendering"))).toBe(true); + expect(notifications.some((line) => line.includes("using the built-in files pane"))).toBe( + false, + ); + }, + ); + }); + test("a fresh registration clears the failed boundary under unchanged ids", async () => { const files = createTestFiles(); const theme = resolveTheme("github-dark-default", null); @@ -131,12 +178,12 @@ describe("ExtensionSidebarPane failure recovery", () => { // extension produces, and what the id-keyed remount above cannot detect. const fixed = registeredView(() => ); - let swapRegistered: ((next: RegisteredSidebarView) => void) | undefined; + let swapRegistered: ((next: RegisteredPane) => void) | undefined; function Harness() { const [registered, setRegistered] = useState(broken); swapRegistered = setRegistered; return ( - { showTopChrome={true} theme={theme} width={30} + height={100} + placement="left" + currentLine={null} keybindings={TEST_KEYBINDINGS} notify={(message) => notifications.push(message)} onSelectFile={() => {}} @@ -157,6 +207,9 @@ describe("ExtensionSidebarPane failure recovery", () => { // The broken view fell back to the built-in sidebar and warned once. expect(setup.captureCharFrame()).toContain("alpha.ts"); expect(notifications.some((line) => line.includes("failed rendering"))).toBe(true); + expect(notifications.some((line) => line.includes("using the built-in files pane"))).toBe( + true, + ); await act(async () => { swapRegistered?.(fixed); diff --git a/src/ui/components/panes/ExtensionPane.tsx b/src/ui/components/panes/ExtensionPane.tsx new file mode 100644 index 000000000..21f35b48f --- /dev/null +++ b/src/ui/components/panes/ExtensionPane.tsx @@ -0,0 +1,184 @@ +import { Component, memo, useMemo, type ReactNode } from "react"; +import type { + ExtensionDiffFile, + ExtensionNotifyType, + ExtensionPaneActions, + ExtensionPaneKeybindings, + ExtensionPaneProps, + ExtensionCurrentLinePaint, +} from "../../../extension-api/types"; +import type { DiffFile } from "../../../core/types"; +import { paneKey } from "../../../extensions/apply"; +import { BuiltInSidebarView } from "../../../extensions/default/ui/sidebar"; +import { HUNK_FILES_PANE_KEY } from "../../../extensions/extensionIds"; +import type { ExtensionNotifySink, RegisteredPane } from "../../../extensions/types"; +import { createGuardedReviewNavigation } from "../../lib/extensionNavigation"; +import { toExtensionPaintTheme } from "../../lib/extensionPaintTheme"; +import type { AppTheme } from "../../themes"; + +function describeError(error: unknown) { + return error instanceof Error ? error.message || error.name : String(error); +} + +/** Contain render failures to one registration identity. */ +class ExtensionPaneErrorBoundary extends Component< + { + registered: RegisteredPane; + fallback: ReactNode; + onError: (error: unknown) => void; + children: ReactNode; + }, + { failed: boolean; registered: RegisteredPane | null } +> { + override state = { failed: false, registered: null as RegisteredPane | null }; + static getDerivedStateFromError() { + return { failed: true }; + } + static getDerivedStateFromProps( + props: { registered: RegisteredPane }, + state: { failed: boolean; registered: RegisteredPane | null }, + ) { + return props.registered !== state.registered + ? { registered: props.registered, failed: false } + : null; + } + override componentDidCatch(error: unknown) { + this.props.onError(error); + } + override render() { + return this.state.failed ? this.props.fallback : this.props.children; + } +} + +export interface ExtensionPaneHostProps { + registered: RegisteredPane; + files: DiffFile[]; + fileViews: ExtensionDiffFile[]; + selectedFileId: string | null; + selectedHunkIndex: number | null; + placement: ExtensionPaneProps["placement"]; + theme: AppTheme; + width: number; + height: number; + currentLine: ExtensionCurrentLinePaint | null; + showTopChrome?: boolean; + keybindings: ExtensionPaneKeybindings; + notify: ExtensionNotifySink; + onSelectFile: (fileId: string) => void; + onSelectHunk: (fileId: string, hunkIndex: number) => void; + onRenderFailure?: () => void; +} + +/** Mount a public pane component inside the exact rectangle planned by the host. */ +function ExtensionPaneHostView({ + registered, + files, + fileViews, + selectedFileId, + selectedHunkIndex, + placement, + theme, + width, + height, + currentLine, + showTopChrome = false, + keybindings, + notify, + onSelectFile, + onSelectHunk, + onRenderFailure, +}: ExtensionPaneHostProps) { + const { extensionId } = registered; + const publicTheme = useMemo(() => toExtensionPaintTheme(theme), [theme]); + const actions = useMemo( + () => + Object.freeze({ + ...createGuardedReviewNavigation({ + extensionId, + getFiles: () => files, + notify, + onSelectFile, + onSelectHunk, + }), + notify(message: string, type: ExtensionNotifyType = "info") { + notify(`${extensionId}: ${message}`, type); + }, + }), + [extensionId, files, notify, onSelectFile, onSelectHunk], + ); + const View = registered.pane.component as (props: ExtensionPaneProps) => ReactNode; + const viewProps: ExtensionPaneProps = { + files: fileViews, + selectedFileId, + selectedHunkIndex, + placement, + width, + height, + theme: publicTheme, + keybindings, + actions, + currentLine: registered.pane.currentLine ? currentLine : null, + }; + const filesChrome = paneKey(registered) === HUNK_FILES_PANE_KEY; + const box = (children: ReactNode) => ( + + {children} + + ); + const fallback = onRenderFailure + ? null + : filesChrome + ? box(Files pane unavailable) + : box(); + return ( + { + const fallbackNotice = + onRenderFailure || filesChrome ? "" : " • using the built-in files pane"; + notify( + `Extension ${extensionId} pane "${registered.pane.id}" failed rendering • ${describeError(error)}${fallbackNotice}`, + "warning", + ); + onRenderFailure?.(); + }} + > + {box()} + + ); +} + +/** Avoid repainting panes that did not opt into current-line updates. */ +export const ExtensionPaneHost = memo( + ExtensionPaneHostView, + (previous, next) => + previous.registered === next.registered && + previous.files.length === next.files.length && + previous.files.every((file, index) => file === next.files[index]) && + previous.selectedFileId === next.selectedFileId && + previous.selectedHunkIndex === next.selectedHunkIndex && + previous.placement === next.placement && + previous.theme === next.theme && + previous.width === next.width && + previous.height === next.height && + previous.showTopChrome === next.showTopChrome && + previous.keybindings === next.keybindings && + (!next.registered.pane.currentLine || previous.currentLine === next.currentLine), +); diff --git a/src/ui/components/panes/ExtensionSidebarPane.tsx b/src/ui/components/panes/ExtensionSidebarPane.tsx deleted file mode 100644 index 83f1fe092..000000000 --- a/src/ui/components/panes/ExtensionSidebarPane.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import { Component, useMemo, type ReactNode } from "react"; -import type { - ExtensionDiffFile, - ExtensionNotifyType, - ExtensionSidebarActions, - ExtensionSidebarKeybindings, - ExtensionSidebarViewProps, -} from "../../../extension-api/types"; -import { BuiltInSidebarView } from "../../../extensions/default/ui/sidebar"; -import type { ExtensionNotifySink, RegisteredSidebarView } from "../../../extensions/types"; -import type { DiffFile } from "../../../core/types"; -import { createGuardedReviewNavigation } from "../../lib/extensionNavigation"; -import type { AppTheme } from "../../themes"; -import { toExtensionPaintTheme } from "../../lib/extensionPaintTheme"; - -/** Read an error's message without assuming extension components throw `Error` instances. */ -function describeError(error: unknown) { - if (error instanceof Error) { - return error.message || error.name; - } - - return String(error); -} - -/** - * Contain one extension component's render failures to the sidebar. - * - * The isolation contract promises a misbehaving extension costs a warning, not - * the session: a throw during render lands here instead of unwinding the whole - * app tree, the extension is named once, and the built-in sidebar takes over. - * - * The failure is scoped to the *registration*, not the session: every - * extension load pass registers a fresh `RegisteredSidebarView` object, so a - * reload that ships a fixed component arrives as a new identity — even under - * the same extension and view ids — and clears the failed state to give it a - * real chance instead of leaving the fallback pinned for the session. - */ -class ExtensionSidebarErrorBoundary extends Component< - { - registered: RegisteredSidebarView; - fallback: ReactNode; - onError: (error: unknown) => void; - children: ReactNode; - }, - { failed: boolean; registered: RegisteredSidebarView | null } -> { - override state = { failed: false, registered: null as RegisteredSidebarView | null }; - - static getDerivedStateFromError() { - return { failed: true }; - } - - static getDerivedStateFromProps( - props: { registered: RegisteredSidebarView }, - state: { failed: boolean; registered: RegisteredSidebarView | null }, - ) { - if (props.registered !== state.registered) { - return { registered: props.registered, failed: false }; - } - - return null; - } - - override componentDidCatch(error: unknown) { - this.props.onError(error); - } - - override render() { - return this.state.failed ? this.props.fallback : this.props.children; - } -} - -/** - * Mount the active sidebar view — bundled or extension-contributed. - * - * The host stays the authority on layout: this renders inside the exact box - * the sidebar occupies — width, border, and panel surface — and only the - * contents come from the view component. Everything handed to the component is - * either a frozen view or a guarded callback, so the review model cannot be - * corrupted from inside a custom sidebar. The built-in sidebar takes this - * exact path too: it is a bundled extension consuming these same props, which - * is what keeps them sufficient for third-party sidebars. - */ -export function ExtensionSidebarPane({ - registered, - files, - fileViews, - selectedFileId, - selectedHunkIndex, - showTopChrome, - theme, - width, - keybindings, - notify, - onSelectFile, - onSelectHunk, - onRenderFailure, -}: { - registered: RegisteredSidebarView; - /** - * The visible review-stream files, already filtered like the built-in - * sidebar's. Host-side only: the guarded actions validate navigation targets - * against it, while the component sees `fileViews`. - */ - files: DiffFile[]; - /** - * The same files as frozen read-only views, converted once by the host. - * - * Passed in rather than derived here so sidebar props and the selection - * command handlers receive come out of one conversion. - */ - fileViews: ExtensionDiffFile[]; - selectedFileId: string | null; - selectedHunkIndex: number | null; - showTopChrome: boolean; - theme: AppTheme; - width: number; - keybindings: ExtensionSidebarKeybindings; - notify: ExtensionNotifySink; - onSelectFile: (fileId: string) => void; - onSelectHunk: (fileId: string, hunkIndex: number) => void; - /** - * Called when the view fails rendering, in place of the in-pane fallback. - * - * With several panes open, a crashed extra pane should close rather than - * turn into a second copy of the built-in file navigation; the host owns - * that policy, so it owns this callback. - */ - onRenderFailure?: () => void; -}) { - const { extensionId } = registered; - const publicTheme = useMemo(() => toExtensionPaintTheme(theme), [theme]); - - const actions = useMemo( - () => - Object.freeze({ - // The same guarded navigation a command handler's `navigation` uses, - // so a sidebar row click and a command jump enforce one contract. - ...createGuardedReviewNavigation({ - extensionId, - getFiles: () => files, - notify, - onSelectFile, - onSelectHunk, - }), - notify(message: string, type: ExtensionNotifyType = "info") { - notify(`${extensionId}: ${message}`, type); - }, - }), - [extensionId, files, notify, onSelectFile, onSelectHunk], - ); - - // The published contract types the component's return opaquely (`unknown`) - // because the contract module carries no React types; inside the host it is - // an ordinary function component rendered in Hunk's own tree. - const View = registered.view.component as (props: ExtensionSidebarViewProps) => ReactNode; - - const viewProps: ExtensionSidebarViewProps = { - files: fileViews, - selectedFileId, - selectedHunkIndex, - width, - theme: publicTheme, - keybindings, - actions, - }; - - /** The pane chrome the host owns, whichever component fills it. */ - const paneBox = (children: ReactNode) => ( - - {children} - - ); - - return ( - )} - onError={(error) => { - notify( - `Extension ${extensionId} sidebar view "${registered.view.id}" failed rendering • ` + - `${describeError(error)}${onRenderFailure ? "" : " • using the built-in sidebar"}`, - "warning", - ); - onRenderFailure?.(); - }} - > - {paneBox()} - - ); -} diff --git a/src/ui/components/panes/PaneDivider.tsx b/src/ui/components/panes/PaneDivider.tsx index c6a879ad8..e1f3c69df 100644 --- a/src/ui/components/panes/PaneDivider.tsx +++ b/src/ui/components/panes/PaneDivider.tsx @@ -1,10 +1,14 @@ import type { MouseEvent as TuiMouseEvent } from "@opentui/core"; import type { AppTheme } from "../../themes"; -/** Render the visible divider plus a wider invisible drag target. */ +const PANE_DIVIDER_HIT_AREA_SIZE = 5; +const PANE_DIVIDER_HIT_AREA_OFFSET = Math.floor(PANE_DIVIDER_HIT_AREA_SIZE / 2); + +/** Render a one-cell pane divider with a larger pointer target on either axis. */ export function PaneDivider({ - dividerHitLeft, - dividerHitWidth, + orientation, + width, + height, isResizing, theme, onMouseDown, @@ -12,8 +16,9 @@ export function PaneDivider({ onMouseDragEnd, onMouseUp, }: { - dividerHitLeft: number; - dividerHitWidth: number; + orientation: "vertical" | "horizontal"; + width: number; + height: number; isResizing: boolean; theme: AppTheme; onMouseDown: (event: TuiMouseEvent) => void; @@ -21,20 +26,41 @@ export function PaneDivider({ onMouseDragEnd: (event: TuiMouseEvent) => void; onMouseUp: (event: TuiMouseEvent) => void; }) { + const handlers = { onMouseDown, onMouseDrag, onMouseUp, onMouseDragEnd }; + const hitAreaStyle = + orientation === "vertical" + ? { + position: "absolute" as const, + left: -PANE_DIVIDER_HIT_AREA_OFFSET, + top: 0, + width: PANE_DIVIDER_HIT_AREA_SIZE, + height, + zIndex: 30, + } + : { + position: "absolute" as const, + left: 0, + top: -PANE_DIVIDER_HIT_AREA_OFFSET, + width, + height: PANE_DIVIDER_HIT_AREA_SIZE, + zIndex: 30, + }; return ( <> - - + ); } diff --git a/src/ui/diff/PierreDiffView.tsx b/src/ui/diff/PierreDiffView.tsx index 8cf47cc9e..cbceba6d7 100644 --- a/src/ui/diff/PierreDiffView.tsx +++ b/src/ui/diff/PierreDiffView.tsx @@ -11,7 +11,7 @@ import type { AppTheme } from "../themes"; import { type FileSourceStatus } from "./expandCollapsedRows"; import { spansForHighlightedSourceLine, type DiffRow } from "./pierre"; import { plannedReviewRowVisible } from "./plannedReviewRows"; -import { buildDiffSectionRowPlan } from "./diffSectionRowPlan"; +import { buildDiffSectionRowPlan, type DiffSectionRowPlan } from "./diffSectionRowPlan"; import { resolveVisiblePlannedRowWindow, type VisibleBodyBounds } from "./rowWindowing"; import { diffMessage, @@ -76,6 +76,7 @@ export function PierreDiffView({ onHover, onActiveAddNoteAffordanceChange, onStartUserNoteAtHunk, + onRowPlanChange, onToggleGap, showLineNumbers = true, showHunkHeaders = true, @@ -104,6 +105,7 @@ export function PierreDiffView({ onHover?: () => void; onActiveAddNoteAffordanceChange?: (affordance: ActiveAddNoteAffordance | null) => void; onStartUserNoteAtHunk?: (hunkIndex: number, target?: UserNoteLineTarget) => void; + onRowPlanChange?: (rowPlan: DiffSectionRowPlan, highlighted: boolean) => void; onToggleGap?: (gapKey: string) => void; showLineNumbers?: boolean; showHunkHeaders?: boolean; @@ -241,6 +243,13 @@ export function PierreDiffView({ visibleAgentNotes, ], ); + const rowPlanHighlighted = + resolvedHighlighted !== null && + (sourceTextForHighlight === undefined || resolvedHighlightedSource !== null); + useEffect(() => { + onRowPlanChange?.(sectionRowPlan, rowPlanHighlighted); + }, [onRowPlanChange, rowPlanHighlighted, sectionRowPlan]); + const plannedRows = sectionRowPlan.plannedRows; const lineNumberDigits = sectionRowPlan.lineNumberDigits; const fileHasSourceFetcher = Boolean(file?.sourceFetcher); diff --git a/src/ui/lib/extensionCurrentLine.test.ts b/src/ui/lib/extensionCurrentLine.test.ts new file mode 100644 index 000000000..59144a47c --- /dev/null +++ b/src/ui/lib/extensionCurrentLine.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, test } from "bun:test"; +import { createTestDiffFile } from "../../../test/helpers/diff-helpers"; +import { buildDiffSectionRowPlan } from "../diff/diffSectionRowPlan"; +import { resolveTheme } from "../themes"; +import { + applyExtensionCurrentLinePaintUpdate, + createExtensionCurrentLinePaint, + extensionCurrentLinePaintMatchesCursor, +} from "./extensionCurrentLine"; +import type { LineCursor } from "./lineCursors"; + +/** Build one accepted split row plan and a cursor that resolves inside it. */ +function splitPlanFixture() { + const file = createTestDiffFile({ + id: "alpha", + path: "alpha.ts", + before: "const value = 1;\n", + after: "const value = 222;\n", + }); + const theme = resolveTheme("github-dark-default", null); + const rowPlan = buildDiffSectionRowPlan({ + file, + highlightedDiff: null, + layout: "split", + showHunkHeaders: true, + theme, + }); + const planned = rowPlan.plannedRows.find( + (row) => row.kind === "diff-row" && row.row.type === "split-line", + ); + if (!planned || planned.kind !== "diff-row" || planned.row.type !== "split-line") { + throw new Error("Expected a split row fixture."); + } + const cursor: LineCursor = { + fileId: file.id, + hunkIndex: planned.row.hunkIndex, + stableKey: planned.stableKey, + target: { side: "new", line: planned.row.right.lineNumber ?? 1 }, + }; + return { cursor, rowPlan, splitRow: planned.row, theme }; +} + +describe("extension current-line paint", () => { + test("exposes only an opaque painter backed by the accepted row plan", () => { + const fixture = splitPlanFixture(); + const paint = createExtensionCurrentLinePaint({ + ...fixture, + showLineNumbers: true, + codeHorizontalOffset: 0, + }); + + expect(paint).not.toBeNull(); + expect(Object.keys(paint!)).toEqual(["render"]); + const oldPaint = paint!.render("old", 60) as { + props: { + row: { cell: Record }; + width: number; + showLineNumbers: boolean; + codeHorizontalOffset: number; + }; + }; + const newPaint = paint!.render("new", 60) as typeof oldPaint; + + expect(oldPaint.props.row.cell).toEqual({ + kind: fixture.splitRow.left.kind, + sign: fixture.splitRow.left.sign, + oldLineNumber: fixture.splitRow.left.lineNumber, + spans: fixture.splitRow.left.spans, + }); + expect(newPaint.props.row.cell).toEqual({ + kind: fixture.splitRow.right.kind, + sign: fixture.splitRow.right.sign, + newLineNumber: fixture.splitRow.right.lineNumber, + spans: fixture.splitRow.right.spans, + }); + expect(oldPaint.props.width).toBe(60); + expect(oldPaint.props.showLineNumbers).toBe(true); + expect(oldPaint.props.codeHorizontalOffset).toBe(0); + }); + + test("preserves move paint and turns an absent side into an explicit blank row", () => { + const fixture = splitPlanFixture(); + fixture.splitRow.left = { kind: "empty", sign: " ", spans: [] }; + fixture.splitRow.right.moveKind = "moved"; + const paint = createExtensionCurrentLinePaint({ + ...fixture, + showLineNumbers: false, + codeHorizontalOffset: 17, + }); + const oldPaint = paint!.render("old", 42) as { + props: { row: { cell: Record }; codeHorizontalOffset: number }; + }; + const newPaint = paint!.render("new", 42) as typeof oldPaint; + + expect(oldPaint.props.row.cell).toEqual({ kind: "context", sign: " ", spans: [] }); + expect(newPaint.props.row.cell.moveKind).toBe("moved"); + expect(newPaint.props.codeHorizontalOffset).toBe(17); + }); + + test("returns null when the existing cursor does not resolve in that plan", () => { + const fixture = splitPlanFixture(); + const paint = createExtensionCurrentLinePaint({ + ...fixture, + cursor: { ...fixture.cursor, stableKey: "missing" }, + showLineNumbers: true, + codeHorizontalOffset: 0, + }); + + expect(paint).toBeNull(); + }); + + test("withholds stale paint while a new plan is pending", () => { + const paint = { render: () => null }; + const ready = applyExtensionCurrentLinePaintUpdate( + { status: "unavailable", fileId: null, cursorKey: null, paint: null }, + { status: "ready", fileId: "alpha", cursorKey: "row:1", paint }, + ); + expect( + extensionCurrentLinePaintMatchesCursor(ready, { + fileId: "beta", + stableKey: "row:1", + }), + ).toBe(false); + expect( + extensionCurrentLinePaintMatchesCursor(ready, { + fileId: "alpha", + stableKey: "row:1", + }), + ).toBe(true); + + const pending = applyExtensionCurrentLinePaintUpdate(ready, { status: "pending" }); + expect(pending).toEqual({ status: "pending", fileId: null, cursorKey: null, paint: null }); + expect(applyExtensionCurrentLinePaintUpdate(pending, { status: "pending" })).toBe(pending); + }); + + test("clears accepted paint when the current-line capability becomes unavailable", () => { + const paint = { render: () => null }; + const unavailable = applyExtensionCurrentLinePaintUpdate( + { status: "ready", fileId: "alpha", cursorKey: "row:1", paint }, + { status: "unavailable" }, + ); + + expect(unavailable).toEqual({ + status: "unavailable", + fileId: null, + cursorKey: null, + paint: null, + }); + }); +}); diff --git a/src/ui/lib/extensionCurrentLine.tsx b/src/ui/lib/extensionCurrentLine.tsx new file mode 100644 index 000000000..4f97a62a5 --- /dev/null +++ b/src/ui/lib/extensionCurrentLine.tsx @@ -0,0 +1,120 @@ +import type { ExtensionCurrentLinePaint } from "../../extension-api/types"; +import type { DiffRow, SplitLineCell, StackLineCell } from "../diff/pierre"; +import { DiffRowView } from "../diff/renderRows"; +import type { DiffSectionRowPlan } from "../diff/diffSectionRowPlan"; +import type { LineCursor } from "./lineCursors"; +import type { AppTheme } from "../themes"; + +type SplitLineRow = Extract; +type StackLineRow = Extract; + +/** One lifecycle update from the review renderer to pane orchestration. */ +export type ExtensionCurrentLinePaintUpdate = + | { status: "unavailable" } + | { status: "pending" } + | { status: "ready"; fileId: string; cursorKey: string; paint: ExtensionCurrentLinePaint }; + +/** Accepted paint plus the cursor identity it was built from. */ +export interface ExtensionCurrentLinePaintState { + status: ExtensionCurrentLinePaintUpdate["status"]; + fileId: string | null; + cursorKey: string | null; + paint: ExtensionCurrentLinePaint | null; +} + +/** Match paint only to the exact file-scoped cursor identity that produced it. */ +export function extensionCurrentLinePaintMatchesCursor( + state: ExtensionCurrentLinePaintState, + cursor: { fileId: string; stableKey: string } | null, +): boolean { + return ( + state.status === "ready" && + state.fileId === cursor?.fileId && + state.cursorKey === cursor?.stableKey + ); +} + +/** Apply one exact renderer lifecycle update. */ +export function applyExtensionCurrentLinePaintUpdate( + current: ExtensionCurrentLinePaintState, + update: ExtensionCurrentLinePaintUpdate, +): ExtensionCurrentLinePaintState { + if (update.status === "ready") { + return { + status: "ready", + fileId: update.fileId, + cursorKey: update.cursorKey, + paint: update.paint, + }; + } + if (current.status === update.status && current.paint === null) return current; + return { status: update.status, fileId: null, cursorKey: null, paint: null }; +} + +/** Adapt one private split cell into the private full-width row painter. */ +function stackRow(row: SplitLineRow, cell: SplitLineCell, side: "old" | "new"): StackLineRow { + const adapted: StackLineCell = { + kind: cell.kind === "empty" ? "context" : cell.kind, + sign: cell.kind === "empty" ? " " : cell.sign, + ...(side === "old" ? { oldLineNumber: cell.lineNumber } : { newLineNumber: cell.lineNumber }), + ...(cell.moveKind ? { moveKind: cell.moveKind } : {}), + spans: cell.spans, + }; + return { + type: "stack-line", + key: `${row.key}:pane:${side}`, + fileId: row.fileId, + hunkIndex: row.hunkIndex, + cell: adapted, + }; +} + +/** Build an opaque public painter from the exact accepted private row plan. */ +export function createExtensionCurrentLinePaint({ + cursor, + rowPlan, + showLineNumbers, + codeHorizontalOffset, + theme, +}: { + cursor: LineCursor; + rowPlan: DiffSectionRowPlan; + showLineNumbers: boolean; + codeHorizontalOffset: number; + theme: AppTheme; +}): ExtensionCurrentLinePaint | null { + let splitRow: SplitLineRow | undefined; + for (const planned of rowPlan.plannedRows) { + if (planned.kind !== "diff-row" || planned.row.type !== "split-line") continue; + if ( + planned.stableKey === cursor.stableKey || + planned.stableAliasKeys?.includes(cursor.stableKey) + ) { + splitRow = planned.row; + break; + } + } + if (!splitRow) return null; + const rows = { + old: stackRow(splitRow, splitRow.left, "old"), + new: stackRow(splitRow, splitRow.right, "new"), + }; + return Object.freeze({ + render(side: "old" | "new", width: number) { + return ( + + ); + }, + }); +} diff --git a/src/ui/lib/extensionPanes.test.ts b/src/ui/lib/extensionPanes.test.ts new file mode 100644 index 000000000..9e2cf6a8e --- /dev/null +++ b/src/ui/lib/extensionPanes.test.ts @@ -0,0 +1,277 @@ +import { describe, expect, test } from "bun:test"; +import type { + ExtensionPaneAvailabilityContext, + ExtensionPaneComponent, + ExtensionPanePlacement, + ExtensionPaneSize, +} from "../../extension-api/types"; +import type { RegisteredPane } from "../../extensions/types"; +import { createEmptyExtensionLoadResult } from "../../extensions/types"; +import { HUNK_FILES_PANE_KEY } from "../../extensions/extensionIds"; +import { + buildSessionPanes, + initialPaneOpenState, + planExtensionPanes, + reconcilePaneOpenState, + resolvePaneKey, + type SessionPane, +} from "./extensionPanes"; + +type TestPaneOverrides = Partial<{ + title: string; + placement: ExtensionPanePlacement; + width: ExtensionPaneSize; + height: ExtensionPaneSize; + defaultOpen: boolean; + replaces: string; + currentLine: boolean; + available: (context: ExtensionPaneAvailabilityContext) => boolean; + component: ExtensionPaneComponent; +}>; + +function registeredPane( + extensionId: string, + id: string, + pane: TestPaneOverrides = {}, +): RegisteredPane { + return { + extensionId, + pane: { id, component: () => null, ...pane } as RegisteredPane["pane"], + }; +} +function loadResultWith(panes: RegisteredPane[]) { + const result = createEmptyExtensionLoadResult(); + result.registry.panes.push(...panes); + return result; +} + +describe("extension panes", () => { + test("offers the bundled files pane before user panes", () => { + const panes = buildSessionPanes(undefined); + expect(panes.map((pane) => pane.key)).toEqual([HUNK_FILES_PANE_KEY]); + expect(panes.map((pane) => pane.defaultOpen)).toEqual([true]); + }); + + test("a replacement changes only the initial bundled files default", () => { + const panes = buildSessionPanes( + loadResultWith([registeredPane("meta", "files", { replaces: HUNK_FILES_PANE_KEY })]), + ); + expect(panes.map((pane) => [pane.key, pane.defaultOpen])).toEqual([ + [HUNK_FILES_PANE_KEY, false], + ["meta:files", true], + ]); + }); + + test("replacement defaults apply to any registered pane key", () => { + const panes = buildSessionPanes( + loadResultWith([ + registeredPane("meta", "base", { defaultOpen: true }), + registeredPane("other", "replacement", { + replaces: "meta:base", + defaultOpen: false, + }), + ]), + ); + + expect(panes.find((pane) => pane.key === "meta:base")?.defaultOpen).toBe(false); + expect(panes.find((pane) => pane.key === "other:replacement")?.defaultOpen).toBe(true); + }); + + test("preserves open choices across reloads and applies new defaults", () => { + const before = buildSessionPanes( + loadResultWith([registeredPane("meta", "extra", { defaultOpen: true })]), + ); + const state = initialPaneOpenState(before); + const closed = { known: state.known, open: [HUNK_FILES_PANE_KEY] }; + const after = buildSessionPanes( + loadResultWith([ + registeredPane("meta", "extra", { defaultOpen: true }), + registeredPane("meta", "fresh", { defaultOpen: true }), + ]), + ); + expect(reconcilePaneOpenState(after, closed).open).toEqual([HUNK_FILES_PANE_KEY, "meta:fresh"]); + }); + + test("resolves local, files, and qualified ids", () => { + const panes = buildSessionPanes(loadResultWith([registeredPane("meta", "extra")])); + expect(resolvePaneKey(panes, "meta", "extra")).toBe("meta:extra"); + expect(resolvePaneKey(panes, "meta", "files")).toBe(HUNK_FILES_PANE_KEY); + expect(resolvePaneKey(panes, "other", "meta:extra")).toBe("meta:extra"); + }); + + test("plans all four edges around one review rectangle", () => { + const session = ( + key: string, + placement: SessionPane["placement"], + size: number, + ): SessionPane => ({ + key, + placement, + title: key, + defaultOpen: true, + registered: registeredPane(key.split(":")[0]!, key.split(":")[1]!, { + placement, + ...(placement === "left" || placement === "right" + ? { width: { preferred: size, min: size, max: size } } + : { height: { preferred: size, min: size, max: size } }), + }), + }); + const panes = [ + session("a:left", "left", 20), + session("b:right", "right", 15), + session("c:top", "top", 4), + session("d:bottom", "bottom", 3), + ]; + const plan = planExtensionPanes({ + panes, + openKeys: panes.map((pane) => pane.key), + sizes: {}, + bodyWidth: 100, + bodyHeight: 30, + minReviewWidth: 40, + minReviewHeight: 5, + currentLine: null, + availabilityContext: { files: [], selectedFileId: null, selectedHunkIndex: null }, + }); + expect(plan.reviewBounds).toEqual({ x: 20, y: 4, width: 65, height: 23 }); + expect(plan.panes.map((entry) => entry.pane.placement)).toEqual([ + "left", + "right", + "top", + "bottom", + ]); + }); + + test("keeps logical open preferences while synchronous availability omits a pane", () => { + let available = false; + let availabilityCalls = 0; + const registered = registeredPane("a", "detail", { + placement: "bottom", + height: { preferred: 3, min: 3, max: 3 }, + currentLine: true, + available: ({ currentLine }) => { + availabilityCalls += 1; + return available && currentLine !== null; + }, + }); + const panes = buildSessionPanes(loadResultWith([registered])); + const state = { known: panes.map((pane) => pane.key), open: ["a:detail"] }; + const options = { + panes, + openKeys: state.open, + sizes: {}, + bodyWidth: 100, + bodyHeight: 20, + minReviewWidth: 40, + minReviewHeight: 5, + availabilityContext: { files: [], selectedFileId: null, selectedHunkIndex: null }, + } as const; + + const unavailable = planExtensionPanes({ ...options, currentLine: null }); + expect(unavailable.panes.some((entry) => entry.pane.key === "a:detail")).toBe(false); + expect(unavailable.omittedKeys).toContain("a:detail"); + expect(state.open).toEqual(["a:detail"]); + + available = true; + const paint = { render: () => null }; + const restored = planExtensionPanes({ ...options, currentLine: paint }); + expect(restored.panes.some((entry) => entry.pane.key === "a:detail")).toBe(true); + + const callsBeforePending = availabilityCalls; + const pending = planExtensionPanes({ + ...options, + currentLine: null, + retainCurrentLineKeys: new Set(["a:detail"]), + }); + expect(pending.panes.some((entry) => entry.pane.key === "a:detail")).toBe(true); + expect(availabilityCalls).toBe(callsBeforePending); + expect(state.open).toEqual(["a:detail"]); + }); + + test("quarantines an availability callback that throws or returns asynchronously", () => { + const throwing = registeredPane("a", "throwing", { + available: () => { + throw new Error("availability exploded"); + }, + }); + const asyncPane = registeredPane("a", "async", { + available: (() => Promise.resolve(true)) as never, + }); + const panes = buildSessionPanes(loadResultWith([throwing, asyncPane])); + const quarantined = new WeakSet(); + const errors: string[] = []; + const plan = planExtensionPanes({ + panes, + openKeys: ["a:throwing", "a:async"], + sizes: {}, + bodyWidth: 100, + bodyHeight: 20, + minReviewWidth: 40, + minReviewHeight: 5, + currentLine: null, + availabilityContext: { files: [], selectedFileId: null, selectedHunkIndex: null }, + quarantined, + onAvailabilityError: (_pane, error) => + errors.push(error instanceof Error ? error.message : String(error)), + }); + + expect(plan.panes).toEqual([]); + expect(plan.omittedKeys).toEqual(["a:throwing", "a:async"]); + expect(quarantined.has(throwing)).toBe(true); + expect(quarantined.has(asyncPane)).toBe(true); + expect(errors).toEqual([ + "availability exploded", + "available() must return a boolean synchronously", + ]); + }); + + test("uses explicit height overrides and reserves a divider only for resizable panes", () => { + const registered = registeredPane("a", "top", { + placement: "top", + height: { preferred: 4, min: 2, max: 8 }, + }); + const panes = buildSessionPanes(loadResultWith([registered])); + const plan = planExtensionPanes({ + panes, + openKeys: ["a:top"], + sizes: { "a:top": 7 }, + bodyWidth: 100, + bodyHeight: 20, + minReviewWidth: 40, + minReviewHeight: 5, + currentLine: null, + availabilityContext: { files: [], selectedFileId: null, selectedHunkIndex: null }, + }); + + const top = plan.panes.find((entry) => entry.pane.key === "a:top"); + expect(top?.bounds).toEqual({ x: 0, y: 0, width: 100, height: 7 }); + expect(top?.divider).toEqual({ x: 0, y: 7, width: 100, height: 1 }); + expect(plan.reviewBounds).toEqual({ x: 0, y: 8, width: 100, height: 12 }); + }); + + test("omits later panes when minimum review bounds are exhausted", () => { + const panes: SessionPane[] = ["one", "two", "three"].map((id) => ({ + key: `a:${id}`, + placement: "left", + title: id, + defaultOpen: true, + registered: registeredPane("a", id, { + placement: "left", + width: { preferred: 30, min: 20 }, + }), + })); + const plan = planExtensionPanes({ + panes, + openKeys: panes.map((pane) => pane.key), + sizes: {}, + bodyWidth: 110, + bodyHeight: 30, + minReviewWidth: 48, + minReviewHeight: 5, + currentLine: null, + availabilityContext: { files: [], selectedFileId: null, selectedHunkIndex: null }, + }); + expect(plan.panes.map((entry) => entry.pane.key)).toEqual(["a:one", "a:two"]); + expect(plan.omittedKeys).toContain("a:three"); + }); +}); diff --git a/src/ui/lib/extensionPanes.ts b/src/ui/lib/extensionPanes.ts new file mode 100644 index 000000000..35fe6292b --- /dev/null +++ b/src/ui/lib/extensionPanes.ts @@ -0,0 +1,263 @@ +import { paneKey, resolveExtensionPanes } from "../../extensions/apply"; +import { getBundledUIRegistry } from "../../extensions/default/ui"; +import { HUNK_FILES_PANE_KEY } from "../../extensions/extensionIds"; +import type { + ExtensionCurrentLinePaint, + ExtensionPaneAvailabilityContext, + ExtensionPanePlacement, +} from "../../extension-api/types"; +import { extensionPaneSize } from "../../extensions/panes"; +import type { ExtensionLoadResult, RegisteredPane } from "../../extensions/types"; + +/** One cell reserved between each resizable pane and its neighbor. */ +export const EXTENSION_PANE_DIVIDER_SIZE = 1; +/** Smallest review height preserved while edge panes are open or resized. */ +export const MIN_EXTENSION_REVIEW_HEIGHT = 5; + +/** One pane offered to a review session. */ +export interface SessionPane { + key: string; + registered: RegisteredPane; + placement: ExtensionPanePlacement; + title: string; + defaultOpen: boolean; +} + +/** Compose bundled UI panes before user panes, preserving stable keys and replacement defaults. */ +export function buildSessionPanes(extensions: ExtensionLoadResult | undefined): SessionPane[] { + const bundled = resolveExtensionPanes(getBundledUIRegistry()).panes; + const user = extensions ? resolveExtensionPanes(extensions.registry).panes : []; + const all = [...bundled, ...user]; + const replacements = new Set(all.map((entry) => entry.pane.replaces).filter(Boolean)); + return all.map((registered) => { + const key = paneKey(registered); + const pane = registered.pane; + return { + key, + registered, + placement: pane.placement ?? "left", + title: pane.title ?? pane.id, + defaultOpen: + !replacements.has(key) && + (key === HUNK_FILES_PANE_KEY || pane.defaultOpen === true || pane.replaces !== undefined), + }; + }); +} + +export interface PaneOpenState { + known: readonly string[]; + open: readonly string[]; +} + +/** Initialize logical pane preferences from registrations. */ +export function initialPaneOpenState(panes: readonly SessionPane[]): PaneOpenState { + return { + known: panes.map((pane) => pane.key), + open: panes.filter((pane) => pane.defaultOpen).map((pane) => pane.key), + }; +} + +/** Reconcile registrations without overwriting choices for known pane keys. */ +export function reconcilePaneOpenState( + panes: readonly SessionPane[], + state: PaneOpenState, +): PaneOpenState { + const keys = panes.map((pane) => pane.key); + const known = new Set(state.known); + const open = new Set(state.open); + const nextOpen = panes + .filter((pane) => (known.has(pane.key) ? open.has(pane.key) : pane.defaultOpen)) + .map((pane) => pane.key); + if ( + keys.length === state.known.length && + keys.every((key, index) => state.known[index] === key) && + nextOpen.length === state.open.length && + nextOpen.every((key, index) => state.open[index] === key) + ) + return state; + return { known: keys, open: nextOpen }; +} + +/** Resolve a bare local id, `files`, or a fully-qualified pane key. */ +export function resolvePaneKey( + panes: readonly SessionPane[], + extensionId: string, + id: string, +): string | undefined { + const candidates = id.includes(":") + ? [id] + : [`${extensionId}:${id}`, id === "files" ? HUNK_FILES_PANE_KEY : id]; + return candidates.find((candidate) => panes.some((pane) => pane.key === candidate)); +} + +export interface PaneBounds { + x: number; + y: number; + width: number; + height: number; +} +export interface PlannedPane { + pane: SessionPane; + bounds: PaneBounds; + divider?: PaneBounds; +} +export interface ExtensionPaneLayoutPlan { + panes: readonly PlannedPane[]; + reviewBounds: PaneBounds; + omittedKeys: readonly string[]; +} + +export interface PlanExtensionPanesOptions { + panes: readonly SessionPane[]; + openKeys: readonly string[]; + sizes: Readonly>; + bodyWidth: number; + bodyHeight: number; + minReviewWidth: number; + minReviewHeight: number; + currentLine: ExtensionCurrentLinePaint | null; + /** Keep previously accepted current-line panes mounted while fresh paint is pending. */ + retainCurrentLineKeys?: ReadonlySet; + availabilityContext: Omit; + quarantined?: WeakSet; + onAvailabilityError?: (pane: SessionPane, error: unknown) => void; +} + +/** Plan exact rectangles on all four edges while reserving minimum review bounds. */ +export function planExtensionPanes(options: PlanExtensionPanesOptions): ExtensionPaneLayoutPlan { + const open = new Set(options.openKeys); + const omittedKeys: string[] = []; + const accepted: SessionPane[] = []; + for (const pane of options.panes) { + if (!open.has(pane.key) || options.quarantined?.has(pane.registered)) continue; + const registration = pane.registered.pane; + if (registration.currentLine && options.retainCurrentLineKeys?.has(pane.key)) { + accepted.push(pane); + continue; + } + if (registration.available) { + try { + const result = registration.available({ + ...options.availabilityContext, + placement: pane.placement, + currentLine: registration.currentLine ? options.currentLine : null, + }); + if (typeof result !== "boolean") + throw new Error("available() must return a boolean synchronously"); + if (!result) { + omittedKeys.push(pane.key); + continue; + } + } catch (error) { + options.quarantined?.add(pane.registered); + options.onAvailabilityError?.(pane, error); + omittedKeys.push(pane.key); + continue; + } + } + accepted.push(pane); + } + + let left = 0; + let right = Math.max(0, options.bodyWidth); + let top = 0; + let bottom = Math.max(0, options.bodyHeight); + const planned = new Map(); + + const sizeSpec = (pane: SessionPane) => { + const spec = extensionPaneSize(pane.registered.pane, pane.placement); + const min = spec.min ?? 1; + const max = spec.max ?? Number.MAX_SAFE_INTEGER; + return { preferred: options.sizes[pane.key] ?? spec.preferred, min, max, fixed: min === max }; + }; + + for (const pane of accepted.filter( + (pane) => pane.placement === "left" || pane.placement === "right", + )) { + const spec = sizeSpec(pane); + const dividerSize = spec.fixed ? 0 : EXTENSION_PANE_DIVIDER_SIZE; + const remaining = right - left - options.minReviewWidth - dividerSize; + const width = Math.min(Math.max(spec.preferred, spec.min), spec.max, remaining); + if (width < spec.min) { + omittedKeys.push(pane.key); + continue; + } + if (pane.placement === "left") { + const bounds = { x: left, y: 0, width, height: options.bodyHeight }; + const divider = dividerSize + ? { + x: left + width, + y: 0, + width: EXTENSION_PANE_DIVIDER_SIZE, + height: options.bodyHeight, + } + : undefined; + planned.set(pane.key, { pane, bounds, ...(divider ? { divider } : {}) }); + left += width + dividerSize; + } else { + const bounds = { x: right - width, y: 0, width, height: options.bodyHeight }; + const divider = dividerSize + ? { + x: right - width - EXTENSION_PANE_DIVIDER_SIZE, + y: 0, + width: EXTENSION_PANE_DIVIDER_SIZE, + height: options.bodyHeight, + } + : undefined; + planned.set(pane.key, { pane, bounds, ...(divider ? { divider } : {}) }); + right -= width + dividerSize; + } + } + + for (const pane of accepted.filter( + (pane) => pane.placement === "top" || pane.placement === "bottom", + )) { + const spec = sizeSpec(pane); + const dividerSize = spec.fixed ? 0 : EXTENSION_PANE_DIVIDER_SIZE; + const remaining = bottom - top - options.minReviewHeight - dividerSize; + const height = Math.min(Math.max(spec.preferred, spec.min), spec.max, remaining); + if (height < spec.min) { + omittedKeys.push(pane.key); + continue; + } + if (pane.placement === "top") { + const bounds = { x: left, y: top, width: right - left, height }; + const divider = dividerSize + ? { + x: left, + y: top + height, + width: right - left, + height: EXTENSION_PANE_DIVIDER_SIZE, + } + : undefined; + planned.set(pane.key, { pane, bounds, ...(divider ? { divider } : {}) }); + top += height + dividerSize; + } else { + const bounds = { x: left, y: bottom - height, width: right - left, height }; + const divider = dividerSize + ? { + x: left, + y: bottom - height - EXTENSION_PANE_DIVIDER_SIZE, + width: right - left, + height: EXTENSION_PANE_DIVIDER_SIZE, + } + : undefined; + planned.set(pane.key, { pane, bounds, ...(divider ? { divider } : {}) }); + bottom -= height + dividerSize; + } + } + + return { + panes: options.panes.flatMap((pane) => { + const entry = planned.get(pane.key); + return entry ? [entry] : []; + }), + reviewBounds: { + x: left, + y: top, + width: Math.max(0, right - left), + height: Math.max(0, bottom - top), + }, + omittedKeys, + }; +} diff --git a/src/ui/lib/keymap.test.ts b/src/ui/lib/keymap.test.ts index bdd09a2cc..ad7e2bfb6 100644 --- a/src/ui/lib/keymap.test.ts +++ b/src/ui/lib/keymap.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { - createExtensionSidebarKeybindings, + createExtensionPaneKeybindings, formatKeyChord, resolveCommandKeys, type CommandKeyDefaults, @@ -135,10 +135,10 @@ describe("resolveCommandKeys", () => { }); }); -describe("extension sidebar keybindings", () => { +describe("extension pane keybindings", () => { test("matches resolved commands and exposes their effective chords", () => { const { keys } = resolve({ "hunk.review.nextHunk": "ctrl+n", "hunk.app.quit": false }); - const keybindings = createExtensionSidebarKeybindings(keys); + const keybindings = createExtensionPaneKeybindings(keys); expect(keybindings.getKeys("hunk.review.nextHunk")).toEqual(["ctrl+n"]); expect(keybindings.matches({ name: "n", ctrl: true }, "hunk.review.nextHunk")).toBe(true); @@ -150,7 +150,7 @@ describe("extension sidebar keybindings", () => { test("treats unknown command ids as unbound", () => { const { keys } = resolveCommandKeys({ defaults: DEFAULTS }); - const keybindings = createExtensionSidebarKeybindings(keys); + const keybindings = createExtensionPaneKeybindings(keys); expect(keybindings.getKeys("missing.command")).toEqual([]); expect(keybindings.matches({ name: "q" }, "missing.command")).toBe(false); diff --git a/src/ui/lib/keymap.ts b/src/ui/lib/keymap.ts index 6293f1941..dddb7df5a 100644 --- a/src/ui/lib/keymap.ts +++ b/src/ui/lib/keymap.ts @@ -1,5 +1,5 @@ import type { UserKeyBinding } from "../../core/types"; -import type { ExtensionSidebarKeybindings } from "../../extension-api/types"; +import type { ExtensionPaneKeybindings } from "../../extension-api/types"; import { HUNK_VENDOR_EXTENSION_ID } from "../../extensions/extensionIds"; import { matchesKeyChord, parseKeyChord, type ParsedKeyChord } from "../../lib/commandKeys"; @@ -42,16 +42,16 @@ export interface ResolvedKeymap { const NO_KEY_CHORDS: readonly string[] = Object.freeze([]); /** - * Build the immutable keybindings manager injected into sidebar components. + * Build the immutable keybindings manager injected into pane components. * * Components receive command ids rather than raw default chords, exactly as * Pi custom components receive its `KeybindingsManager`. Capturing the * resolved map here means a component's local key handling observes the same * remaps, unbindings, and extension bindings as the app dispatcher. */ -export function createExtensionSidebarKeybindings( +export function createExtensionPaneKeybindings( resolvedKeys: ReadonlyMap, -): ExtensionSidebarKeybindings { +): ExtensionPaneKeybindings { const keysByCommand = new Map( Array.from(resolvedKeys, ([commandId, keys]) => [commandId, Object.freeze([...keys])]), ); @@ -64,7 +64,7 @@ export function createExtensionSidebarKeybindings( ]), ); - const keybindings: ExtensionSidebarKeybindings = { + const keybindings: ExtensionPaneKeybindings = { matches(key, commandId) { return parsedByCommand.get(commandId)?.some((chord) => matchesKeyChord(chord, key)) ?? false; }, diff --git a/src/ui/lib/sidebarPanes.test.ts b/src/ui/lib/sidebarPanes.test.ts deleted file mode 100644 index 7dc355938..000000000 --- a/src/ui/lib/sidebarPanes.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { RegisteredSidebarView } from "../../extensions/types"; -import { createEmptyExtensionLoadResult } from "../../extensions/types"; -import { - buildSessionSidebarViews, - bundledSidebarViewKey, - initialSidebarOpenState, - planSidebarLayout, - reconcileSidebarOpenState, - resolveSidebarViewKey, - type SessionSidebarView, -} from "./sidebarPanes"; - -function registeredView( - extensionId: string, - id: string, - view: Partial = {}, -): RegisteredSidebarView { - return { extensionId, view: { id, component: () => null, ...view } }; -} - -/** Load result whose registry carries exactly the given sidebar views. */ -function loadResultWith(views: RegisteredSidebarView[]) { - const result = createEmptyExtensionLoadResult(); - result.registry.sidebarViews.push(...views); - return result; -} - -describe("buildSessionSidebarViews", () => { - test("offers the bundled file navigation first, open by default", () => { - const views = buildSessionSidebarViews(undefined); - - expect(views.map((view) => view.key)).toEqual([bundledSidebarViewKey()]); - expect(views[0]?.defaultOpen).toBe(true); - expect(views[0]?.placement).toBe("left"); - }); - - test("registered views join closed unless they ask to open", () => { - const views = buildSessionSidebarViews( - loadResultWith([ - registeredView("meta", "extra", { placement: "right" }), - registeredView("meta", "eager", { defaultOpen: true }), - ]), - ); - - expect(views.map((view) => [view.key, view.defaultOpen, view.placement])).toEqual([ - [bundledSidebarViewKey(), true, "left"], - ["meta:extra", false, "right"], - ["meta:eager", true, "left"], - ]); - }); - - test("a replacesDefault view opens and closes the bundled view", () => { - const views = buildSessionSidebarViews( - loadResultWith([registeredView("meta", "replacement", { replacesDefault: true })]), - ); - - expect(views.map((view) => [view.key, view.defaultOpen])).toEqual([ - [bundledSidebarViewKey(), false], - ["meta:replacement", true], - ]); - }); -}); - -describe("reconcileSidebarOpenState", () => { - test("keeps user choices for surviving views and applies defaults to new ones", () => { - const before = buildSessionSidebarViews( - loadResultWith([registeredView("meta", "extra", { defaultOpen: true })]), - ); - const state = initialSidebarOpenState(before); - // The user closed the extension view mid-session. - const closed = { known: state.known, open: [bundledSidebarViewKey()] }; - - const after = buildSessionSidebarViews( - loadResultWith([ - registeredView("meta", "extra", { defaultOpen: true }), - registeredView("meta", "fresh", { defaultOpen: true }), - ]), - ); - const next = reconcileSidebarOpenState(after, closed); - - // "extra" stays closed (the user said so); "fresh" opens per its default. - expect(next.open).toEqual([bundledSidebarViewKey(), "meta:fresh"]); - }); - - test("returns the same state object when nothing changed", () => { - const views = buildSessionSidebarViews(undefined); - const state = initialSidebarOpenState(views); - - expect(reconcileSidebarOpenState(views, state)).toBe(state); - }); -}); - -describe("resolveSidebarViewKey", () => { - const views = buildSessionSidebarViews(loadResultWith([registeredView("meta", "extra")])); - - test("resolves bare ids within the calling extension", () => { - expect(resolveSidebarViewKey(views, "meta", "extra")).toBe("meta:extra"); - }); - - test('resolves "files" to the bundled view and full keys to anyone', () => { - expect(resolveSidebarViewKey(views, "meta", "files")).toBe(bundledSidebarViewKey()); - expect(resolveSidebarViewKey(views, "other", "meta:extra")).toBe("meta:extra"); - }); - - test("reports unknown views as undefined", () => { - expect(resolveSidebarViewKey(views, "meta", "missing")).toBeUndefined(); - }); -}); - -describe("planSidebarLayout", () => { - function sessionView(key: string, placement: "left" | "right"): SessionSidebarView { - return { - key, - registered: registeredView(key.split(":")[0] ?? key, key.split(":")[1] ?? key), - placement, - title: key, - defaultOpen: false, - }; - } - - const options = { - defaultWidth: 30, - minWidth: 20, - dividerWidth: 1, - bodyWidth: 200, - diffMinWidth: 48, - }; - - test("splits open panes by placement and totals their columns", () => { - const plan = planSidebarLayout({ - ...options, - views: [sessionView("a:one", "left"), sessionView("b:two", "right")], - openKeys: ["a:one", "b:two"], - widths: { "b:two": 40 }, - }); - - expect(plan.left.map((pane) => [pane.view.key, pane.width])).toEqual([["a:one", 30]]); - expect(plan.right.map((pane) => [pane.view.key, pane.width])).toEqual([["b:two", 40]]); - expect(plan.leftWidth).toBe(31); - expect(plan.totalWidth).toBe(72); - }); - - test("drops the panes that no longer fit, later views first", () => { - const plan = planSidebarLayout({ - ...options, - bodyWidth: 110, - views: [ - sessionView("a:one", "left"), - sessionView("b:two", "left"), - sessionView("c:three", "left"), - ], - openKeys: ["a:one", "b:two", "c:three"], - widths: {}, - }); - - // 110 - 48 leaves 62: two 30-column panes with dividers fit, the third not. - expect(plan.left.map((pane) => pane.view.key)).toEqual(["a:one", "b:two"]); - }); - - test("closed views consume nothing", () => { - const plan = planSidebarLayout({ - ...options, - views: [sessionView("a:one", "left")], - openKeys: [], - widths: {}, - }); - - expect(plan.left).toEqual([]); - expect(plan.totalWidth).toBe(0); - }); -}); diff --git a/src/ui/lib/sidebarPanes.ts b/src/ui/lib/sidebarPanes.ts deleted file mode 100644 index 007c38a9d..000000000 --- a/src/ui/lib/sidebarPanes.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { resolveExtensionSidebarViews, sidebarViewKey } from "../../extensions/apply"; -import { getBundledSidebarView } from "../../extensions/default/ui/sidebar"; -import type { ExtensionLoadResult, RegisteredSidebarView } from "../../extensions/types"; - -/** - * The session's sidebar model: which views exist, which are open, and how the - * open ones share the terminal with the review stream. - * - * Everything here is a pure derivation so rendering, resizing, and the - * extension-facing sidebar controls all read one plan instead of re-deriving - * pane arrangement ad hoc. - */ - -/** Which side of the review stream one pane sits on. */ -export type SidebarPlacement = "left" | "right"; - -/** One sidebar view available to this session, open or not. */ -export interface SessionSidebarView { - /** Stable key: `:`; the bundled file navigation is `hunk:files`. */ - key: string; - registered: RegisteredSidebarView; - placement: SidebarPlacement; - title: string; - defaultOpen: boolean; -} - -/** The key the bundled file-navigation view is addressed by. */ -export function bundledSidebarViewKey() { - return sidebarViewKey(getBundledSidebarView()); -} - -/** - * Compose the session's sidebar views: the bundled file navigation first, - * then every extension-registered view in registration order. - * - * A registered view with `replacesDefault` starts open in place of the - * bundled view — which stays available, just closed, so a command or a - * future menu can reopen it. - */ -export function buildSessionSidebarViews( - extensions: ExtensionLoadResult | undefined, -): SessionSidebarView[] { - const bundled = getBundledSidebarView(); - const registered = extensions ? resolveExtensionSidebarViews(extensions.registry).views : []; - const replacesDefault = registered.some((entry) => entry.view.replacesDefault === true); - - return [ - { - key: sidebarViewKey(bundled), - registered: bundled, - placement: "left", - title: "Files", - defaultOpen: !replacesDefault, - }, - ...registered.map((entry) => ({ - key: sidebarViewKey(entry), - registered: entry, - placement: entry.view.placement ?? ("left" as const), - title: entry.view.title ?? entry.view.id, - defaultOpen: entry.view.defaultOpen === true || entry.view.replacesDefault === true, - })), - ]; -} - -/** - * Which views are open, plus which keys have been seen before. - * - * `known` is what distinguishes "newly registered, apply its defaultOpen" - * from "the user closed this earlier" when extensions reload mid-session. - */ -export interface SidebarOpenState { - known: readonly string[]; - open: readonly string[]; -} - -/** Build the open state a fresh session starts with. */ -export function initialSidebarOpenState(views: readonly SessionSidebarView[]): SidebarOpenState { - return { - known: views.map((view) => view.key), - open: views.filter((view) => view.defaultOpen).map((view) => view.key), - }; -} - -/** - * Carry open/closed choices across an extension reload. - * - * Views that disappeared drop out; views seen before keep the user's choice; - * brand-new views apply their own `defaultOpen`. Returns the previous state - * object untouched when nothing changed, so effect loops stay quiet. - */ -export function reconcileSidebarOpenState( - views: readonly SessionSidebarView[], - state: SidebarOpenState, -): SidebarOpenState { - const keys = views.map((view) => view.key); - const known = new Set(state.known); - const open = new Set(state.open); - - const nextOpen = views - .filter((view) => (known.has(view.key) ? open.has(view.key) : view.defaultOpen)) - .map((view) => view.key); - - const sameKnown = keys.length === state.known.length && keys.every((key) => known.has(key)); - const sameOpen = - nextOpen.length === state.open.length && - nextOpen.every((key, index) => state.open[index] === key); - if (sameKnown && sameOpen) { - return state; - } - - return { known: keys, open: nextOpen }; -} - -/** - * Resolve the view a sidebar-controls call names. - * - * A bare id resolves within the calling extension first; `"files"` is the - * bundled file navigation; a `:` key addresses any view. - */ -export function resolveSidebarViewKey( - views: readonly SessionSidebarView[], - callerExtensionId: string, - viewId: string, -): string | undefined { - const candidates = viewId.includes(":") - ? [viewId] - : [`${callerExtensionId}:${viewId}`, viewId === "files" ? bundledSidebarViewKey() : viewId]; - - for (const candidate of candidates) { - if (views.some((view) => view.key === candidate)) { - return candidate; - } - } - - return undefined; -} - -/** One pane the layout decided to draw, at its resolved width. */ -export interface SidebarPanePlan { - view: SessionSidebarView; - width: number; -} - -/** The panes that fit this frame, split by side, plus the columns they consume. */ -export interface SidebarLayoutPlan { - left: SidebarPanePlan[]; - right: SidebarPanePlan[]; - /** Total columns used by panes and their dividers, both sides. */ - totalWidth: number; - /** Columns consumed left of the review stream (left panes plus dividers). */ - leftWidth: number; -} - -export interface PlanSidebarLayoutOptions { - views: readonly SessionSidebarView[]; - openKeys: readonly string[]; - /** Per-view preferred widths from user resizes; absent views use the default. */ - widths: Readonly>; - defaultWidth: number; - minWidth: number; - dividerWidth: number; - /** Columns available for panes plus the review stream. */ - bodyWidth: number; - /** Columns the review stream may never drop below. */ - diffMinWidth: number; -} - -/** - * Decide which open panes fit and at what width. - * - * Panes are considered in view order — bundled first, then registration - * order — and each takes its preferred width, shrinking to what remains once - * the review stream's minimum is reserved. A pane that cannot get its minimum - * is skipped rather than squeezing the ones before it, so a narrow terminal - * degrades by dropping the latest-registered panes first. - */ -export function planSidebarLayout(options: PlanSidebarLayoutOptions): SidebarLayoutPlan { - const open = new Set(options.openKeys); - const left: SidebarPanePlan[] = []; - const right: SidebarPanePlan[] = []; - let used = 0; - - for (const view of options.views) { - if (!open.has(view.key)) { - continue; - } - - const preferred = options.widths[view.key] ?? options.defaultWidth; - const remaining = options.bodyWidth - options.diffMinWidth - used - options.dividerWidth; - const width = Math.min(Math.max(preferred, options.minWidth), remaining); - if (width < options.minWidth) { - continue; - } - - used += width + options.dividerWidth; - (view.placement === "right" ? right : left).push({ view, width }); - } - - const leftWidth = left.reduce((sum, pane) => sum + pane.width + options.dividerWidth, 0); - return { left, right, totalWidth: used, leftWidth }; -} diff --git a/test/pty/cursor-line.test.ts b/test/pty/cursor-line.test.ts index d5220f3f7..0248316b6 100644 --- a/test/pty/cursor-line.test.ts +++ b/test/pty/cursor-line.test.ts @@ -1,7 +1,12 @@ import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { createPtyHarness, lineIndexOf, measureKeyScroll } from "./harness"; const harness = createPtyHarness(); +const CURRENT_LINE_LENS_EXTENSION = resolve( + fileURLToPath(new URL("../../examples/extensions/current-line-lens", import.meta.url)), +); /** Give PTY-backed startup and redraws enough headroom for slower CI machines. */ setDefaultTimeout(20_000); @@ -44,6 +49,76 @@ describe("PTY current line", () => { } }); + test("the current-line lens example pins old above new and hides in stack mode", async () => { + const fixture = harness.createLongWrapFilePair(); + const session = await harness.launchHunk({ + args: [ + "diff", + fixture.before, + fixture.after, + "--mode", + "split", + "--extension", + CURRENT_LINE_LENS_EXTENSION, + ], + cols: 140, + rows: 18, + }); + + try { + const split = await session.waitForText(/Current line · old above, new below/, { + timeout: 15_000, + }); + const splitLines = split.split("\n"); + const lensIndex = lineIndexOf(split, "Current line"); + expect(splitLines[lensIndex + 1]).toContain("export const message = 'short';"); + expect(splitLines[lensIndex + 2]).toContain("this is a very long wrapped line"); + + await session.press("2"); + await harness.waitForSnapshot(session, (text) => !text.includes("Current line"), 5_000); + + await session.press("1"); + await session.waitForText(/Current line · old above, new below/, { timeout: 5_000 }); + } finally { + session.close(); + } + }); + + test("stepping updates lens content without moving its fixed rectangle", async () => { + const fixture = harness.createWideCharacterFilePair(); + const session = await harness.launchHunk({ + args: [ + "diff", + fixture.before, + fixture.after, + "--mode", + "split", + "--extension", + CURRENT_LINE_LENS_EXTENSION, + ], + cols: 140, + rows: 18, + }); + + try { + const initial = await session.waitForText(/Current line · old above, new below/, { + timeout: 15_000, + }); + const lensRow = lineIndexOf(initial, "Current line"); + expect(initial.split("\n")[lensRow + 1]).toContain("日本語"); + expect(initial.split("\n")[lensRow + 2]).toContain("한국어"); + + await harness.ensureKeyboardIsLive(session); + for (let step = 0; step < 4; step += 1) await session.press("j"); + const moved = await session.waitForText(/plain = 'after'/, { timeout: 5_000 }); + expect(lineIndexOf(moved, "Current line")).toBe(lensRow); + expect(moved.split("\n")[lensRow + 1]).toContain("plain = 'before'"); + expect(moved.split("\n")[lensRow + 2]).toContain("plain = 'after'"); + } finally { + session.close(); + } + }); + test("a held step key advances one line per press", async () => { const fixture = harness.createPinnedHeaderRepoFixture(); const session = await harness.launchHunk({ diff --git a/test/pty/extensions-integration.test.ts b/test/pty/extensions-integration.test.ts index b292a97d9..4152fe1ee 100644 --- a/test/pty/extensions-integration.test.ts +++ b/test/pty/extensions-integration.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; import { existsSync, readFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { createPtyHarness, lineIndexOf } from "./harness"; +import { createPtyHarness, dragMouse, lineIndexOf } from "./harness"; const harness = createPtyHarness(); const REVIEW_TRIAGE_EXTENSION = resolve( @@ -87,6 +87,29 @@ export default function (hunk) { * dialog path: a registered key opens the modal, Enter resolves the handler's * awaited promise, and the answer comes back as a toast. */ +const FOUR_EDGE_PANE_EXTENSION_SOURCE = `import { createElement } from "react"; +export default function (hunk) { + for (const placement of ["top", "bottom"]) { + hunk.registerPane({ + id: placement, + placement, + defaultOpen: false, + height: placement === "top" + ? { preferred: 2, min: 2, max: 5 } + : { preferred: 2, min: 2, max: 2 }, + component: (props) => createElement("text", { + content: "PANE " + placement.toUpperCase() + " " + props.width + "x" + props.height, + style: { fg: props.theme.text, bg: props.theme.panel }, + }), + }); + } + hunk.registerCommand({ id: "toggle-edges", title: "Toggle edge panes", key: "y" }, (ctx) => { + ctx.panes.toggle("top"); + ctx.panes.toggle("bottom"); + }); +} +`; + const DIALOG_EXTENSION_SOURCE = `export default function (hunk) { hunk.registerCommand({ id: "ask", title: "Ask", key: "y" }, async (ctx) => { const proceed = await ctx.dialogs.confirm({ @@ -302,6 +325,50 @@ describe("PTY extensions", () => { } }); + test("an extension can dock edge panes and resize through horizontal divider hit slop", async () => { + const configHome = harness.createIsolatedConfigHome(); + const fixture = harness.createRepoExtensionFixture(FOUR_EDGE_PANE_EXTENSION_SOURCE); + const session = await harness.launchHunk({ + args: [ + "diff", + "--mode", + "stack", + "--extension", + join(fixture.dir, ".hunk", "extensions", "fixture.ts"), + ], + cwd: fixture.dir, + cols: 140, + rows: 24, + env: { XDG_CONFIG_HOME: configHome }, + }); + try { + await harness.ensureKeyboardIsLive(session); + await session.press("y"); + const frame = await harness.waitForSnapshot( + session, + (text) => + text.includes("PANE TOP") && text.includes("PANE BOTTOM") && text.includes("alpha.ts"), + 20_000, + ); + expect(frame).toContain("PANE TOP 138x2"); + expect(frame).toContain("PANE BOTTOM 138x2"); + + // The visible divider is on row 3. Start one row below it to prove the + // enlarged horizontal hit area wins over review-stream text selection. + await dragMouse(session, 70, 4, 70, 6); + await session.waitForText(/PANE TOP 138x4/, { timeout: 5_000 }); + + await session.press("y"); + await harness.waitForSnapshot( + session, + (text) => !text.includes("PANE TOP") && !text.includes("PANE BOTTOM"), + 20_000, + ); + } finally { + session.close(); + } + }); + test("a command key opens an extension confirm dialog that enter resolves", async () => { const configHome = harness.createIsolatedConfigHome(); const fixture = harness.createRepoExtensionFixture(DIALOG_EXTENSION_SOURCE); diff --git a/test/pty/layout.test.ts b/test/pty/layout.test.ts index 48e43c97d..baeb55130 100644 --- a/test/pty/layout.test.ts +++ b/test/pty/layout.test.ts @@ -314,7 +314,10 @@ describe("PTY layout", () => { session.resize({ cols: 140, rows: 24 }); const tight = await harness.waitForSnapshot( session, - (text) => /▌.*▌/.test(text) && harness.countMatches(text, /alpha\.ts/g) === 1, + (text) => + /▌.*▌/.test(text) && + harness.countMatches(text, /alpha\.ts/g) === 1 && + text.includes("betaValue = 1"), 5_000, ); diff --git a/website/src/content/docs/docs/extend/custom-sidebars.md b/website/src/content/docs/docs/extend/custom-sidebars.md index 09d17eca0..568cbf9ab 100644 --- a/website/src/content/docs/docs/extend/custom-sidebars.md +++ b/website/src/content/docs/docs/extend/custom-sidebars.md @@ -1,16 +1,16 @@ --- -title: Custom sidebars -description: Render your own React sidebar view inside Hunk, with selection, scrolling, and event-driven state. +title: Custom panes +description: Render React panes around Hunk's review stream. --- -`hunk.registerSidebarView(view)` contributes a sidebar view — your own React component, rendered inside Hunk's OpenTUI tree. Registration is additive: your view exists beside the built-in file navigation, on either side of the review stream, and any number of views can be open at once. Pair it with [`registerCommand`](/docs/extend/extension-api/#hunkregistercommandcommand-handler) so a key opens it: +`hunk.registerPane(pane)` renders a React component on the left, right, top, or bottom of the review. Pair it with [`registerCommand`](/docs/extend/extension-api/#hunkregistercommandcommand-handler) so a key opens it: ```tsx // ~/.config/hunk/extensions/flat-sidebar.tsx import { useMemo } from "react"; -import type { ExtensionSidebarViewProps, HunkExtensionAPI } from "hunkdiff/extension"; +import type { ExtensionPaneProps, HunkExtensionAPI } from "hunkdiff/extension"; -function FlatSidebar({ files, selectedFileId, theme, actions }: ExtensionSidebarViewProps) { +function FlatPane({ files, selectedFileId, theme, actions }: ExtensionPaneProps) { const ordered = useMemo(() => [...files].sort((a, b) => a.path.localeCompare(b.path)), [files]); return ( @@ -31,22 +31,23 @@ function FlatSidebar({ files, selectedFileId, theme, actions }: ExtensionSidebar } export default function (hunk: HunkExtensionAPI) { - hunk.registerSidebarView({ + hunk.registerPane({ id: "flat", title: "Flat files", placement: "right", - component: FlatSidebar, + component: FlatPane, + }); + hunk.registerCommand({ id: "toggle-flat", title: "Toggle flat pane", key: "ctrl+f" }, (ctx) => { + ctx.panes.toggle("flat"); }); - hunk.registerCommand( - { id: "toggle-flat", title: "Toggle flat sidebar", key: "ctrl+f" }, - (ctx) => { - ctx.sidebars.toggle("flat"); - }, - ); } ``` -Beyond `id` and `component`, a view may declare a `title` (for diagnostics and future menu listings), a `placement` of `"left"` (default) or `"right"`, `defaultOpen: true` to start open, or `replacesDefault: true` to start open _in place of_ the built-in file navigation — which stays available, just closed, so a command can reopen it. +`placement` defaults to `"left"`. Left/right panes use `width`; top/bottom panes use `height`. Both accept `{ preferred, min?, max? }`, defaulting to `{ preferred: 34, min: 22 }` columns or `{ preferred: 8, min: 3 }` rows. Equal bounds make a fixed pane. Use `defaultOpen` to open a pane initially, `replaces: "hunk:files"` to replace it (and override `defaultOpen`), or `available(context)` to hide it conditionally. + +Set `currentLine: true` to receive Hunk's opaque selected-row painter. The [`current-line-lens` example](https://github.com/modem-dev/hunk/tree/main/examples/extensions/current-line-lens) uses it and is not bundled with Hunk. + +API-v3 sidebar names remain as deprecated aliases. Import `react` normally — Hunk serves its own React instance to extension files at import time, so hooks, context, and JSX all run on the reconciler drawing the rest of the app. **Never bundle or vendor a copy of React into an extension**: a second React means a second hooks dispatcher, and the component will fail to render. OpenTUI elements (`box`, `text`, `scrollbox`, ...) are plain intrinsic elements and need no import. @@ -59,12 +60,15 @@ The component receives fresh props as the app changes: | `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` | -| `width` | terminal columns the sidebar pane occupies | +| `placement` | the accepted terminal edge | +| `width` | exact terminal columns in the host-owned rectangle | +| `height` | exact terminal rows in the host-owned rectangle | +| `currentLine` | opaque selected-row painter 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 the sidebar may trigger | +| `actions` | guarded navigation and notifications the pane may trigger | -`actions.selectFile(fileId)` and `actions.selectHunk(fileId, hunkIndex)` route through the same review controller as the built-in sidebar 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, 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. +`actions.selectFile(fileId)` and `actions.selectHunk(fileId, hunkIndex)` 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, 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. The three hunk surfaces line up by design: each file's `hunks` lists public `ExtensionDiffHunk` summaries (`index`, the `@@` header, inclusive old/new line spans) in render order, `selectedHunkIndex` reports the same index, and `actions.selectHunk(fileId, hunkIndex)` accepts it. That is everything a hunk checklist, a per-hunk progress view, or an agent-annotation navigator needs — match an annotation's `oldRange`/`newRange` against the summaries' spans to find its hunk — without touching the opaque `metadata`. @@ -73,9 +77,9 @@ The three hunk surfaces line up by design: each file's `hunks` lists public `Ext A component that owns a key event should ask the injected `keybindings` manager about a **command id**, rather than hard-coding the command's default chord. This keeps local component behavior synchronized with the user's remaps and unbindings: ```ts -import type { ExtensionKeyEvent, ExtensionSidebarViewProps } from "hunkdiff/extension"; +import type { ExtensionKeyEvent, ExtensionPaneProps } from "hunkdiff/extension"; -export function handleSidebarKey(props: ExtensionSidebarViewProps, key: ExtensionKeyEvent) { +export function handlePaneKey(props: ExtensionPaneProps, key: ExtensionKeyEvent) { const nextFile = props.files[1]; if (nextFile && props.keybindings.matches(key, "hunk.review.nextFile")) { // The user may have remapped this from `.` to another chord. @@ -90,18 +94,18 @@ export function handleSidebarKey(props: ExtensionSidebarViewProps, key: Extensio ## The pane is Hunk's, the content is yours -Hunk keeps owning pane arrangement — widths, resize dividers, responsive show/hide, and dropping panes that no longer fit a narrow terminal — and your component fills the pane it is given. A component that throws while rendering costs you the pane, not the user the session: the failure is reported as a toast naming your extension, the pane closes, and the built-in file navigation reopens if nothing else is showing. +Hunk owns pane geometry, dividers, and responsive omission. Render failures are contained to the pane; a failed files-pane replacement restores file navigation. -Props carry the pane's `width` but not its height: the pane is a flex cell, so give your root element `height="100%"` and let layout size it. Everything else about scrolling — pane viewport height, scroll position, keeping a row visible — goes through the `` itself, via a plain React ref. Hunk serves its own `@opentui/core` to extension files, so the renderable a ref hands you is the very instance the host renders with. +Props carry the pane's exact `width` and `height`. Use a `` ref for scroll position and selection following; Hunk serves the matching `@opentui/core` instance. ## Scrolling: the scrollbox ref contract -The one behavior a list sidebar always ends up needing is following the selection. Give your rows stable `id` props, hold a ref to the scrollbox, and scroll the selected row into view from an effect: +The one behavior a list pane always ends up needing is following the selection. Give your rows stable `id` props, hold a ref to the scrollbox, and scroll the selected row into view from an effect: ```tsx import { useEffect, useRef } from "react"; import type { ScrollBoxRenderable } from "@opentui/core"; -import type { ExtensionSidebarViewProps } from "hunkdiff/extension"; +import type { ExtensionPaneProps } from "hunkdiff/extension"; function HunkList({ files, @@ -109,7 +113,7 @@ function HunkList({ selectedHunkIndex, theme, actions, -}: ExtensionSidebarViewProps) { +}: ExtensionPaneProps) { const scrollRef = useRef(null); // Follow policy is deliberately yours: the host never scrolls a pane it @@ -145,21 +149,21 @@ function HunkList({ } ``` -The ref surface this recipe stands on is the exact one the built-in sidebar runs on: +The ref surface this recipe stands on is the exact one the built-in files pane runs on: - **`scrollChildIntoView(id)`** scrolls the descendant with that `id` prop into view. -- **`scrollTop`** and **`viewport.height`** read the current scroll offset and the pane's viewport rows — the pane-height number the props do not carry. A read before the first layout pass reports `0`, so viewport-dependent code belongs behind the events below rather than a bare mount effect. +- **`scrollTop`** and **`viewport.height`** read the current scroll offset and the scrollbox's live viewport rows. A read before the first layout pass reports `0`, so viewport-dependent code belongs behind the events below rather than a bare mount effect. - **`verticalScrollBar.on("change", handler)`**, **`viewport.on("layout-changed", handler)`**, and **`viewport.on("resized", handler)`** report scrolling and pane resizes; unsubscribe with the matching `.off` in your effect's cleanup. -That is enough to window a long list yourself: the built-in sidebar renders only the rows near the viewport, plus spacer boxes sized from those same reads (its render-window helper is host code, but nothing it computes needs anything beyond this surface — `useTerminalDimensions` from `@opentui/react` serves as its pre-first-layout viewport estimate). +That is enough to window a long list yourself: the built-in files pane renders only the rows near the viewport, plus spacer boxes sized from those same reads (its render-window helper is host code, but nothing it computes needs anything beyond this surface — `useTerminalDimensions` from `@opentui/react` serves as its pre-first-layout viewport estimate). -One honest caveat: this contract rides on OpenTUI's renderable API, served at whatever version Hunk pins — a wider surface than `hunkdiff/extension` itself. The built-in sidebar exercising the exact same calls is the compatibility guarantee: a change that breaks your scroll code breaks Hunk's own sidebar first. Still, keep scroll handling small and behind your own helpers. +One honest caveat: this contract rides on OpenTUI's renderable API, served at whatever version Hunk pins — a wider surface than `hunkdiff/extension` itself. The built-in files pane exercising the exact same calls is the compatibility guarantee: a change that breaks your scroll code breaks Hunk's own files pane first. Still, keep scroll handling small and behind your own helpers. -The built-in sidebar is itself a bundled extension (`src/extensions/default/ui/sidebar/` in the Hunk repository): it registers through this exact call, its component consumes exactly the props documented above, and its windowing and selection follow run on exactly the ref contract above — so it doubles as the reference implementation for everything a third-party sidebar can build, from grouping and stat badges down to scroll behavior. +The built-in files pane is itself a bundled extension (`src/extensions/default/ui/sidebar/` in the Hunk repository): it registers through this exact call, its component consumes exactly the props documented above, and its windowing and selection follow run on exactly the ref contract above — so it doubles as the reference implementation for everything a third-party pane can build, from grouping and stat badges down to scroll behavior. -## Sidebar state from events +## Pane state from events -Lifecycle handlers run outside React, but a sidebar component only rerenders when React sees a change. The recipe that connects them is a module-local store read through `useSyncExternalStore`: the event handler updates the store, and any mounted component subscribed to it rerenders — while the store keeps accumulating even when the pane is closed. +Lifecycle handlers run outside React, but a pane component only rerenders when React sees a change. The recipe that connects them is a module-local store read through `useSyncExternalStore`: the event handler updates the store, and any mounted component subscribed to it rerenders — while the store keeps accumulating even when the pane is closed. ```tsx import { useSyncExternalStore } from "react"; @@ -191,7 +195,7 @@ function ViewedCount() { export default function (hunk: HunkExtensionAPI) { hunk.on("file_viewed", ({ file }) => markViewed(file.path)); - hunk.registerSidebarView({ id: "progress", component: ViewedCount }); + hunk.registerPane({ id: "progress", component: ViewedCount }); } ``` diff --git a/website/src/content/docs/docs/extend/extension-api.md b/website/src/content/docs/docs/extend/extension-api.md index 1f8488424..f87f9e4d6 100644 --- a/website/src/content/docs/docs/extend/extension-api.md +++ b/website/src/content/docs/docs/extend/extension-api.md @@ -7,7 +7,7 @@ The extension factory receives one API object. Registration calls are only valid ## `hunk.apiVersion` -The API generation this Hunk speaks (currently `4`). Branch on it if you want one file to support several Hunk versions. Version 4 adds session-scoped keyboard modes; version 3 added live execution of public Hunk commands from extension command handlers. +The API generation this Hunk speaks (currently `4`). Version 4 adds keyboard modes and docked panes; API-v3 sidebar names remain as deprecated aliases. ## `hunk.registerTheme(theme)` @@ -42,11 +42,11 @@ Contribute an additional version-control backend — the same call Hunk's own bu Full contract: [VCS adapters](/docs/extend/vcs-adapters/). -## `hunk.registerSidebarView(view)` +## `hunk.registerPane(pane)` -Contribute a sidebar view — your own React component, rendered inside Hunk's OpenTUI tree beside (or in place of) the built-in file navigation. Views receive live review props, guarded navigation actions, the user's resolved keybindings, and a scrollbox ref contract for selection-following and windowing. +Render a React component on the `left`, `right`, `top`, or `bottom` of the review. Panes receive their dimensions, review state, actions, keybindings, and optional current-line paint. `registerSidebarView` remains a deprecated alias. -Full contract: [Custom sidebars](/docs/extend/custom-sidebars/). +Full contract: [Custom panes](/docs/extend/custom-sidebars/). ## `hunk.registerFileView(view)` @@ -67,11 +67,11 @@ hunk.transformChangeset((changeset) => ({ })); ``` -The function may be async. Filtering and reordering `files` is fully supported — the sidebar and the review stream follow whatever you return. +The function may be async. Filtering and reordering `files` is fully supported — panes and the review stream follow whatever you return. Each file carries an opaque `metadata` field — the parsed diff the renderer draws from — so pass it through untouched; spreading a file preserves it. Returns are validated: a transform that throws or returns something the review UI cannot draw is skipped, and the previous changeset carries forward. -You never need `metadata` to know a file's hunks: the read-only views Hunk hands outward (event payloads, sidebar props, a command's selection) carry a `hunks` list of public summaries — `index`, the `@@` header, and the inclusive old/new line spans, in render order. Like `changeType`, it is derived at that boundary; a transform neither receives nor produces it. +You never need `metadata` to know a file's hunks: the read-only views Hunk hands outward (event payloads, pane props, a command's selection) carry a `hunks` list of public summaries — `index`, the `@@` header, and the inclusive old/new line spans, in render order. Like `changeType`, it is derived at that boundary; a transform neither receives nor produces it. ## `hunk.registerKeyboardMode(mode)` @@ -122,7 +122,7 @@ The handler fires when the key is pressed outside modal UI (dialogs, menus, and - `ctx.commands.isEnabled(commandId)` / `execute(commandId, { count? })` — probes or invokes an explicitly public built-in `hunk.*` command through the same live table as keyboard and menu actions. Relative movement applies counts atomically; extension-owned and cross-extension commands return `false`. - `ctx.keyboardModes.enterMode(id)` / `exitMode()` / `isActive(id?)` — controls only keyboard modes registered by this command's owning extension. -- `ctx.sidebars.open(viewId)` / `close(viewId)` / `toggle(viewId)` / `isOpen(viewId)` — a bare id names your own view, `"files"` the built-in file navigation, `":"` any registered view. Opening also reveals a hidden sidebar area. +- `ctx.panes.open(paneId)` / `close(paneId)` / `toggle(paneId)` / `isOpen(paneId)` — controls your panes, `"files"`, or a fully qualified `":"`. `ctx.sidebars` is deprecated. - `ctx.fileViews.select(viewId)` / `toggle(viewId)` / `isActive(viewId)` — controls a matching [file preview](/docs/extend/file-previews/) for the current file; `select(null)` restores raw diff. - `ctx.fileViews.refresh(viewId, options?)` — marks that view's prepared layouts stale so a stateful view re-derives; every file presenting it re-lays out, keeping its current rows visible until the replacement resolves. Pass `{ fileId }` to scope the invalidation to one reviewed file's presentation of the view. - `ctx.fileViews.enterMode(viewId)` / `exitMode()` / `isModeActive(viewId)` — starts, stops, or checks an [interactive preview](/docs/extend/file-previews/#interactive-previews). Entering selects the view and returns whether its mode started. @@ -146,9 +146,9 @@ hunk.registerCommand( ); ``` -`selection.file` is a frozen view, identical to a sidebar's `files` entries; it is `null` only when no files are visible. `selection.hunkIndex` is `null` whenever `file` is, or when the file has no hunks. The values are captured when the command fires, so an async handler keeps the selection it started from. +`selection.file` is a frozen view, identical to a pane's `files` entries; it is `null` only when no files are visible. `selection.hunkIndex` is `null` whenever `file` is, or when the file has no hunks. The values are captured when the command fires, so an async handler keeps the selection it started from. -`ctx.navigation.selectFile(fileId)` and `selectHunk(fileId, hunkIndex)` route through the same guarded review controller as a sidebar's `actions` — the stream scrolls, selection updates, `selection_changed` fires. Unlike `selection` it is live: a handler that awaits a dialog and then navigates still works. +`ctx.navigation.selectFile(fileId)` and `selectHunk(fileId, hunkIndex)` route through the same guarded review controller as a pane's `actions` — the stream scrolls, selection updates, `selection_changed` fires. Unlike `selection` it is live: a handler that awaits a dialog and then navigates still works. All built-ins listed in the [keybindings reference](https://github.com/modem-dev/hunk/blob/main/docs/keybindings.md) are public to command handlers. This includes the unbound `hunk.review.alignCurrentLineTop`, `hunk.review.alignCurrentLineCenter`, and `hunk.review.alignCurrentLineBottom` commands. `count` defaults to `1`, is capped at `10,000`, and scales relative row, viewport, horizontal, file, hunk, and annotated navigation in one host transition. Absolute and one-shot commands run once. Unknown, disabled, non-public, extension-owned, or stale commands return `false`. `isEnabled` also returns `false` for a malformed id; malformed `execute` ids, options, and counts throw into normal extension failure containment. @@ -235,7 +235,7 @@ Writes require a reloadable, unstaged working-tree review and a writable reviewe ## `hunk.on(event, handler)` -Subscribe to a lifecycle or UI event. Handlers may be async; Hunk never blocks the UI waiting for one. Every handler receives `ctx.sidebars` alongside `cwd` and `notify`, so a `changeset_loaded` handler can reveal its extension's sidebar without a keypress. +Subscribe to a lifecycle or UI event. Handlers may be async and receive `ctx.panes`, `cwd`, and `notify`. `ctx.sidebars` is deprecated. | Event | Payload | When | | ---------------------- | ----------------------- | -------------------------------------------------------- | @@ -266,12 +266,12 @@ import type { HunkExtensionAPI } from "hunkdiff/extension"; export default function (hunk: HunkExtensionAPI) { hunk.events.on<{ fileCount: number }>("summary:ready", (payload, ctx) => { - if (payload.fileCount > 100) ctx.sidebars.open("summary"); + if (payload.fileCount > 100) ctx.panes.open("summary"); }); hunk.on("changeset_loaded", ({ changeset }, ctx) => { hunk.events.emit("summary:ready", { fileCount: changeset.files.length }); - ctx.sidebars.open("summary"); + ctx.panes.open("summary"); }); } ``` @@ -296,7 +296,7 @@ const patterns = (hunk.config.patterns as string[] | undefined) ?? ["*.lock"]; ## `ctx.notify(message, type?)` -Every handler and transform receives a context with `cwd` and `notify`; event and bus handlers add `sidebars` and `events.emit`, command handlers add `commands`, `sidebars`, `fileViews`, `selection`, `navigation`, and `dialogs`. `notify` shows one transient line at the bottom of the app; `type` is `"info"` (default), `"warning"`, or `"error"`. Messages raised before the UI mounts are buffered, so a `startup` handler can notify safely. +Every handler and transform receives a context with `cwd` and `notify`; event and bus handlers add `panes` and `events.emit`, command handlers add `commands`, `panes`, `fileViews`, `selection`, `navigation`, and `dialogs`. `notify` shows one transient line at the bottom of the app; `type` is `"info"` (default), `"warning"`, or `"error"`. Messages raised before the UI mounts are buffered, so a `startup` handler can notify safely. ## `hunk.log(message)` diff --git a/website/src/content/docs/docs/extend/extensions.md b/website/src/content/docs/docs/extend/extensions.md index 2142f2947..6aa66b308 100644 --- a/website/src/content/docs/docs/extend/extensions.md +++ b/website/src/content/docs/docs/extend/extensions.md @@ -18,7 +18,7 @@ export default function (hunk: HunkExtensionAPI) { **The API is experimental**: `hunkdiff/extension` may change in breaking ways between minor releases while it stabilizes. Breaking changes are called out in release notes, and `hunk.apiVersion` identifies the surface an extension was written against. -What an extension can register is covered by the companion pages: the [extension API](/docs/extend/extension-api/), [file previews](/docs/extend/file-previews/), [VCS adapters](/docs/extend/vcs-adapters/), and [custom sidebars](/docs/extend/custom-sidebars/). +What an extension can register is covered by the companion pages: the [extension API](/docs/extend/extension-api/), [file previews](/docs/extend/file-previews/), [VCS adapters](/docs/extend/vcs-adapters/), and [custom panes](/docs/extend/custom-sidebars/). Writing one with a coding agent? `hunk skill path hunk-extensions` prints a bundled skill that maps these touchpoints for agents, the way `hunk skill path` does for reviewing. @@ -61,14 +61,14 @@ The **id** is the file stem, or the folder name for `/index.ts` and single - config: `[extension.]` - commands: `.` -- sidebar views: `:` +- panes: `:` - file previews: `:` Ids start with a letter or digit, then letters, digits, `-`, or `_`. `hunk`, `git`, `jj`, and `sl` are reserved. An invalid id — or a second source offering an already-loaded id — is skipped with a startup notice. ## Bundled extensions -Hunk's own Git, Jujutsu, and Sapling backends and the built-in file-navigation sidebar are themselves extensions, registered through the same public API — which is what keeps that API honest. They differ from yours in three ways: +Hunk's Git, Jujutsu, Sapling, and file-navigation pane use the same public extension API. Bundled extensions differ from yours in three ways: - statically imported, so they load before config resolution picks the session's VCS - implicitly trusted, with no `[extension.]` config table