From 53175326edc7a9c6ae1efef0248e75720bb1bf97 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 28 Jul 2026 00:54:10 -0400 Subject: [PATCH] feat(extensions): inject resolved sidebar keybindings --- .changeset/inject-sidebar-keybindings.md | 5 +++ docs/extensions.md | 35 +++++++++------- src/extension-api/index.ts | 1 + src/extension-api/types.ts | 28 +++++++++++++ src/ui/App.tsx | 12 +++++- src/ui/AppHost.extension-sidebar.test.tsx | 38 ++++++++++++++++++ .../panes/ExtensionSidebarPane.test.tsx | 8 ++++ .../components/panes/ExtensionSidebarPane.tsx | 4 ++ src/ui/components/ui-components.test.tsx | 1 + src/ui/lib/keymap.test.ts | 29 +++++++++++++- src/ui/lib/keymap.ts | 40 ++++++++++++++++++- 11 files changed, 183 insertions(+), 18 deletions(-) create mode 100644 .changeset/inject-sidebar-keybindings.md diff --git a/.changeset/inject-sidebar-keybindings.md b/.changeset/inject-sidebar-keybindings.md new file mode 100644 index 00000000..b9eca45b --- /dev/null +++ b/.changeset/inject-sidebar-keybindings.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Expose resolved command keybindings to custom extension sidebar components so local handlers honor user remapping and unbinding. diff --git a/docs/extensions.md b/docs/extensions.md index 819a1de0..68722620 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -526,6 +526,7 @@ The component receives fresh props as the app changes: | `selectedHunkIndex` | the selected hunk within that file, or `null` | | `width` | terminal columns the sidebar pane occupies | | `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.selectFile(fileId)` and `actions.selectHunk(fileId, hunkIndex)` route @@ -536,28 +537,32 @@ 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. -A component that wants keys of its own should match them with the same grammar -Hunk uses, rather than reading modifier flags by hand: +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. Like Pi's injected `KeybindingsManager`, this keeps local component +behavior synchronized with the user's remaps and unbindings: ```ts -import { matchesKey } from "hunkdiff/extension"; -import type { ExtensionKeyEvent } from "hunkdiff/extension"; +import type { ExtensionKeyEvent, ExtensionSidebarViewProps } from "hunkdiff/extension"; -export function handleSidebarKey(key: ExtensionKeyEvent) { - if (matchesKey("ctrl+n", key)) { - return "next"; +export function handleSidebarKey(props: ExtensionSidebarViewProps, 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. + props.actions.selectFile(nextFile.id); } - - return matchesKey("G", key) ? "last" : "none"; } ``` -`matchesKey(chord, key)` parses and matches in one call and returns `false` for -an unparsable chord, so a typo is a binding that never fires rather than one -that swallows unrelated keys. `parseKeyChord` and `matchesKeyChord` are exported -too, for a component that would rather parse its chords once up front. Any -object with `name`, `sequence`, `ctrl`, `meta`, `option`, and `shift` fields -works — OpenTUI's `KeyEvent` included, so pass the event straight through. +`keybindings.getKeys(commandId)` returns the current chord list for a label or +hint; unknown and unbound commands return an empty list. `matches(key, +commandId)` returns `false` for those commands too. The manager includes both +Hunk commands and extension commands under their documented ids, and its key +event argument is structural — OpenTUI's `KeyEvent` works directly. + +`matchesKey`, `parseKeyChord`, and `matchesKeyChord` remain exported for +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 diff --git a/src/extension-api/index.ts b/src/extension-api/index.ts index fce0d9c1..79d1ba80 100644 --- a/src/extension-api/index.ts +++ b/src/extension-api/index.ts @@ -55,6 +55,7 @@ export type { ExtensionSelectOptions, ExtensionNotifyType, ExtensionSidebarActions, + ExtensionSidebarKeybindings, ExtensionSidebarComponent, ExtensionSidebarControls, ExtensionSidebarPlacement, diff --git a/src/extension-api/types.ts b/src/extension-api/types.ts index 39552256..4310bf13 100644 --- a/src/extension-api/types.ts +++ b/src/extension-api/types.ts @@ -618,6 +618,32 @@ export interface ExtensionSidebarActions { notify(message: string, type?: ExtensionNotifyType): void; } +/** + * The resolved command bindings available to a custom sidebar. + * + * This mirrors Pi's injected keybindings manager: sidebar 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 { + /** Report whether one terminal key event matches the command's current binding. */ + matches( + key: { + name?: string; + sequence?: string; + ctrl?: boolean; + meta?: boolean; + option?: boolean; + shift?: boolean; + }, + commandId: string, + ): boolean; + /** Return the command's current chords, or an empty list when it is unbound or unknown. */ + getKeys(commandId: string): readonly string[]; +} + /** Everything a custom sidebar component receives, refreshed as the app changes. */ export interface ExtensionSidebarViewProps { /** @@ -632,6 +658,8 @@ export interface ExtensionSidebarViewProps { /** 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; } diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 832ead13..3925b203 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -74,7 +74,7 @@ import { buildAppMenus } from "./lib/appMenus"; import { buildExtensionAppCommands, extensionCommandKeyDefaults } from "./lib/extensionCommands"; import { createExtensionDialogQueue } from "./lib/extensionDialogs"; import { buildExtensionReviewSelection } from "./lib/extensionSelection"; -import { resolveCommandKeys } from "./lib/keymap"; +import { createExtensionSidebarKeybindings, resolveCommandKeys } from "./lib/keymap"; import { buildSessionSidebarViews, bundledSidebarViewKey, @@ -661,6 +661,15 @@ export function App({ }), [registeredExtensionCommands, resolvedCommandKeys, runExtensionCommand], ); + // Sidebar views receive the dispatcher’s effective keys, including command + // conflicts, rather than independently resolving their default bindings. + const sidebarKeybindings = useMemo(() => { + const effectiveKeys = new Map(resolvedCommandKeys); + for (const command of extensionAppCommands.commands) { + effectiveKeys.set(command.id, command.keys); + } + return createExtensionSidebarKeybindings(effectiveKeys); + }, [extensionAppCommands.commands, resolvedCommandKeys]); const reportedCommandConflictsRef = useRef(new Set()); useEffect(() => { for (const conflict of extensionAppCommands.conflicts) { @@ -1548,6 +1557,7 @@ export function App({ showTopChrome={showMenuBar} theme={activeTheme} width={pane.width} + keybindings={sidebarKeybindings} notify={(message, type) => extensions?.context.notify(message, type)} onSelectFile={(fileId) => { focusFiles(); diff --git a/src/ui/AppHost.extension-sidebar.test.tsx b/src/ui/AppHost.extension-sidebar.test.tsx index 74989dc8..8e53357c 100644 --- a/src/ui/AppHost.extension-sidebar.test.tsx +++ b/src/ui/AppHost.extension-sidebar.test.tsx @@ -208,6 +208,44 @@ describe("extension sidebar views", () => { }); }); + test("injects the user's resolved command bindings into a sidebar view", async () => { + const repo = createTestRepo("hunk-ext-sidebar-keybindings-"); + const extPath = join(createTempDir("hunk-ext-sidebar-keybindings-ext-"), "ext.ts"); + writeFileSync( + extPath, + `import { createElement } from "react";\n` + + `export default function (hunk) {\n` + + ` hunk.registerSidebarView({\n` + + ` id: "keys",\n` + + ` defaultOpen: true,\n` + + ` component: (props) => createElement("box", { style: { flexDirection: "column" } },\n` + + ` createElement("text", {\n` + + ` content: "EXTKEYS " + props.keybindings.getKeys("hunk.review.nextFile").join(",") +\n` + + ` " matched=" + props.keybindings.matches({ name: "n", ctrl: true }, "hunk.review.nextFile"),\n` + + ` }),\n` + + ` createElement("text", {\n` + + ` content: "BLOCKED " + (props.keybindings.getKeys("ext.blocked").join(",") || "none"),\n` + + ` }),\n` + + ` ),\n` + + ` });\n` + + ` hunk.registerCommand({ id: "blocked", title: "Blocked", key: "s" }, () => {});\n` + + `}\n`, + ); + + const bootstrap = await launchWithExtension(repo, extPath); + bootstrap.keybindings = { "hunk.review.nextFile": "ctrl+n" }; + await withAppHost(bootstrap, async (setup) => { + await flushUntil( + setup, + () => { + const frame = setup.captureCharFrame(); + return frame.includes("EXTKEYS ctrl+n matched=true") && frame.includes("BLOCKED none"); + }, + "the sidebar to receive the remapped command manager", + ); + }); + }); + test("a command handler sees the current review selection", async () => { const repo = createTestRepo("hunk-ext-selection-"); // Outside the repo, so the fixture and its log never join the review as diff --git a/src/ui/components/panes/ExtensionSidebarPane.test.tsx b/src/ui/components/panes/ExtensionSidebarPane.test.tsx index d42660e6..01b7dc2b 100644 --- a/src/ui/components/panes/ExtensionSidebarPane.test.tsx +++ b/src/ui/components/panes/ExtensionSidebarPane.test.tsx @@ -4,6 +4,7 @@ import { act, useState, type ReactNode } from "react"; import { createTestDiffFile } from "../../../../test/helpers/diff-helpers"; import type { ExtensionSidebarActions, + ExtensionSidebarKeybindings, ExtensionSidebarViewProps, } from "../../../extension-api/types"; import { toReadOnlyFileViews } from "../../../extensions/events"; @@ -19,6 +20,11 @@ function registeredView(component: (props: ExtensionSidebarViewProps) => ReactNo } as RegisteredSidebarView; } +const TEST_KEYBINDINGS: ExtensionSidebarKeybindings = { + matches: () => false, + getKeys: () => [], +}; + function createTestFiles() { return [ createTestDiffFile({ @@ -76,6 +82,7 @@ describe("ExtensionSidebarPane actions", () => { showTopChrome={true} theme={theme} width={30} + keybindings={TEST_KEYBINDINGS} notify={(message) => notifications.push(message)} onSelectFile={() => {}} onSelectHunk={(fileId, hunkIndex) => hunkSelections.push([fileId, hunkIndex])} @@ -138,6 +145,7 @@ describe("ExtensionSidebarPane failure recovery", () => { showTopChrome={true} theme={theme} width={30} + keybindings={TEST_KEYBINDINGS} notify={(message) => notifications.push(message)} onSelectFile={() => {}} onSelectHunk={() => {}} diff --git a/src/ui/components/panes/ExtensionSidebarPane.tsx b/src/ui/components/panes/ExtensionSidebarPane.tsx index 076e1a98..2d462104 100644 --- a/src/ui/components/panes/ExtensionSidebarPane.tsx +++ b/src/ui/components/panes/ExtensionSidebarPane.tsx @@ -3,6 +3,7 @@ import type { ExtensionDiffFile, ExtensionNotifyType, ExtensionSidebarActions, + ExtensionSidebarKeybindings, ExtensionSidebarTheme, ExtensionSidebarViewProps, } from "../../../extension-api/types"; @@ -113,6 +114,7 @@ export function ExtensionSidebarPane({ showTopChrome, theme, width, + keybindings, notify, onSelectFile, onSelectHunk, @@ -137,6 +139,7 @@ export function ExtensionSidebarPane({ showTopChrome: boolean; theme: AppTheme; width: number; + keybindings: ExtensionSidebarKeybindings; notify: ExtensionNotifySink; onSelectFile: (fileId: string) => void; onSelectHunk: (fileId: string, hunkIndex: number) => void; @@ -223,6 +226,7 @@ export function ExtensionSidebarPane({ selectedHunkIndex, width, theme: publicTheme, + keybindings, actions, }; diff --git a/src/ui/components/ui-components.test.tsx b/src/ui/components/ui-components.test.tsx index 175e3bc7..298bb779 100644 --- a/src/ui/components/ui-components.test.tsx +++ b/src/ui/components/ui-components.test.tsx @@ -458,6 +458,7 @@ describe("UI components", () => { selectedHunkIndex={0} theme={theme} width={30} + keybindings={{ matches: () => false, getKeys: () => [] }} actions={{ selectFile: () => {}, selectHunk: () => {}, notify: () => {} }} />, 36, diff --git a/src/ui/lib/keymap.test.ts b/src/ui/lib/keymap.test.ts index b4bf129c..bdd09a2c 100644 --- a/src/ui/lib/keymap.test.ts +++ b/src/ui/lib/keymap.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { formatKeyChord, resolveCommandKeys, type CommandKeyDefaults } from "./keymap"; +import { + createExtensionSidebarKeybindings, + formatKeyChord, + resolveCommandKeys, + type CommandKeyDefaults, +} from "./keymap"; const DEFAULTS: CommandKeyDefaults[] = [ { id: "hunk.app.quit", defaultKeys: ["q"] }, @@ -130,6 +135,28 @@ describe("resolveCommandKeys", () => { }); }); +describe("extension sidebar 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); + + expect(keybindings.getKeys("hunk.review.nextHunk")).toEqual(["ctrl+n"]); + expect(keybindings.matches({ name: "n", ctrl: true }, "hunk.review.nextHunk")).toBe(true); + // The manager follows user remaps, so the shipped bracket chord is gone. + expect(keybindings.matches({ sequence: "]" }, "hunk.review.nextHunk")).toBe(false); + expect(keybindings.getKeys("hunk.app.quit")).toEqual([]); + expect(keybindings.matches({ name: "q" }, "hunk.app.quit")).toBe(false); + }); + + test("treats unknown command ids as unbound", () => { + const { keys } = resolveCommandKeys({ defaults: DEFAULTS }); + const keybindings = createExtensionSidebarKeybindings(keys); + + expect(keybindings.getKeys("missing.command")).toEqual([]); + expect(keybindings.matches({ name: "q" }, "missing.command")).toBe(false); + }); +}); + describe("formatKeyChord", () => { test("renders chords the way keyboards label them", () => { expect(formatKeyChord("q")).toBe("q"); diff --git a/src/ui/lib/keymap.ts b/src/ui/lib/keymap.ts index 1ade380f..6293f194 100644 --- a/src/ui/lib/keymap.ts +++ b/src/ui/lib/keymap.ts @@ -1,6 +1,7 @@ import type { UserKeyBinding } from "../../core/types"; +import type { ExtensionSidebarKeybindings } from "../../extension-api/types"; import { HUNK_VENDOR_EXTENSION_ID } from "../../extensions/extensionIds"; -import { parseKeyChord, type ParsedKeyChord } from "../../lib/commandKeys"; +import { matchesKeyChord, parseKeyChord, type ParsedKeyChord } from "../../lib/commandKeys"; /** * Resolve which chords each command answers to, defaults against user config. @@ -38,6 +39,43 @@ export interface ResolvedKeymap { issues: KeymapIssue[]; } +const NO_KEY_CHORDS: readonly string[] = Object.freeze([]); + +/** + * Build the immutable keybindings manager injected into sidebar 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( + resolvedKeys: ReadonlyMap, +): ExtensionSidebarKeybindings { + const keysByCommand = new Map( + Array.from(resolvedKeys, ([commandId, keys]) => [commandId, Object.freeze([...keys])]), + ); + const parsedByCommand = new Map( + Array.from(keysByCommand, ([commandId, keys]) => [ + commandId, + keys + .map((key) => parseKeyChord(key)) + .filter((key): key is ParsedKeyChord => !("error" in key)), + ]), + ); + + const keybindings: ExtensionSidebarKeybindings = { + matches(key, commandId) { + return parsedByCommand.get(commandId)?.some((chord) => matchesKeyChord(chord, key)) ?? false; + }, + getKeys(commandId) { + return keysByCommand.get(commandId) ?? NO_KEY_CHORDS; + }, + }; + + return Object.freeze(keybindings); +} + export interface ResolveCommandKeysOptions { /** Every command that can be bound, in dispatch order. */ defaults: readonly CommandKeyDefaults[];