diff --git a/docs/design-system.md b/docs/design-system.md index c5ccabe4d..85773128d 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -117,9 +117,35 @@ 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 each owner's deliberate numeric order, +per-row Change/Reset and Reset all. Buzz's host rows use a functional sequence +(navigation, text sizing, search/settings, then development-only actions); plugins +choose the order of their own actions. Equal orders use stable registry identity +and then title as tie-breakers. It +is built from existing components (`Input`, `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. A row whose chord +another listed shortcut also answers to carries a plain "Also used by …" line in +subtle text, no colour. 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: capture composes the shared Input with feature-owned sizing +and keyboard handling; notices retain explicit alert text in neutral roles. +The keycaps use standard text, surface, border and radius tokens. Both 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. +Capture keeps the shared keyboard-focus treatment. Escape cancels; Tab/Shift+Tab +leave capture without saving, with an accessible instruction explaining the exit. +Rows wrap their actions before the title collapses. + `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..d03334d90 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -480,6 +480,8 @@ export function apply(ctx: Context) { id: "show-details", title: "Show details", binding: { key: "k", mod: true, shift: true }, + // Optional Settings presentation order within this plugin's category. + order: 10, when: () => detailsViewIsAvailable(), run: () => showDetails(), }; @@ -510,10 +512,62 @@ or a promise that every binding wins every current focus conflict. The host-only registration method is deliberately absent from the injected type contract; plugins remain trusted same-process code, not sandboxed adversaries. +`order` is optional and defaults to `0`. It controls only the row order in Settings +within this owner's category; lower values appear first. Every bundled plugin +assigns deliberate values to its actions (for example, a primary action starts +at `10`), leaving gaps for related actions to be added later. Equal orders use +the stable namespaced contribution key (`pluginId/shortcutId`), then title, as +presentation tie-breakers. The core Buzz host category uses the same metadata +and a host-owned functional sequence: navigation, text sizing, search/settings, +then development-only actions. Host rows use their bare IDs for tie-breaking. +Presentation order does not affect dispatch precedence, and +shortcuts with duplicate titles remain separate rows because registry keys—not +titles—identify bindings and their overrides. + See [`shortcut-counter`](../examples/plugins/shortcut-counter/README.md) for a 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, refuses the copy, cut, +paste and select-all chords (and close-window/quit in the desktop build) because a +match would prevent their default everywhere, and warns when a chord is one the +message editor handles locally. A conflict can still appear after capture, for +example when a plugin that was disabled at the time is re-enabled with the same +default or a new plugin ships one; the dispatcher then resolves it silently, so +each affected row shows an "Also used by …" line naming the others. A malformed +stored override falls back to the registered default rather than stopping +dispatch. `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. Xterm is the intentional local-first +exception: before translating a keydown into PTY input, it synchronously forwards +the original event to this same dispatcher through a private DOM handoff. Eligible +app shortcuts (including live rebinds) win there; unhandled keys stay with xterm. +No plugin shortcut API or preference access is added. Ordinary editors continue +to handle keys before the window's bubbling dispatcher. + +Known limitations. Capture and matching both use the logical `KeyboardEvent.key`. +On macOS an Option chord reports the composed character, so Option+K is stored +and shown as `⌥˚`, and Shift+digit chords store the punctuation (`!` rather than +`1`). This is internally consistent, so the binding fires, but it depends on the +active keyboard layout and the displayed chord can differ from the keys pressed. +The intended fix is to match Alt/Option chords on the physical `event.code` in +both the capture control and the dispatcher's `matches`, which is a coordinated +change to the plugin-facing matching rules and is deliberately not part of the +Settings page. diff --git a/docs/terminal.md b/docs/terminal.md index 4581c9cea..e62bbf1d1 100644 --- a/docs/terminal.md +++ b/docs/terminal.md @@ -7,6 +7,14 @@ hide the drawer without stopping work; reopening reattaches the same emulator an shell. **End session** explicitly terminates it; **Restart** starts a fresh shell. An exited shell remains visible until ended/restarted and never respawns automatically. +Settings → Shortcuts can rebind the toggle. Before xterm translates a key into +shell input, a private synchronous DOM handoff gives the original keydown to the +host's existing dispatcher. Live overrides, eligibility and plugin lifetime stay +host-owned; Terminal neither reads preferences nor reserves the old default. +Handled keys are prevented once, not also sent to the shell. Ordinary message +editors retain their local-first bubbling behavior. The launcher tooltip still +shows the registered default rather than the effective binding. + ## Run locally From the agreed feature worktree, use `bin/just desktop`. Native commands require @@ -97,8 +105,11 @@ Focused coverage lives in `src/bundled/terminal/sessions.test.ts`, the channel/p composition tests, `src-tauri/src/terminal/tests.rs`, and the two `tests/browser/terminal*.spec.mjs` journeys. The separate renderer journey exercises real xterm input/Ctrl+C, resize, alternate-screen restoration, detach/reopen and -app-chord release without starting a shell. The channel journey exercises actual -browser launcher/shortcut absence, including plugin disable/re-enable. Desktop +focused-terminal rebinding/reset/storage restore through the actual bundled +registration and dispatcher, without starting a shell. The fixture replaces the +native bridge and surrounding channel/relay services, not xterm or dispatch. The +channel journey exercises actual browser launcher/shortcut absence, including +plugin disable/re-enable. Desktop registration is covered by `src/bundled/terminal/index.test.ts`; the actual desktop header/dispatcher journey remains an attended acceptance check. Native tests exercise real PTYs, public-context/environment fencing, limits, final output and teardown. diff --git a/examples/plugins/shortcut-counter/plugin.js b/examples/plugins/shortcut-counter/plugin.js index 39aef89ff..a2191ce06 100644 --- a/examples/plugins/shortcut-counter/plugin.js +++ b/examples/plugins/shortcut-counter/plugin.js @@ -12,6 +12,8 @@ export function apply(ctx) { id: "increment", title: "Increment shortcut counter", binding: { key: "k", mod: true, shift: true }, + // Settings order within the Shortcut counter category. + order: 10, run: increment, }); ctx.pages.register({ 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..b4ad5f685 --- /dev/null +++ b/src/app/ShortcutSettings.test.tsx @@ -0,0 +1,940 @@ +// @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"; +import { createAppearance } from "../shared/theme/service"; +import { createNavigationController } from "../features/navigation/controller"; +import { createMemoryHistory } from "../features/navigation/history"; +import { registerAppShortcuts, registerNavigationShortcuts } from "./shortcuts"; +import { PageSearch, type SearchServices } from "./shell/PageSearch"; + +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 }, + order: 80, + run: runs.settings, + }); + shortcuts.registerHost({ + id: "global-search", + title: "Search Buzz", + binding: { key: "k", mod: true }, + order: 70, + 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 }, + ], + order: 41, + 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 }, + order: 10, + run: runs.increment, + }); + await contribute("example.counter", { + id: "first", + title: "First action", + binding: { key: "l", mod: true, shift: true }, + order: -10, + run: vi.fn(), + }); + await contribute("buzz.terminal", { + id: "toggle", + title: "Toggle channel terminal", + binding: { key: "j", mod: true }, + run: runs.terminal, + }); + return { + runs, + bindings, + shortcuts, + contribute, + 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 without search or intro text and follows plugin state", async () => { + 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", + "Search Buzz", + "Open Settings", + "First action", + "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(); + + expect(screen.queryByRole("searchbox")).not.toBeInTheDocument(); + expect( + screen.queryByText( + "Every shortcut from Buzz and your enabled plugins. Choose Change, then press the new keys; Escape cancels. Saved on this device.", + ), + ).not.toBeInTheDocument(); + + act(() => h.setActive("buzz.terminal", false)); + expect( + screen.queryByRole("heading", { name: "Terminal" }), + ).not.toBeInTheDocument(); + expect(screen.getAllByRole("article")).toHaveLength(5); + act(() => h.setActive("buzz.terminal", true)); + expect(row("Toggle channel terminal")).toBeInTheDocument(); + } finally { + await h.dispose(); + } +}); + +it("presents actual host registrations in navigation, text sizing, search/settings, then development order", async () => { + const root = new Context(); + root.provide("pluginStatus", { + isActive: () => true, + subscribe: () => () => {}, + }); + const bindings = createShortcutBindings(window); + const shortcuts = new ShortcutsService(root, window, bindings); + const appearance = createAppearance(window); + const navigation = createNavigationController(createMemoryHistory()); + const removeApp = registerAppShortcuts(shortcuts, appearance, vi.fn(), true); + const removeNavigation = registerNavigationShortcuts( + shortcuts, + navigation.navigation, + ); + try { + render( + <> + + + , + ); + expect( + screen + .getAllByRole("article") + .map( + (article) => + within(article).getByRole("heading", { level: 3 }).textContent, + ), + ).toEqual([ + "Go back", + "Go forward", + "Increase text size", + "Decrease text size", + "Reset text size", + "Search Buzz", + "Open Settings", + ...(import.meta.env.DEV ? ["Reload development app"] : []), + ]); + } finally { + cleanup(); + removeNavigation(); + removeApp(); + navigation.dispose(); + appearance.dispose(); + bindings.dispose(); + await root.fiber.dispose(); + } +}); + +it("orders plugin rows by metadata then contribution key without merging duplicate titles", async () => { + const h = await harness(); + try { + await h.contribute("example.counter", { + id: "aaa", + title: "Increment shortcut counter", + binding: { key: "l", mod: true }, + order: 10, + run: vi.fn(), + }); + await h.contribute("example.counter", { + id: "zzz", + title: "Zed action", + binding: { key: "m", mod: true }, + order: 10, + run: vi.fn(), + }); + render( + , + ); + expect( + screen + .getAllByRole("article") + .map( + (article) => + within(article).getByRole("heading", { level: 3 }).textContent, + ), + ).toEqual([ + "Increase text size", + "Search Buzz", + "Open Settings", + "First action", + "Increment shortcut counter", + "Increment shortcut counter", + "Zed action", + "Toggle channel terminal", + ]); + const duplicates = screen.getAllByRole("article", { + name: "Increment shortcut counter", + }); + const [firstDuplicate, secondDuplicate] = duplicates; + if (!firstDuplicate || !secondDuplicate) + throw new Error("Missing duplicate row"); + expect(within(firstDuplicate).getByText("Control L")).toBeInTheDocument(); + expect( + within(secondDuplicate).getByText("Control Shift K"), + ).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.", + ); + // Dead and unidentified keys, and the chords copy, paste and select all need. + fireEvent.keyDown(input, { key: "Dead", altKey: true }); + expect(screen.getByRole("alert")).toHaveTextContent( + "That key can’t be used for a shortcut. Try another.", + ); + fireEvent.keyDown(input, { key: "Unidentified", ctrlKey: true }); + expect(screen.getByRole("alert")).toHaveTextContent( + "That key can’t be used for a shortcut. Try another.", + ); + for (const key of ["c", "v", "x", "a"]) { + fireEvent.keyDown(input, { key, ctrlKey: true }); + expect(screen.getByRole("alert")).toHaveTextContent( + `Ctrl+${key.toUpperCase()} is reserved for copy, cut, paste and select all. Try another.`, + ); + } + // 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-standard"); + expect(input).toHaveAttribute( + "aria-describedby", + expect.stringContaining(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(); + // Reset unmounts its own button; keyboard focus stays anchored in the row. + expect(change(title)).toHaveFocus(); + expect(h.bindings.resolve("example.counter/increment")).toBeUndefined(); + h.press("k", { shiftKey: true }); + expect(h.runs.increment).toHaveBeenCalledTimes(2); + + // Escape cancels whatever else is held, as does blur; nothing is saved and + // focus returns to the row action. + for (const held of [ + {}, + { shiftKey: true }, + { ctrlKey: true }, + { metaKey: true, altKey: true }, + ]) { + await user.click(change("Open Settings")); + fireEvent.keyDown(capture("Open Settings"), { key: "Escape", ...held }); + expect( + screen.queryByRole("textbox", { name: /New shortcut/ }), + ).toBeNull(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + 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-standard"); + expect(h.bindings.resolve("settings")).toEqual([{ key: "z", mod: true }]); + for (const [key, chord] of [ + ["y", "Ctrl+Shift+Y"], + ["End", "Ctrl+Shift+End"], + ] as const) { + await user.click(change("Open Settings")); + fireEvent.keyDown(capture("Open Settings"), { + key, + ctrlKey: true, + shiftKey: true, + }); + expect(within(row("Open Settings")).getByRole("alert")).toHaveTextContent( + `message editor handles ${chord}`, + ); + } + 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.each([true, false])( + "warns for every accepted Enter modifier combination but still dispatches outside the editor (Apple=%s)", + async (apple) => { + vi.spyOn(navigator, "platform", "get").mockReturnValue( + apple ? "MacIntel" : "Win32", + ); + const user = userEvent.setup(); + const h = await harness(); + try { + render( + , + ); + const title = "Open Settings"; + await user.click(change(title)); + for (const shiftKey of [false, true]) { + fireEvent.keyDown(capture(title), { key: "Enter", shiftKey }); + expect(screen.getByRole("alert")).toHaveTextContent("Include"); + expect(h.bindings.resolve("settings")).toBeUndefined(); + } + let dispatched = 0; + for (const modifiers of [ + { mod: true }, + { mod: true, shift: true }, + { alt: true }, + { alt: true, shift: true }, + { mod: true, alt: true }, + { mod: true, alt: true, shift: true }, + ]) { + if (dispatched) await user.click(change(title)); + const event = { + key: "Enter", + ctrlKey: !apple && !!modifiers.mod, + metaKey: apple && !!modifiers.mod, + altKey: !!modifiers.alt, + shiftKey: !!modifiers.shift, + }; + fireEvent.keyDown(capture(title), event); + expect(within(row(title)).getByRole("alert")).toHaveTextContent( + "Saved. The message editor handles", + ); + expect(within(row(title)).getByRole("alert")).toHaveClass( + "text-standard", + ); + expect(h.bindings.resolve("settings")).toEqual([ + { key: "Enter", ...modifiers }, + ]); + expect(change(title)).toHaveFocus(); + expect(h.runs.settings).toHaveBeenCalledTimes(dispatched); + expect(fireEvent.keyDown(document.body, event)).toBe(false); + expect(h.runs.settings).toHaveBeenCalledTimes(++dispatched); + } + } finally { + await h.dispose(); + } + }, +); + +it("ignores AltGraph capture without changing a saved override, then captures and dispatches a non-AltGraph chord", async () => { + const user = userEvent.setup(); + const h = await harness(); + try { + h.bindings.set("settings", { key: "u", mod: true }); + const saved = localStorage.getItem(SHORTCUT_BINDINGS_KEY); + render( + , + ); + const title = "Open Settings"; + await user.click(change(title)); + const input = capture(title); + // Windows German AltGr+Q reports @ with Control and Alt held. + const chord = { key: "@", ctrlKey: true, altKey: true }; + const altGraph = () => + new KeyboardEvent("keydown", { + ...chord, + modifierAltGraph: true, + bubbles: true, + cancelable: true, + }); + const event = altGraph(); + expect(event.getModifierState("AltGraph")).toBe(true); + fireEvent(input, event); + expect(capture(title)).toBe(input); + expect(input).toHaveFocus(); + expect(h.bindings.resolve("settings")).toEqual([{ key: "u", mod: true }]); + expect(localStorage.getItem(SHORTCUT_BINDINGS_KEY)).toBe(saved); + expect(h.runs.settings).not.toHaveBeenCalled(); + + // Control+Alt is still usable when the event is not AltGraph. + fireEvent.keyDown(input, chord); + expect(change(title)).toHaveFocus(); + expect(h.bindings.resolve("settings")).toEqual([ + { key: "@", mod: true, alt: true }, + ]); + expect(localStorage.getItem(SHORTCUT_BINDINGS_KEY)).toBe( + JSON.stringify({ settings: { key: "@", mod: true, alt: true } }), + ); + // The original dispatcher safety guard must still ignore AltGraph even + // when its key and modifier flags match a valid saved override exactly. + expect(fireEvent(document.body, altGraph())).toBe(true); + expect(h.press("u")).toBe(true); + expect(h.runs.settings).not.toHaveBeenCalled(); + expect(fireEvent.keyDown(document.body, chord)).toBe(false); + 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(alert).toHaveClass("text-standard"); + 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(); + } +}); + +it("stores Option and Shift chords as the composed key on Apple platforms (known limitation)", async () => { + const user = userEvent.setup(); + const h = await harness(); + try { + render( + , + ); + // macOS reports the composed character for Option chords: Option+K is "˚". + // Capture and dispatch both read event.key, so the chord fires but is + // layout-dependent and displays as the composed character. + const terminal = "Toggle channel terminal"; + await user.click(change(terminal)); + fireEvent.keyDown(capture(terminal), { key: "˚", altKey: true }); + expect(within(row(terminal)).getByText("Option ˚")).toBeInTheDocument(); + expect( + row(terminal).querySelector("[data-design-pass='pending']"), + ).toHaveAttribute("data-binding", "⌥˚"); + expect(h.bindings.resolve("buzz.terminal/toggle")).toEqual([ + { key: "˚", alt: true }, + ]); + expect(fireEvent.keyDown(document.body, { key: "˚", altKey: true })).toBe( + false, + ); + expect(h.runs.terminal).toHaveBeenCalledTimes(1); + // Shift+digit likewise stores the punctuation the layout produced. + const settings = "Open Settings"; + await user.click(change(settings)); + fireEvent.keyDown(capture(settings), { + key: "!", + metaKey: true, + shiftKey: true, + }); + expect( + within(row(settings)).getByText("Shift Command !"), + ).toBeInTheDocument(); + expect(h.bindings.resolve("settings")).toEqual([ + { key: "!", mod: true, shift: true }, + ]); + expect(h.press("!", { shiftKey: true })).toBe(false); + expect(h.runs.settings).toHaveBeenCalledTimes(1); + } finally { + await h.dispose(); + } +}); + +it("refuses close and quit chords only in the desktop build", async () => { + const user = userEvent.setup(); + const h = await harness(); + try { + const desktop = render( + , + ); + const title = "Open Settings"; + await user.click(change(title)); + for (const key of ["q", "w"]) { + fireEvent.keyDown(capture(title), { key, metaKey: true }); + expect(screen.getByRole("alert")).toHaveTextContent( + `⌘${key.toUpperCase()} is reserved for closing the window and quitting Buzz. Try another.`, + ); + } + expect(h.bindings.snapshot().overrides).toEqual({}); + desktop.unmount(); + // A browser tab handles these before the page sees them, so nothing is lost. + render( + , + ); + await user.click(change(title)); + fireEvent.keyDown(capture(title), { key: "w", metaKey: true }); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(within(row(title)).getByText("Command W")).toBeInTheDocument(); + } finally { + await h.dispose(); + } +}); + +it("marks rows that share an effective chord once a plugin is re-enabled, and clears the marker on reset", async () => { + const user = userEvent.setup(); + const h = await harness(); + try { + render( + , + ); + const counter = "Increment shortcut counter"; + const terminal = "Toggle channel terminal"; + expect(screen.queryByText(/Also used by/)).toBeNull(); + // With the terminal disabled its chord is not listed, so Ctrl+J is accepted. + act(() => h.setActive("buzz.terminal", false)); + await user.click(change(counter)); + fireEvent.keyDown(capture(counter), { key: "j", ctrlKey: true }); + expect(within(row(counter)).getByText("Control J")).toBeInTheDocument(); + expect(screen.queryByText(/Also used by/)).toBeNull(); + // Re-enabling brings the default back; the dispatcher would pick one silently. + act(() => h.setActive("buzz.terminal", true)); + const marker = within(row(counter)).getByText( + "Also used by Toggle channel terminal (Terminal)", + ); + expect(marker).toHaveClass("text-subtle"); + expect(marker).not.toHaveClass("text-danger"); + expect(marker).not.toHaveClass("text-warning"); + expect(marker).not.toHaveAttribute("role"); + expect( + within(row(terminal)).getByText( + "Also used by Increment shortcut counter (Shortcut counter)", + ), + ).toBeInTheDocument(); + h.press("j"); + expect(h.runs.terminal).toHaveBeenCalledTimes(1); + expect(h.runs.increment).not.toHaveBeenCalled(); + // A host chord shared with a plugin is marked on both rows as well. + act(() => h.bindings.set("global-search", { key: "j", mod: true })); + expect( + within(row("Search Buzz")).getByText(/^Also used by/), + ).toHaveTextContent( + "Also used by Increment shortcut counter (Shortcut counter), Toggle channel terminal (Terminal)", + ); + act(() => h.bindings.set("global-search", null)); + expect(within(row("Search Buzz")).queryByText(/Also used by/)).toBeNull(); + await user.click( + screen.getByRole("button", { name: `Reset shortcut for ${counter}` }), + ); + expect(screen.queryByText(/Also used by/)).toBeNull(); + expect(change(counter)).toHaveFocus(); + } finally { + await h.dispose(); + } +}); + +it("keeps the host group distinct from a plugin whose manifest id is buzz", async () => { + const h = await harness(); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + await h.contribute("buzz", { + id: "ping", + title: "Ping", + binding: { key: "p", mod: true }, + run: vi.fn(), + }); + render( + , + ); + expect( + screen + .getAllByRole("heading", { level: 2 }) + .map((heading) => heading.textContent), + ).toEqual([ + "Shortcuts", + "Buzz", + "Buzz plugin", + "Shortcut counter", + "Terminal", + ]); + expect(row("Ping")).toBeInTheDocument(); + expect(error).not.toHaveBeenCalled(); + } finally { + error.mockRestore(); + await h.dispose(); + } +}); + +it.each([false, true])( + "Tab leaves capture without saving (backwards=%s)", + async (shift) => { + const h = await harness(); + const user = userEvent.setup(); + try { + const run = vi.fn(); + await h.contribute("example.tab", { + id: "tab", + title: "Plugin Tab", + binding: { key: "Tab", shift }, + allowInEditable: true, + run, + }); + render( + , + ); + await user.click(change("Open Settings")); + expect(capture("Open Settings")).toHaveAccessibleDescription( + /Press Escape to cancel, or Tab to leave/, + ); + await user.tab({ shift }); + expect( + screen.queryByRole("textbox", { name: /New shortcut/ }), + ).toBeNull(); + expect(h.bindings.snapshot().overrides).toEqual({}); + expect(h.runs.settings).not.toHaveBeenCalled(); + expect(run).not.toHaveBeenCalled(); + expect(document.activeElement).not.toBe(document.body); + } finally { + await h.dispose(); + } + }, +); diff --git a/src/app/ShortcutSettings.tsx b/src/app/ShortcutSettings.tsx new file mode 100644 index 000000000..26929fc4e --- /dev/null +++ b/src/app/ShortcutSettings.tsx @@ -0,0 +1,427 @@ +import { + useEffect, + useId, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import { isTauri } from "@tauri-apps/api/core"; +import { Button } from "../shared/design-system/ui/Button"; +import { NavigationSection } from "../shared/design-system/ui/NavigationSection"; +import { + sameBinding, + type KeyBinding, + type NormalizedShortcut, +} 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 { ShortcutsService } from "../features/shortcuts/service"; +import type { PluginManager } from "../plugins/manager"; + +type Row = Readonly<{ + key: string; + title: string; + owner: string; + /** Owner-defined presentation order within the Settings category. */ + order: number; + defaults: readonly KeyBinding[]; + override: KeyBinding | undefined; + effective: readonly KeyBinding[]; +}>; +type Group = Readonly<{ + id: string; + label: string; + /** Stable owner identity for equal display labels. */ + sortKey: string; + rows: readonly Row[]; +}>; +type Notice = Readonly<{ + key: string; + tone: "error" | "warning"; + message: string; +}>; + +/** Chords the message editor handles locally before the window dispatcher: undo/redo and line/document jumps. */ +const EDITOR_CHORDS: readonly KeyBinding[] = [ + { key: "z", mod: true }, + { key: "z", mod: true, shift: true }, + { key: "y", mod: true }, + { key: "y", mod: true, shift: true }, + { key: "Home", mod: true }, + { key: "Home", mod: true, shift: true }, + { key: "End", mod: true }, + { key: "End", mod: true, shift: true }, +]; +/** Copy, paste, cut and select all: a matching shortcut would preventDefault them everywhere. */ +const CLIPBOARD_CHORDS: readonly KeyBinding[] = [ + { key: "c", mod: true }, + { key: "v", mod: true }, + { key: "x", mod: true }, + { key: "a", mod: true }, +]; +/** Close window and quit: the desktop shell owns these, so they are refused there. */ +const DESKTOP_CHORDS: readonly KeyBinding[] = [ + { key: "q", mod: true }, + { key: "w", mod: true }, +]; +const includes = (chords: readonly KeyBinding[], binding: KeyBinding) => + chords.some((chord) => sameBinding(chord, binding)); +/** Every owner supplies order; identity and title make ties deterministic. */ +const byPresentation = (a: Row, b: Row) => + a.order - b.order || + a.key.localeCompare(b.key) || + a.title.localeCompare(b.title); + +/** + * 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), + desktop = isTauri(), +}: { + shortcuts: ShortcutsService; + bindings: ShortcutBindings; + /** Display names for plugin groups come from the catalog. */ + plugins: Pick; + apple?: boolean; + /** Packaged desktop build, where close/quit chords belong to the shell. */ + desktop?: 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 [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: NormalizedShortcut, + owner: string, + ): Row => { + const override = overrides[key]; + return { + key, + title: shortcut.title, + owner, + order: shortcut.order, + defaults: shortcut.binding, + override, + effective: override ? [override] : shortcut.binding, + }; + }; + // Group ids are namespaced so a plugin whose manifest id is "buzz" or "host" + // cannot share a React key with the host group. + const groups: Group[] = [ + { + id: "host", + label: "Buzz", + sortKey: "", + rows: host.map((shortcut) => row(shortcut.id, shortcut, "Buzz")), + }, + ...[...new Set(contributed.map((shortcut) => shortcut.pluginId))] + .map((pluginId) => { + const label = pluginName(pluginId); + return { + id: `plugin:${pluginId}`, + label, + sortKey: pluginId, + rows: contributed + .filter((shortcut) => shortcut.pluginId === pluginId) + .map((shortcut) => row(shortcut.key, shortcut, label)), + }; + }) + .sort( + (a, b) => + a.label.localeCompare(b.label) || a.sortKey.localeCompare(b.sortKey), + ), + ] + .map((group) => ({ + ...group, + rows: [...group.rows].sort(byPresentation), + })) + .filter((group) => group.rows.length); + const rows = groups.flatMap((group) => group.rows); + // A chord can become shared after capture (a plugin enabled later, a new + // default in a release). The dispatcher then picks one silently, so each + // affected row names the others. + const sharedWith = (target: Row) => + rows.filter( + (other) => + other.key !== target.key && + other.effective.some((binding) => + target.effective.some((current) => sameBinding(current, binding)), + ), + ); + 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 (binding.key === "Dead" || binding.key === "Unidentified") + return refuse("That key can’t be used for a shortcut. Try another."); + 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 (includes(CLIPBOARD_CHORDS, binding)) + return refuse( + `${chord} is reserved for copy, cut, paste and select all. Try another.`, + ); + if (desktop && includes(DESKTOP_CHORDS, binding)) + return refuse( + `${chord} is reserved for closing the window and quitting Buzz. 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); + // EditableInput handles Enter locally regardless of the held modifiers. + setNotice( + binding.key === "Enter" || includes(EDITOR_CHORDS, 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 +

+
+ {groups.length ? ( +
+ {groups.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); + }} + /> + ))} +
+
+ ))} +
+ ) : ( +

+ No shortcuts are available yet. +

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

{error}

+
+ +
+
+ )} +
+
+ ); +} + +function ShortcutRow({ + row, + apple, + sharedWith, + listening, + notice, + onStart, + onCancel, + onCapture, + onReset, +}: { + row: Row; + apple: boolean; + /** Other listed shortcuts whose effective chord this row also answers to. */ + sharedWith: readonly Row[]; + 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); + const resetting = useRef(false); + // Return focus to the row's action when the capture control or the Reset + // button goes away without the person having moved focus somewhere else. + useEffect(() => { + const stopped = wasListening.current && !listening; + wasListening.current = listening; + if ( + (stopped || (resetting.current && !row.override)) && + document.activeElement === document.body + ) + change.current?.focus(); + resetting.current = false; + }, [listening, row.override]); + const primary = row.effective[0]; + return ( +
+
+

+ {row.title} +

+ {row.override && ( +

Modified

+ )} + {sharedWith.length > 0 && ( +

+ Also used by{" "} + {sharedWith + .map((other) => `${other.title} (${other.owner})`) + .join(", ")} +

+ )} + {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.test.tsx b/src/app/shell/PageSearch.test.tsx index d6409e5e6..5b32a7cb3 100644 --- a/src/app/shell/PageSearch.test.tsx +++ b/src/app/shell/PageSearch.test.tsx @@ -1,6 +1,8 @@ // @vitest-environment jsdom import "@testing-library/jest-dom/vitest"; +import { Context } from "@deepseek-ai/cordis"; import { + act, cleanup, fireEvent, render, @@ -9,8 +11,13 @@ import { } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, expect, it, vi } from "vitest"; -import { PageSearch } from "./PageSearch"; +import { PageSearch, type SearchServices } from "./PageSearch"; import type { RegisteredPage } from "../../features/pages/service"; +import { + createShortcutBindings, + SHORTCUT_BINDINGS_KEY, +} from "../../features/shortcuts/preferences"; +import { ShortcutsService } from "../../features/shortcuts/service"; afterEach(() => { cleanup(); @@ -149,3 +156,81 @@ it("invalidates selection when result identities change, even at the same index" await user.keyboard("{ArrowUp}{Enter}"); expect(select).toHaveBeenCalledExactlyOnceWith("test/first"); }); + +it("shows the live search shortcut in the trigger hint and follows a rebind", async () => { + Object.defineProperty(navigator, "platform", { + configurable: true, + value: "MacIntel", + }); + const root = new Context(); + root.provide("pluginStatus", { + isActive: () => true, + subscribe: () => () => {}, + }); + const bindings = createShortcutBindings(window); + const shortcuts = new ShortcutsService(root, window, bindings); + try { + // Only the shortcut services are read while the dialog is closed. + render( + , + ); + // The title renders as a focus/hover tooltip rather than a native attribute. + const user = userEvent.setup(); + await user.tab(); + expect(screen.getByRole("button", { name: "Search Buzz" })).toHaveFocus(); + expect(await screen.findByRole("tooltip")).toHaveTextContent( + "Search Buzz (⌘K)", + ); + act(() => + bindings.set("global-search", { key: "p", mod: true, shift: true }), + ); + expect(screen.getByRole("tooltip")).toHaveTextContent("Search Buzz (⇧⌘P)"); + act(() => bindings.reset()); + expect(screen.getByRole("tooltip")).toHaveTextContent("Search Buzz (⌘K)"); + } finally { + Reflect.deleteProperty(navigator, "platform"); + bindings.dispose(); + await root.fiber.dispose(); + } +}); + +it("renders a restored prototype-named search key and remains resettable", async () => { + localStorage.setItem( + SHORTCUT_BINDINGS_KEY, + JSON.stringify({ "global-search": { key: "constructor", mod: true } }), + ); + const root = new Context(); + root.provide("pluginStatus", { + isActive: () => true, + subscribe: () => () => {}, + }); + const bindings = createShortcutBindings(window); + const shortcuts = new ShortcutsService(root, window, bindings); + try { + render( + , + ); + const user = userEvent.setup(); + await user.tab(); + expect(screen.getByRole("button", { name: "Search Buzz" })).toHaveFocus(); + expect(await screen.findByRole("tooltip")).toHaveTextContent("constructor"); + act(() => bindings.reset()); + expect(screen.getByRole("tooltip")).toHaveTextContent("Ctrl+K"); + } finally { + bindings.dispose(); + localStorage.clear(); + await root.fiber.dispose(); + } +}); diff --git a/src/app/shell/PageSearch.tsx b/src/app/shell/PageSearch.tsx index a65554d9d..cb2764ddf 100644 --- a/src/app/shell/PageSearch.tsx +++ b/src/app/shell/PageSearch.tsx @@ -12,7 +12,14 @@ 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 { HOST_SHORTCUT_ORDER } from "../shortcuts"; import { orderPages, pagePresentation, @@ -27,8 +34,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 +67,20 @@ export function PageSearch({ useEffect( () => services?.shortcuts.registerHost({ - id: "global-search", + id: SEARCH_ID, title: "Search Buzz", - binding: { key: "k", mod: true }, + binding: SEARCH_BINDING, + order: HOST_SHORTCUT_ORDER.search, 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 +100,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 ( <> ready, run: openSettings, @@ -22,6 +36,7 @@ export function registerAppShortcuts( [ "font-increase", "Increase text size", + HOST_SHORTCUT_ORDER.textSizeIncrease, [ { key: "=", mod: true }, { key: "=", mod: true, shift: true }, @@ -33,20 +48,23 @@ export function registerAppShortcuts( [ "font-decrease", "Decrease text size", + HOST_SHORTCUT_ORDER.textSizeDecrease, { key: "-", mod: true }, () => appearance.setFontScale(appearance.snapshot().fontScale - 0.1), ], [ "font-reset", "Reset text size", + HOST_SHORTCUT_ORDER.textSizeReset, { key: "0", mod: true }, () => appearance.setFontScale(1), ], ] as const - ).map(([id, title, binding, run]) => + ).map(([id, title, order, binding, run]) => shortcuts.registerHost({ id, title, + order, binding, run, allowInEditable: true, @@ -61,6 +79,7 @@ export function registerAppShortcuts( id: "development-reload", title: "Reload development app", binding: { key: "r", mod: true }, + order: HOST_SHORTCUT_ORDER.development, allowInEditable: true, allowInModal: true, run: () => window.location.reload(), @@ -80,6 +99,7 @@ export function registerNavigationShortcuts( shortcuts.registerHost({ id: "navigation-back", title: "Go back", + order: HOST_SHORTCUT_ORDER.navigationBack, binding: { key: "[", mod: true }, allowInEditable: true, when: () => navigation.snapshot().canGoBack, @@ -88,27 +108,12 @@ export function registerNavigationShortcuts( shortcuts.registerHost({ id: "navigation-forward", title: "Go forward", + order: HOST_SHORTCUT_ORDER.navigationForward, binding: { key: "]", mod: true }, allowInEditable: true, when: () => navigation.snapshot().canGoForward, run: navigation.forward, }), - // Alt arrows are native word-editing chords on macOS. Keep them outside - // editable fields; Mod+[ / Mod+] remain explicit history shortcuts. - shortcuts.registerHost({ - id: "navigation-back-arrow", - title: "Go back", - binding: { key: "ArrowLeft", alt: true }, - when: () => navigation.snapshot().canGoBack, - run: navigation.back, - }), - shortcuts.registerHost({ - id: "navigation-forward-arrow", - title: "Go forward", - binding: { key: "ArrowRight", alt: true }, - when: () => navigation.snapshot().canGoForward, - run: navigation.forward, - }), ]; return () => { for (const dispose of remove) dispose(); diff --git a/src/bundled/terminal/index.tsx b/src/bundled/terminal/index.tsx index de00aae46..3571eb53b 100644 --- a/src/bundled/terminal/index.tsx +++ b/src/bundled/terminal/index.tsx @@ -3,11 +3,16 @@ import { TerminalWindowIcon } from "../../shared/design-system/icons/index"; import { IconButton } from "../../shared/design-system/ui/IconButton"; import type { PluginModule } from "../../plugins/api"; import type { ChannelLauncherProps } from "../../features/panels/service"; +import { + formatBinding, + isApplePlatform, +} from "../../features/shortcuts/format"; import { nativeBridge } from "./bridge"; import { createSessions } from "./sessions"; import { TerminalPanel } from "./TerminalPanel"; export const inject = ["panels", "shortcuts", "relay"]; +const TOGGLE_BINDING = { key: "j", mod: true } as const; export const apply: PluginModule["apply"] = (ctx) => { // No unusable launcher or reserved shortcut in a browser-only host. if (!nativeBridge.available) return; @@ -24,7 +29,9 @@ export const apply: PluginModule["apply"] = (ctx) => { ctx.shortcuts.register({ id: "toggle", title: "Toggle channel terminal", - binding: { key: "j", mod: true }, + binding: TOGGLE_BINDING, + // Plugin Settings order: the terminal action is the category's primary action. + order: 10, allowInEditable: true, when: () => !!binding?.available(), run: () => binding?.toggle(), @@ -49,7 +56,7 @@ export const apply: PluginModule["apply"] = (ctx) => { variant={pressed ? "tint" : "ghost"} icon={