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>
284 lines
9.3 KiB
TypeScript
284 lines
9.3 KiB
TypeScript
import { describe, it, expect, afterEach, vi } from "vitest";
|
|
import { pluginRegistry } from "./registry";
|
|
|
|
const TASK_SIDEBAR_SLOT = "task-sidebar";
|
|
const TASK_CREATED_ACTION = "task.created";
|
|
const APP_STATUS_LEFT_SLOT = "app-status-bar-left";
|
|
|
|
function cleanup(...pluginIds: string[]) {
|
|
pluginIds.forEach((id) => pluginRegistry.unregisterPlugin(id));
|
|
}
|
|
|
|
describe("pluginRegistry", () => {
|
|
afterEach(() => {
|
|
cleanup("plugin-a", "plugin-b");
|
|
});
|
|
|
|
it("registers and returns a route via the scoped registry view", () => {
|
|
const scoped = pluginRegistry.forPlugin("plugin-a");
|
|
function Page() {
|
|
return null;
|
|
}
|
|
|
|
scoped.registerRoute("/plugin-a", Page);
|
|
|
|
const routes = pluginRegistry.getRoutes();
|
|
expect(routes).toContainEqual({
|
|
pluginId: "plugin-a",
|
|
path: "/plugin-a",
|
|
Component: Page,
|
|
options: undefined,
|
|
});
|
|
});
|
|
|
|
it("registers and returns a nav item", () => {
|
|
const scoped = pluginRegistry.forPlugin("plugin-a");
|
|
|
|
scoped.registerNavItem({ id: "nav-a", label: "A", path: "/plugin-a" });
|
|
|
|
expect(pluginRegistry.getNavItems()).toContainEqual({
|
|
id: "nav-a",
|
|
label: "A",
|
|
path: "/plugin-a",
|
|
});
|
|
});
|
|
|
|
it("registers and returns a settings route", () => {
|
|
const scoped = pluginRegistry.forPlugin("plugin-a");
|
|
function Settings() {
|
|
return null;
|
|
}
|
|
|
|
scoped.registerSettingsRoute("/settings/plugins/plugin-a", Settings);
|
|
|
|
expect(pluginRegistry.getSettingsRoutes()).toContainEqual({
|
|
path: "/settings/plugins/plugin-a",
|
|
Component: Settings,
|
|
});
|
|
});
|
|
|
|
it("registers a slot component and only returns it for the matching slot", () => {
|
|
const scoped = pluginRegistry.forPlugin("plugin-a");
|
|
function Sidebar() {
|
|
return null;
|
|
}
|
|
|
|
scoped.registerComponent(TASK_SIDEBAR_SLOT, Sidebar);
|
|
|
|
expect(pluginRegistry.getSlotComponents(TASK_SIDEBAR_SLOT)).toEqual([Sidebar]);
|
|
expect(pluginRegistry.getSlotComponents("settings-nav")).toEqual([]);
|
|
});
|
|
|
|
it("returns slot registrations with stable owner and registration identity", () => {
|
|
const scopedA = pluginRegistry.forPlugin("plugin-a");
|
|
function Sidebar() {
|
|
return null;
|
|
}
|
|
|
|
scopedA.registerComponent(TASK_SIDEBAR_SLOT, Sidebar);
|
|
|
|
const [registration] = pluginRegistry.getSlotRegistrations(TASK_SIDEBAR_SLOT);
|
|
|
|
expect(registration).toEqual({
|
|
registrationId: expect.any(String),
|
|
orderingId: expect.any(String),
|
|
pluginId: "plugin-a",
|
|
Component: Sidebar,
|
|
});
|
|
|
|
scopedA.registerComponent(TASK_SIDEBAR_SLOT, () => null);
|
|
|
|
expect(pluginRegistry.getSlotRegistrations(TASK_SIDEBAR_SLOT)[0]?.registrationId).toBe(
|
|
registration?.registrationId,
|
|
);
|
|
});
|
|
|
|
it("restores deterministic ordering identities after plugin re-enable", () => {
|
|
function First() {
|
|
return null;
|
|
}
|
|
function Second() {
|
|
return null;
|
|
}
|
|
const scoped = pluginRegistry.forPlugin("plugin-a");
|
|
scoped.registerComponent(APP_STATUS_LEFT_SLOT, First);
|
|
scoped.registerComponent(APP_STATUS_LEFT_SLOT, Second);
|
|
const before = pluginRegistry
|
|
.getSlotRegistrations(APP_STATUS_LEFT_SLOT)
|
|
.map((registration) => registration.orderingId);
|
|
|
|
pluginRegistry.unregisterPlugin("plugin-a");
|
|
const reenabled = pluginRegistry.forPlugin("plugin-a");
|
|
reenabled.registerComponent(APP_STATUS_LEFT_SLOT, First);
|
|
reenabled.registerComponent(APP_STATUS_LEFT_SLOT, Second);
|
|
|
|
expect(pluginRegistry.getSlotRegistrations(APP_STATUS_LEFT_SLOT)).toMatchObject([
|
|
{ orderingId: before[0], pluginId: "plugin-a", Component: First },
|
|
{ orderingId: before[1], pluginId: "plugin-a", Component: Second },
|
|
]);
|
|
expect(before[0]).not.toBe(before[1]);
|
|
});
|
|
});
|
|
|
|
describe("pluginRegistry — lifecycle", () => {
|
|
afterEach(() => {
|
|
cleanup("plugin-a", "plugin-b");
|
|
});
|
|
|
|
it("registers a WS handler and only returns it for the matching action", () => {
|
|
const scoped = pluginRegistry.forPlugin("plugin-a");
|
|
const handler = () => {};
|
|
|
|
scoped.registerWsHandler(TASK_CREATED_ACTION, handler);
|
|
|
|
expect(pluginRegistry.getWsHandlers(TASK_CREATED_ACTION)).toEqual([handler]);
|
|
expect(pluginRegistry.getWsHandlers("task.deleted")).toEqual([]);
|
|
});
|
|
|
|
it("bulk-revokes every registration owned by a plugin on unregisterPlugin", () => {
|
|
const scopedA = pluginRegistry.forPlugin("plugin-a");
|
|
const scopedB = pluginRegistry.forPlugin("plugin-b");
|
|
function PageA() {
|
|
return null;
|
|
}
|
|
function PageB() {
|
|
return null;
|
|
}
|
|
|
|
scopedA.registerRoute("/plugin-a", PageA);
|
|
scopedA.registerNavItem({ id: "nav-a", label: "A", path: "/plugin-a" });
|
|
scopedA.registerComponent(TASK_SIDEBAR_SLOT, PageA);
|
|
scopedA.registerWsHandler(TASK_CREATED_ACTION, () => {});
|
|
scopedB.registerRoute("/plugin-b", PageB);
|
|
|
|
pluginRegistry.unregisterPlugin("plugin-a");
|
|
|
|
expect(pluginRegistry.getRoutes()).toEqual([
|
|
{ pluginId: "plugin-b", path: "/plugin-b", Component: PageB, options: undefined },
|
|
]);
|
|
expect(pluginRegistry.getNavItems().find((item) => item.id === "nav-a")).toBeUndefined();
|
|
expect(pluginRegistry.getSlotComponents(TASK_SIDEBAR_SLOT)).toEqual([]);
|
|
expect(pluginRegistry.getWsHandlers(TASK_CREATED_ACTION)).toEqual([]);
|
|
});
|
|
|
|
it("notifies subscribers when a registration is added", () => {
|
|
const scoped = pluginRegistry.forPlugin("plugin-a");
|
|
let notified = 0;
|
|
const unsubscribe = pluginRegistry.subscribe(() => {
|
|
notified += 1;
|
|
});
|
|
|
|
scoped.registerNavItem({ id: "nav-a", label: "A", path: "/plugin-a" });
|
|
|
|
unsubscribe();
|
|
expect(notified).toBe(1);
|
|
});
|
|
|
|
it("does not notify subscribers when unregistering a plugin with no registrations", () => {
|
|
let notified = 0;
|
|
const unsubscribe = pluginRegistry.subscribe(() => {
|
|
notified += 1;
|
|
});
|
|
|
|
pluginRegistry.unregisterPlugin("plugin-with-nothing-registered");
|
|
|
|
unsubscribe();
|
|
expect(notified).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("pluginRegistry — route options and plugin names", () => {
|
|
afterEach(() => {
|
|
cleanup("plugin-a");
|
|
});
|
|
|
|
it("stores route options and the plugin display name for page chrome", () => {
|
|
const scoped = pluginRegistry.forPlugin("plugin-a", "Plugin A");
|
|
function Page() {
|
|
return null;
|
|
}
|
|
|
|
scoped.registerRoute("/plugin-a", Page, { topbar: { title: "Custom", icon: "ticket" } });
|
|
|
|
const route = pluginRegistry.getRoutes().find((entry) => entry.path === "/plugin-a");
|
|
expect(route?.options).toEqual({ topbar: { title: "Custom", icon: "ticket" } });
|
|
expect(pluginRegistry.getPluginName("plugin-a")).toBe("Plugin A");
|
|
});
|
|
|
|
it("clears the plugin display name on unregisterPlugin", () => {
|
|
pluginRegistry.forPlugin("plugin-a", "Plugin A");
|
|
expect(pluginRegistry.getPluginName("plugin-a")).toBe("Plugin A");
|
|
|
|
pluginRegistry.unregisterPlugin("plugin-a");
|
|
|
|
expect(pluginRegistry.getPluginName("plugin-a")).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("pluginRegistry — keybinding handlers", () => {
|
|
afterEach(() => {
|
|
cleanup("plugin-a", "plugin-b");
|
|
});
|
|
|
|
it("registers a keybinding handler scoped to the owning plugin", () => {
|
|
const scopedA = pluginRegistry.forPlugin("plugin-a");
|
|
const scopedB = pluginRegistry.forPlugin("plugin-b");
|
|
const handlerA = () => {};
|
|
const handlerB = () => {};
|
|
|
|
scopedA.registerKeybinding("open", handlerA);
|
|
scopedB.registerKeybinding("open", handlerB);
|
|
|
|
expect(pluginRegistry.getKeybindingHandler("plugin-a", "open")).toBe(handlerA);
|
|
expect(pluginRegistry.getKeybindingHandler("plugin-b", "open")).toBe(handlerB);
|
|
expect(pluginRegistry.getKeybindingHandlers()).toEqual([
|
|
{ id: "open", handler: handlerA, pluginId: "plugin-a" },
|
|
{ id: "open", handler: handlerB, pluginId: "plugin-b" },
|
|
]);
|
|
});
|
|
|
|
it("revokes keybinding handlers on unregisterPlugin", () => {
|
|
const scoped = pluginRegistry.forPlugin("plugin-a");
|
|
scoped.registerKeybinding("open", () => {});
|
|
|
|
pluginRegistry.unregisterPlugin("plugin-a");
|
|
|
|
expect(pluginRegistry.getKeybindingHandlers()).toEqual([]);
|
|
expect(pluginRegistry.getKeybindingHandler("plugin-a", "open")).toBeUndefined();
|
|
});
|
|
|
|
it("warns when registering a handler for an id not declared in the manifest", () => {
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
pluginRegistry.setDeclaredKeybindingIds("plugin-a", ["open"]);
|
|
const scoped = pluginRegistry.forPlugin("plugin-a");
|
|
|
|
scoped.registerKeybinding("not-declared", () => {});
|
|
|
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("not-declared"));
|
|
// The handler is still stored despite the warning.
|
|
expect(pluginRegistry.getKeybindingHandler("plugin-a", "not-declared")).toBeDefined();
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it("does not warn when the declared id list has not been synced yet", () => {
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
const scoped = pluginRegistry.forPlugin("plugin-a");
|
|
|
|
scoped.registerKeybinding("anything", () => {});
|
|
|
|
expect(warnSpy).not.toHaveBeenCalled();
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it("does not warn when the id is declared in the manifest", () => {
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
pluginRegistry.setDeclaredKeybindingIds("plugin-a", ["open"]);
|
|
const scoped = pluginRegistry.forPlugin("plugin-a");
|
|
|
|
scoped.registerKeybinding("open", () => {});
|
|
|
|
expect(warnSpy).not.toHaveBeenCalled();
|
|
warnSpy.mockRestore();
|
|
});
|
|
});
|