From ce5cbb019279d0cfad93e0ff7aecd24393fe49c8 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 23 Sep 2026 13:04:39 +1000 Subject: [PATCH 01/10] feat(shortcuts): add rebindable keyboard shortcut settings Settings gains a Shortcuts section that lists every host binding and every active plugin contribution, grouped by owner, with search, inline key capture, per-row Change/Reset and Reset all. Overrides persist in the device-local buzz-shortcut-bindings.v1 preference and are resolved by the dispatcher at match time. Relation to the plugin architecture: the plugin-facing contract is unchanged. Plugins keep calling ctx.shortcuts.register with their default binding via @buzz/author and never see, store or re-register for an override. The page reads the dispatcher's own registries (hostSnapshot/hostSubscribe for host bindings, snapshot/subscribe for plugin contributions), so it cannot drift from what fires and follows plugin enable/disable live. Overrides are keyed by the registry identity the dispatcher already uses (bare id for host bindings, pluginId/id for contributions), so they survive disable, re-enable and replacement, and orphaned entries are ignored rather than deleted. Host chords remain reserved: the page refuses chords already used by any listed shortcut and warns about chords the message editor handles locally. There is no Settings extension point and no Rust catalog change; this is host-owned feature code modelled on Appearance. Docs updated accordingly. FOUNDATION files touched (minimal, additive, behaviour-preserving): - src/features/shortcuts/service.ts: optional BindingOverrides constructor argument consulted at match time (falls back to the registered binding); host-only hostSnapshot/hostSubscribe so the Settings page can list host bindings and their defaults. Plugin snapshot/subscribe and matching rules are unchanged. - src/features/shortcuts/bindings.ts: export isKeyBinding (the existing per-binding validation) and add sameBinding; matches() accepts an alias array so effective bindings resolve in one place. Validation semantics are unchanged. - src/app/services.ts: construct/dispose the shortcut bindings store, pass it to ShortcutsService, and expose it on AppServices. - src/app/App.tsx: hand the shortcuts service and bindings store to Settings. Provisional components awaiting a design pass (plain black-and-white on standard tokens, kept outside src/shared/design-system/ui/, marked with a DESIGN PASS PENDING file comment and data-design-pass="pending"): - src/features/shortcuts/KeyCombo.tsx: key-combo chip. - src/features/shortcuts/KeyCaptureControl.tsx: inline key-capture control. Also adds formatBinding (glyphs in Control/Option/Shift/Command order on Apple platforms, Ctrl+Shift+K style elsewhere, accessible plain-words label) and uses it for the search and terminal hints; unit tests for the store, formatter, dispatcher overrides and the page; a Playwright journey rebinding the example plugin's shortcut; and the Settings tab-order assertion. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Matt Toohey --- docs/design-system.md | 17 +- docs/plugin-architecture.md | 22 +- src/app/App.tsx | 2 + src/app/Settings.tsx | 16 + src/app/ShortcutSettings.test.tsx | 427 ++++++++++++++++++ src/app/ShortcutSettings.tsx | 374 +++++++++++++++ src/app/navigation.ts | 11 +- src/app/services.ts | 6 +- src/app/shell/PageSearch.tsx | 27 +- src/bundled/terminal/index.tsx | 9 +- .../shortcuts/KeyCaptureControl.module.css | 12 + src/features/shortcuts/KeyCaptureControl.tsx | 87 ++++ src/features/shortcuts/KeyCombo.module.css | 19 + src/features/shortcuts/KeyCombo.tsx | 31 ++ src/features/shortcuts/bindings.ts | 42 +- src/features/shortcuts/format.test.ts | 66 +++ src/features/shortcuts/format.ts | 65 +++ src/features/shortcuts/preferences.test.ts | 141 ++++++ src/features/shortcuts/preferences.ts | 128 ++++++ src/features/shortcuts/service.test.ts | 94 +++- src/features/shortcuts/service.ts | 36 +- src/shared/design-system/icons/index.ts | 2 + tests/browser/settings.spec.mjs | 4 + tests/browser/shortcuts.spec.mjs | 61 +++ 24 files changed, 1665 insertions(+), 34 deletions(-) create mode 100644 src/app/ShortcutSettings.test.tsx create mode 100644 src/app/ShortcutSettings.tsx create mode 100644 src/features/shortcuts/KeyCaptureControl.module.css create mode 100644 src/features/shortcuts/KeyCaptureControl.tsx create mode 100644 src/features/shortcuts/KeyCombo.module.css create mode 100644 src/features/shortcuts/KeyCombo.tsx create mode 100644 src/features/shortcuts/format.test.ts create mode 100644 src/features/shortcuts/format.ts create mode 100644 src/features/shortcuts/preferences.test.ts create mode 100644 src/features/shortcuts/preferences.ts diff --git a/docs/design-system.md b/docs/design-system.md index c5ccabe4d..797b64ba0 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -117,9 +117,24 @@ font size by the scale again. Use unitless or scaled line-height so enlarged tex does not overlap. Independent plugins that hard-code sizes and third-party shadow widgets need their own adapter; this is not a forced CSS rewrite of arbitrary code. +Settings → Shortcuts lists every host and active plugin shortcut from the live +dispatcher, grouped by owner, with search, per-row Change/Reset and Reset all. It +is built from existing components (`SearchField`, `Button`, `NavigationSection`, +the Plugins-list row pattern) and `formatBinding`, which renders chords as glyphs +in Control, Option, Shift, Command order on Apple platforms (⇧⌘K) and as words +elsewhere (Ctrl+Shift+K), with a plain-words accessible label. Two pieces are +provisional and await a design pass: the key-combo `` chip +(`src/features/shortcuts/KeyCombo.tsx`) and the inline key-capture control +(`src/features/shortcuts/KeyCaptureControl.tsx`). Both are deliberately +black-and-white on standard text, surface, border and radius tokens, live outside +`src/shared/design-system/ui/`, and are marked with a `DESIGN PASS PENDING` file +comment and `data-design-pass="pending"` on their root so they are greppable. + `tests/browser/shortcuts.spec.mjs` covers real key dispatch to Settings and actual message/composer text, draft/node preservation, reset/limits/reload, modal/editor/ -Shadow DOM guards, and the independent example's disable/re-enable path. These +Shadow DOM guards, the independent example's disable/re-enable path, and rebinding +that example's shortcut from Settings → Shortcuts (host conflict refused, new chord +fires, old chord does not, persists across reload, reset restores). These Chromium/WebKit checks use a fixture broker, not native menu accelerators. An attended desktop shortcut try remains necessary for native acceptance. diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 79d88d846..51f89bad4 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -515,5 +515,23 @@ self-contained external plugin using the real service without a DOM listener. The generated type-only `@buzz/author` exports `Shortcuts`, `Shortcut`, `KeyBinding` and `RegisteredShortcut`. This is a host-matched preview: older hosts without the `shortcuts` capability cannot activate such a plugin. `apiVersion: 1` alone is not -runtime feature negotiation. Chords, user rebinding, conflict UI and command palettes -are outside this initial contract. +runtime feature negotiation. Multi-key chord sequences and command palettes are +outside this initial contract. + +Users can rebind any registered shortcut in Settings → Shortcuts without plugin +changes. The page lists host bindings and every active plugin contribution from the +dispatcher's own `hostSnapshot`/`snapshot` registries, grouped by owner, so it +cannot drift from what fires. Overrides live in the host-owned device-local +`buzz-shortcut-bindings.v1` preference, keyed by the registry identity the +dispatcher already uses: the bare id for host bindings and `pluginId/id` for plugin +contributions. The dispatcher resolves the effective binding at match time, so a +plugin keeps registering its default and never sees, stores or re-registers for +an override; the override follows the plugin across disable, re-enable and +replacement, and an override whose owner is no longer installed is ignored rather +than deleted. Rebinding replaces an alias set with the single chosen chord; reset +restores every alias. Host chords stay reserved: the page refuses to assign a chord +that another listed shortcut already uses, host or plugin, and warns when a chord +is one the message editor handles locally. `formatBinding` in +`features/shortcuts/format.ts` renders any `KeyBinding` for the current platform; +plugins that print their own hint (the bundled terminal does) show their registered +default because overrides are host state. diff --git a/src/app/App.tsx b/src/app/App.tsx index 96507bf82..e1d43846f 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -106,6 +106,8 @@ export function App({ services }: { services: AppServices }) { plugins={plugins} communities={services.communities} appearance={services.appearance} + shortcuts={services.shortcuts} + shortcutBindings={services.shortcutBindings} notifications={services.notifications} navigation={route.request} onSection={(section) => diff --git a/src/app/Settings.tsx b/src/app/Settings.tsx index 17040c385..67fdbf4dd 100644 --- a/src/app/Settings.tsx +++ b/src/app/Settings.tsx @@ -11,6 +11,7 @@ import { PaletteIcon, BellIcon, ChatCircleIcon, + KeyboardIcon, WrenchIcon, } from "../shared/design-system/icons/index"; import type { PluginManager } from "../plugins/manager"; @@ -22,6 +23,9 @@ import type { Appearance } from "../shared/theme/service"; import { AppearanceSettings } from "./AppearanceSettings"; import { NotificationSettings } from "./NotificationSettings"; import type { NotificationsService } from "../features/notifications/service"; +import type { ShortcutsService } from "../features/shortcuts/service"; +import type { ShortcutBindings } from "../features/shortcuts/preferences"; +import { ShortcutSettings } from "./ShortcutSettings"; import { DeveloperSettings } from "./DeveloperSettings"; import { MessageSettings } from "./MessageSettings"; @@ -31,6 +35,7 @@ const baseSections: Section[] = [ { id: "profile", label: "Profile", icon: UserIcon }, { id: "plugins", label: "Plugins", icon: SquaresFourIcon }, { id: "appearance", label: "Appearance", icon: PaletteIcon }, + { id: "shortcuts", label: "Shortcuts", icon: KeyboardIcon }, { id: "messages", label: "Messages", icon: ChatCircleIcon }, { id: "notifications", label: "Notifications", icon: BellIcon }, ]; @@ -49,6 +54,8 @@ export function Settings({ plugins, communities, appearance, + shortcuts, + shortcutBindings, notifications, navigation, onSection, @@ -56,6 +63,8 @@ export function Settings({ plugins: PluginManager; communities: Communities; appearance: Appearance; + shortcuts: ShortcutsService; + shortcutBindings: ShortcutBindings; notifications: NotificationsService; navigation?: | import("../features/navigation/service").PageNavigation @@ -117,6 +126,13 @@ export function Settings({ + diff --git a/src/app/ShortcutSettings.test.tsx b/src/app/ShortcutSettings.test.tsx new file mode 100644 index 000000000..49f33a1bb --- /dev/null +++ b/src/app/ShortcutSettings.test.tsx @@ -0,0 +1,427 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { Context } from "@deepseek-ai/cordis"; +import { + act, + cleanup, + fireEvent, + render, + screen, + within, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, expect, it, vi } from "vitest"; +import { ShortcutSettings } from "./ShortcutSettings"; +import type { Shortcut } from "../features/shortcuts/bindings"; +import { + createShortcutBindings, + SHORTCUT_BINDINGS_KEY, +} from "../features/shortcuts/preferences"; +import { ShortcutsService } from "../features/shortcuts/service"; +import type { PluginManager } from "../plugins/manager"; +import type { PluginInfo } from "../plugins/types"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + localStorage.clear(); +}); + +const info = (id: string, name: string): PluginInfo => ({ + manifest: { id, name, apiVersion: 1 }, + source: "external", + enabled: true, + revision: "one", + previous: null, + reloadable: false, + error: null, +}); +const catalog = ( + plugins: PluginInfo[], +): Pick => { + const snapshot = { + configuration: { + status: "ready" as const, + catalog: { profile: "", location: "", plugins }, + externalPluginsPaused: false, + }, + activation: {}, + busy: false, + error: null, + refreshError: null, + }; + return { subscribe: () => () => {}, snapshot: () => snapshot }; +}; + +// jsdom reports an empty platform, so the real dispatcher on `window` treats +// Control as Mod; the page follows the same test unless told otherwise. +async function harness() { + const root = new Context(); + const inactive = new Set(); + const statusListeners = new Set<() => void>(); + root.provide("pluginStatus", { + isActive: (id: string) => !inactive.has(id), + subscribe: (listener: () => void) => { + statusListeners.add(listener); + return () => { + statusListeners.delete(listener); + }; + }, + }); + const bindings = createShortcutBindings(window); + const shortcuts = new ShortcutsService(root, window, bindings); + const runs = { + settings: vi.fn(), + search: vi.fn(), + grow: vi.fn(), + increment: vi.fn(), + terminal: vi.fn(), + }; + shortcuts.registerHost({ + id: "settings", + title: "Open Settings", + binding: { key: ",", mod: true }, + run: runs.settings, + }); + shortcuts.registerHost({ + id: "global-search", + title: "Search Buzz", + binding: { key: "k", mod: true }, + run: runs.search, + }); + shortcuts.registerHost({ + id: "font-increase", + title: "Increase text size", + binding: [ + { key: "=", mod: true }, + { key: "+", mod: true }, + { key: "=", mod: true, shift: true }, + { key: "+", mod: true, shift: true }, + ], + run: runs.grow, + }); + const contribute = (id: string, shortcut: Shortcut) => + root + .extend({ pluginOwner: { id, revision: "one" } }) + .plugin((ctx) => { + ctx.shortcuts.register(shortcut); + }) + .await(); + await contribute("example.counter", { + id: "increment", + title: "Increment shortcut counter", + binding: { key: "k", mod: true, shift: true }, + run: runs.increment, + }); + await contribute("buzz.terminal", { + id: "toggle", + title: "Toggle channel terminal", + binding: { key: "j", mod: true }, + run: runs.terminal, + }); + return { + runs, + bindings, + shortcuts, + plugins: catalog([ + info("example.counter", "Shortcut counter"), + info("buzz.terminal", "Terminal"), + ]), + setActive(id: string, active: boolean) { + if (active) inactive.delete(id); + else inactive.add(id); + for (const listener of statusListeners) listener(); + }, + /** Dispatch to the window listener from a non-editable target. */ + press: (key: string, init: KeyboardEventInit = {}) => + fireEvent.keyDown(document.body, { key, ctrlKey: true, ...init }), + async dispose() { + bindings.dispose(); + await root.fiber.dispose(); + }, + }; +} +const row = (title: string) => screen.getByRole("article", { name: title }); +const change = (title: string) => + screen.getByRole("button", { name: `Change shortcut for ${title}` }); +const capture = (title: string) => + screen.getByRole("textbox", { name: `New shortcut for ${title}` }); + +it("lists live host and plugin shortcuts grouped by owner, searchable, and follows plugin state", async () => { + const user = userEvent.setup(); + const h = await harness(); + try { + render( + , + ); + const groups = screen + .getAllByRole("heading", { level: 2 }) + .map((heading) => heading.textContent); + expect(groups).toEqual([ + "Shortcuts", + "Buzz", + "Shortcut counter", + "Terminal", + ]); + expect( + screen + .getAllByRole("article") + .map( + (article) => + within(article).getByRole("heading", { level: 3 }).textContent, + ), + ).toEqual([ + "Increase text size", + "Open Settings", + "Search Buzz", + "Increment shortcut counter", + "Toggle channel terminal", + ]); + // Chips show the first alias with glyphs; the label reads as words. + const grow = row("Increase text size"); + expect(within(grow).getByText("Command =")).toHaveClass("sr-only"); + const chips = grow.querySelectorAll("kbd kbd"); + expect([...chips].map((chip) => chip.textContent)).toEqual(["⌘", "="]); + expect(chips[0]?.parentElement).toHaveAttribute("aria-hidden", "true"); + expect(grow.querySelector("[data-design-pass='pending']")).toHaveAttribute( + "data-binding", + "⌘=", + ); + expect( + within(row("Increment shortcut counter")).getByText("Shift Command K"), + ).toBeInTheDocument(); + expect(screen.queryByText("Modified")).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Reset all shortcuts" }), + ).toBeDisabled(); + + const search = screen.getByRole("searchbox", { name: "Search shortcuts" }); + await user.type(search, "counter"); + expect(screen.getAllByRole("article")).toHaveLength(1); + expect(row("Increment shortcut counter")).toBeInTheDocument(); + await user.clear(search); + await user.type(search, "⌘J"); + expect(screen.getAllByRole("article")).toHaveLength(1); + expect(row("Toggle channel terminal")).toBeInTheDocument(); + await user.clear(search); + await user.type(search, "command ,"); + expect(screen.getAllByRole("article")).toHaveLength(1); + expect(row("Open Settings")).toBeInTheDocument(); + await user.clear(search); + await user.type(search, "zzz"); + expect(screen.queryAllByRole("article")).toHaveLength(0); + expect(screen.getByText("No matching shortcuts.")).toBeInTheDocument(); + await user.clear(search); + + act(() => h.setActive("buzz.terminal", false)); + expect( + screen.queryByRole("heading", { name: "Terminal" }), + ).not.toBeInTheDocument(); + expect(screen.getAllByRole("article")).toHaveLength(4); + act(() => h.setActive("buzz.terminal", true)); + expect(row("Toggle channel terminal")).toBeInTheDocument(); + } finally { + await h.dispose(); + } +}); + +it("captures a chord, refuses conflicts and bare keys, applies overrides to the dispatcher, and resets", async () => { + const user = userEvent.setup(); + const h = await harness(); + try { + render( + , + ); + const title = "Increment shortcut counter"; + expect(within(row(title)).getByText("Control Shift K")).toBeInTheDocument(); + await user.click(change(title)); + const input = capture(title); + expect(input).toHaveFocus(); + expect(input).toHaveValue("Press a shortcut…"); + expect(input).toHaveAttribute("data-state", "listening"); + expect(input).toHaveAttribute("data-design-pass", "pending"); + expect( + screen.getByRole("button", { name: `Cancel changing ${title}` }), + ).toBeInTheDocument(); + // IME, bare modifiers and unmodified keys never become bindings. + fireEvent.keyDown(input, { key: "k", ctrlKey: true, isComposing: true }); + fireEvent.keyDown(input, { key: "k", ctrlKey: true, keyCode: 229 }); + fireEvent.keyDown(input, { key: "Control", ctrlKey: true }); + fireEvent.keyDown(input, { key: "Shift", shiftKey: true }); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(capture(title)).toBe(input); + fireEvent.keyDown(input, { key: "k", shiftKey: true }); + expect(screen.getByRole("alert")).toHaveTextContent( + "Include Control or Alt so ordinary typing keeps working.", + ); + fireEvent.keyDown(input, { key: "k", metaKey: true }); + expect(screen.getByRole("alert")).toHaveTextContent( + "The Windows/Command key isn’t used for shortcuts on this device.", + ); + // Conflicts are reported by title and owner; nothing is saved or fired. + fireEvent.keyDown(input, { key: "k", ctrlKey: true }); + expect(screen.getByRole("alert")).toHaveTextContent( + "Ctrl+K is already used by Search Buzz (Buzz).", + ); + expect(screen.getByRole("alert")).toHaveClass("text-danger"); + expect(input).toHaveAttribute( + "aria-describedby", + screen.getByRole("alert").id, + ); + fireEvent.keyDown(input, { key: "j", ctrlKey: true }); + expect(screen.getByRole("alert")).toHaveTextContent( + "Ctrl+J is already used by Toggle channel terminal (Terminal).", + ); + expect(h.runs.search).not.toHaveBeenCalled(); + expect(h.runs.terminal).not.toHaveBeenCalled(); + expect(h.bindings.snapshot().overrides).toEqual({}); + expect(capture(title)).toBe(input); + + await user.keyboard("{Control>}{Shift>}u{/Shift}{/Control}"); + expect(screen.queryByRole("textbox", { name: /New shortcut/ })).toBeNull(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(within(row(title)).getByText("Modified")).toBeInTheDocument(); + expect(within(row(title)).getByText("Control Shift U")).toBeInTheDocument(); + expect(change(title)).toHaveFocus(); + expect(h.bindings.snapshot().overrides).toEqual({ + "example.counter/increment": { key: "u", mod: true, shift: true }, + }); + expect(localStorage.getItem(SHORTCUT_BINDINGS_KEY)).toBe( + JSON.stringify({ + "example.counter/increment": { key: "u", mod: true, shift: true }, + }), + ); + expect(h.press("k", { shiftKey: true })).toBe(true); + expect(h.runs.increment).not.toHaveBeenCalled(); + expect(h.press("u", { shiftKey: true })).toBe(false); + expect(h.runs.increment).toHaveBeenCalledTimes(1); + await user.click( + screen.getByRole("button", { name: `Reset shortcut for ${title}` }), + ); + expect(within(row(title)).queryByText("Modified")).toBeNull(); + expect(h.bindings.resolve("example.counter/increment")).toBeUndefined(); + h.press("k", { shiftKey: true }); + expect(h.runs.increment).toHaveBeenCalledTimes(2); + + // Escape and blur cancel without saving; focus returns to the row action. + await user.click(change("Open Settings")); + fireEvent.keyDown(capture("Open Settings"), { key: "Escape" }); + expect(screen.queryByRole("textbox", { name: /New shortcut/ })).toBeNull(); + expect(change("Open Settings")).toHaveFocus(); + await user.click(change("Open Settings")); + await user.click(document.body); + expect(screen.queryByRole("textbox", { name: /New shortcut/ })).toBeNull(); + expect(h.bindings.snapshot().overrides).toEqual({}); + + // Editor-local chords save with a warning; the freed chord becomes usable. + await user.click(change("Open Settings")); + fireEvent.keyDown(capture("Open Settings"), { key: "z", ctrlKey: true }); + const warning = within(row("Open Settings")).getByRole("alert"); + expect(warning).toHaveTextContent("message editor handles Ctrl+Z"); + expect(warning).toHaveClass("text-warning"); + expect(h.bindings.resolve("settings")).toEqual({ key: "z", mod: true }); + await user.click(change("Toggle channel terminal")); + fireEvent.keyDown(capture("Toggle channel terminal"), { + key: ",", + ctrlKey: true, + }); + expect( + within(row("Toggle channel terminal")).getByText("Modified"), + ).toBeInTheDocument(); + h.press(","); + expect(h.runs.terminal).toHaveBeenCalledTimes(1); + expect(h.runs.settings).not.toHaveBeenCalled(); + // Choosing a shortcut's own default clears its override instead. + await user.click(change("Toggle channel terminal")); + fireEvent.keyDown(capture("Toggle channel terminal"), { + key: "j", + ctrlKey: true, + }); + expect(within(row("Toggle channel terminal")).queryByText("Modified")).toBe( + null, + ); + expect(h.bindings.resolve("buzz.terminal/toggle")).toBeUndefined(); + + // Rebinding an alias set replaces the whole set; reset restores it. + await user.click(change("Increase text size")); + fireEvent.keyDown(capture("Increase text size"), { + key: "=", + ctrlKey: true, + altKey: true, + }); + expect( + within(row("Increase text size")).getByText("Control Alt ="), + ).toBeInTheDocument(); + h.press("="); + h.press("+", { shiftKey: true }); + expect(h.runs.grow).not.toHaveBeenCalled(); + h.press("=", { altKey: true }); + expect(h.runs.grow).toHaveBeenCalledTimes(1); + const resetAll = screen.getByRole("button", { + name: "Reset all shortcuts", + }); + expect(resetAll).toBeEnabled(); + await user.click(resetAll); + expect(screen.queryByText("Modified")).not.toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(resetAll).toBeDisabled(); + expect(h.bindings.snapshot().overrides).toEqual({}); + expect(localStorage.getItem(SHORTCUT_BINDINGS_KEY)).toBeNull(); + h.press("+", { shiftKey: true }); + expect(h.runs.grow).toHaveBeenCalledTimes(2); + h.press(","); + expect(h.runs.settings).toHaveBeenCalledTimes(1); + } finally { + await h.dispose(); + } +}); + +it("keeps a change active when saving fails and offers a retry", async () => { + const user = userEvent.setup(); + const h = await harness(); + try { + render( + , + ); + const write = vi + .spyOn(Storage.prototype, "setItem") + .mockImplementation(() => { + throw new Error("quota"); + }); + await user.click(change("Toggle channel terminal")); + fireEvent.keyDown(capture("Toggle channel terminal"), { + key: "u", + ctrlKey: true, + }); + const alert = screen.getByRole("alert"); + expect(alert).toHaveTextContent("could not be saved on this device"); + expect( + within(row("Toggle channel terminal")).getByText("Modified"), + ).toBeInTheDocument(); + h.press("u"); + expect(h.runs.terminal).toHaveBeenCalledTimes(1); + write.mockRestore(); + await user.click( + within(alert).getByRole("button", { name: "Retry saving shortcuts" }), + ); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(localStorage.getItem(SHORTCUT_BINDINGS_KEY)).toBe( + JSON.stringify({ "buzz.terminal/toggle": { key: "u", mod: true } }), + ); + } finally { + await h.dispose(); + } +}); diff --git a/src/app/ShortcutSettings.tsx b/src/app/ShortcutSettings.tsx new file mode 100644 index 000000000..d6693afa1 --- /dev/null +++ b/src/app/ShortcutSettings.tsx @@ -0,0 +1,374 @@ +import { + useEffect, + useId, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import { Button } from "../shared/design-system/ui/Button"; +import { NavigationSection } from "../shared/design-system/ui/NavigationSection"; +import { SearchField } from "../shared/design-system/ui/SearchField"; +import { sameBinding, type KeyBinding } from "../features/shortcuts/bindings"; +import { formatBinding, isApplePlatform } from "../features/shortcuts/format"; +import { KeyCombo } from "../features/shortcuts/KeyCombo"; +import { + KeyCaptureControl, + type CapturedChord, +} from "../features/shortcuts/KeyCaptureControl"; +import type { ShortcutBindings } from "../features/shortcuts/preferences"; +import type { Shortcut, ShortcutsService } from "../features/shortcuts/service"; +import type { PluginManager } from "../plugins/manager"; + +type Row = Readonly<{ + key: string; + title: string; + owner: string; + defaults: readonly KeyBinding[]; + override: KeyBinding | undefined; + effective: readonly KeyBinding[]; +}>; +type Group = Readonly<{ id: string; label: string; rows: readonly Row[] }>; +type Notice = Readonly<{ + key: string; + tone: "error" | "warning"; + message: string; +}>; + +/** Chords the message editor handles locally before the window dispatcher. */ +const EDITOR_CHORDS: readonly KeyBinding[] = [ + { key: "z", mod: true }, + { key: "z", mod: true, shift: true }, + { key: "y", mod: true }, + { key: "Home", mod: true }, + { key: "End", mod: true }, +]; +const bindingsOf = (shortcut: Shortcut): readonly KeyBinding[] => + "key" in shortcut.binding ? [shortcut.binding] : shortcut.binding; +const byTitle = (a: Row, b: Row) => + a.title.localeCompare(b.title) || a.key.localeCompare(b.key); + +/** + * Lists every host binding and every active plugin contribution from the live + * dispatcher registry, so the page cannot drift from what actually fires. + * Overrides are keyed by the registry's own identity: host id or `pluginId/id`. + */ +export function ShortcutSettings({ + shortcuts, + bindings, + plugins, + apple = isApplePlatform(navigator.platform), +}: { + shortcuts: ShortcutsService; + bindings: ShortcutBindings; + /** Display names for plugin groups come from the catalog. */ + plugins: Pick; + apple?: boolean; +}) { + const host = useSyncExternalStore( + shortcuts.hostSubscribe, + shortcuts.hostSnapshot, + ); + const contributed = useSyncExternalStore( + shortcuts.subscribe, + shortcuts.snapshot, + ); + const { overrides, error } = useSyncExternalStore( + bindings.subscribe, + bindings.snapshot, + ); + const { configuration } = useSyncExternalStore( + plugins.subscribe, + plugins.snapshot, + ); + const [query, setQuery] = useState(""); + const [editing, setEditing] = useState(null); + const [notice, setNotice] = useState(null); + + const pluginName = (id: string) => + (configuration.status === "ready" + ? configuration.catalog.plugins.find( + (plugin) => plugin.manifest.id === id, + )?.manifest.name + : undefined) ?? id; + const row = (key: string, shortcut: Shortcut, owner: string): Row => { + const defaults = bindingsOf(shortcut); + const override = overrides[key]; + return { + key, + title: shortcut.title, + owner, + defaults, + override, + effective: override ? [override] : defaults, + }; + }; + const groups: Group[] = [ + { + id: "buzz", + label: "Buzz", + rows: host.map((shortcut) => row(shortcut.id, shortcut, "Buzz")), + }, + ...[...new Set(contributed.map((shortcut) => shortcut.pluginId))] + .map((pluginId) => { + const label = pluginName(pluginId); + return { + id: pluginId, + label, + rows: contributed + .filter((shortcut) => shortcut.pluginId === pluginId) + .map((shortcut) => row(shortcut.key, shortcut, label)), + }; + }) + .sort((a, b) => a.label.localeCompare(b.label)), + ] + .map((group) => ({ ...group, rows: [...group.rows].sort(byTitle) })) + .filter((group) => group.rows.length); + const rows = groups.flatMap((group) => group.rows); + const needle = query.trim().toLowerCase(); + const visible = needle + ? groups + .map((group) => ({ + ...group, + rows: group.rows.filter((row) => + [ + row.title, + row.owner, + ...row.effective.flatMap((binding) => { + const { text, label } = formatBinding(binding, apple); + return [text, label]; + }), + ].some((text) => text.toLowerCase().includes(needle)), + ), + })) + .filter((group) => group.rows.length) + : groups; + const modified = Object.keys(overrides).length > 0; + + const start = (key: string) => { + setEditing(key); + setNotice(null); + }; + const cancel = (key: string) => { + setEditing((current) => (current === key ? null : current)); + setNotice((current) => + current?.key === key && current.tone === "error" ? null : current, + ); + }; + // Conflicts are decided here, not in the dispatcher: host chords always win + // there, and plugin ties resolve by sorted key, neither of which is a choice. + const capture = (target: Row, { binding, otherPrimary }: CapturedChord) => { + const chord = formatBinding(binding, apple).text; + const refuse = (message: string) => + setNotice({ key: target.key, tone: "error", message }); + if (otherPrimary) + return refuse( + apple + ? "Control isn’t used for shortcuts on this device. Try Command or Option." + : "The Windows/Command key isn’t used for shortcuts on this device. Try Control or Alt.", + ); + if (!binding.mod && !binding.alt) + return refuse( + `Include ${apple ? "Command or Option" : "Control or Alt"} so ordinary typing keeps working.`, + ); + if (binding.key === "Dead" || binding.key === "Unidentified") + return refuse("That key can’t be used for a shortcut. Try another."); + const conflict = rows.find( + (row) => + row.key !== target.key && + row.effective.some((current) => sameBinding(current, binding)), + ); + if (conflict) + return refuse( + `${chord} is already used by ${conflict.title} (${conflict.owner}).`, + ); + const isDefault = + target.defaults.length === 1 && + target.defaults.every((current) => sameBinding(current, binding)); + bindings.set(target.key, isDefault ? null : binding); + setEditing(null); + setNotice( + EDITOR_CHORDS.some((current) => sameBinding(current, binding)) + ? { + key: target.key, + tone: "warning", + message: `Saved. The message editor handles ${chord} itself while you are typing, so it wins there.`, + } + : null, + ); + }; + + return ( +
+

+ Shortcuts +

+
+

+ Every shortcut from Buzz and your enabled plugins. Choose Change, then + press the new keys; Escape cancels. Saved on this device. +

+ + {visible.length ? ( +
+ {visible.map((group) => ( + +
+ {group.rows.map((row) => ( + start(row.key)} + onCancel={() => cancel(row.key)} + onCapture={(chord) => capture(row, chord)} + onReset={() => { + cancel(row.key); + setNotice(null); + bindings.set(row.key, null); + }} + /> + ))} +
+
+ ))} +
+ ) : ( +

+ {rows.length + ? "No matching shortcuts." + : "No shortcuts are available yet."} +

+ )} +
+ +
+ {error && ( +
+

{error}

+
+ +
+
+ )} +
+
+ ); +} + +function ShortcutRow({ + row, + apple, + listening, + notice, + onStart, + onCancel, + onCapture, + onReset, +}: { + row: Row; + apple: boolean; + listening: boolean; + notice: Notice | null; + onStart: () => void; + onCancel: () => void; + onCapture: (chord: CapturedChord) => void; + onReset: () => void; +}) { + const titleId = useId(); + const noticeId = useId(); + const change = useRef(null); + const wasListening = useRef(listening); + // Return focus to the row's action when the capture control goes away + // without the person having moved focus somewhere else. + useEffect(() => { + if ( + wasListening.current && + !listening && + document.activeElement === document.body + ) + change.current?.focus(); + wasListening.current = listening; + }, [listening]); + const primary = row.effective[0]; + return ( +
+
+

+ {row.title} +

+ {row.override && ( +

Modified

+ )} + {notice && ( + + )} +
+
+ {listening ? ( + + ) : ( + primary && + )} + + {row.override && !listening && ( + + )} +
+
+ ); +} diff --git a/src/app/navigation.ts b/src/app/navigation.ts index ddf13e0df..7350898a4 100644 --- a/src/app/navigation.ts +++ b/src/app/navigation.ts @@ -78,9 +78,14 @@ export function useAppNavigation(services: AppServices) { if ( target.kind === "settings" && target.section && - !["profile", "plugins", "appearance", "messages", "notifications"].includes( - target.section, - ) && + ![ + "profile", + "plugins", + "appearance", + "shortcuts", + "messages", + "notifications", + ].includes(target.section) && !(developerMode && target.section === "developer") ) failure = "unavailable"; diff --git a/src/app/services.ts b/src/app/services.ts index c36a3f944..c4bbc5afb 100644 --- a/src/app/services.ts +++ b/src/app/services.ts @@ -9,6 +9,7 @@ import { notificationAuthorized, } from "../features/notifications/messages"; import { ShortcutsService } from "../features/shortcuts/service"; +import { createShortcutBindings } from "../features/shortcuts/preferences"; import { ConversationService } from "../features/conversation/service"; import { createAppearance } from "../shared/theme/service"; import { createCommunities } from "../features/communities/service"; @@ -21,6 +22,7 @@ import { withTimeout } from "../plugins/timeout"; export function createServices() { const appearance = createAppearance(); + const shortcutBindings = createShortcutBindings(); const ctx = new Context(); const plugins = createPluginManager(ctx, { bundled: bundledPlugins, @@ -28,7 +30,7 @@ export function createServices() { const agentControl = provideAgentControl(ctx); const navigationHost = provideNavigation(ctx); const navigation = navigationHost.navigation; - const shortcuts = new ShortcutsService(ctx); + const shortcuts = new ShortcutsService(ctx, undefined, shortcutBindings); const pages = new PagesService(ctx); const panels = new PanelsService(ctx); const conversation = new ConversationService(ctx); @@ -57,6 +59,7 @@ export function createServices() { navigation, navigationHost, shortcuts, + shortcutBindings, conversation, pages, panels, @@ -66,6 +69,7 @@ export function createServices() { appearance, dispose() { appearance.dispose(); + shortcutBindings.dispose(); // Start root cancellation without waiting for plugin-owned cleanup. Cordis // starts sibling effects independently; the runtime still owns replacement // barriers. A timeout reports incomplete cleanup, never successful disposal. diff --git a/src/app/shell/PageSearch.tsx b/src/app/shell/PageSearch.tsx index a65554d9d..56837243e 100644 --- a/src/app/shell/PageSearch.tsx +++ b/src/app/shell/PageSearch.tsx @@ -12,6 +12,12 @@ import { Button } from "../../shared/design-system/ui/Button"; import type { RegisteredPage } from "../../features/pages/service"; import { communityDestination } from "../../features/communities/destination"; import { useRelayConnection } from "../../features/relay/react"; +import type { KeyBinding } from "../../features/shortcuts/bindings"; +import { + formatBinding, + isApplePlatform, +} from "../../features/shortcuts/format"; +import type { ShortcutBindingsSnapshot } from "../../features/shortcuts/preferences"; import type { AppServices } from "../services"; import { orderPages, @@ -27,8 +33,13 @@ import { SearchResults } from "./SearchResults"; export type SearchServices = Pick< AppServices, - "communities" | "shortcuts" | "navigation" + "communities" | "shortcuts" | "shortcutBindings" | "navigation" >; +const SEARCH_ID = "global-search"; +const SEARCH_BINDING: KeyBinding = { key: "k", mod: true }; +const NO_OVERRIDES: ShortcutBindingsSnapshot = { overrides: {}, error: null }; +const noSubscribe = () => () => {}; +const noOverrides = () => NO_OVERRIDES; export function PageSearch({ pages, @@ -55,14 +66,19 @@ export function PageSearch({ useEffect( () => services?.shortcuts.registerHost({ - id: "global-search", + id: SEARCH_ID, title: "Search Buzz", - binding: { key: "k", mod: true }, + binding: SEARCH_BINDING, allowInEditable: true, run: begin, }), [services, begin], ); + // The hint follows the person's rebind, derived from the same binding object. + const { overrides } = useSyncExternalStore( + services?.shortcutBindings.subscribe ?? noSubscribe, + services?.shortcutBindings.snapshot ?? noOverrides, + ); const destinations: SearchDestination[] = [ { key: "home", ...shellPresentation.home }, ...orderPages(pages).map((page) => ({ @@ -82,7 +98,10 @@ export function PageSearch({ setOpen(false); }, })); - const shortcut = /Mac|iPhone|iPad/.test(navigator.platform) ? "⌘K" : "Ctrl+K"; + const shortcut = formatBinding( + overrides[SEARCH_ID] ?? SEARCH_BINDING, + isApplePlatform(navigator.platform), + ).text; return ( <> { // No unusable launcher or reserved shortcut in a browser-only host. if (!nativeBridge.available) return; @@ -24,7 +29,7 @@ export const apply: PluginModule["apply"] = (ctx) => { ctx.shortcuts.register({ id: "toggle", title: "Toggle channel terminal", - binding: { key: "j", mod: true }, + binding: TOGGLE_BINDING, allowInEditable: true, when: () => !!binding?.available(), run: () => binding?.toggle(), @@ -49,7 +54,7 @@ export const apply: PluginModule["apply"] = (ctx) => { variant={pressed ? "tint" : "ghost"} icon={