Files
João Salavisa 99dfbbde58 feat(plugins): add keybindings and modal window capabilities (#1895)
* 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>
2026-07-24 11:45:28 +01:00

95 lines
3.5 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { parseCombo } from "./parse-combo";
describe("parseCombo", () => {
it("parses a plain single-key combo", () => {
expect(parseCombo("k")).toEqual({ key: "k" });
});
it("parses mod+key as ctrlOrCmd (platform resolved by matchesShortcut)", () => {
expect(parseCombo("mod+k")).toEqual({ key: "k", modifiers: { ctrlOrCmd: true } });
});
it("parses multiple modifiers plus one key", () => {
expect(parseCombo("mod+shift+k")).toEqual({
key: "k",
modifiers: { ctrlOrCmd: true, shift: true },
});
});
it("treats ctrl as an explicit ctrl modifier (not ctrlOrCmd)", () => {
expect(parseCombo("ctrl+r")).toEqual({ key: "r", modifiers: { ctrl: true } });
});
it("treats cmd/meta as an explicit cmd modifier", () => {
expect(parseCombo("cmd+k")).toEqual({ key: "k", modifiers: { cmd: true } });
expect(parseCombo("meta+k")).toEqual({ key: "k", modifiers: { cmd: true } });
});
it("returns null for undocumented modifier aliases (control/super are not in the accepted grammar)", () => {
expect(parseCombo("control+r")).toBeNull();
expect(parseCombo("super+k")).toBeNull();
});
it("treats alt/option as an explicit alt modifier", () => {
expect(parseCombo("alt+k")).toEqual({ key: "k", modifiers: { alt: true } });
expect(parseCombo("option+k")).toEqual({ key: "k", modifiers: { alt: true } });
});
it("resolves named key tokens to KeyboardEvent.key equivalents", () => {
expect(parseCombo("mod+enter")).toEqual({ key: "Enter", modifiers: { ctrlOrCmd: true } });
expect(parseCombo("shift+arrowup")).toEqual({ key: "ArrowUp", modifiers: { shift: true } });
});
it("resolves a symbol key token when shift is not involved", () => {
expect(parseCombo("mod+slash")).toEqual({ key: "/", modifiers: { ctrlOrCmd: true } });
});
it("returns null when shift is combined with a key whose character shift would alter", () => {
// Physically holding Shift means the browser reports "?"/"!" for
// KeyboardEvent.key, not "/"/"1" — so these combos could never dispatch.
expect(parseCombo("shift+slash")).toBeNull();
expect(parseCombo("mod+shift+slash")).toBeNull();
expect(parseCombo("shift+1")).toBeNull();
expect(parseCombo("shift+minus")).toBeNull();
});
it("still allows shift with keys Shift doesn't alter (letters, named/function keys)", () => {
expect(parseCombo("shift+k")).toEqual({ key: "k", modifiers: { shift: true } });
expect(parseCombo("shift+tab")).toEqual({ key: "Tab", modifiers: { shift: true } });
expect(parseCombo("shift+f1")).toEqual({ key: "f1", modifiers: { shift: true } });
});
it("resolves function keys", () => {
expect(parseCombo("mod+f5")).toEqual({ key: "f5", modifiers: { ctrlOrCmd: true } });
});
it("is case-insensitive on tokens", () => {
expect(parseCombo("MOD+SHIFT+K")).toEqual({
key: "k",
modifiers: { ctrlOrCmd: true, shift: true },
});
});
it("returns null for an empty combo", () => {
expect(parseCombo("")).toBeNull();
expect(parseCombo(" ")).toBeNull();
});
it("returns null for an unknown token", () => {
expect(parseCombo("mod+banana")).toBeNull();
});
it("returns null when the combo has more than one non-modifier key", () => {
expect(parseCombo("k+j")).toBeNull();
});
it("returns null when the combo has only modifiers and no key", () => {
expect(parseCombo("mod+shift")).toBeNull();
});
it("returns null for a token with an empty segment", () => {
expect(parseCombo("mod++k")).toBeNull();
});
});