99dfbbde58
* feat(plugins): add keybindings and modal window capabilities
Add two independent plugin UI capabilities.
Keybindings: plugins declare keybindings in the manifest under
ui.keybindings ({id, default, description}), validated backend-side
(combo grammar + unique ids, requires a UI bundle) and exposed to the
frontend. Plugin JS binds a handler via registry.registerKeybinding(id,
handler); a global dispatcher fires it. Combos are user-overridable in
Settings > Keyboard Shortcuts (namespaced plugin:{pluginId}:{id}) with
conflict detection against core and other plugins.
Modals: host.openModal({title?, content, size?, dismissible?}) returns a
{ close() } handle and renders in a single host-owned Dialog isolated by
PluginErrorBoundary, cleaned up on plugin unload.
Fixture plugin, PLUGIN-API.md / spec, and a Playwright e2e demonstrate a
keybinding handler opening a modal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(plugins): address review feedback on keybinding dispatch and validation
Core shortcuts now take precedence over plugin keybindings on a colliding
combo (defaultPrevented cooperation between the two capture-phase
dispatchers), a throwing plugin handler no longer aborts dispatch to other
plugins, and the keybinding combo grammar is tightened: the backend validator
and frontend parser both drop the undocumented control/super modifier
aliases and reject shift+digit/symbol combos that can never actually fire
(KeyboardEvent.key reports the shifted character, not the token).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(plugins): stop conflicting core shortcut from double-firing with plugin keybindings
The earlier defaultPrevented guard only covered the central useAppShortcuts
dispatcher. Per-component core shortcuts registered via useKeyboardShortcut
run in the bubble phase, after the capture-phase plugin keybinding dispatcher,
so a plugin-handled-and-prevented combo still triggered the core action too.
Guard useKeyboardShortcut (and use-editor-keybinds, another capture-phase core
listener) on event.defaultPrevented so exactly one action fires per combo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(plugins): make core shortcuts always win over plugin keybindings
Panel-search, task-switcher, and other core listeners never checked
event.defaultPrevented, so a plugin bound to the same combo (e.g. Cmd/Ctrl+F)
could still fire alongside them. Instead of patching every core listener,
usePluginShortcuts now computes the set of effective core shortcut combos
(registry, resolved through user overrides, plus FIND_IN_PANEL/SAVE) and
skips invoking any plugin handler whose combo is in that set, so the
matching core listener always handles the event exactly once.
Also adds a hook-level regression test for useEditorKeybinds' existing
defaultPrevented guard, and fixes a leaked capture-phase window listener in
use-keyboard-shortcut.test.ts that could bleed into later tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): keep Cmd+K closing the command panel after shortcut-precedence guard
cmdk's built-in vim bindings intercept Ctrl/Cmd+K and +P as list-navigation
shortcuts and call preventDefault() before the event reaches our window-level
useKeyboardShortcut listeners. Once that hook started bailing on
event.defaultPrevented (added to fix core/plugin shortcut double-firing), the
palette's own Cmd+K close/reopen toggle got silently suppressed whenever focus
was inside the open panel. Disable cmdk's vim bindings on the command palette
so its Ctrl/Cmd+K and +P always reach the app-level toggle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(plugins): document keybindings and modal window plugin APIs
Fill in the public author-facing docs that the internal plugin-keybindings
and modal-window contract (PLUGIN-API.md, spec.md) already covers: the
registry.registerKeybinding / host.openModal JS surface, the ui.keybindings
manifest field with its combo grammar, and a capability mention on the
Plugins overview page.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
389 lines
14 KiB
TypeScript
389 lines
14 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { renderHook, cleanup } from "@testing-library/react";
|
|
import type { StoredShortcutOverrides } from "@/lib/keyboard/shortcut-overrides";
|
|
import type { PluginRecord } from "@/lib/types/plugins";
|
|
import { pluginRegistry } from "@/lib/plugins/registry";
|
|
|
|
let mockOverrides: StoredShortcutOverrides = {};
|
|
let mockItems: PluginRecord[] = [];
|
|
const mockToggleAppSidebar = vi.fn();
|
|
|
|
vi.mock("@/components/state-provider", () => ({
|
|
useAppStoreApi: () => ({
|
|
getState: () => ({
|
|
userSettings: { keyboardShortcuts: mockOverrides },
|
|
toggleAppSidebar: mockToggleAppSidebar,
|
|
}),
|
|
}),
|
|
}));
|
|
|
|
vi.mock("@/hooks/domains/plugins/use-plugins", () => ({
|
|
usePlugins: () => ({ items: mockItems, loaded: true, loading: false, error: null }),
|
|
}));
|
|
|
|
import { usePluginShortcuts } from "./use-plugin-shortcuts";
|
|
import { useAppShortcuts } from "./use-app-shortcuts";
|
|
import { useKeyboardShortcut } from "./use-keyboard-shortcut";
|
|
import type { KeyboardShortcut } from "@/lib/keyboard/constants";
|
|
|
|
const PLUGIN_ID = "session-cost";
|
|
|
|
function makePlugin(overrides: Partial<PluginRecord> = {}): PluginRecord {
|
|
return {
|
|
id: PLUGIN_ID,
|
|
api_version: 1,
|
|
version: "1.0.0",
|
|
display_name: "Session Cost",
|
|
description: "",
|
|
author: "",
|
|
categories: [],
|
|
capabilities: {},
|
|
status: "active",
|
|
install_path: "",
|
|
signed: true,
|
|
installed_at: "",
|
|
restart_count: 0,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function pressKey(
|
|
key: string,
|
|
init: KeyboardEventInit = {},
|
|
target: EventTarget = window,
|
|
): KeyboardEvent {
|
|
const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init });
|
|
target.dispatchEvent(event);
|
|
return event;
|
|
}
|
|
|
|
const TOGGLE_KEYBINDING = { id: "toggle", default: "mod+shift+e", description: "Toggle" };
|
|
/** A free combo (not used by any core shortcut) shared by two plugins bound to the same "open" id. */
|
|
const OPEN_COMBO = "mod+shift+o";
|
|
|
|
function withToggleKeybinding(overrides: Partial<PluginRecord> = {}): PluginRecord {
|
|
return makePlugin({ ui: { keybindings: [TOGGLE_KEYBINDING] }, ...overrides });
|
|
}
|
|
|
|
describe("usePluginShortcuts", () => {
|
|
beforeEach(() => {
|
|
mockOverrides = {};
|
|
mockItems = [];
|
|
mockToggleAppSidebar.mockClear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
pluginRegistry.unregisterPlugin(PLUGIN_ID);
|
|
pluginRegistry.unregisterPlugin("plugin-a");
|
|
pluginRegistry.unregisterPlugin("plugin-b");
|
|
});
|
|
|
|
it("invokes the bound handler when the manifest default combo is pressed", () => {
|
|
mockItems = [withToggleKeybinding()];
|
|
const handler = vi.fn();
|
|
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("toggle", handler);
|
|
|
|
renderHook(() => usePluginShortcuts());
|
|
const event = pressKey("e", { ctrlKey: true, shiftKey: true });
|
|
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
expect(event.defaultPrevented).toBe(true);
|
|
});
|
|
|
|
it("does not invoke the handler when no key matches", () => {
|
|
mockItems = [withToggleKeybinding()];
|
|
const handler = vi.fn();
|
|
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("toggle", handler);
|
|
|
|
renderHook(() => usePluginShortcuts());
|
|
pressKey("j", { ctrlKey: true });
|
|
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("skips dispatch while typing in an editable element", () => {
|
|
mockItems = [withToggleKeybinding()];
|
|
const handler = vi.fn();
|
|
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("toggle", handler);
|
|
|
|
renderHook(() => usePluginShortcuts());
|
|
const input = document.createElement("input");
|
|
document.body.appendChild(input);
|
|
pressKey("e", { ctrlKey: true, shiftKey: true }, input);
|
|
input.remove();
|
|
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does nothing when the plugin bundle has not registered a handler yet", () => {
|
|
mockItems = [withToggleKeybinding()];
|
|
|
|
renderHook(() => usePluginShortcuts());
|
|
expect(() => pressKey("e", { ctrlKey: true, shiftKey: true })).not.toThrow();
|
|
});
|
|
|
|
it("respects a user override combo instead of the manifest default", () => {
|
|
mockItems = [withToggleKeybinding()];
|
|
mockOverrides = {
|
|
[`plugin:${PLUGIN_ID}:toggle`]: { key: "u", modifiers: { ctrlOrCmd: true } },
|
|
};
|
|
const handler = vi.fn();
|
|
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("toggle", handler);
|
|
|
|
renderHook(() => usePluginShortcuts());
|
|
pressKey("e", { ctrlKey: true, shiftKey: true }); // manifest default — no longer bound
|
|
expect(handler).not.toHaveBeenCalled();
|
|
|
|
pressKey("u", { ctrlKey: true }); // user override — now bound
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("dispatches to every plugin bound to the same combo, in registration order", () => {
|
|
mockItems = [
|
|
makePlugin({
|
|
id: "plugin-a",
|
|
display_name: "Plugin A",
|
|
ui: { keybindings: [{ id: "open", default: OPEN_COMBO, description: "Open" }] },
|
|
}),
|
|
makePlugin({
|
|
id: "plugin-b",
|
|
display_name: "Plugin B",
|
|
ui: { keybindings: [{ id: "open", default: OPEN_COMBO, description: "Open" }] },
|
|
}),
|
|
];
|
|
const callOrder: string[] = [];
|
|
pluginRegistry.forPlugin("plugin-a").registerKeybinding("open", () => callOrder.push("a"));
|
|
pluginRegistry.forPlugin("plugin-b").registerKeybinding("open", () => callOrder.push("b"));
|
|
|
|
renderHook(() => usePluginShortcuts());
|
|
pressKey("o", { ctrlKey: true, shiftKey: true });
|
|
|
|
expect(callOrder).toEqual(["a", "b"]);
|
|
});
|
|
|
|
it("removes its listener on unmount", () => {
|
|
mockItems = [withToggleKeybinding()];
|
|
const handler = vi.fn();
|
|
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("toggle", handler);
|
|
|
|
const { unmount } = renderHook(() => usePluginShortcuts());
|
|
unmount();
|
|
pressKey("e", { ctrlKey: true, shiftKey: true });
|
|
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("usePluginShortcuts handler exceptions", () => {
|
|
beforeEach(() => {
|
|
mockOverrides = {};
|
|
mockItems = [];
|
|
mockToggleAppSidebar.mockClear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
pluginRegistry.unregisterPlugin("plugin-a");
|
|
pluginRegistry.unregisterPlugin("plugin-b");
|
|
});
|
|
|
|
it("logs and continues past a throwing handler so later handlers still run", () => {
|
|
mockItems = [
|
|
makePlugin({
|
|
id: "plugin-a",
|
|
display_name: "Plugin A",
|
|
ui: { keybindings: [{ id: "open", default: OPEN_COMBO, description: "Open" }] },
|
|
}),
|
|
makePlugin({
|
|
id: "plugin-b",
|
|
display_name: "Plugin B",
|
|
ui: { keybindings: [{ id: "open", default: OPEN_COMBO, description: "Open" }] },
|
|
}),
|
|
];
|
|
const secondHandler = vi.fn();
|
|
pluginRegistry.forPlugin("plugin-a").registerKeybinding("open", () => {
|
|
throw new Error("boom");
|
|
});
|
|
pluginRegistry.forPlugin("plugin-b").registerKeybinding("open", secondHandler);
|
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
|
|
renderHook(() => usePluginShortcuts());
|
|
expect(() => pressKey("o", { ctrlKey: true, shiftKey: true })).not.toThrow();
|
|
|
|
expect(secondHandler).toHaveBeenCalledTimes(1);
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining("plugin-a"),
|
|
expect.any(Error),
|
|
);
|
|
|
|
consoleErrorSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe("core vs plugin shortcut precedence", () => {
|
|
beforeEach(() => {
|
|
mockOverrides = {};
|
|
mockItems = [];
|
|
mockToggleAppSidebar.mockClear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
pluginRegistry.unregisterPlugin(PLUGIN_ID);
|
|
});
|
|
|
|
it("fires only the core action when a combo matches both a core shortcut and a plugin keybinding", () => {
|
|
mockOverrides = { TOGGLE_SIDEBAR: { key: "b", modifiers: { ctrlOrCmd: true } } };
|
|
mockItems = [
|
|
makePlugin({
|
|
ui: { keybindings: [{ id: "open", default: "mod+b", description: "Open" }] },
|
|
}),
|
|
];
|
|
const pluginHandler = vi.fn();
|
|
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("open", pluginHandler);
|
|
|
|
renderHook(() => {
|
|
useAppShortcuts();
|
|
usePluginShortcuts();
|
|
});
|
|
const event = pressKey("b", { ctrlKey: true });
|
|
|
|
expect(mockToggleAppSidebar).toHaveBeenCalledTimes(1);
|
|
expect(pluginHandler).not.toHaveBeenCalled();
|
|
expect(event.defaultPrevented).toBe(true);
|
|
});
|
|
|
|
it("still fires the plugin handler for a plugin-only combo (no core shortcut match)", () => {
|
|
mockItems = [withToggleKeybinding()];
|
|
const handler = vi.fn();
|
|
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("toggle", handler);
|
|
|
|
renderHook(() => {
|
|
useAppShortcuts();
|
|
usePluginShortcuts();
|
|
});
|
|
pressKey("e", { ctrlKey: true, shiftKey: true });
|
|
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
expect(mockToggleAppSidebar).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fires only the plugin action when a combo matches both a plugin keybinding and a per-component core shortcut bound via useKeyboardShortcut", () => {
|
|
// Regression for Greptile P1 (PR #1895, comment 3638620657): a
|
|
// per-component core shortcut registered through `useKeyboardShortcut`
|
|
// runs in the bubble phase, after this capture-phase plugin dispatcher.
|
|
// Without `useKeyboardShortcut` respecting `event.defaultPrevented`, the
|
|
// plugin handler AND the core action would both fire for the same combo.
|
|
mockItems = [withToggleKeybinding()];
|
|
const pluginHandler = vi.fn();
|
|
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("toggle", pluginHandler);
|
|
|
|
const coreShortcut: KeyboardShortcut = {
|
|
key: "e",
|
|
modifiers: { ctrlOrCmd: true, shift: true },
|
|
};
|
|
const coreCallback = vi.fn();
|
|
|
|
renderHook(() => {
|
|
// usePluginShortcuts registers its capture-phase listener on mount,
|
|
// which — like the real app (`components/global-commands.tsx`) —
|
|
// fires before the bubble-phase useKeyboardShortcut listener below.
|
|
usePluginShortcuts();
|
|
useKeyboardShortcut(coreShortcut, coreCallback);
|
|
});
|
|
|
|
const event = pressKey("e", { ctrlKey: true, shiftKey: true });
|
|
|
|
expect(pluginHandler).toHaveBeenCalledTimes(1);
|
|
expect(coreCallback).not.toHaveBeenCalled();
|
|
expect(event.defaultPrevented).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("core shortcut combo shadowing (robust, non-registry-aware core shortcuts)", () => {
|
|
beforeEach(() => {
|
|
mockOverrides = {};
|
|
mockItems = [];
|
|
mockToggleAppSidebar.mockClear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
pluginRegistry.unregisterPlugin(PLUGIN_ID);
|
|
});
|
|
|
|
it("does not invoke or preventDefault a plugin bound to FIND_IN_PANEL's combo, leaving core panel-search to handle it", () => {
|
|
// Regression for Greptile P1 (PR #1895, comment 3638902528): FIND_IN_PANEL
|
|
// (Cmd/Ctrl+F) is handled by independent per-panel capture-phase
|
|
// listeners (`use-panel-search.ts`) that never call `preventDefault` up
|
|
// front and don't check it either. The old "bail on defaultPrevented"
|
|
// guard couldn't protect this combo at all — only skipping the plugin
|
|
// handler outright (by combo, not by event state) fixes it.
|
|
mockItems = [
|
|
makePlugin({
|
|
ui: { keybindings: [{ id: "find", default: "mod+f", description: "Find" }] },
|
|
}),
|
|
];
|
|
const pluginHandler = vi.fn();
|
|
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("find", pluginHandler);
|
|
|
|
renderHook(() => usePluginShortcuts());
|
|
const event = pressKey("f", { ctrlKey: true });
|
|
|
|
expect(pluginHandler).not.toHaveBeenCalled();
|
|
expect(event.defaultPrevented).toBe(false);
|
|
});
|
|
|
|
it("still fires a plugin bound to a genuinely free combo (no core shortcut uses it)", () => {
|
|
mockItems = [
|
|
makePlugin({
|
|
ui: { keybindings: [{ id: "open", default: "mod+shift+u", description: "Open" }] },
|
|
}),
|
|
];
|
|
const pluginHandler = vi.fn();
|
|
pluginRegistry.forPlugin(PLUGIN_ID).registerKeybinding("open", pluginHandler);
|
|
|
|
renderHook(() => usePluginShortcuts());
|
|
const event = pressKey("u", { ctrlKey: true, shiftKey: true });
|
|
|
|
expect(pluginHandler).toHaveBeenCalledTimes(1);
|
|
expect(event.defaultPrevented).toBe(true);
|
|
});
|
|
|
|
it("shadows the plugin by a user's REMAPPED core combo, not the old default", () => {
|
|
// A user remaps TASK_SWITCHER (default Cmd/Ctrl+Space) to Cmd/Ctrl+U. A
|
|
// plugin bound to the OLD default (mod+space) should now fire freely,
|
|
// and a plugin bound to the NEW combo (mod+u) should be shadowed.
|
|
mockOverrides = { TASK_SWITCHER: { key: "u", modifiers: { ctrlOrCmd: true } } };
|
|
mockItems = [
|
|
makePlugin({
|
|
id: "plugin-old-combo",
|
|
display_name: "Old Combo Plugin",
|
|
ui: { keybindings: [{ id: "open", default: "mod+space", description: "Open" }] },
|
|
}),
|
|
makePlugin({
|
|
id: "plugin-new-combo",
|
|
display_name: "New Combo Plugin",
|
|
ui: { keybindings: [{ id: "open", default: "mod+u", description: "Open" }] },
|
|
}),
|
|
];
|
|
const oldComboHandler = vi.fn();
|
|
const newComboHandler = vi.fn();
|
|
pluginRegistry.forPlugin("plugin-old-combo").registerKeybinding("open", oldComboHandler);
|
|
pluginRegistry.forPlugin("plugin-new-combo").registerKeybinding("open", newComboHandler);
|
|
|
|
renderHook(() => usePluginShortcuts());
|
|
|
|
pressKey(" ", { ctrlKey: true }); // old TASK_SWITCHER default — no longer core, plugin fires
|
|
expect(oldComboHandler).toHaveBeenCalledTimes(1);
|
|
|
|
const event = pressKey("u", { ctrlKey: true }); // new TASK_SWITCHER combo — shadows the plugin
|
|
expect(newComboHandler).not.toHaveBeenCalled();
|
|
expect(event.defaultPrevented).toBe(false);
|
|
|
|
pluginRegistry.unregisterPlugin("plugin-old-combo");
|
|
pluginRegistry.unregisterPlugin("plugin-new-combo");
|
|
});
|
|
});
|