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>
290 lines
10 KiB
TypeScript
290 lines
10 KiB
TypeScript
/**
|
|
* Reactive singleton `PluginRegistry` (docs/plans/plugins/PLUGIN-API.md).
|
|
*
|
|
* Holds every registration made by every loaded plugin, tracks the owning
|
|
* pluginId so a disabled/uninstalled plugin can be bulk-revoked, and exposes
|
|
* a tiny external-store subscription so React components re-render on
|
|
* registration changes (`usePluginRegistry()`).
|
|
*
|
|
* `pluginRegistry.forPlugin(pluginId)` returns the exact `PluginRegistry`
|
|
* shape from the frozen contract (no pluginId param on the register*
|
|
* methods) — this is what `host.ts` passes into a plugin's `initialize()`.
|
|
*/
|
|
import { useSyncExternalStore } from "react";
|
|
import type {
|
|
NavItem,
|
|
PluginRegistry,
|
|
PluginRouteOptions,
|
|
SlotComponent,
|
|
WsHandler,
|
|
} from "./types";
|
|
import type { ComponentType } from "react";
|
|
|
|
interface Owned<T> {
|
|
pluginId: string;
|
|
value: T;
|
|
}
|
|
|
|
/** A handler bound via `PluginRegistry.registerKeybinding`. */
|
|
export interface PluginKeybindingHandler {
|
|
/** Plugin-local keybinding id (matches `ui.keybindings[].id`). */
|
|
id: string;
|
|
handler: (event: KeyboardEvent) => void;
|
|
}
|
|
|
|
export interface RouteRegistration {
|
|
path: string;
|
|
Component: ComponentType;
|
|
options?: PluginRouteOptions;
|
|
}
|
|
|
|
/** Route registration plus the owning pluginId — what `getRoutes()` returns. */
|
|
export interface PluginRouteRegistration extends RouteRegistration {
|
|
pluginId: string;
|
|
}
|
|
|
|
interface SlotRegistration {
|
|
registrationId: string;
|
|
orderingId: string;
|
|
slot: string;
|
|
Component: SlotComponent;
|
|
}
|
|
|
|
/** Slot component plus its stable registry identity and owning plugin. */
|
|
export interface PluginSlotRegistration {
|
|
registrationId: string;
|
|
orderingId: string;
|
|
pluginId: string;
|
|
Component: SlotComponent;
|
|
}
|
|
|
|
interface WsHandlerRegistration {
|
|
action: string;
|
|
handler: WsHandler;
|
|
}
|
|
|
|
function removeByPlugin<T>(list: Owned<T>[], pluginId: string): Owned<T>[] {
|
|
return list.filter((entry) => entry.pluginId !== pluginId);
|
|
}
|
|
|
|
class PluginRegistryStore {
|
|
private routes: Owned<RouteRegistration>[] = [];
|
|
private settingsRoutes: Owned<RouteRegistration>[] = [];
|
|
private navItems: Owned<NavItem>[] = [];
|
|
private slotComponents: Owned<SlotRegistration>[] = [];
|
|
private wsHandlers: Owned<WsHandlerRegistration>[] = [];
|
|
private keybindingHandlers: Owned<PluginKeybindingHandler>[] = [];
|
|
private nextSlotRegistrationId = 0;
|
|
/** Display names from the boot payload, used for derived page-chrome titles. */
|
|
private pluginNames = new Map<string, string>();
|
|
/**
|
|
* Keybinding ids declared in each plugin's `ui.keybindings` manifest,
|
|
* synced by the shortcut dispatcher (`hooks/use-plugin-shortcuts.ts`) from
|
|
* the plugin records store. Used only to warn on `registerKeybinding`
|
|
* calls for an id the manifest never declared — an empty/missing entry
|
|
* (descriptors not loaded yet) skips the check rather than false-warning.
|
|
*/
|
|
private declaredKeybindingIds = new Map<string, Set<string>>();
|
|
private listeners = new Set<() => void>();
|
|
private version = 0;
|
|
|
|
subscribe = (listener: () => void): (() => void) => {
|
|
this.listeners.add(listener);
|
|
return () => {
|
|
this.listeners.delete(listener);
|
|
};
|
|
};
|
|
|
|
getVersion = (): number => this.version;
|
|
|
|
registerRoute(
|
|
pluginId: string,
|
|
path: string,
|
|
Component: ComponentType,
|
|
options?: PluginRouteOptions,
|
|
): void {
|
|
this.routes.push({ pluginId, value: { path, Component, options } });
|
|
this.notify();
|
|
}
|
|
|
|
registerSettingsRoute(pluginId: string, path: string, Component: ComponentType): void {
|
|
this.settingsRoutes.push({ pluginId, value: { path, Component } });
|
|
this.notify();
|
|
}
|
|
|
|
registerNavItem(pluginId: string, item: NavItem): void {
|
|
this.navItems.push({ pluginId, value: item });
|
|
this.notify();
|
|
}
|
|
|
|
registerComponent(pluginId: string, slot: string, Component: SlotComponent): void {
|
|
const ordinal = this.slotComponents.filter(
|
|
(entry) => entry.pluginId === pluginId && entry.value.slot === slot,
|
|
).length;
|
|
this.slotComponents.push({
|
|
pluginId,
|
|
value: {
|
|
registrationId: `slot-registration-${this.nextSlotRegistrationId++}`,
|
|
orderingId: pluginSlotOrderingId(pluginId, slot, ordinal),
|
|
slot,
|
|
Component,
|
|
},
|
|
});
|
|
this.notify();
|
|
}
|
|
|
|
registerWsHandler(pluginId: string, action: string, handler: WsHandler): void {
|
|
this.wsHandlers.push({ pluginId, value: { action, handler } });
|
|
this.notify();
|
|
}
|
|
|
|
registerKeybinding(pluginId: string, id: string, handler: (event: KeyboardEvent) => void): void {
|
|
const declared = this.declaredKeybindingIds.get(pluginId);
|
|
if (declared && !declared.has(id)) {
|
|
console.warn(
|
|
`[plugins] "${pluginId}" registered a keybinding handler for id "${id}", which is not declared in its ui.keybindings manifest`,
|
|
);
|
|
}
|
|
this.keybindingHandlers.push({ pluginId, value: { id, handler } });
|
|
this.notify();
|
|
}
|
|
|
|
/**
|
|
* Records the keybinding ids declared in `pluginId`'s `ui.keybindings`
|
|
* manifest, so `registerKeybinding` can warn on an undeclared id. Safe to
|
|
* call repeatedly (e.g. every time the plugin records store refreshes).
|
|
*/
|
|
setDeclaredKeybindingIds(pluginId: string, ids: string[]): void {
|
|
this.declaredKeybindingIds.set(pluginId, new Set(ids));
|
|
}
|
|
|
|
/** Bulk-revoke every registration owned by `pluginId` (disable/uninstall). */
|
|
unregisterPlugin(pluginId: string): void {
|
|
const before = this.totalCount();
|
|
this.routes = removeByPlugin(this.routes, pluginId);
|
|
this.settingsRoutes = removeByPlugin(this.settingsRoutes, pluginId);
|
|
this.navItems = removeByPlugin(this.navItems, pluginId);
|
|
this.slotComponents = removeByPlugin(this.slotComponents, pluginId);
|
|
this.wsHandlers = removeByPlugin(this.wsHandlers, pluginId);
|
|
this.keybindingHandlers = removeByPlugin(this.keybindingHandlers, pluginId);
|
|
this.pluginNames.delete(pluginId);
|
|
this.declaredKeybindingIds.delete(pluginId);
|
|
if (this.totalCount() !== before) this.notify();
|
|
}
|
|
|
|
getRoutes(): PluginRouteRegistration[] {
|
|
return this.routes.map((entry) => ({ ...entry.value, pluginId: entry.pluginId }));
|
|
}
|
|
|
|
/** Display name recorded by `forPlugin` (boot payload `ActivePlugin.name`). */
|
|
getPluginName(pluginId: string): string | undefined {
|
|
return this.pluginNames.get(pluginId);
|
|
}
|
|
|
|
getSettingsRoutes(): RouteRegistration[] {
|
|
return this.settingsRoutes.map((entry) => entry.value);
|
|
}
|
|
|
|
getNavItems(): NavItem[] {
|
|
return this.navItems.map((entry) => entry.value);
|
|
}
|
|
|
|
getSlotComponents(slot: string): SlotComponent[] {
|
|
return this.getSlotRegistrations(slot).map((registration) => registration.Component);
|
|
}
|
|
|
|
/** Stable, plugin-owned slot registrations for host render boundaries. */
|
|
getSlotRegistrations(slot: string): PluginSlotRegistration[] {
|
|
return this.slotComponents
|
|
.filter((entry) => entry.value.slot === slot)
|
|
.map((entry) => ({
|
|
registrationId: entry.value.registrationId,
|
|
orderingId: entry.value.orderingId,
|
|
pluginId: entry.pluginId,
|
|
Component: entry.value.Component,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Slot components for `slot` registered by `pluginId` only. Used by
|
|
* owner-scoped slots (e.g. "plugin-settings") that render on a specific
|
|
* plugin's own surface, so the host filters by owner instead of making
|
|
* every plugin author gate on the current plugin id.
|
|
*/
|
|
getSlotComponentsForPlugin(slot: string, pluginId: string): SlotComponent[] {
|
|
return this.slotComponents
|
|
.filter((entry) => entry.value.slot === slot && entry.pluginId === pluginId)
|
|
.map((entry) => entry.value.Component);
|
|
}
|
|
|
|
getWsHandlers(action: string): WsHandler[] {
|
|
return this.wsHandlers
|
|
.filter((entry) => entry.value.action === action)
|
|
.map((entry) => entry.value.handler);
|
|
}
|
|
|
|
/**
|
|
* All registered keybinding handlers plus their owning pluginId, in
|
|
* registration order. Registration order is the dispatch-order tiebreaker
|
|
* when two plugins bind the same effective combo (see
|
|
* `hooks/use-plugin-shortcuts.ts`).
|
|
*/
|
|
getKeybindingHandlers(): (PluginKeybindingHandler & { pluginId: string })[] {
|
|
return this.keybindingHandlers.map((entry) => ({ ...entry.value, pluginId: entry.pluginId }));
|
|
}
|
|
|
|
/** The `pluginId`'s bound handler for `id`, if any (first match wins). */
|
|
getKeybindingHandler(pluginId: string, id: string): ((event: KeyboardEvent) => void) | undefined {
|
|
return this.keybindingHandlers.find(
|
|
(entry) => entry.pluginId === pluginId && entry.value.id === id,
|
|
)?.value.handler;
|
|
}
|
|
|
|
/** Registry view scoped to one plugin — matches the frozen `PluginRegistry` contract. */
|
|
forPlugin(pluginId: string, pluginName?: string): PluginRegistry {
|
|
if (pluginName) this.pluginNames.set(pluginId, pluginName);
|
|
return {
|
|
registerRoute: (path, Component, options) =>
|
|
this.registerRoute(pluginId, path, Component, options),
|
|
registerNavItem: (item) => this.registerNavItem(pluginId, item),
|
|
registerSettingsRoute: (path, Component) =>
|
|
this.registerSettingsRoute(pluginId, path, Component),
|
|
registerComponent: (slot, Component) => this.registerComponent(pluginId, slot, Component),
|
|
registerWsHandler: (action, handler) => this.registerWsHandler(pluginId, action, handler),
|
|
registerKeybinding: (id, handler) => this.registerKeybinding(pluginId, id, handler),
|
|
};
|
|
}
|
|
|
|
private totalCount(): number {
|
|
return (
|
|
this.routes.length +
|
|
this.settingsRoutes.length +
|
|
this.navItems.length +
|
|
this.slotComponents.length +
|
|
this.wsHandlers.length +
|
|
this.keybindingHandlers.length
|
|
);
|
|
}
|
|
|
|
private notify(): void {
|
|
this.version += 1;
|
|
this.listeners.forEach((listener) => listener());
|
|
}
|
|
}
|
|
|
|
function pluginSlotOrderingId(pluginId: string, slot: string, ordinal: number): string {
|
|
return `plugin:${encodeURIComponent(pluginId)}:${encodeURIComponent(slot)}:${ordinal}`;
|
|
}
|
|
|
|
export const pluginRegistry = new PluginRegistryStore();
|
|
|
|
/** Snapshot hook: re-renders the caller whenever any plugin registration changes. */
|
|
export function usePluginRegistry(): PluginRegistryStore {
|
|
useSyncExternalStore(
|
|
pluginRegistry.subscribe,
|
|
pluginRegistry.getVersion,
|
|
pluginRegistry.getVersion,
|
|
);
|
|
return pluginRegistry;
|
|
}
|