Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/four-edge-extension-panes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Generalize extension sidebars into dockable panes on all four review edges.
52 changes: 21 additions & 31 deletions docs/extension-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<extensionId>.<commandId>`), sidebar views
owns for commands (`<extensionId>.<commandId>`), panes
(`<extensionId>:<viewId>`), and config (`[extension.<id>]`). `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
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading