Skip to content
Merged
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/inject-sidebar-keybindings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Expose resolved command keybindings to custom extension sidebar components so local handlers honor user remapping and unbinding.
35 changes: 20 additions & 15 deletions docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/extension-api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export type {
ExtensionSelectOptions,
ExtensionNotifyType,
ExtensionSidebarActions,
ExtensionSidebarKeybindings,
ExtensionSidebarComponent,
ExtensionSidebarControls,
ExtensionSidebarPlacement,
Expand Down
28 changes: 28 additions & 0 deletions src/extension-api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
* (`"<extensionId>.<commandId>"`).
*/
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 {
/**
Expand All @@ -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;
}

Expand Down
12 changes: 11 additions & 1 deletion src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string>());
useEffect(() => {
for (const conflict of extensionAppCommands.conflicts) {
Expand Down Expand Up @@ -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();
Expand Down
38 changes: 38 additions & 0 deletions src/ui/AppHost.extension-sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions src/ui/components/panes/ExtensionSidebarPane.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -19,6 +20,11 @@ function registeredView(component: (props: ExtensionSidebarViewProps) => ReactNo
} as RegisteredSidebarView;
}

const TEST_KEYBINDINGS: ExtensionSidebarKeybindings = {
matches: () => false,
getKeys: () => [],
};

function createTestFiles() {
return [
createTestDiffFile({
Expand Down Expand Up @@ -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])}
Expand Down Expand Up @@ -138,6 +145,7 @@ describe("ExtensionSidebarPane failure recovery", () => {
showTopChrome={true}
theme={theme}
width={30}
keybindings={TEST_KEYBINDINGS}
notify={(message) => notifications.push(message)}
onSelectFile={() => {}}
onSelectHunk={() => {}}
Expand Down
4 changes: 4 additions & 0 deletions src/ui/components/panes/ExtensionSidebarPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
ExtensionDiffFile,
ExtensionNotifyType,
ExtensionSidebarActions,
ExtensionSidebarKeybindings,
ExtensionSidebarTheme,
ExtensionSidebarViewProps,
} from "../../../extension-api/types";
Expand Down Expand Up @@ -113,6 +114,7 @@ export function ExtensionSidebarPane({
showTopChrome,
theme,
width,
keybindings,
notify,
onSelectFile,
onSelectHunk,
Expand All @@ -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;
Expand Down Expand Up @@ -223,6 +226,7 @@ export function ExtensionSidebarPane({
selectedHunkIndex,
width,
theme: publicTheme,
keybindings,
actions,
};

Expand Down
1 change: 1 addition & 0 deletions src/ui/components/ui-components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,7 @@ describe("UI components", () => {
selectedHunkIndex={0}
theme={theme}
width={30}
keybindings={{ matches: () => false, getKeys: () => [] }}
actions={{ selectFile: () => {}, selectHunk: () => {}, notify: () => {} }}
/>,
36,
Expand Down
29 changes: 28 additions & 1 deletion src/ui/lib/keymap.test.ts
Original file line number Diff line number Diff line change
@@ -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"] },
Expand Down Expand Up @@ -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");
Expand Down
40 changes: 39 additions & 1 deletion src/ui/lib/keymap.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<string, readonly string[]>,
): 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[];
Expand Down
Loading