From a926f7aaba2febf7801a9f1e8ffb6497113a611c Mon Sep 17 00:00:00 2001 From: moreih29 Date: Wed, 10 Jun 2026 12:08:03 +0900 Subject: [PATCH 1/4] fix(keybindings): stop cmd+/ from hijacking split-right, restore Monaco comment toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "\\" accelerator token matched both Backslash and Slash physical keys under a mistaken "Korean keyboard parity" assumption — KeyboardEvent.code is layout-independent, so Korean layouts already report the ₩/\ key as "Backslash". The extra "Slash" code made ⌘/ resolve to group.splitRight at capture phase, swallowing Monaco's comment toggle. Now "\\" matches only the Backslash code; tests assert ⌘/ passes through. Co-Authored-By: Claude Fable 5 --- src/shared/keybindings/index.ts | 6 +++--- src/shared/keybindings/keybinding-parse.ts | 19 +++++++++++-------- .../renderer/keybindings/dispatcher.test.ts | 7 ++++--- .../renderer/keybindings/resolver.test.ts | 6 ++++-- tests/unit/shared/keybinding-parse.test.ts | 12 ++++++++---- 5 files changed, 30 insertions(+), 20 deletions(-) diff --git a/src/shared/keybindings/index.ts b/src/shared/keybindings/index.ts index e7a96726..fc3ddbaf 100644 --- a/src/shared/keybindings/index.ts +++ b/src/shared/keybindings/index.ts @@ -122,9 +122,9 @@ export const KEYBINDINGS: readonly KeybindingDecl[] = [ { command: COMMANDS.tabFocusNext, primary: "Cmd+Ctrl+Right" }, // Groups (panels) - // `\\` here matches both Backslash and Slash physical keys, so - // Korean keyboards (where Shift+Backslash maps to Slash) hit the - // same shortcut. Documented in keybinding-parse.ts. + // `\\` matches only the Backslash physical key. KeyboardEvent.code is + // layout-independent, so Korean layouts (₩/\ key) work without extra + // codes — see tokenToCodes in keybinding-parse.ts. { command: COMMANDS.groupSplitRight, primary: "CmdOrCtrl+\\" }, { command: COMMANDS.groupSplitDown, primary: "CmdOrCtrl+Shift+\\" }, { command: COMMANDS.groupClose, primary: "CmdOrCtrl+Shift+W" }, diff --git a/src/shared/keybindings/keybinding-parse.ts b/src/shared/keybindings/keybinding-parse.ts index 5d557b8e..1d0138b7 100644 --- a/src/shared/keybindings/keybinding-parse.ts +++ b/src/shared/keybindings/keybinding-parse.ts @@ -38,9 +38,11 @@ export interface ParsedKeystroke { alt: boolean; /** * Acceptable `KeyboardEvent.code` values. Usually a single entry. - * Backslash is special-cased to also accept `Slash`: Korean - * keyboards type the slash key for what would be backslash on QWERTY, - * and we want our split shortcut (⌘\) to fire on both. + * `code` identifies the physical key independent of keyboard layout + * (a Korean layout's ₩/\ key still reports "Backslash"), so one code + * per token is sufficient — do NOT add sibling codes for layout + * parity, or unrelated shortcuts (e.g. ⌘/ comment toggle) get + * swallowed by the wrong binding. */ codes: readonly string[]; } @@ -115,11 +117,12 @@ function tokenToCodes(tok: string): readonly string[] { case "Right": return ["ArrowRight"]; case "\\": - // Korean keyboard parity: Shift+Backslash on a US layout sends - // KeyboardEvent.code === "Backslash"; the same physical key on - // a Korean layout often surfaces as "Slash". Accepting both - // keeps `⌘\` working for everyone without per-layout config. - return ["Backslash", "Slash"]; + // KeyboardEvent.code is layout-independent: Korean layouts also + // report the ₩/\ physical key as "Backslash" (only e.key differs). + // This previously returned ["Backslash", "Slash"] for a mistaken + // "Korean keyboard parity" reason, which made ⌘/ trigger the ⌘\ + // split shortcut and swallow Monaco's comment toggle. + return ["Backslash"]; case "/": return ["Slash"]; case "Backslash": diff --git a/tests/unit/renderer/keybindings/dispatcher.test.ts b/tests/unit/renderer/keybindings/dispatcher.test.ts index 189b41b8..5b95bd31 100644 --- a/tests/unit/renderer/keybindings/dispatcher.test.ts +++ b/tests/unit/renderer/keybindings/dispatcher.test.ts @@ -198,7 +198,7 @@ describe("handleGlobalKeyDown — fileOpen fires regardless of focus", () => { }); // --------------------------------------------------------------------------- -// handleGlobalKeyDown — split (Backslash / Slash for Korean keyboards) +// handleGlobalKeyDown — split (Backslash only; ⌘/ belongs to Monaco) // --------------------------------------------------------------------------- describe("handleGlobalKeyDown — split", () => { @@ -210,11 +210,12 @@ describe("handleGlobalKeyDown — split", () => { expect(e.defaultPrevented).toBe(true); }); - it("fires group.splitRight on Cmd+/ (code=Slash) — Korean keyboard", () => { + it("does NOT fire split on Cmd+/ (code=Slash) — Monaco's comment toggle", () => { const spies = setupCommandSpies(); const e = makeEvent("/", { metaKey: true, code: "Slash" }); handleGlobalKeyDown(e as unknown as KeyboardEvent); - expect(spies[COMMANDS.groupSplitRight]).toHaveBeenCalledTimes(1); + expect(spies[COMMANDS.groupSplitRight]).not.toHaveBeenCalled(); + expect(e.defaultPrevented).toBe(false); }); it("fires group.splitDown on Cmd+Shift+\\", () => { diff --git a/tests/unit/renderer/keybindings/resolver.test.ts b/tests/unit/renderer/keybindings/resolver.test.ts index 56c91bf4..a9ed0a0f 100644 --- a/tests/unit/renderer/keybindings/resolver.test.ts +++ b/tests/unit/renderer/keybindings/resolver.test.ts @@ -49,13 +49,15 @@ describe("resolveEvent — no chord pending", () => { expect(r.command).toBe(COMMANDS.filesRefresh); }); - it("matches ⌘\\ on both Backslash and Slash codes (Korean keyboard parity)", () => { + it("matches ⌘\\ only on the Backslash code — ⌘/ must pass through to Monaco", () => { expect( resolveEvent(ev("Backslash", { metaKey: true }) as unknown as KeyboardEvent, null).kind, ).toBe("single"); + // Regression guard: ⌘/ (Slash) is Monaco's comment toggle; resolving it + // here would swallow the event at capture phase. expect( resolveEvent(ev("Slash", { metaKey: true }) as unknown as KeyboardEvent, null).kind, - ).toBe("single"); + ).toBe("none"); }); }); diff --git a/tests/unit/shared/keybinding-parse.test.ts b/tests/unit/shared/keybinding-parse.test.ts index b4614800..4365511f 100644 --- a/tests/unit/shared/keybinding-parse.test.ts +++ b/tests/unit/shared/keybinding-parse.test.ts @@ -83,9 +83,11 @@ describe("parseAccelerator", () => { expect(parseAccelerator("CmdOrCtrl+Alt+Left").codes).toEqual(["ArrowLeft"]); }); - it("treats backslash as accepting both Backslash and Slash (Korean keyboard parity)", () => { + it("treats backslash as accepting only the Backslash physical key", () => { + // KeyboardEvent.code is layout-independent (Korean ₩/\ key still + // reports "Backslash"); including "Slash" here hijacked ⌘/. const p = parseAccelerator("CmdOrCtrl+\\"); - expect(p.codes.slice().sort()).toEqual(["Backslash", "Slash"]); + expect(p.codes).toEqual(["Backslash"]); }); it("treats CmdOrCtrl/Cmd/Command/Meta as the cmd modifier", () => { @@ -182,13 +184,15 @@ describe("matchesEvent", () => { ); }); - it("matches CmdOrCtrl+\\ on both Backslash and Slash physical keys (Mac)", () => { + it("matches CmdOrCtrl+\\ only on the Backslash physical key (Mac)", () => { const p = parseAccelerator("CmdOrCtrl+\\"); expect( matchesEvent(p, ev("Backslash", { metaKey: true }) as unknown as KeyboardEvent, true), ).toBe(true); + // Regression guard: ⌘/ (Slash) must NOT match ⌘\ — it previously did, + // hijacking Monaco's comment-toggle shortcut into split-right. expect(matchesEvent(p, ev("Slash", { metaKey: true }) as unknown as KeyboardEvent, true)).toBe( - true, + false, ); }); From b726efc381a190f8e3f94fbdce9b5800cd39ae1c Mon Sep 17 00:00:00 2001 From: moreih29 Date: Wed, 10 Jun 2026 12:08:12 +0900 Subject: [PATCH 2/4] fix(theme): make editor highlight tiers translucent so selection stays visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tier() pre-blended fg onto the surface and emitted OPAQUE colors (alpha=ff). Monaco renders DecorationsOverlay above SelectionsOverlay, and WordHighlighter keeps the word-at-cursor decorated (own range included) while a selection stays inside one word — so the opaque, surface-colored wordHighlightBackground painted a cover over the selection, hiding it until the selection crossed a word boundary. tier() now emits true translucent overlays (white over dark surfaces, black over light) with the alpha embedded in the 8-digit hex, matching stock VS Code themes; decorations compose with the selection underneath. inactiveSelectionBackground also derives from the selection hue at half alpha instead of a near-invisible surface blend. Orphaned alphaOnSurface removed. Co-Authored-By: Claude Fable 5 --- src/shared/design-tokens/theme-adapter.ts | 66 +++++++++++------------ 1 file changed, 31 insertions(+), 35 deletions(-) diff --git a/src/shared/design-tokens/theme-adapter.ts b/src/shared/design-tokens/theme-adapter.ts index 834b947d..557ab42b 100644 --- a/src/shared/design-tokens/theme-adapter.ts +++ b/src/shared/design-tokens/theme-adapter.ts @@ -139,31 +139,20 @@ function toHex8(value: string, alphaOverride?: number): string { return formatHex8(parsed) ?? value; } -/** Mix a foreground color over a solid surface at a given alpha, return #rrggbb. */ -function alphaOnSurface(fg: string, surface: string, alpha: number): string { - const fgP = parse(fg); - const sP = parse(surface); - if (!fgP || !sP) return fg; - // Simple Porter-Duff over compositing in sRGB (Monaco doesn't care about - // perceptual blend, and the difference vs OKLCH blend is imperceptible at - // these alphas). - const fgRgb = { - r: (fgP as { r?: number }).r ?? 0, - g: (fgP as { g?: number }).g ?? 0, - b: (fgP as { b?: number }).b ?? 0, - }; - const sRgb = { - r: (sP as { r?: number }).r ?? 0, - g: (sP as { g?: number }).g ?? 0, - b: (sP as { b?: number }).b ?? 0, - }; - const mix = { - mode: "rgb" as const, - r: fgRgb.r * alpha + sRgb.r * (1 - alpha), - g: fgRgb.g * alpha + sRgb.g * (1 - alpha), - b: fgRgb.b * alpha + sRgb.b * (1 - alpha), - }; - return formatHex8(mix) ?? fg; +/** + * Scale a color's own alpha by `factor`, return #rrggbbaa. + * Used for `inactiveSelectionBackground`: the inactive selection must stay + * recognizably the selection hue (just dimmer), because Monaco paints it + * whenever the editor lacks the `.focused` class — including transient + * focus-tracker races (EditContext, monaco 0.53+) where a selection being + * actively extended briefly renders as "inactive". A surface-blend tier + * here is nearly identical to the background and made those frames + * invisible. + */ +function scaleAlpha(value: string, factor: number): string { + const parsed = parse(value); + if (!parsed) return value; + return formatHex8({ ...parsed, alpha: (parsed.alpha ?? 1) * factor }) ?? value; } // --------------------------------------------------------------------------- @@ -442,15 +431,20 @@ export function buildSemanticTokens(source: ThemeSource): SemanticTokenSet { // --------------------------------------------------------------------------- export function buildEditorPalette(source: ThemeSource): EditorPalette { - const fg = source.fg.primary; - const surface = source.bg.primary; - - // alpha tiers blended against the editor surface so Monaco's parser - // accepts them (8-digit hex). For overlay backgrounds we mix the fg - // onto the surface at the given alpha and emit an 8-digit hex with - // the same alpha embedded — Monaco renders this on top of the - // editor.background slot, producing the intended tier. - const tier = (alpha: number): string => alphaOnSurface(fg, surface, alpha); + // Decoration tiers — translucent overlays with the alpha EMBEDDED in the + // 8-digit hex (white over dark surfaces, black over light), converted via + // toHex8 because Monaco's color parser rejects rgba() strings. + // + // These must NOT be opaque pre-blends. Monaco's view renders + // DecorationsOverlay ABOVE SelectionsOverlay (view.ts registers + // CurrentLineHighlight → Selections → IndentGuides → Decorations, later = + // on top), and WordHighlighter keeps the word-at-cursor decorated — its own + // range included — for as long as a selection stays inside one word. An + // opaque surface-colored tier therefore paints a cover over the selection, + // hiding it until the selection crosses a word boundary (whitespace). + // Translucent tiers compose with the selection underneath, like stock + // VS Code themes. + const tier = (alpha: number): string => toHex8(overlay(source.base, alpha)); return { // word highlight @@ -471,7 +465,9 @@ export function buildEditorPalette(source: ThemeSource): EditorPalette { linkActiveForeground: toHex8(source.fg.primary), // selection selectionBackground: toHex8(source.selection), - inactiveSelectionBackground: tier(0.1), + // Same hue as the active selection at half its alpha — see scaleAlpha + // doc for why this must not be a surface-blend tier. + inactiveSelectionBackground: scaleAlpha(source.selection, 0.5), selectionHighlightBackground: tier(0.06), // widget surfaces hoverWidgetBackground: toHex8(source.bg.floating), From 21dfa76ee49b677581c3a1dbf17d34b66db658c1 Mon Sep 17 00:00:00 2001 From: moreih29 Date: Wed, 10 Jun 2026 15:06:06 +0900 Subject: [PATCH 3/4] feat(keybindings): user-customizable keybindings + in-page browser shortcuts Add a VSCode-style keybinding customization system on top of the declarative KEYBINDINGS table, plus fixes for embedded-component shortcut delivery. Core (override/conflict engine): - overrides.ts: immutable defaults + persisted delta (replace / unbind / default) applied at runtime; unknown command ids dropped at apply time for version-skew tolerance. - conflicts.ts: command-vs-command + reserved-key detection. Table-wide detectTableConflicts() surfaces conflicts as a property of the whole effective table (incl. indirect ones), with X-vs-!X disjoint-scope suppression so browser-routed defaults (CmdR/Cmd+ShiftR) aren't false positives. - reserved-keys.ts: explicit catalog of system/electron/monaco/terminal keys (the Cmd+/ shadow class made into data). - resolver runtime recompile (setActiveBindings) + lazy shortcut labels. - persistence via appState.keybindingOverrides + keybindingsChanged broadcast; main rebuilds the native menu labels on change. Settings UI (Settings -> Keyboard Shortcuts): - Collapsible Application / Editor sections (default collapsed, chevron affordance, auto-expand on search/filter, conflict count badge). - Inline recorder (raw keystroke capture, IME guard, chord support); saving is never blocked - conflicts warn and persist (VSCode model), with persistent per-row conflict badges + "Conflicts only" filter. Editor (curated Monaco commands): - editor-commands.ts catalog + apply.ts reconciler that diffs against Monaco's addKeybindingRules (idempotent live rebinding/reset). - separate editorKeybindingOverrides delta + editorKeybindingsChanged broadcast. Browser tab shortcuts: - Route the formerly-hardcoded browser shortcuts (focusUrl/reload/ hardReload/back/forward) through the table, scoped by a browserTabActive state probe; fixes the Cmd+R files.refresh race via disjoint when-scopes. - main before-input-event interceptor (keyboard.ts) so these fire even while the WebContentsView page has focus (outside the renderer DOM), honoring user overrides. DevTools intentionally left to the Electron menu role (app window); browser page DevTools stays button-only. Foundation: - remove the duplicate Monaco Cmd+S ghost binding (global dispatcher owns it) - shared matchesKeyState core reused by the renderer dispatcher and the main key interceptor (single source of accelerator matching) - Win/Linux shell-key guard (!terminalFocus || isMac) on Ctrl-letter bindings to preserve readline/job-control keys Co-Authored-By: Claude Fable 5 --- src/main/features/app-state/index.ts | 39 +- src/main/features/browser/ipc.ts | 37 + src/main/features/browser/keyboard.ts | 122 ++++ src/main/features/menu/index.ts | 29 +- src/main/features/menu/template.ts | 78 +- src/main/index.ts | 37 + src/renderer/app.tsx | 8 + src/renderer/bootstrap.ts | 23 + src/renderer/commands/domains/browser.ts | 96 +++ src/renderer/commands/domains/workbench.ts | 3 + .../settings/panels/keybindings-panel.tsx | 686 ++++++++++++++++++ .../components/settings/settings-dialog.tsx | 6 + .../workspace/content/browser-view.tsx | 81 +-- .../install-editor-integrations.ts | 7 +- src/renderer/keybindings/context-keys.ts | 61 +- src/renderer/keybindings/dispatcher.ts | 5 + src/renderer/keybindings/resolver.ts | 90 ++- src/renderer/keybindings/shortcut-labels.ts | 100 ++- .../keybindings/use-global-keybindings.ts | 2 + src/renderer/services/editor/index.ts | 7 + .../services/editor/keybindings/apply.ts | 157 ++++ src/renderer/services/editor/save/service.ts | 11 +- src/renderer/state/operations/browser.ts | 9 + src/renderer/state/stores/browser-runtime.ts | 20 + src/renderer/state/stores/keybindings.ts | 183 +++++ src/shared/i18n/locales/en/settings.json | 125 ++++ src/shared/i18n/locales/ko/settings.json | 125 ++++ src/shared/ipc/contract.ts | 29 + src/shared/keybindings/commands.ts | 20 + src/shared/keybindings/conflicts.ts | 252 +++++++ src/shared/keybindings/editor-commands.ts | 81 +++ src/shared/keybindings/index.ts | 74 +- src/shared/keybindings/keybinding-parse.ts | 158 +++- src/shared/keybindings/overrides.ts | 206 ++++++ src/shared/keybindings/reserved-keys.ts | 123 ++++ src/shared/types/app-state.ts | 18 + .../browser/browser-permission-ipc.test.ts | 16 +- .../main/features/browser/keyboard.test.ts | 128 ++++ .../renderer/keybindings/dispatcher.test.ts | 20 + .../palette-command-keybinding.test.ts | 2 +- .../renderer/keybindings/resolver.test.ts | 165 ++++- .../services/editor/keybindings/apply.test.ts | 132 ++++ tests/unit/shared/conflicts-table.test.ts | 93 +++ tests/unit/shared/editor-commands.test.ts | 70 ++ .../unit/shared/keybinding-overrides.test.ts | 331 +++++++++ 45 files changed, 3878 insertions(+), 187 deletions(-) create mode 100644 src/main/features/browser/keyboard.ts create mode 100644 src/renderer/commands/domains/browser.ts create mode 100644 src/renderer/components/settings/panels/keybindings-panel.tsx create mode 100644 src/renderer/services/editor/keybindings/apply.ts create mode 100644 src/renderer/state/stores/keybindings.ts create mode 100644 src/shared/keybindings/conflicts.ts create mode 100644 src/shared/keybindings/editor-commands.ts create mode 100644 src/shared/keybindings/overrides.ts create mode 100644 src/shared/keybindings/reserved-keys.ts create mode 100644 tests/unit/main/features/browser/keyboard.test.ts create mode 100644 tests/unit/renderer/services/editor/keybindings/apply.test.ts create mode 100644 tests/unit/shared/conflicts-table.test.ts create mode 100644 tests/unit/shared/editor-commands.test.ts create mode 100644 tests/unit/shared/keybinding-overrides.test.ts diff --git a/src/main/features/app-state/index.ts b/src/main/features/app-state/index.ts index 4c1709b5..0b3a02c3 100644 --- a/src/main/features/app-state/index.ts +++ b/src/main/features/app-state/index.ts @@ -1,7 +1,7 @@ import { ipcContract } from "../../../shared/ipc/contract"; import type { AppState } from "../../../shared/types/app-state"; -import type { StateService } from "../../infra/storage/state-service"; import { register, validateArgs } from "../../infra/ipc-router"; +import type { StateService } from "../../infra/storage/state-service"; const c = ipcContract.appState.call; @@ -17,6 +17,29 @@ export interface AppStateChannelOptions { * (c) broadcast `appState.languageChanged` to all renderer windows. */ onLanguageChanged?: (language: AppState["language"] & string) => void; + /** + * Invoked synchronously after a `set` call that includes a + * `keybindingOverrides` field, with the new override list. + * + * The main/index.ts wiring uses this to: + * (a) reinstall the native application menu so accelerator labels + * reflect the effective bindings; + * (b) broadcast `appState.keybindingsChanged` to all renderer + * windows so each recompiles its dispatcher tables. + */ + onKeybindingsChanged?: (overrides: NonNullable) => void; + /** + * Invoked synchronously after a `set` call that includes an + * `editorKeybindingOverrides` field, with the new override list. + * + * The main/index.ts wiring uses this only to broadcast + * `appState.editorKeybindingsChanged` to all renderer windows so each + * re-reconciles Monaco. No native-menu rebuild — editor commands are + * not menu items. + */ + onEditorKeybindingsChanged?: ( + overrides: NonNullable, + ) => void; } export function registerAppStateChannel( @@ -39,6 +62,20 @@ export function registerAppStateChannel( if (patch.language !== undefined && opts.onLanguageChanged !== undefined) { opts.onLanguageChanged(patch.language); } + + // Same pattern for keybinding overrides: menu rebuild + broadcast + // are owned by the caller so this module stays IPC-only. + if (patch.keybindingOverrides !== undefined && opts.onKeybindingsChanged !== undefined) { + opts.onKeybindingsChanged(patch.keybindingOverrides); + } + + // Editor (Monaco) overrides: broadcast-only, no menu involvement. + if ( + patch.editorKeybindingOverrides !== undefined && + opts.onEditorKeybindingsChanged !== undefined + ) { + opts.onEditorKeybindingsChanged(patch.editorKeybindingOverrides); + } }, }, listen: {}, diff --git a/src/main/features/browser/ipc.ts b/src/main/features/browser/ipc.ts index 5babceb9..aeee7d81 100644 --- a/src/main/features/browser/ipc.ts +++ b/src/main/features/browser/ipc.ts @@ -25,9 +25,11 @@ import { ipcContract } from "../../../shared/ipc/contract"; import { ipcOk } from "../../../shared/ipc/result"; +import { COMMANDS } from "../../../shared/keybindings/commands"; import { broadcast, register, validateArgs } from "../../infra/ipc-router"; import type { GlobalStorage } from "../../infra/storage/global-storage"; import type { WorkspaceStorage } from "../../infra/storage/workspace-storage"; +import { installBrowserKeyInterceptor } from "./keyboard"; import type { BrowserPermissionPromptManager } from "./permission-prompt-manager"; import type { BrowserTabRegistry } from "./registry"; @@ -47,6 +49,32 @@ export interface BrowserChannelDeps { * Must be called after the registry is initialised (i.e. after the main * window exists). */ +/** + * Run a browser command resolved by the key interceptor against `tabId`. + * Shared by the page-view and docked-DevTools-view interceptors so both + * surfaces behave identically. Five act on the registry directly; URL + * focus needs the renderer, so it is bounced over IPC. + */ +function runBrowserCommand(registry: BrowserTabRegistry, command: string, tabId: string): void { + switch (command) { + case COMMANDS.browserReload: + registry.reload({ tabId }); + break; + case COMMANDS.browserHardReload: + registry.reload({ tabId, ignoreCache: true }); + break; + case COMMANDS.browserGoBack: + registry.goBack({ tabId }); + break; + case COMMANDS.browserGoForward: + registry.goForward({ tabId }); + break; + case COMMANDS.browserFocusUrl: + broadcast("browser", "focusUrl", { tabId }); + break; + } +} + export function registerBrowserChannel( registry: BrowserTabRegistry, channelDeps?: BrowserChannelDeps, @@ -124,6 +152,15 @@ export function registerBrowserChannel( broadcast("browser", "focused", { tabId }); }); + // Intercept keystrokes that land while the PAGE has focus — they + // never reach the renderer dispatcher (the WebContentsView is + // outside the renderer DOM), so match them here and run the + // browser command. (The docked DevTools view is covered by the + // registry's DevTools key interceptor wired above.) + installBrowserKeyInterceptor(wc, tabId, (command, t) => + runBrowserCommand(registry, command, t), + ); + return ipcOk(undefined); }, diff --git a/src/main/features/browser/keyboard.ts b/src/main/features/browser/keyboard.ts new file mode 100644 index 00000000..e1724f60 --- /dev/null +++ b/src/main/features/browser/keyboard.ts @@ -0,0 +1,122 @@ +/** + * Browser-view key interceptor (main process). + * + * WHY THIS EXISTS + * A browser tab is a `WebContentsView` — a separate web contents painted + * over the renderer DOM. Keystrokes that land while the *page* has focus + * never reach the renderer window's document, so the global keybinding + * dispatcher (which listens there) cannot see them. That is why the + * browser shortcuts (⌘R reload, ⌘⇧R hard reload, ⌘[ / ⌘] back/forward, + * ⌘L focus URL, ⌘⌥I devtools) appeared dead once you clicked into a page. + * + * The fix: intercept input on each browser webContents via + * `before-input-event` in main, match it against the SAME declarative + * KEYBINDINGS table (with user overrides applied), and run the command. + * Five of the six act in main directly through the registry; URL focus is + * a renderer concern, so it is bounced back over IPC. + * + * Customization: `updateBrowserKeybindings(overrides)` recomputes the + * match list from defaults + user overrides; main calls it at boot and on + * every `keybindingsChanged`. Matching reuses `parseAccelerator` + + * `matchesKeyState`, so the renderer dispatcher and this interceptor can + * never diverge on what a given accelerator means. + * + * Scope note: `before-input-event` only fires for the focused webContents, + * and we only attach to browser views — so the `when: "browserTabActive"` + * scope is satisfied structurally. We match on the keystroke alone. + */ + +import type { WebContents } from "electron"; +import { ALL_COMMAND_IDS, COMMANDS, type CommandId } from "../../../shared/keybindings/commands"; +import { KEYBINDINGS } from "../../../shared/keybindings/index"; +import { + matchesKeyState, + type ParsedKeystroke, + parseAccelerator, +} from "../../../shared/keybindings/keybinding-parse"; +import { + applyKeybindingOverrides, + type KeybindingOverride, +} from "../../../shared/keybindings/overrides"; +import { createLogger } from "../../../shared/log/main"; + +const logger = createLogger("browser-keyboard"); + +const KNOWN_COMMANDS: ReadonlySet = new Set(ALL_COMMAND_IDS); +const IS_MAC = process.platform === "darwin"; + +/** + * Commands this interceptor routes when the browser page view has focus. + * (DevTools is intentionally excluded — ⌘⌥I belongs to the Electron menu + * role for app-window DevTools; the browser page's DevTools is button-only.) + */ +const BROWSER_COMMANDS: readonly CommandId[] = [ + COMMANDS.browserFocusUrl, + COMMANDS.browserReload, + COMMANDS.browserHardReload, + COMMANDS.browserGoBack, + COMMANDS.browserGoForward, +]; +const BROWSER_COMMAND_SET: ReadonlySet = new Set(BROWSER_COMMANDS); + +interface CompiledBinding { + command: CommandId; + parsed: ParsedKeystroke; +} + +/** Current effective (defaults + overrides) primary bindings for browser commands. */ +let compiled: CompiledBinding[] = compile(undefined); + +function compile(overrides: readonly KeybindingOverride[] | undefined): CompiledBinding[] { + const effective = applyKeybindingOverrides(KEYBINDINGS, overrides, KNOWN_COMMANDS); + const out: CompiledBinding[] = []; + for (const decl of effective) { + if (!BROWSER_COMMAND_SET.has(decl.command)) continue; + if (decl.primary === undefined) continue; // unbound or chord-only — nothing to match + try { + out.push({ command: decl.command as CommandId, parsed: parseAccelerator(decl.primary) }); + } catch (err) { + logger.warn(`skipping unparseable browser binding ${decl.command}=${decl.primary}: ${err}`); + } + } + return out; +} + +/** Recompute the browser match list from user overrides (boot + on change). */ +export function updateBrowserKeybindings( + overrides: readonly KeybindingOverride[] | undefined, +): void { + compiled = compile(overrides); +} + +/** + * Attach the `before-input-event` interceptor to one browser webContents. + * `run` executes the resolved command for `tabId` (registry action or an + * IPC bounce). Safe to call once per view at creation time. + */ +export function installBrowserKeyInterceptor( + wc: WebContents, + tabId: string, + run: (command: CommandId, tabId: string) => void, +): void { + wc.on("before-input-event", (event, input) => { + if (input.type !== "keyDown") return; + if (typeof input.code !== "string" || input.code === "") return; + + const state = { + code: input.code, + meta: input.meta, + ctrl: input.control, + shift: input.shift, + alt: input.alt, + }; + + for (const b of compiled) { + if (matchesKeyState(b.parsed, state, IS_MAC)) { + event.preventDefault(); + run(b.command, tabId); + return; + } + } + }); +} diff --git a/src/main/features/menu/index.ts b/src/main/features/menu/index.ts index f48d6cec..2e9acf9a 100644 --- a/src/main/features/menu/index.ts +++ b/src/main/features/menu/index.ts @@ -18,13 +18,20 @@ * registration and are unaffected by menu replacement. */ -import type { TFunction } from "i18next"; import { app, BrowserWindow, Menu, type MenuItemConstructorOptions } from "electron"; -import { COMMANDS } from "../../../shared/keybindings/commands"; +import type { TFunction } from "i18next"; import type { CommandId } from "../../../shared/keybindings/commands"; +import { ALL_COMMAND_IDS, COMMANDS } from "../../../shared/keybindings/commands"; +import { KEYBINDINGS } from "../../../shared/keybindings/index"; +import { + applyKeybindingOverrides, + type KeybindingOverride, +} from "../../../shared/keybindings/overrides"; import { isMac } from "../../infra/platform"; import { buildMenuTemplate, type MenuItemSpec } from "./template"; +const KNOWN_COMMANDS: ReadonlySet = new Set(ALL_COMMAND_IDS); + export interface InstallAppMenuOptions { /** Called when the user clicks "Check for Updates..." in the App menu. */ onCheckForUpdates?: () => void; @@ -34,6 +41,14 @@ export interface InstallAppMenuOptions { * fallback strings embedded in buildMenuTemplate are used. */ t?: TFunction; + /** + * User keybinding overrides from appState. Menu accelerator labels + * render the EFFECTIVE binding (defaults + overrides) so the menu + * never shows a shortcut the dispatcher would not honor. Pass + * `stateService.getState().keybindingOverrides` — both at boot and on + * every rebuild (language change, keybinding change). + */ + keybindingOverrides?: readonly KeybindingOverride[]; } export function installAppMenu(options: InstallAppMenuOptions = {}): void { @@ -41,13 +56,17 @@ export function installAppMenu(options: InstallAppMenuOptions = {}): void { isMac: isMac(), appName: app.getName(), t: options.t, + bindings: applyKeybindingOverrides(KEYBINDINGS, options.keybindingOverrides, KNOWN_COMMANDS), }); const electronTemplate = template.map((spec) => toElectron(spec, options)); Menu.setApplicationMenu(Menu.buildFromTemplate(electronTemplate)); } -function toElectron(spec: MenuItemSpec, options: InstallAppMenuOptions): MenuItemConstructorOptions { +function toElectron( + spec: MenuItemSpec, + options: InstallAppMenuOptions, +): MenuItemConstructorOptions { switch (spec.type) { case "separator": return { type: "separator" }; @@ -71,9 +90,7 @@ function toElectron(spec: MenuItemSpec, options: InstallAppMenuOptions): MenuIte // no canvas-vs-DOM mismatch — Cocoa's native dispatch reaches the // correct target in every focus context. const base: MenuItemConstructorOptions = - spec.label !== undefined - ? { role: spec.role, label: spec.label } - : { role: spec.role }; + spec.label !== undefined ? { role: spec.role, label: spec.label } : { role: spec.role }; if (spec.role === "copy") base.registerAccelerator = false; return base; } diff --git a/src/main/features/menu/template.ts b/src/main/features/menu/template.ts index 47e34ca2..c559f9f6 100644 --- a/src/main/features/menu/template.ts +++ b/src/main/features/menu/template.ts @@ -21,8 +21,8 @@ */ import type { TFunction } from "i18next"; import { COMMANDS, type CommandId } from "../../../shared/keybindings/commands"; +import { KEYBINDINGS, type KeybindingDecl } from "../../../shared/keybindings/index"; import { chordToLabel } from "../../../shared/keybindings/keybinding-parse"; -import { findChordBinding, findPrimaryBinding } from "../../../shared/keybindings/index"; export type MenuItemSpec = | { type: "separator" } @@ -68,6 +68,14 @@ interface BuildMenuOptions { * embedded in each builder are used (e.g. in tests that build without i18n). */ t?: TFunction; + /** + * EFFECTIVE binding table (defaults + user overrides, merged by + * `applyKeybindingOverrides`). Injected by the installer so this + * module stays pure. When omitted, falls back to the static + * `KEYBINDINGS` defaults — correct for tests and for boots with no + * stored overrides. + */ + bindings?: readonly KeybindingDecl[]; } export function buildMenuTemplate(opts: BuildMenuOptions): MenuItemSpec[] { @@ -87,7 +95,7 @@ export function buildMenuTemplate(opts: BuildMenuOptions): MenuItemSpec[] { menu.push(windowMenu(t)); } - return enrichWithKeybindings(menu, opts.isMac); + return enrichWithKeybindings(menu, opts.isMac, opts.bindings ?? KEYBINDINGS); } /** @@ -111,7 +119,10 @@ function appMenu(appName: string, t?: TFunction): MenuItemSpec { role: "about", label: t != null ? t("menu:appMenu.about", { appName }) : `About ${appName}`, }, - cmd(t != null ? t("menu:appMenu.checkForUpdates") : "Check for Updates...", COMMANDS.updatesCheck), + cmd( + t != null ? t("menu:appMenu.checkForUpdates") : "Check for Updates...", + COMMANDS.updatesCheck, + ), { type: "separator" }, // macOS convention: Settings… right under About, ⌘, accelerator. cmd(t != null ? t("menu:appMenu.settings") : "Settings…", COMMANDS.settingsOpen), @@ -178,12 +189,21 @@ function viewMenu(t?: TFunction): MenuItemSpec { type: "submenu", label: t != null ? t("menu:view.label") : "View", submenu: [ - cmd(t != null ? t("menu:view.toggleFilesPanel") : "Toggle Files Panel", COMMANDS.workbenchToggleFilesPanel), - cmd(t != null ? t("menu:view.toggleSidebar") : "Toggle Sidebar", COMMANDS.workbenchToggleSidebar), + cmd( + t != null ? t("menu:view.toggleFilesPanel") : "Toggle Files Panel", + COMMANDS.workbenchToggleFilesPanel, + ), + cmd( + t != null ? t("menu:view.toggleSidebar") : "Toggle Sidebar", + COMMANDS.workbenchToggleSidebar, + ), { type: "separator" }, cmd(t != null ? t("menu:view.revealInFinder") : "Reveal in Finder", COMMANDS.pathReveal), cmd(t != null ? t("menu:view.copyPath") : "Copy Path", COMMANDS.pathCopy), - cmd(t != null ? t("menu:view.copyRelativePath") : "Copy Relative Path", COMMANDS.pathCopyRelative), + cmd( + t != null ? t("menu:view.copyRelativePath") : "Copy Relative Path", + COMMANDS.pathCopyRelative, + ), { type: "separator" }, { type: "role", role: "toggleDevTools" }, { type: "separator" }, @@ -201,17 +221,32 @@ function workspaceMenu(t?: TFunction): MenuItemSpec { type: "submenu", label: t != null ? t("menu:workspace.label") : "Workspace", submenu: [ - cmd(t != null ? t("menu:workspace.previousWorkspace") : "Previous Workspace", COMMANDS.workspaceFocusPrev), - cmd(t != null ? t("menu:workspace.nextWorkspace") : "Next Workspace", COMMANDS.workspaceFocusNext), + cmd( + t != null ? t("menu:workspace.previousWorkspace") : "Previous Workspace", + COMMANDS.workspaceFocusPrev, + ), + cmd( + t != null ? t("menu:workspace.nextWorkspace") : "Next Workspace", + COMMANDS.workspaceFocusNext, + ), { type: "separator" }, cmd(t != null ? t("menu:workspace.splitRight") : "Split Right", COMMANDS.groupSplitRight), cmd(t != null ? t("menu:workspace.splitDown") : "Split Down", COMMANDS.groupSplitDown), cmd(t != null ? t("menu:workspace.closeGroup") : "Close Group", COMMANDS.groupClose), { type: "separator" }, - cmd(t != null ? t("menu:workspace.focusGroupLeft") : "Focus Group Left", COMMANDS.groupFocusLeft), - cmd(t != null ? t("menu:workspace.focusGroupRight") : "Focus Group Right", COMMANDS.groupFocusRight), + cmd( + t != null ? t("menu:workspace.focusGroupLeft") : "Focus Group Left", + COMMANDS.groupFocusLeft, + ), + cmd( + t != null ? t("menu:workspace.focusGroupRight") : "Focus Group Right", + COMMANDS.groupFocusRight, + ), cmd(t != null ? t("menu:workspace.focusGroupUp") : "Focus Group Up", COMMANDS.groupFocusUp), - cmd(t != null ? t("menu:workspace.focusGroupDown") : "Focus Group Down", COMMANDS.groupFocusDown), + cmd( + t != null ? t("menu:workspace.focusGroupDown") : "Focus Group Down", + COMMANDS.groupFocusDown, + ), ], }; } @@ -232,26 +267,33 @@ function windowMenu(t?: TFunction): MenuItemSpec { /** * Walk the spec tree and fill in `accelerator` (and the chord label - * suffix) for each command item from `shared/keybindings.ts`. Pure - * over the spec — no Electron access here. + * suffix) for each command item from the EFFECTIVE binding table + * (defaults + user overrides). Pure over the spec — no Electron access + * here. * * Mirrors VSCode's `withKeybinding` strategy: - * - Single-key binding → set `accelerator` (Electron registers it). + * - Single-key binding → set `accelerator` (display only — every + * command item sets `registerAccelerator: false`). * - Chord binding → no accelerator, append `[⌘K ⌘W]` to the label * (Electron can't register chords; renderer dispatches them). + * - No binding (user unbound it) → bare label, no shortcut shown. */ -function enrichWithKeybindings(specs: MenuItemSpec[], isMac: boolean): MenuItemSpec[] { +function enrichWithKeybindings( + specs: MenuItemSpec[], + isMac: boolean, + bindings: readonly KeybindingDecl[], +): MenuItemSpec[] { return specs.map((spec): MenuItemSpec => { if (spec.type === "submenu") { - return { ...spec, submenu: enrichWithKeybindings(spec.submenu, isMac) }; + return { ...spec, submenu: enrichWithKeybindings(spec.submenu, isMac, bindings) }; } if (spec.type !== "command") return spec; - const primary = findPrimaryBinding(spec.command); + const primary = bindings.find((b) => b.command === spec.command && b.primary !== undefined); if (primary?.primary !== undefined) { return { ...spec, accelerator: primary.primary }; } - const chord = findChordBinding(spec.command); + const chord = bindings.find((b) => b.command === spec.command && b.chord !== undefined); if (chord?.chord !== undefined) { const label = chordToLabel(chord.chord, { isMac }); return { ...spec, label: `${spec.label} [${label}]` }; diff --git a/src/main/index.ts b/src/main/index.ts index 57d91667..458753ec 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,6 +5,7 @@ import { GIT_STATUS_COALESCE_DEBOUNCE_MS } from "../shared/util/timing-constants import { installErrorSafetyNet } from "./error-safety-net"; import { registerAppStateChannel } from "./features/app-state"; import { getBrowserRegistry, initBrowserFeature, registerBrowserCloser } from "./features/browser"; +import { updateBrowserKeybindings } from "./features/browser/keyboard"; import { BrowserPermissionPromptManager } from "./features/browser/permission-prompt-manager"; import { setupClaudeFeature } from "./features/claude/index"; import { registerClipboardChannel } from "./features/clipboard/ipc"; @@ -212,11 +213,46 @@ registerAppStateChannel(stateService, { updatesHandle?.checkManual(); }, t: getMainT(), + keybindingOverrides: stateService.getState().keybindingOverrides, }); broadcast("appState", "languageChanged", { language }); }); }, + + /** + * Mirror of onLanguageChanged for keybinding overrides: the renderer + * persisted a new override list via appState.set — rebuild the native + * menu so accelerator labels show the effective bindings, then + * broadcast to every window (including the originator; hydration is + * idempotent) so all dispatchers recompile. + */ + onKeybindingsChanged(overrides) { + installAppMenu({ + onCheckForUpdates: () => { + updatesHandle?.checkManual(); + }, + t: getMainT(), + keybindingOverrides: overrides, + }); + broadcast("appState", "keybindingsChanged", { overrides }); + // Keep the browser-view key interceptor's match list current. + updateBrowserKeybindings(overrides); + }, + + /** + * Editor (Monaco) keybinding overrides changed: pure broadcast so + * every window re-reconciles its Monaco keybinding service. No menu + * rebuild — editor commands are not application-menu items. + */ + onEditorKeybindingsChanged(overrides) { + broadcast("appState", "editorKeybindingsChanged", { overrides }); + }, }); + +// Seed the browser-view key interceptor with persisted overrides so the +// in-page browser shortcuts (⌘R, ⌘[, …) reflect any customization from the +// first tab onward. Kept in sync by onKeybindingsChanged above. +updateBrowserKeybindings(stateService.getState().keybindingOverrides); registerFsChannel(workspaceManager, agentFsWatcher, workspaceStorage); registerPanelChannel(workspaceStorage); registerSshChannel(); @@ -270,6 +306,7 @@ app.whenReady().then(async () => { updatesHandle?.checkManual(); }, t: getMainT(), + keybindingOverrides: stateService.getState().keybindingOverrides, }); // Fire the one-time silent auto update check now that the app is ready. diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 788853c3..49740f03 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -16,6 +16,7 @@ import { AboutPanel } from "./components/settings/panels/about-panel"; import { AppearancePanel } from "./components/settings/panels/appearance-panel"; import { BrowserPermissionsPanel } from "./components/settings/panels/browser-permissions-panel"; import { EditorPanel } from "./components/settings/panels/editor-panel"; +import { KeybindingsPanel } from "./components/settings/panels/keybindings-panel"; import { NotificationsPanel } from "./components/settings/panels/notifications-panel"; import { TerminalPanel } from "./components/settings/panels/terminal-panel"; import { SettingsDialog } from "./components/settings/settings-dialog"; @@ -175,6 +176,12 @@ export function App() { group: t("nav.group.settings"), keywords: ["browser", "permission", "camera", "microphone", "location", "clipboard"], }, + { + id: "keybindings", + label: t("nav.keybindings"), + group: t("nav.group.settings"), + keywords: ["keyboard", "shortcut", "keybinding", "hotkey", "단축키", "키보드"], + }, { id: "about", label: t("nav.about"), @@ -392,6 +399,7 @@ export function App() { if (activeId === "terminal") return ; if (activeId === "notifications") return ; if (activeId === "browser-permissions") return ; + if (activeId === "keybindings") return ; if (activeId === "about") return ; return null; }} diff --git a/src/renderer/bootstrap.ts b/src/renderer/bootstrap.ts index 5d125fed..a41df69d 100644 --- a/src/renderer/bootstrap.ts +++ b/src/renderer/bootstrap.ts @@ -23,6 +23,7 @@ import { useBrowserPermissionsStore } from "./state/stores/browser-permissions"; import { useClaudeStatusStore } from "./state/stores/claude-status"; import { useEditorFontStore } from "./state/stores/editor-font"; import { useIconThemeStore } from "./state/stores/icon-theme"; +import { useKeybindingsStore } from "./state/stores/keybindings"; import { useLanguageStore } from "./state/stores/language"; import { useLayoutStore } from "./state/stores/layout"; import { useLspEnabledStore } from "./state/stores/lsp-enabled"; @@ -144,6 +145,28 @@ export async function bootstrapAppState(): Promise { useLanguageStore.getState().hydrate(language); }); + // Hydrate user keybinding overrides — recompiles the dispatcher's + // match tables so customized shortcuts are live before first input. + useKeybindingsStore.getState().hydrate(state.keybindingOverrides); + + // Mirror of languageChanged: another window edited a binding → main + // persisted it and broadcast the full override list. Hydration is + // idempotent and does not write back, so windows converge. + ipcListen("appState", "keybindingsChanged", ({ overrides }) => { + useKeybindingsStore.getState().hydrate(overrides); + }); + + // Hydrate editor (Monaco) keybinding overrides. Reconciliation is a + // no-op here when Monaco has not mounted yet — initializeEditorServices + // re-applies from the store once the singleton is live. + useKeybindingsStore.getState().hydrateEditor(state.editorKeybindingOverrides); + + // Mirror of keybindingsChanged for editor overrides — another window + // edited an editor binding; re-reconcile Monaco. Idempotent. + ipcListen("appState", "editorKeybindingsChanged", ({ overrides }) => { + useKeybindingsStore.getState().hydrateEditor(overrides); + }); + // Hydrate editor font settings from appState (authoritative store). useEditorFontStore.getState().hydrate({ size: state.editorFontSize, diff --git a/src/renderer/commands/domains/browser.ts b/src/renderer/commands/domains/browser.ts new file mode 100644 index 00000000..3afdec42 --- /dev/null +++ b/src/renderer/commands/domains/browser.ts @@ -0,0 +1,96 @@ +/** + * Browser-domain commands: URL-bar focus, reload / hard reload, + * back / forward, DevTools toggle (Chrome parity for embedded browser + * tabs). + * + * These used to be a hardcoded capture-phase keydown listener inside + * browser-view.tsx. Routing them through the command registry instead + * means: + * - they live in the declarative KEYBINDINGS table (customizable, + * conflict-checkable, label-rendered like every other shortcut); + * - the ⌘R/⌘⇧R race against `files.refresh` is resolved by `when` + * scoping (`browserTabActive` here, `!browserTabActive` there) + * instead of listener registration order — the old component + * listener registered *after* the global dispatcher and never saw + * ⌘R at all. + * + * Target resolution: every command acts on the ACTIVE GROUP's active + * tab, and only when that tab is a browser tab. This matches the + * `browserTabActive` context probe registered below, so a binding that + * resolved through `when: "browserTabActive"` always finds a target. + */ + +import { COMMANDS } from "../../../shared/keybindings/commands"; +import { registerCommand } from "../../commands/registry"; +import { Grid } from "../../engine"; +import { ipcCallResult } from "../../ipc/client"; +import { registerContextProbe } from "../../keybindings/context-keys"; +import { useActiveStore } from "../../state/stores/active"; +import { useBrowserRuntimeStore } from "../../state/stores/browser-runtime"; +import { useLayoutStore } from "../../state/stores/layout"; +import { useTabsStore } from "../../state/stores/tabs"; + +/** + * The tabId of the active group's active tab IF that tab is a browser + * tab; `null` otherwise. State probe — the WebContentsView is a native + * view outside the renderer DOM, so "focus" cannot be derived from a + * keydown target the way editor/terminal probes are. + */ +function getActiveBrowserTabId(): string | null { + const wsId = useActiveStore.getState().activeWorkspaceId; + if (!wsId) return null; + const layout = useLayoutStore.getState().byWorkspace[wsId]; + if (!layout) return null; + const leaf = Grid.findLeaf(layout.root, layout.activeGroupId); + if (!leaf?.activeTabId) return null; + const tab = useTabsStore.getState().byWorkspace[wsId]?.[leaf.activeTabId]; + return tab?.type === "browser" ? leaf.activeTabId : null; +} + +function withActiveBrowserTab(run: (tabId: string) => void): () => void { + return () => { + const tabId = getActiveBrowserTabId(); + if (tabId !== null) run(tabId); + }; +} + +export function registerBrowserCommands(): Array<() => void> { + return [ + // `when: "browserTabActive"` in the KEYBINDINGS table resolves + // through this probe on every candidate keydown. + registerContextProbe("browserTabActive", () => getActiveBrowserTabId() !== null), + + registerCommand( + COMMANDS.browserFocusUrl, + withActiveBrowserTab((tabId) => { + useBrowserRuntimeStore.getState().requestUrlFocus(tabId); + }), + ), + registerCommand( + COMMANDS.browserReload, + withActiveBrowserTab((tabId) => { + void ipcCallResult("browser", "reload", { tabId }); + }), + ), + registerCommand( + COMMANDS.browserHardReload, + withActiveBrowserTab((tabId) => { + void ipcCallResult("browser", "reload", { tabId, ignoreCache: true }); + }), + ), + registerCommand( + COMMANDS.browserGoBack, + withActiveBrowserTab((tabId) => { + void ipcCallResult("browser", "goBack", { tabId }); + }), + ), + registerCommand( + COMMANDS.browserGoForward, + withActiveBrowserTab((tabId) => { + void ipcCallResult("browser", "goForward", { tabId }); + }), + ), + // No DevTools command: ⌘⌥I is the Electron menu role (app DevTools); + // the browser page's DevTools is opened from the toolbar button. + ]; +} diff --git a/src/renderer/commands/domains/workbench.ts b/src/renderer/commands/domains/workbench.ts index 3f59b081..11df1a10 100644 --- a/src/renderer/commands/domains/workbench.ts +++ b/src/renderer/commands/domains/workbench.ts @@ -6,6 +6,9 @@ import { useUIStore } from "../../state/stores/ui"; * Workbench-level layout commands. Currently scoped to left-sidebar * visibility — both flags live in the UI store and persist via * appState, so the handlers are one-liners that delegate the bookkeeping. + * + * (DevTools toggle, ⌘⌥I, lives in the browser command domain — its target + * depends on the active browser tab.) */ export function registerWorkbenchCommands(): Array<() => void> { return [ diff --git a/src/renderer/components/settings/panels/keybindings-panel.tsx b/src/renderer/components/settings/panels/keybindings-panel.tsx new file mode 100644 index 00000000..60917f32 --- /dev/null +++ b/src/renderer/components/settings/panels/keybindings-panel.tsx @@ -0,0 +1,686 @@ +// src/renderer/components/settings/panels/keybindings-panel.tsx +// +// Keyboard Shortcuts panel — VSCode-style command table with inline +// recording. Two sections: +// +// - "Application" — commands the global dispatcher owns (KEYBINDINGS +// table). Rebinding recompiles the dispatcher and rebuilds the +// native menu's accelerator labels. +// - "Editor" — a curated set of Monaco editor commands (see +// shared/keybindings/editor-commands.ts). Rebinding reconciles +// Monaco's own keybinding service; these fire only while the editor +// has focus. +// +// Conflict model (VSCode-aligned): saving is NEVER blocked. A conflict +// is a property of the effective TABLE, not of the recording moment, so +// every row that collides with another command carries a persistent +// badge — including conflicts created indirectly (resetting a command +// onto a now-taken default, or the *other* side being rebound onto your +// key). The "Conflicts only" filter and the header count surface them at +// a glance. The live recorder additionally previews record-time warnings +// (including shadow/system collisions with built-in keys) but only warns +// — it does not refuse the save. +// +// `when` scopes are NOT editable — a recorded binding inherits the +// command's default scope (see overrides.ts for the rationale). +// +// Recording grammar: commands whose DEFAULT binding is a chord record +// two strokes; everything else records one. Esc cancels (bare Escape is +// therefore not bindable — it is the universal dismiss key); Enter with +// a complete capture saves (bare Enter is recordable only as the FIRST +// stroke, which is fine — the only bare-Enter default is scope-gated). + +import { ChevronRight, Pencil, RotateCcw, X } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/utils/cn"; +import { ALL_COMMAND_IDS, COMMANDS, type CommandId } from "../../../../shared/keybindings/commands"; +import { + type ConflictBinding, + detectConflicts, + detectTableConflicts, + type KeybindingConflict, +} from "../../../../shared/keybindings/conflicts"; +import { EDITOR_COMMANDS } from "../../../../shared/keybindings/editor-commands"; +import { KEYBINDINGS, type KeybindingDecl } from "../../../../shared/keybindings/index"; +import { + acceleratorToLabel, + chordToLabel, + eventToAccelerator, + isModifierCode, +} from "../../../../shared/keybindings/keybinding-parse"; +import type { KeybindingOverride } from "../../../../shared/keybindings/overrides"; +import { isMac } from "../../../keybindings/shortcut-labels"; +import { useKeybindingsStore } from "../../../state/stores/keybindings"; + +// `updates.check` is dispatched inside the main process from the menu — +// the renderer registry has no handler for it, so a keybinding would be +// a silent no-op. Excluded from the panel. +type BindableCommandId = Exclude; +const BINDABLE_COMMANDS = ALL_COMMAND_IDS.filter( + (id): id is BindableCommandId => id !== COMMANDS.updatesCheck, +); + +type BindingKind = "primary" | "chord"; + +interface Row { + command: string; + label: string; + /** Rendered label of the effective binding, or null when unbound. */ + bindingLabel: string | null; + kind: BindingKind; + when: string | undefined; + overridden: boolean; + /** Command-vs-command collisions affecting this row (persistent). */ + conflicts: KeybindingConflict[]; +} + +function defaultDeclFor(command: CommandId): KeybindingDecl | undefined { + return KEYBINDINGS.find((b) => b.command === command); +} + +function bindingLabelFor(bindings: readonly KeybindingDecl[], command: CommandId): string | null { + const primary = bindings.find((b) => b.command === command && b.primary !== undefined); + if (primary?.primary !== undefined) return acceleratorToLabel(primary.primary, { isMac }); + const chord = bindings.find((b) => b.command === command && b.chord !== undefined); + if (chord?.chord !== undefined) return chordToLabel(chord.chord, { isMac }); + return null; +} + +// --------------------------------------------------------------------------- +// Editor-command helpers (Monaco namespace) +// --------------------------------------------------------------------------- + +/** Effective primary keystroke for one editor command (override → default). */ +function editorEffectivePrimary( + overrides: readonly KeybindingOverride[], + id: string, + fallback: string | undefined, +): string | null { + const ov = overrides.find((o) => o.command === id); + if (ov === undefined || ov.primary === undefined) return fallback ?? null; + return ov.primary; // string = replace, null = unbind +} + +/** Synthetic binding list the conflict engine compares editor captures against. */ +function editorConflictBindings(overrides: readonly KeybindingOverride[]): ConflictBinding[] { + const out: ConflictBinding[] = []; + for (const c of EDITOR_COMMANDS) { + const primary = editorEffectivePrimary(overrides, c.id, c.defaultPrimary); + if (primary !== null) out.push({ command: c.id, primary }); + } + return out; +} + +export function KeybindingsPanel() { + const { t } = useTranslation("settings"); + const overrides = useKeybindingsStore((s) => s.overrides); + const effectiveBindings = useKeybindingsStore((s) => s.effectiveBindings); + const resetCommand = useKeybindingsStore((s) => s.resetCommand); + const resetAll = useKeybindingsStore((s) => s.resetAll); + const setOverride = useKeybindingsStore((s) => s.setOverride); + + const editorOverrides = useKeybindingsStore((s) => s.editorOverrides); + const setEditorOverride = useKeybindingsStore((s) => s.setEditorOverride); + const resetEditorCommand = useKeybindingsStore((s) => s.resetEditorCommand); + + const [query, setQuery] = useState(""); + const [modifiedOnly, setModifiedOnly] = useState(false); + const [conflictsOnly, setConflictsOnly] = useState(false); + // One recorder at a time across BOTH sections — keyed by command id. + const [recordingFor, setRecordingFor] = useState(null); + const [confirmResetAll, setConfirmResetAll] = useState(false); + + const appCommandLabel = (id: string): string => t(`keybindings.command.${id}`); + const editorSlugById = useMemo(() => { + const m = new Map(); + for (const c of EDITOR_COMMANDS) m.set(c.id, c.slug); + return m; + }, []); + const editorCommandLabel = (id: string): string => + t(`keybindings.editorCommand.${editorSlugById.get(id) ?? id}`); + + // Persistent, table-wide conflict maps (command-vs-command) — recomputed + // whenever either effective table changes. App and editor namespaces are + // graded independently. + const appConflicts = useMemo( + () => detectTableConflicts(effectiveBindings, isMac), + [effectiveBindings], + ); + const editorConflictBase = useMemo( + () => editorConflictBindings(editorOverrides), + [editorOverrides], + ); + const editorConflicts = useMemo( + () => detectTableConflicts(editorConflictBase, isMac), + [editorConflictBase], + ); + + const conflictCount = appConflicts.size + editorConflicts.size; + + // Sections default collapsed; an active search/filter force-expands the + // ones that have matches so results are never hidden behind a closed + // section. + const filterActive = query.trim() !== "" || modifiedOnly || conflictsOnly; + + const matchesFilter = useCallback( + (row: Row): boolean => { + if (modifiedOnly && !row.overridden) return false; + if (conflictsOnly && row.conflicts.length === 0) return false; + const q = query.trim().toLowerCase(); + if (q === "") return true; + return ( + row.label.toLowerCase().includes(q) || + row.command.toLowerCase().includes(q) || + (row.bindingLabel?.toLowerCase().includes(q) ?? false) + ); + }, + [query, modifiedOnly, conflictsOnly], + ); + + const appRows = useMemo(() => { + return BINDABLE_COMMANDS.map((command): Row => { + const def = defaultDeclFor(command); + return { + command, + label: t(`keybindings.command.${command}`), + bindingLabel: bindingLabelFor(effectiveBindings, command), + kind: def?.chord !== undefined ? "chord" : "primary", + when: def?.when, + overridden: overrides.some((o) => o.command === command), + conflicts: appConflicts.get(command) ?? [], + }; + }).filter(matchesFilter); + }, [t, effectiveBindings, overrides, appConflicts, matchesFilter]); + + const editorRows = useMemo(() => { + return EDITOR_COMMANDS.map((c): Row => { + const primary = editorEffectivePrimary(editorOverrides, c.id, c.defaultPrimary); + return { + command: c.id, + label: t(`keybindings.editorCommand.${c.slug}`), + bindingLabel: primary !== null ? acceleratorToLabel(primary, { isMac }) : null, + kind: "primary", + when: undefined, + overridden: editorOverrides.some((o) => o.command === c.id), + conflicts: editorConflicts.get(c.id) ?? [], + }; + }).filter(matchesFilter); + }, [t, editorOverrides, editorConflicts, matchesFilter]); + + const hasOverrides = overrides.length > 0 || editorOverrides.length > 0; + + return ( +
+ {/* Toolbar: search + filters + reset all */} +
+ setQuery(e.target.value)} + placeholder={t("keybindings.searchPlaceholder")} + className={cn( + "min-w-0 flex-1 rounded-(--radius-control) border border-border bg-background", + "px-2 py-1 text-app-ui-sm text-foreground outline-none", + "placeholder:text-muted-foreground focus:ring-1 focus:ring-ring", + )} + /> + + + +
+ + {/* Application commands */} + + setOverride( + kind === "chord" + ? { command, chord: value as [string, string] } + : { command, primary: value[0] as string }, + ) + } + onUnbind={(command, kind) => + setOverride(kind === "chord" ? { command, chord: null } : { command, primary: null }) + } + onReset={resetCommand} + /> + + {/* Editor (Monaco) commands */} + + setEditorOverride({ command, primary: value[0] as string }) + } + onUnbind={(command) => setEditorOverride({ command, primary: null })} + onReset={resetEditorCommand} + /> +
+ ); +} + +// --------------------------------------------------------------------------- +// Section + row +// --------------------------------------------------------------------------- + +interface CommandSectionProps { + heading: string; + description?: string; + rows: Row[]; + /** Total conflicts in this section's namespace (shown on the collapsed summary). */ + conflictCount: number; + /** A search/filter is active — force-expand when this section has matches. */ + filterActive: boolean; + conflictBindings: readonly ConflictBinding[]; + commandLabel: (id: string) => string; + recordingFor: string | null; + setRecordingFor: (id: string | null) => void; + onCommit: (command: string, kind: BindingKind, value: readonly string[]) => void; + onUnbind: (command: string, kind: BindingKind) => void; + onReset: (command: string) => void; +} + +function CommandSection({ + heading, + description, + rows, + conflictCount, + filterActive, + conflictBindings, + commandLabel, + recordingFor, + setRecordingFor, + onCommit, + onUnbind, + onReset, +}: CommandSectionProps) { + const { t } = useTranslation("settings"); + // Default collapsed; the user can expand manually. An active filter with + // matches forces it open regardless, so search results are never hidden. + const [userOpen, setUserOpen] = useState(false); + const forceOpen = filterActive && rows.length > 0; + const open = forceOpen || userOpen; + + // Render the persistent conflict line for a row, naming the other side(s). + const conflictLine = (conflicts: KeybindingConflict[]): string => { + return conflicts + .map((c) => + t(`keybindings.conflict.${c.kind === "overlap" ? "overlap" : "blocking"}`, { + command: c.command !== undefined ? commandLabel(c.command) : "?", + }), + ) + .join(" · "); + }; + + return ( +
{ + // Ignore the programmatic toggle that fires when `forceOpen` drives + // the element — only persist genuine user clicks. + if (!forceOpen) setUserOpen(e.currentTarget.open); + }} + className="flex flex-col gap-1.5" + > + + + {description !== undefined && ( + {description} + )} +
+ {rows.map((row) => ( +
+
+ {row.label} + + {row.command} + + {row.conflicts.length > 0 && ( + + ⚠ {conflictLine(row.conflicts)} + + )} +
+ + {recordingFor === row.command ? ( + { + onCommit(row.command, row.kind, value); + setRecordingFor(null); + }} + onUnbind={() => { + onUnbind(row.command, row.kind); + setRecordingFor(null); + }} + onCancel={() => setRecordingFor(null)} + /> + ) : ( + <> + 0 + ? "text-destructive" + : row.bindingLabel !== null + ? "text-foreground" + : "text-muted-foreground", + )} + > + {row.bindingLabel ?? t("keybindings.unbound")} + + + {row.overridden ? t("keybindings.source.user") : t("keybindings.source.default")} + + + + + )} +
+ ))} + {rows.length === 0 && ( +
+ {t("keybindings.empty")} +
+ )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Recorder +// --------------------------------------------------------------------------- + +interface KeybindingRecorderProps { + command: string; + kind: BindingKind; + when: string | undefined; + bindings: readonly ConflictBinding[]; + hasBinding: boolean; + commandLabel: (id: string) => string; + /** value: [primary] for kind=primary, [leader, secondary] for chord. */ + onCommit: (value: readonly string[]) => void; + onUnbind: () => void; + onCancel: () => void; +} + +function KeybindingRecorder({ + command, + kind, + when, + bindings, + hasBinding, + commandLabel, + onCommit, + onUnbind, + onCancel, +}: KeybindingRecorderProps) { + const { t } = useTranslation("settings"); + const [captured, setCaptured] = useState([]); + const [unsupported, setUnsupported] = useState(false); + const containerRef = useRef(null); + const captureRef = useRef(null); + + useEffect(() => { + captureRef.current?.focus(); + }, []); + + const needed = kind === "chord" ? 2 : 1; + const complete = captured.length === needed; + + // Record-time preview: includes shadow/system warnings against built-in + // keys (which the persistent table view omits). Informational only — a + // conflict never blocks the save (VSCode-aligned). + const conflicts = useMemo(() => { + if (!complete) return []; + return detectConflicts({ + command, + ...(kind === "chord" + ? { chord: [captured[0] as string, captured[1] as string] as const } + : { primary: captured[0] as string }), + ...(when !== undefined ? { when } : {}), + bindings, + isMac, + }); + }, [complete, captured, command, kind, when, bindings]); + + function handleKeyDown(e: React.KeyboardEvent) { + e.preventDefault(); + e.stopPropagation(); + // IME composition guard — same dual check as the dispatcher. + if (e.nativeEvent.isComposing || e.keyCode === 229) return; + if (isModifierCode(e.nativeEvent.code)) return; + + const noMods = !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey; + if (e.key === "Escape" && noMods) { + onCancel(); + return; + } + if (e.key === "Enter" && noMods && complete) { + onCommit(captured); + return; + } + + const accel = eventToAccelerator(e.nativeEvent, isMac); + if (accel === null) { + setUnsupported(true); + return; + } + setUnsupported(false); + setCaptured((prev) => { + if (kind === "primary") return [accel]; + // chord: fill slots; a third stroke restarts the capture. + if (prev.length === 0 || prev.length >= 2) return [accel]; + return [prev[0] as string, accel]; + }); + } + + const capturedLabel = + captured.length > 0 + ? captured.map((a) => acceleratorToLabel(a, { isMac })).join(" ") + : t("keybindings.record.prompt"); + const promptSecond = kind === "chord" && captured.length === 1; + + return ( + // biome-ignore lint/a11y/useSemanticElements:
styles poorly inline and adds form semantics this transient recorder doesn't have; role="group" + label conveys the structure. +
{ + // Cancel when focus leaves the recorder entirely (clicking one of + // the inline buttons keeps focus inside the container). + if (containerRef.current && !containerRef.current.contains(e.relatedTarget as Node)) { + onCancel(); + } + }} + > +
+ {/* biome-ignore lint/a11y/useSemanticElements: a real would fight the raw-keystroke capture; this div mimics VSCode's recorder box. */} +
+ {promptSecond + ? `${capturedLabel} · ${t("keybindings.record.promptSecond")}` + : capturedLabel} +
+ + {hasBinding && ( + + )} +
+ + + {unsupported ? t("keybindings.record.unsupported") : t("keybindings.record.hint")} + + + {conflicts.map((c, i) => { + const key = `${c.kind}-${c.command ?? c.reserved?.accelerator ?? i}`; + let message: string; + if (c.kind === "blocking" || c.kind === "overlap") { + message = t(`keybindings.conflict.${c.kind}`, { + command: c.command !== undefined ? commandLabel(c.command) : "?", + }); + } else if (c.kind === "system") { + message = t("keybindings.conflict.system", { note: c.reserved?.note ?? "" }); + } else { + message = t("keybindings.conflict.shadow", { + owner: c.reserved !== undefined ? t(`keybindings.owner.${c.reserved.source}`) : "?", + note: c.reserved?.note ?? "", + }); + } + const severe = c.kind === "blocking" || c.kind === "system"; + return ( + + ⚠ {message} + + ); + })} +
+ ); +} diff --git a/src/renderer/components/settings/settings-dialog.tsx b/src/renderer/components/settings/settings-dialog.tsx index ff9478b8..f1c00a60 100644 --- a/src/renderer/components/settings/settings-dialog.tsx +++ b/src/renderer/components/settings/settings-dialog.tsx @@ -99,6 +99,12 @@ export function SettingsDialog({ group: t("nav.group.settings"), keywords: ["browser", "permission", "camera", "microphone", "location", "clipboard"], }, + { + id: "keybindings", + label: t("nav.keybindings"), + group: t("nav.group.settings"), + keywords: ["keyboard", "shortcut", "keybinding", "hotkey", "단축키", "키보드"], + }, { id: "about", label: t("nav.about"), diff --git a/src/renderer/components/workspace/content/browser-view.tsx b/src/renderer/components/workspace/content/browser-view.tsx index b3af4204..2d34b986 100644 --- a/src/renderer/components/workspace/content/browser-view.tsx +++ b/src/renderer/components/workspace/content/browser-view.tsx @@ -39,14 +39,14 @@ * any pending frame before scheduling a new one. This collapses rapid consecutive * resize events into a single IPC call per paint cycle. * - * KEYBOARD SHORTCUTS (active only when this browser tab is the active tab) - * ------------------------------------------------------------------------- - * ⌘L → focus URL bar + select all - * ⌘R → reload - * ⌘⇧R → hard reload (ignoreCache: true) - * ⌘[ → go back - * ⌘] → go forward - * ⌘⌥I → toggle DevTools (inline docked) + * KEYBOARD SHORTCUTS + * ------------------ + * Browser shortcuts (⌘L / ⌘R / ⌘⇧R / ⌘[ / ⌘] / ⌘⌥I) are NOT handled + * here. They live in the declarative KEYBINDINGS table + * (shared/keybindings, `when: "browserTabActive"`) and execute through + * the browser command domain (commands/domains/browser.ts), which + * resolves the active group's active browser tab itself. ⌘L lands back + * in this component via the runtime store's `urlFocusToken`. */ import i18next from "i18next"; import { Wrench } from "lucide-react"; @@ -120,8 +120,6 @@ export function BrowserTabView({ // updates both regions so a splitter drag still emits a single IPC pair // per paint cycle. const rafIdRef = useRef(null); - // URL bar focus imperative trigger — incrementing causes UrlBar to focus+select. - const [urlFocusToken, setUrlFocusToken] = useState(0); // Whether browser.create has been sent for this tabId. const createdRef = useRef(false); // User-controlled height of the inline DevTools region. Per-tab-mount; @@ -143,6 +141,10 @@ export function BrowserTabView({ // Whether DevTools is currently docked inside the tab area. Drives the // splitter region and the toolbar toggle button's active style. const devtoolsOpen = runtime?.devtoolsOpen ?? false; + // URL bar focus imperative trigger — bumped by the `browser.focusUrl` + // command (⌘L) via the runtime store; UrlBar focuses + selects-all on + // every change. + const urlFocusToken = runtime?.urlFocusToken ?? 0; // Empty state: show when no real URL has been committed for this tab. // `about:blank` is used as the synthetic initial load and is treated as @@ -324,65 +326,6 @@ export function BrowserTabView({ window.addEventListener("pointerup", onUp); } - // ------------------------------------------------------------------------- - // Keyboard shortcuts (active only when this tab is active) - // ------------------------------------------------------------------------- - useEffect(() => { - if (!isActive) return; - - function onKeyDown(e: KeyboardEvent) { - const meta = e.metaKey || e.ctrlKey; - if (!meta) return; - - // ⌘L — focus URL bar - if (e.key === "l" && !e.shiftKey && !e.altKey) { - e.preventDefault(); - setUrlFocusToken((t) => t + 1); - return; - } - - // ⌘⇧R — hard reload - if (e.key === "r" && e.shiftKey && !e.altKey) { - e.preventDefault(); - void ipcCallResult("browser", "reload", { tabId, ignoreCache: true }); - return; - } - - // ⌘R — reload - if (e.key === "r" && !e.shiftKey && !e.altKey) { - e.preventDefault(); - void ipcCallResult("browser", "reload", { tabId }); - return; - } - - // ⌘[ — go back - if (e.key === "[" && !e.shiftKey && !e.altKey) { - e.preventDefault(); - void ipcCallResult("browser", "goBack", { tabId }); - return; - } - - // ⌘] — go forward - if (e.key === "]" && !e.shiftKey && !e.altKey) { - e.preventDefault(); - void ipcCallResult("browser", "goForward", { tabId }); - return; - } - - // ⌘⌥I — toggle DevTools - if (e.key === "i" && e.altKey && !e.shiftKey) { - e.preventDefault(); - void ipcCallResult("browser", "openDevTools", { tabId }); - return; - } - } - - window.addEventListener("keydown", onKeyDown, { capture: true }); - return () => { - window.removeEventListener("keydown", onKeyDown, { capture: true }); - }; - }, [tabId, isActive]); - // ------------------------------------------------------------------------- // Render // ------------------------------------------------------------------------- diff --git a/src/renderer/components/workspace/content/editor-mount/install-editor-integrations.ts b/src/renderer/components/workspace/content/editor-mount/install-editor-integrations.ts index 5367d223..3fa49f94 100644 --- a/src/renderer/components/workspace/content/editor-mount/install-editor-integrations.ts +++ b/src/renderer/components/workspace/content/editor-mount/install-editor-integrations.ts @@ -14,8 +14,9 @@ import { installDirtyDiffForEditor } from "./install-dirty-diff"; * `editor.openCodeEditor` (which is a no-op when there's no * editorService) so Cmd-click on an LSP definition opens the target * file in our tab system. - * - Save action — wires Cmd+S to the save service for this editor's - * `EditorInput`. + * - Save action — exposes "Save File" in Monaco's F1 palette for this + * editor's `EditorInput`. The ⌘S keystroke itself is owned by the + * global dispatcher (COMMANDS.fileSave), not by Monaco. * * Returns a single disposer the caller drives on unmount or when the * editor instance is replaced. Save action is intentionally NOT disposed @@ -52,7 +53,7 @@ export function installEditorIntegrations({ opener.openCodeEditor(source, resource), }); - installEditorSaveAction(editor, monaco, input); + installEditorSaveAction(editor, input); // Install the conflict-resolution CodeLens + decoration provider. Activates // only when the current model contains Git conflict markers; disposal is diff --git a/src/renderer/keybindings/context-keys.ts b/src/renderer/keybindings/context-keys.ts index f21b126d..6d978050 100644 --- a/src/renderer/keybindings/context-keys.ts +++ b/src/renderer/keybindings/context-keys.ts @@ -26,12 +26,51 @@ * - `isMac` — resolved via navigator.platform, not DOM target. * Always evaluates to the same value for a given * device; safe to use in `when` expressions. + * - `browserTabActive` — STATE probe (not DOM): the active group's + * active tab is a browser tab. Registered via + * {@link registerContextProbe} by the browser + * command domain. A DOM probe cannot express + * this — the embedded WebContentsView is a + * native view outside the renderer's DOM, so + * the keydown target is never "inside" it. * * Unknown names resolve to `false`. Treating an unknown context key * as "not active" matches VSCode's behaviour and keeps a typo'd * binding fail-safe (it just won't match) rather than fail-loud. */ +// --------------------------------------------------------------------------- +// Dynamic (state-backed) context probes +// --------------------------------------------------------------------------- +// +// Some context keys cannot be derived from the keydown target — they +// describe app *state* (e.g. which tab is active in the focused group). +// Domains register a probe function once at mount; `evaluateContextKey` +// falls back to the probe table for any name the DOM switch doesn't +// handle. Probes must be cheap and synchronous (they run per keydown). + +type ContextProbe = () => boolean; + +const dynamicProbes = new Map(); + +/** + * Register a state-backed context probe for `name`. Returns an + * unregister function. Re-registering the same name replaces the probe + * (last writer wins) — domains are mounted once, so this only matters + * for tests. + */ +export function registerContextProbe(name: string, probe: ContextProbe): () => void { + dynamicProbes.set(name, probe); + return () => { + if (dynamicProbes.get(name) === probe) dynamicProbes.delete(name); + }; +} + +/** Test-only — drop all dynamic probes between tests. */ +export function __resetContextProbesForTests(): void { + dynamicProbes.clear(); +} + /** * Resolves a `when`-clause context key (e.g. `editorFocus`, `fileTreeFocus`) * against the currently focused element on a keyboard event. Returns `false` @@ -51,10 +90,22 @@ export function evaluateContextKey(name: string, event: KeyboardEvent): boolean return isInTerminal(target); case "commandPaletteFocus": return isInCommandPalette(target); + case "keybindingRecorderFocus": + return isInKeybindingRecorder(target); case "isMac": return IS_MAC; // resolved at module load, stable per device - default: + default: { + // State-backed keys (e.g. `browserTabActive`) registered by domains. + const probe = dynamicProbes.get(name); + if (probe !== undefined) { + try { + return probe(); + } catch { + return false; // a throwing probe must never break dispatch + } + } return false; + } } } @@ -95,6 +146,14 @@ function isInCommandPalette(target: HTMLElement | null): boolean { return closest(target, "[data-command-palette-root]") != null; } +// The keybinding recorder must receive EVERY keystroke verbatim — +// including ones the dispatcher would otherwise claim (⌘W, ⌘K, …). +// The dispatcher early-returns when the keydown target sits inside a +// recorder, exactly like the command-palette guard above. +function isInKeybindingRecorder(target: HTMLElement | null): boolean { + return closest(target, "[data-keybinding-recorder]") != null; +} + function closest(target: HTMLElement | null, selector: string): Element | null { if (!target || typeof target.closest !== "function") return null; return target.closest(selector); diff --git a/src/renderer/keybindings/dispatcher.ts b/src/renderer/keybindings/dispatcher.ts index 9599001d..e10e642b 100644 --- a/src/renderer/keybindings/dispatcher.ts +++ b/src/renderer/keybindings/dispatcher.ts @@ -78,6 +78,11 @@ export function handleGlobalKeyDown(e: KeyboardEvent): boolean { if (evaluateContextKey("commandPaletteFocus", e)) return false; + // The keybinding recorder (Settings → Keyboard Shortcuts) captures raw + // keystrokes to record a new binding — never dispatch while it owns + // the keydown target, or recording ⌘W would close the tab instead. + if (evaluateContextKey("keybindingRecorderFocus", e)) return false; + // ── 2. Escape during pending cancels silently. Outside pending, // Escape isn't ours; let it through. const pendingLeader = getPendingLeader(); diff --git a/src/renderer/keybindings/resolver.ts b/src/renderer/keybindings/resolver.ts index f1f7a55c..7583c5db 100644 --- a/src/renderer/keybindings/resolver.ts +++ b/src/renderer/keybindings/resolver.ts @@ -1,11 +1,13 @@ /** * Pure resolver: keystroke → resolution. * - * Compiles `KEYBINDINGS` into match predicates once at module load, - * then maps each keydown event (plus the current chord-pending leader) - * to a tagged {@link Resolution}. The dispatcher consumes the - * resolution to decide whether to fire a command, arm a chord, swallow - * the keystroke, or let it bubble. + * Compiles a binding table into match predicates — `KEYBINDINGS` at + * module load, then any EFFECTIVE table (defaults + user overrides) + * pushed through {@link setActiveBindings} when the keybindings store + * hydrates or the user edits a binding. Each keydown event (plus the + * current chord-pending leader) maps to a tagged {@link Resolution}. + * The dispatcher consumes the resolution to decide whether to fire a + * command, arm a chord, swallow the keystroke, or let it bubble. * * Splitting this away from the dispatcher means: * - Match logic is testable without DOM, React, or event listener @@ -16,13 +18,13 @@ */ import type { CommandId } from "../../shared/keybindings/commands"; +import { KEYBINDINGS, type KeybindingDecl } from "../../shared/keybindings/index"; import { matchesEvent, type ParsedKeystroke, parseAccelerator, } from "../../shared/keybindings/keybinding-parse"; import { evaluateWhen, parseWhen, type WhenExpr } from "../../shared/keybindings/keybinding-when"; -import { KEYBINDINGS, type KeybindingDecl } from "../../shared/keybindings/index"; import { evaluateContextKey } from "./context-keys"; interface CompiledPrimary { @@ -53,23 +55,69 @@ interface CompiledChord { const IS_MAC = typeof navigator !== "undefined" && /Mac|iPhone|iPod|iPad/i.test(navigator.platform || ""); -const PRIMARIES: CompiledPrimary[] = []; -const CHORDS: CompiledChord[] = []; +let PRIMARIES: CompiledPrimary[] = []; +let CHORDS: CompiledChord[] = []; +let ACTIVE_BINDINGS: readonly KeybindingDecl[] = KEYBINDINGS; -for (const decl of KEYBINDINGS) { - const when = decl.when !== undefined ? parseWhen(decl.when) : null; - if (decl.primary !== undefined) { - PRIMARIES.push({ decl, parsed: parseAccelerator(decl.primary), when }); - } - if (decl.chord !== undefined) { - CHORDS.push({ - decl, - leader: parseAccelerator(decl.chord[0]), - secondary: parseAccelerator(decl.chord[1]), - leaderId: decl.chord[0], - when, - }); +/** + * Compile `bindings` into the active match tables. A declaration that + * fails to parse is skipped (with the others kept) rather than taking + * the whole table down — user overrides are validated at the schema + * boundary, but a defense here means one bad entry can never cost the + * user every shortcut at once. + */ +function compile(bindings: readonly KeybindingDecl[]): void { + const primaries: CompiledPrimary[] = []; + const chords: CompiledChord[] = []; + for (const decl of bindings) { + try { + const when = decl.when !== undefined ? parseWhen(decl.when) : null; + if (decl.primary !== undefined) { + primaries.push({ decl, parsed: parseAccelerator(decl.primary), when }); + } + if (decl.chord !== undefined) { + chords.push({ + decl, + leader: parseAccelerator(decl.chord[0]), + secondary: parseAccelerator(decl.chord[1]), + leaderId: decl.chord[0], + when, + }); + } + } catch { + // skip unparseable declaration; keep the rest + } } + PRIMARIES = primaries; + CHORDS = chords; + ACTIVE_BINDINGS = bindings; +} + +compile(KEYBINDINGS); + +/** + * Swap the active binding table (defaults + user overrides, already + * merged by `applyKeybindingOverrides`). Recompilation is O(table + * size) string parsing — trivially cheap, safe to call on every store + * update. + */ +export function setActiveBindings(bindings: readonly KeybindingDecl[]): void { + compile(bindings); +} + +/** + * The binding table currently driving dispatch. Label renderers + * (context menus, the settings panel) read THIS — not the static + * `KEYBINDINGS` — so user overrides show up everywhere a shortcut is + * displayed. + */ +export function getActiveBindings(): readonly KeybindingDecl[] { + return ACTIVE_BINDINGS; +} + +/** Test-only — restore the default table between tests. */ +export function __resetActiveBindingsForTests(): void { + compile(KEYBINDINGS); } /** diff --git a/src/renderer/keybindings/shortcut-labels.ts b/src/renderer/keybindings/shortcut-labels.ts index e7c5e0bf..3fade053 100644 --- a/src/renderer/keybindings/shortcut-labels.ts +++ b/src/renderer/keybindings/shortcut-labels.ts @@ -1,20 +1,26 @@ /** * Platform-aware shortcut label resolution for context menus. * - * Reads from `shared/keybindings/index.ts`, which is the single source of - * truth. This module's only job is to format an accelerator for the - * current platform — the underlying binding lives next to the command - * declaration. + * Reads from the resolver's ACTIVE binding table (defaults + user + * overrides) — not the static `KEYBINDINGS` — so a user-customized + * shortcut renders correctly everywhere a label is shown. This module's + * only job is to format an accelerator for the current platform; the + * underlying binding lives next to the command declaration. * - * Every command surfaced in a context menu has a `KEYBINDINGS` entry — + * Every command surfaced in a context menu has a binding entry — * including context-scoped ones like `openToSide` (registered with * `when: "fileTreeFocus"`). There is no separate map of hand-written * labels. + * + * Reactivity note: labels are resolved lazily AT READ TIME (the + * `SHORTCUTS` members below are getters). Context menus and tooltips + * mount on open, so they pick up a rebinding on their next render + * without any subscription plumbing. */ import { COMMANDS, type CommandId } from "../../shared/keybindings/commands"; import { acceleratorToLabel, chordToLabel } from "../../shared/keybindings/keybinding-parse"; -import { findChordBinding, findPrimaryBinding } from "../../shared/keybindings/index"; +import { getActiveBindings } from "./resolver"; // `window.host` is provided by the preload bridge in production; in unit // tests there is no window — fall back to a node-y `process.platform` @@ -34,16 +40,19 @@ function detectIsMac(): boolean { export const isMac = detectIsMac(); /** - * Render the shortcut label for a command. Returns `undefined` if the - * command has neither a primary nor a chord declaration — callers can - * conditionally omit the shortcut column in their menu spec. + * Render the shortcut label for a command from the ACTIVE binding + * table. Returns `undefined` if the command currently has neither a + * primary nor a chord declaration (including the user-unbound case) — + * callers can conditionally omit the shortcut column in their menu + * spec. */ -function shortcutFor(command: CommandId): string | undefined { - const primary = findPrimaryBinding(command); +export function shortcutFor(command: CommandId): string | undefined { + const bindings = getActiveBindings(); + const primary = bindings.find((b) => b.command === command && b.primary !== undefined); if (primary?.primary !== undefined) { return acceleratorToLabel(primary.primary, { isMac }); } - const chord = findChordBinding(command); + const chord = bindings.find((b) => b.command === command && b.chord !== undefined); if (chord?.chord !== undefined) { return chordToLabel(chord.chord, { isMac }); } @@ -53,23 +62,54 @@ function shortcutFor(command: CommandId): string | undefined { /** * Back-compat shim: the previous `SHORTCUTS` map is still consumed in * a handful of places. New callers should prefer `shortcutFor(...)`. - * Each value is computed once at module load by looking up the - * registry, so changing a binding flows through automatically. + * Members are GETTERS — resolved against the active binding table at + * read time, so user rebindings flow through on the next render of + * whatever surface shows the label. */ export const SHORTCUTS = { - closeTab: shortcutFor(COMMANDS.tabClose) ?? "", - closeOthers: shortcutFor(COMMANDS.tabCloseOthers) ?? "", - closeSaved: shortcutFor(COMMANDS.tabCloseSaved) ?? "", - closeAll: shortcutFor(COMMANDS.tabCloseAll) ?? "", - pinTab: shortcutFor(COMMANDS.tabPinToggle) ?? "", - splitRight: shortcutFor(COMMANDS.groupSplitRight) ?? "", - splitDown: shortcutFor(COMMANDS.groupSplitDown) ?? "", - revealInOS: shortcutFor(COMMANDS.pathReveal) ?? "", - copyPath: shortcutFor(COMMANDS.pathCopy) ?? "", - copyRelativePath: shortcutFor(COMMANDS.pathCopyRelative) ?? "", - openToSide: shortcutFor(COMMANDS.openToSide) ?? "", - fileRename: shortcutFor(COMMANDS.fileRename) ?? "", - fileCopy: shortcutFor(COMMANDS.fileCopy) ?? "", - fileCut: shortcutFor(COMMANDS.fileCut) ?? "", - filePaste: shortcutFor(COMMANDS.filePaste) ?? "", -} as const; + get closeTab() { + return shortcutFor(COMMANDS.tabClose) ?? ""; + }, + get closeOthers() { + return shortcutFor(COMMANDS.tabCloseOthers) ?? ""; + }, + get closeSaved() { + return shortcutFor(COMMANDS.tabCloseSaved) ?? ""; + }, + get closeAll() { + return shortcutFor(COMMANDS.tabCloseAll) ?? ""; + }, + get pinTab() { + return shortcutFor(COMMANDS.tabPinToggle) ?? ""; + }, + get splitRight() { + return shortcutFor(COMMANDS.groupSplitRight) ?? ""; + }, + get splitDown() { + return shortcutFor(COMMANDS.groupSplitDown) ?? ""; + }, + get revealInOS() { + return shortcutFor(COMMANDS.pathReveal) ?? ""; + }, + get copyPath() { + return shortcutFor(COMMANDS.pathCopy) ?? ""; + }, + get copyRelativePath() { + return shortcutFor(COMMANDS.pathCopyRelative) ?? ""; + }, + get openToSide() { + return shortcutFor(COMMANDS.openToSide) ?? ""; + }, + get fileRename() { + return shortcutFor(COMMANDS.fileRename) ?? ""; + }, + get fileCopy() { + return shortcutFor(COMMANDS.fileCopy) ?? ""; + }, + get fileCut() { + return shortcutFor(COMMANDS.fileCut) ?? ""; + }, + get filePaste() { + return shortcutFor(COMMANDS.filePaste) ?? ""; + }, +}; diff --git a/src/renderer/keybindings/use-global-keybindings.ts b/src/renderer/keybindings/use-global-keybindings.ts index a370730e..fac9e1b7 100644 --- a/src/renderer/keybindings/use-global-keybindings.ts +++ b/src/renderer/keybindings/use-global-keybindings.ts @@ -13,6 +13,7 @@ */ import { useEffect } from "react"; +import { registerBrowserCommands } from "../commands/domains/browser"; import { registerFileCommands } from "../commands/domains/file"; import { registerGroupCommands } from "../commands/domains/group"; import { registerPaletteCommands } from "../commands/domains/palette"; @@ -36,6 +37,7 @@ export function useGlobalKeybindings(): void { ...registerWorkspaceCommands(), ...registerSettingsCommands(), ...registerWorkbenchCommands(), + ...registerBrowserCommands(), ]; // Capture phase puts our handler ahead of Monaco's standalone diff --git a/src/renderer/services/editor/index.ts b/src/renderer/services/editor/index.ts index bd7a56ee..71a634e8 100644 --- a/src/renderer/services/editor/index.ts +++ b/src/renderer/services/editor/index.ts @@ -1,5 +1,6 @@ import type * as Monaco from "monaco-editor"; import { initializeDiagnosticsStore } from "../../state/stores/diagnostics"; +import { useKeybindingsStore } from "../../state/stores/keybindings"; import { initializeLspServerUxRouter } from "../lsp-ux/server-ux-router"; import { initializeLspBridge } from "./lsp/bridge"; import { initializeModelCache } from "./model/cache"; @@ -38,4 +39,10 @@ export function initializeEditorServices(monaco: typeof Monaco): void { // Monaco basic-languages가 커버하지 못하는 TOML / Makefile / .env / Nix / // Justfile / go.mod / go.sum 을 보강 등록 (TextMate + Monarch). registerExtraLanguages(monaco); + + // Apply persisted editor keybinding overrides now that the Monaco + // singleton is live. Bootstrap hydration usually runs before Monaco + // mounts, so the store holds the overrides but the reconcile was a + // no-op until this point. + useKeybindingsStore.getState().applyEditorBindings(); } diff --git a/src/renderer/services/editor/keybindings/apply.ts b/src/renderer/services/editor/keybindings/apply.ts new file mode 100644 index 00000000..ba8386ae --- /dev/null +++ b/src/renderer/services/editor/keybindings/apply.ts @@ -0,0 +1,157 @@ +/** + * Apply user editor-keybinding overrides to Monaco's keybinding service. + * + * Monaco offers `monaco.editor.addKeybindingRules(rules)` to layer + * keybindings over the built-in defaults: a rule binds a keystroke to a + * command id, or UNBINDS it when the command is prefixed with `-`. + * There is no public API to clear previously-added rules — they + * accumulate for the lifetime of the renderer. So a naïve "re-apply the + * whole override set" would leave stale keystrokes firing after a second + * rebind. + * + * This module is therefore a small RECONCILER: it tracks, per command, + * the keystroke currently bound (a user accelerator or the declared + * default), and on each apply emits only the delta — unbind the old + * keystroke, bind the new — so live changes settle cleanly without an + * app restart. The tracking map is module-scoped (one Monaco instance + * per renderer window). + * + * Accelerators are converted to Monaco's numeric keybinding encoding + * (KeyMod | KeyCode); chords are out of scope for editor commands (all + * curated entries are single-stroke), so only `primary` is honored. + */ + +import { + ALL_EDITOR_COMMAND_IDS, + editorCommandDefault, +} from "../../../../shared/keybindings/editor-commands"; +import type { AcceleratorString } from "../../../../shared/keybindings/index"; +import { parseAccelerator } from "../../../../shared/keybindings/keybinding-parse"; +import type { KeybindingOverride } from "../../../../shared/keybindings/overrides"; +import { createLogger } from "../../../../shared/log/renderer"; +import { isMonacoReady, requireMonaco } from "../runtime/monaco-singleton"; + +const log = createLogger("editor-keybindings"); + +// KeyboardEvent.code → Monaco KeyCode enum member name, for the +// non-alphanumeric / non-function keys. KeyA-Z, Digit0-9 and F1-F12 +// share their `code` string with the Monaco enum name and are handled +// by regex below. +const CODE_TO_KEYCODE_NAME: Readonly> = { + Enter: "Enter", + Escape: "Escape", + Tab: "Tab", + Space: "Space", + Backspace: "Backspace", + Delete: "Delete", + ArrowUp: "UpArrow", + ArrowDown: "DownArrow", + ArrowLeft: "LeftArrow", + ArrowRight: "RightArrow", + Backslash: "Backslash", + Slash: "Slash", + Comma: "Comma", + Period: "Period", + Semicolon: "Semicolon", + Quote: "Quote", + BracketLeft: "BracketLeft", + BracketRight: "BracketRight", + Minus: "Minus", + Equal: "Equal", + Backquote: "Backquote", +}; + +/** What keystroke each editor command is currently bound to in Monaco. */ +const appliedByCommand = new Map(); + +function codeToMonacoKeyCode(monaco: typeof import("monaco-editor"), code: string): number | null { + let name: string | undefined; + if (/^Key[A-Z]$/.test(code) || /^Digit[0-9]$/.test(code) || /^F([1-9]|1[0-2])$/.test(code)) { + name = code; + } else { + name = CODE_TO_KEYCODE_NAME[code]; + } + if (name === undefined) return null; + const value = (monaco.KeyCode as unknown as Record)[name]; + return typeof value === "number" ? value : null; +} + +/** + * Convert an accelerator string to Monaco's numeric keybinding encoding. + * Returns null for anything Monaco can't express (modifier-only, unknown + * key). `CmdOrCtrl` maps to `KeyMod.CtrlCmd`, which Monaco resolves to ⌘ + * on Mac / Ctrl elsewhere — the same platform semantics our parser uses. + */ +function acceleratorToMonacoKeybinding( + monaco: typeof import("monaco-editor"), + accel: AcceleratorString, +): number | null { + let p: ReturnType; + try { + p = parseAccelerator(accel); + } catch { + return null; + } + const code = p.codes[0]; + if (code === undefined) return null; + const keyCode = codeToMonacoKeyCode(monaco, code); + if (keyCode === null) return null; + + let mods = 0; + if (p.cmd) mods |= monaco.KeyMod.CtrlCmd; + if (p.ctrl) mods |= monaco.KeyMod.WinCtrl; + if (p.shift) mods |= monaco.KeyMod.Shift; + if (p.alt) mods |= monaco.KeyMod.Alt; + return mods | keyCode; +} + +/** + * Reconcile Monaco's keybindings to match the given editor overrides. + * Idempotent: emits only the delta versus what was last applied, so + * calling repeatedly (live edit, cross-window broadcast) never piles up + * duplicate bindings. No-op until Monaco is initialized — the caller + * (initializeEditorServices) re-invokes once it is. + */ +export function applyEditorKeybindingOverrides(overrides: readonly KeybindingOverride[]): void { + if (!isMonacoReady()) return; + const monaco = requireMonaco(); + + const byCommand = new Map(); + for (const o of overrides) { + if (ALL_EDITOR_COMMAND_IDS.has(o.command)) byCommand.set(o.command, o); + } + + const rules: { keybinding: number; command: string }[] = []; + + for (const id of ALL_EDITOR_COMMAND_IDS) { + const ov = byCommand.get(id); + const def = editorCommandDefault(id); + + // Desired end state for this command: the user's keystroke, an + // explicit unbind, or the built-in default. + let want: AcceleratorString | null; + if (ov === undefined || ov.primary === undefined) want = def; + else want = ov.primary; // string = replace, null = unbind + + const prev = appliedByCommand.has(id) ? (appliedByCommand.get(id) ?? null) : def; + if (prev === want) continue; + + if (prev !== null) { + const kb = acceleratorToMonacoKeybinding(monaco, prev); + if (kb !== null) rules.push({ keybinding: kb, command: `-${id}` }); + } + if (want !== null) { + const kb = acceleratorToMonacoKeybinding(monaco, want); + if (kb !== null) rules.push({ keybinding: kb, command: id }); + else log.warn(`editor keybinding for ${id} is not Monaco-encodable: ${want}`); + } + appliedByCommand.set(id, want); + } + + if (rules.length > 0) monaco.editor.addKeybindingRules(rules); +} + +/** Test-only: forget what has been applied so a fresh reconcile starts clean. */ +export function __resetAppliedEditorBindingsForTests(): void { + appliedByCommand.clear(); +} diff --git a/src/renderer/services/editor/save/service.ts b/src/renderer/services/editor/save/service.ts index d6d22f8b..139f6d18 100644 --- a/src/renderer/services/editor/save/service.ts +++ b/src/renderer/services/editor/save/service.ts @@ -6,7 +6,7 @@ // - model-cache (read-only model view) // - fs.writeFile IPC (atomic write with mtime/size guard) // -// Public functions: saveModel(input), installEditorSaveAction(editor, monaco, input). +// Public functions: saveModel(input), installEditorSaveAction(editor, input). import type * as Monaco from "monaco-editor"; import { showConflictResolution } from "../../../components/editor/conflict-dialog"; @@ -28,13 +28,18 @@ import { SaveSequentializer, SaveSupersededError } from "./sequentializer"; export function installEditorSaveAction( editor: Monaco.editor.IStandaloneCodeEditor, - monaco: typeof Monaco, input: EditorInput, ): Monaco.IDisposable { return editor.addAction({ id: "nexus.file.save", label: "Save File", - keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS], + // Intentionally NO `keybindings` here. ⌘S is owned by the global + // dispatcher (shared/keybindings → COMMANDS.fileSave), which wins at + // capture phase anyway. Registering a Monaco-side ⌘S as well would + // leave a ghost binding that resurfaces the moment the user rebinds + // fileSave to another key via the keybinding settings — Monaco would + // then fire save on the *old* key. The action stays registered so it + // remains discoverable/invocable from Monaco's F1 command palette. run: () => { runSaveAndReport(input); }, diff --git a/src/renderer/state/operations/browser.ts b/src/renderer/state/operations/browser.ts index 2577b4f0..5e146842 100644 --- a/src/renderer/state/operations/browser.ts +++ b/src/renderer/state/operations/browser.ts @@ -148,6 +148,15 @@ export function initBrowserRuntimeSubscriptions(): void { activateGroupForTab(tabId); }), ); + + // focusUrl: the browser.focusUrl shortcut (⌘L) fired while the page had + // focus, so it was caught by the main key interceptor and bounced here — + // focus the address bar for that tab (mirrors the in-renderer command). + activeUnsubs.push( + ipcListen("browser", "focusUrl", ({ tabId }) => { + useBrowserRuntimeStore.getState().requestUrlFocus(tabId); + }), + ); } /** diff --git a/src/renderer/state/stores/browser-runtime.ts b/src/renderer/state/stores/browser-runtime.ts index 30ec7d73..2136dc26 100644 --- a/src/renderer/state/stores/browser-runtime.ts +++ b/src/renderer/state/stores/browser-runtime.ts @@ -58,6 +58,13 @@ export interface BrowserRuntimeState { * runtime store에만 보관 — 앱 재시작 시 페이지가 다시 로드되며 자연스럽게 갱신. */ faviconUrl: string | null; + /** + * Imperative URL-bar focus trigger. Incremented by `requestUrlFocus` + * (fired by the `browser.focusUrl` command, ⌘L); BrowserTabView passes + * it to UrlBar's `focusToken` prop, which focuses + selects-all on + * every change. Monotonic counter — the value itself is meaningless. + */ + urlFocusToken: number; } type BrowserRuntimeMap = Map; @@ -71,6 +78,7 @@ const DEFAULT_RUNTIME: BrowserRuntimeState = { snapshot: null, devtoolsOpen: false, faviconUrl: null, + urlFocusToken: 0, }; interface BrowserRuntimeStore { @@ -87,6 +95,13 @@ interface BrowserRuntimeStore { * No-op if the entry does not exist. */ removeRuntime(tabId: string): void; + + /** + * Bump the URL-bar focus token for `tabId` so the mounted + * BrowserTabView focuses + selects the URL bar. Creates the runtime + * entry if it does not exist yet (harmless — the view merges state). + */ + requestUrlFocus(tabId: string): void; } // --------------------------------------------------------------------------- @@ -114,6 +129,11 @@ export const useBrowserRuntimeStore = create((set, get) => return { runtimes: next }; }); }, + + requestUrlFocus(tabId) { + const current = get().runtimes.get(tabId)?.urlFocusToken ?? 0; + get().setRuntime(tabId, { urlFocusToken: current + 1 }); + }, })); // --------------------------------------------------------------------------- diff --git a/src/renderer/state/stores/keybindings.ts b/src/renderer/state/stores/keybindings.ts new file mode 100644 index 00000000..bf33316d --- /dev/null +++ b/src/renderer/state/stores/keybindings.ts @@ -0,0 +1,183 @@ +// src/renderer/state/stores/keybindings.ts — user keybinding override store. +// +// Persistence model (mirrors theme.ts minus the localStorage boot cache — +// keybindings paint nothing before React mounts, so there is no FOUC to +// prevent and appState alone is sufficient): +// - appState (main process, via IPC) — authoritative store; main also +// reads it to rebuild native menu accelerator labels. +// - This zustand store — live copy driving (a) the dispatcher via +// `setActiveBindings` recompilation, (b) the Settings panel UI. +// +// Every mutation recompiles the resolver SYNCHRONOUSLY before the IPC +// write — a rebinding must take effect on the very next keydown, not +// after a round-trip. + +import { create } from "zustand"; +import { ALL_COMMAND_IDS } from "../../../shared/keybindings/commands"; +import { ALL_EDITOR_COMMAND_IDS } from "../../../shared/keybindings/editor-commands"; +import { KEYBINDINGS, type KeybindingDecl } from "../../../shared/keybindings/index"; +import { + applyKeybindingOverrides, + type KeybindingOverride, + removeOverride, + upsertOverride, +} from "../../../shared/keybindings/overrides"; +import { createLogger } from "../../../shared/log/renderer"; +import { ipcCallResult } from "../../ipc/client"; +import { setActiveBindings } from "../../keybindings/resolver"; +import { applyEditorKeybindingOverrides } from "../../services/editor/keybindings/apply"; + +const log = createLogger("keybindings"); + +const KNOWN_COMMANDS: ReadonlySet = new Set(ALL_COMMAND_IDS); + +function effective(overrides: readonly KeybindingOverride[]): KeybindingDecl[] { + return applyKeybindingOverrides(KEYBINDINGS, overrides, KNOWN_COMMANDS); +} + +interface KeybindingsState { + /** Raw override list (the persisted delta). */ + overrides: KeybindingOverride[]; + /** Defaults + overrides, already merged — what the dispatcher runs on. */ + effectiveBindings: KeybindingDecl[]; + /** Raw editor (Monaco) override list (the persisted delta). */ + editorOverrides: KeybindingOverride[]; + + /** + * Hydrate from persisted appState — called during bootstrap and on + * `keybindingsChanged` broadcasts from other windows. Recompiles the + * dispatcher; does NOT write back to appState (no feedback loops). + */ + hydrate(overrides: readonly KeybindingOverride[] | undefined): void; + + /** + * Upsert one command's override (recorded binding or explicit + * unbind). Recompiles + persists. + */ + setOverride(patch: KeybindingOverride): void; + + /** Drop one command's override (restore its defaults). Recompiles + persists. */ + resetCommand(command: string): void; + + /** Drop every override. Recompiles + persists. */ + resetAll(): void; + + /** + * Hydrate editor overrides from appState (bootstrap + + * `editorKeybindingsChanged` broadcasts). Reconciles Monaco if it is + * ready; otherwise the binding is applied later by + * `applyEditorBindings()` once editor services initialize. No + * write-back. + */ + hydrateEditor(overrides: readonly KeybindingOverride[] | undefined): void; + + /** + * Re-reconcile Monaco from the current editor overrides. Called from + * `initializeEditorServices` after the Monaco singleton is ready, + * since bootstrap hydration usually runs before Monaco mounts. + */ + applyEditorBindings(): void; + + /** Upsert one editor command's override. Reconciles Monaco + persists. */ + setEditorOverride(patch: KeybindingOverride): void; + + /** Drop one editor command's override (restore its default). Reconciles + persists. */ + resetEditorCommand(command: string): void; + + /** Drop every editor override. Reconciles + persists. */ + resetAllEditor(): void; +} + +function persist(overrides: KeybindingOverride[]): void { + // Fire-and-forget: the in-memory recompile already happened; appState + // is the durable store. Main reacts by rebuilding the native menu and + // broadcasting `keybindingsChanged` to the other windows. + void ipcCallResult("appState", "set", { keybindingOverrides: overrides }).then((result) => { + if (!result.ok) log.warn(`appState set failed: ${result.message}`); + }); +} + +function persistEditor(overrides: KeybindingOverride[]): void { + // Fire-and-forget: Monaco was already reconciled in-memory. Main + // broadcasts `editorKeybindingsChanged` so other windows reconcile too. + void ipcCallResult("appState", "set", { editorKeybindingOverrides: overrides }).then((result) => { + if (!result.ok) log.warn(`appState set (editor) failed: ${result.message}`); + }); +} + +const KNOWN_EDITOR_COMMANDS: ReadonlySet = ALL_EDITOR_COMMAND_IDS; + +/** Keep only overrides whose command is in the curated editor catalog. */ +function knownEditorOverrides( + overrides: readonly KeybindingOverride[] | undefined, +): KeybindingOverride[] { + if (overrides === undefined) return []; + return overrides.filter((o) => KNOWN_EDITOR_COMMANDS.has(o.command)); +} + +export const useKeybindingsStore = create((set, get) => ({ + overrides: [], + effectiveBindings: [...KEYBINDINGS], + editorOverrides: [], + + hydrate(overrides) { + const next = overrides !== undefined ? [...overrides] : []; + const merged = effective(next); + setActiveBindings(merged); + set({ overrides: next, effectiveBindings: merged }); + }, + + setOverride(patch) { + const next = upsertOverride(get().overrides, patch); + const merged = effective(next); + setActiveBindings(merged); + set({ overrides: next, effectiveBindings: merged }); + persist(next); + }, + + resetCommand(command) { + const next = removeOverride(get().overrides, command); + const merged = effective(next); + setActiveBindings(merged); + set({ overrides: next, effectiveBindings: merged }); + persist(next); + }, + + resetAll() { + const merged = effective([]); + setActiveBindings(merged); + set({ overrides: [], effectiveBindings: merged }); + persist([]); + }, + + hydrateEditor(overrides) { + const next = knownEditorOverrides(overrides); + set({ editorOverrides: next }); + // No-op until Monaco is ready; applyEditorBindings() re-runs later. + applyEditorKeybindingOverrides(next); + }, + + applyEditorBindings() { + applyEditorKeybindingOverrides(get().editorOverrides); + }, + + setEditorOverride(patch) { + const next = upsertOverride(get().editorOverrides, patch); + applyEditorKeybindingOverrides(next); + set({ editorOverrides: next }); + persistEditor(next); + }, + + resetEditorCommand(command) { + const next = removeOverride(get().editorOverrides, command); + applyEditorKeybindingOverrides(next); + set({ editorOverrides: next }); + persistEditor(next); + }, + + resetAllEditor() { + applyEditorKeybindingOverrides([]); + set({ editorOverrides: [] }); + persistEditor([]); + }, +})); diff --git a/src/shared/i18n/locales/en/settings.json b/src/shared/i18n/locales/en/settings.json index 0a946d82..275a362b 100644 --- a/src/shared/i18n/locales/en/settings.json +++ b/src/shared/i18n/locales/en/settings.json @@ -7,6 +7,7 @@ "workspaces": "Workspaces", "notifications": "Notifications", "browserPermissions": "Browser Permissions", + "keybindings": "Keyboard Shortcuts", "about": "About", "group": { "settings": "Settings" @@ -214,5 +215,129 @@ }, "checkNow": "Check for Updates", "checking": "Checking…" + }, + "keybindings": { + "searchPlaceholder": "Search commands", + "modifiedOnly": "Modified only", + "conflictsOnly": "Conflicts only", + "conflictsOnlyCount": "Conflicts only ({{count}})", + "sectionConflicts": "{{count}} conflicts", + "resetAll": "Reset All", + "resetAllConfirm": "Reset all?", + "reset": "Reset to default", + "edit": "Change keybinding", + "unbind": "Remove keybinding", + "unbound": "—", + "empty": "No commands match.", + "section": { + "app": "Application", + "editor": "Editor", + "editorDescription": "Monaco editor commands — active only while the editor is focused. Changing one twice in a session may need an app restart to fully settle." + }, + "editorCommand": { + "commentLine": "Toggle Line Comment", + "blockComment": "Toggle Block Comment", + "addSelectionToNextFindMatch": "Add Selection to Next Find Match", + "copyLinesDown": "Copy Line Down", + "copyLinesUp": "Copy Line Up", + "moveLinesDown": "Move Line Down", + "moveLinesUp": "Move Line Up", + "deleteLines": "Delete Line", + "insertCursorBelow": "Add Cursor Below", + "insertCursorAbove": "Add Cursor Above", + "formatDocument": "Format Document", + "rename": "Rename Symbol", + "duplicateSelection": "Duplicate Selection" + }, + "record": { + "prompt": "Press the desired key combination", + "promptSecond": "Press the second key of the chord", + "commit": "Save", + "cancel": "Cancel", + "unsupported": "Unsupported key", + "hint": "Enter to save · Esc to cancel" + }, + "source": { + "default": "Default", + "user": "User" + }, + "conflict": { + "blocking": "Conflicts with “{{command}}”", + "overlap": "Also bound to “{{command}}” (different focus scope)", + "shadow": "Shadows {{owner}}: {{note}}", + "system": "Reserved by the OS: {{note}}" + }, + "owner": { + "monaco": "the editor", + "terminal": "the terminal/shell", + "electron": "the app menu", + "system": "the OS" + }, + "command": { + "file": { + "new": "New File", + "open": "Open File", + "save": "Save File", + "rename": "Rename File", + "delete": "Delete File", + "copy": "Copy File", + "cut": "Cut File", + "paste": "Paste File", + "moveHere": "Move File Here", + "renameByEnter": "Rename with Enter (Mac)" + }, + "files": { + "refresh": "Refresh Files" + }, + "explorer": { + "openToSide": "Open to Side (File Tree)" + }, + "tab": { + "close": "Close Tab", + "closeOthers": "Close Other Tabs", + "closeSaved": "Close Saved Tabs", + "closeAll": "Close All Tabs", + "pinToggle": "Pin / Unpin Tab", + "focusPrev": "Previous Tab", + "focusNext": "Next Tab" + }, + "group": { + "splitRight": "Split Right", + "splitDown": "Split Down", + "close": "Close Group", + "focusLeft": "Focus Group Left", + "focusRight": "Focus Group Right", + "focusUp": "Focus Group Up", + "focusDown": "Focus Group Down" + }, + "workspace": { + "symbolSearch": "Search Workspace Symbols", + "focusPrev": "Previous Workspace", + "focusNext": "Next Workspace", + "add": "Add Workspace" + }, + "settings": { + "open": "Open Settings" + }, + "workbench": { + "toggleFilesPanel": "Toggle Files Panel", + "toggleSidebar": "Toggle Sidebar" + }, + "terminal": { + "new": "New Terminal" + }, + "path": { + "reveal": "Reveal in File Manager", + "copy": "Copy Path", + "copyRelative": "Copy Relative Path" + }, + "browser": { + "focusUrl": "Focus URL Bar (Browser)", + "reload": "Reload Page (Browser)", + "hardReload": "Hard Reload (Browser)", + "goBack": "Back (Browser)", + "goForward": "Forward (Browser)" + } + } } } diff --git a/src/shared/i18n/locales/ko/settings.json b/src/shared/i18n/locales/ko/settings.json index f8705c38..6d2f2991 100644 --- a/src/shared/i18n/locales/ko/settings.json +++ b/src/shared/i18n/locales/ko/settings.json @@ -7,6 +7,7 @@ "workspaces": "워크스페이스", "notifications": "알림", "browserPermissions": "브라우저 권한", + "keybindings": "단축키", "about": "정보", "group": { "settings": "설정" @@ -214,5 +215,129 @@ }, "checkNow": "업데이트 확인", "checking": "확인 중…" + }, + "keybindings": { + "searchPlaceholder": "커맨드 검색", + "modifiedOnly": "수정됨만 보기", + "conflictsOnly": "충돌만 보기", + "conflictsOnlyCount": "충돌만 보기 ({{count}})", + "sectionConflicts": "충돌 {{count}}건", + "resetAll": "전체 초기화", + "resetAllConfirm": "정말 초기화할까요?", + "reset": "기본값으로 되돌리기", + "edit": "단축키 변경", + "unbind": "단축키 해제", + "unbound": "—", + "empty": "일치하는 커맨드가 없습니다.", + "section": { + "app": "애플리케이션", + "editor": "에디터", + "editorDescription": "Monaco 에디터 커맨드 — 에디터에 포커스가 있을 때만 동작합니다. 한 세션에서 같은 항목을 두 번 바꾸면 완전히 반영되는 데 앱 재시작이 필요할 수 있습니다." + }, + "editorCommand": { + "commentLine": "한 줄 주석 토글", + "blockComment": "블록 주석 토글", + "addSelectionToNextFindMatch": "다음 일치 항목 선택 추가", + "copyLinesDown": "줄 아래로 복사", + "copyLinesUp": "줄 위로 복사", + "moveLinesDown": "줄 아래로 이동", + "moveLinesUp": "줄 위로 이동", + "deleteLines": "줄 삭제", + "insertCursorBelow": "아래에 커서 추가", + "insertCursorAbove": "위에 커서 추가", + "formatDocument": "문서 서식", + "rename": "심볼 이름 변경", + "duplicateSelection": "선택 영역 복제" + }, + "record": { + "prompt": "원하는 키 조합을 누르세요", + "promptSecond": "두 번째 키를 누르세요", + "commit": "저장", + "cancel": "취소", + "unsupported": "지원하지 않는 키입니다", + "hint": "Enter 저장 · Esc 취소" + }, + "source": { + "default": "기본값", + "user": "사용자" + }, + "conflict": { + "blocking": "“{{command}}”와 충돌합니다", + "overlap": "“{{command}}”에도 할당되어 있습니다 (다른 포커스 범위)", + "shadow": "{{owner}}의 “{{note}}” 키를 가립니다", + "system": "OS 예약 키입니다: {{note}}" + }, + "owner": { + "monaco": "에디터", + "terminal": "터미널/셸", + "electron": "앱 메뉴", + "system": "OS" + }, + "command": { + "file": { + "new": "새 파일", + "open": "파일 열기", + "save": "파일 저장", + "rename": "파일 이름 변경", + "delete": "파일 삭제", + "copy": "파일 복사", + "cut": "파일 잘라내기", + "paste": "파일 붙여넣기", + "moveHere": "여기로 이동", + "renameByEnter": "Enter로 이름 변경 (Mac)" + }, + "files": { + "refresh": "파일 새로고침" + }, + "explorer": { + "openToSide": "옆으로 열기 (파일 트리)" + }, + "tab": { + "close": "탭 닫기", + "closeOthers": "다른 탭 닫기", + "closeSaved": "저장된 탭 닫기", + "closeAll": "모든 탭 닫기", + "pinToggle": "탭 고정 전환", + "focusPrev": "이전 탭", + "focusNext": "다음 탭" + }, + "group": { + "splitRight": "우측 분할", + "splitDown": "하단 분할", + "close": "그룹 닫기", + "focusLeft": "왼쪽 그룹 포커스", + "focusRight": "오른쪽 그룹 포커스", + "focusUp": "위 그룹 포커스", + "focusDown": "아래 그룹 포커스" + }, + "workspace": { + "symbolSearch": "워크스페이스 심볼 검색", + "focusPrev": "이전 워크스페이스", + "focusNext": "다음 워크스페이스", + "add": "워크스페이스 추가" + }, + "settings": { + "open": "설정 열기" + }, + "workbench": { + "toggleFilesPanel": "파일 패널 토글", + "toggleSidebar": "사이드바 토글" + }, + "terminal": { + "new": "새 터미널" + }, + "path": { + "reveal": "파일 위치 열기", + "copy": "경로 복사", + "copyRelative": "상대 경로 복사" + }, + "browser": { + "focusUrl": "주소창 포커스 (브라우저)", + "reload": "페이지 새로고침 (브라우저)", + "hardReload": "강력 새로고침 (브라우저)", + "goBack": "뒤로 가기 (브라우저)", + "goForward": "앞으로 가기 (브라우저)" + } + } } } diff --git a/src/shared/ipc/contract.ts b/src/shared/ipc/contract.ts index 3bf84939..d2b1db03 100644 --- a/src/shared/ipc/contract.ts +++ b/src/shared/ipc/contract.ts @@ -51,6 +51,7 @@ import { TagSchema, } from "../git/types"; import { CommandIdSchema } from "../keybindings/commands"; +import { KeybindingOverridesSchema } from "../keybindings/overrides"; import { ApplyWorkspaceEditParamsSchema, ApplyWorkspaceEditResultSchema, @@ -768,6 +769,24 @@ export const ipcContract = { * no per-workspace or per-tab scope for a global UI language setting. */ languageChanged: listen(z.object({ language: z.enum(["en", "ko"]) })), + /** + * Broadcast emitted by main when the user keybinding overrides + * change via `appState.set({ keybindingOverrides })`. Sent to all + * renderer windows so each can recompile its dispatcher tables and + * refresh shortcut labels. The full override list is the payload — + * deltas are small (≤ catalog size) and idempotent hydration keeps + * every window convergent regardless of event ordering. + */ + keybindingsChanged: listen(z.object({ overrides: KeybindingOverridesSchema })), + /** + * Broadcast emitted by main when the user EDITOR (Monaco) + * keybinding overrides change via + * `appState.set({ editorKeybindingOverrides })`. Sent to all + * renderer windows so each re-reconciles its Monaco keybinding + * service. Unlike `keybindingsChanged` this does NOT rebuild the + * native menu — editor commands never appear in it. + */ + editorKeybindingsChanged: listen(z.object({ overrides: KeybindingOverridesSchema })), }, }, @@ -1322,6 +1341,16 @@ export const ipcContract = { * clicked, matching the behaviour of every other panel type. */ focused: listen(z.object({ tabId: z.string().uuid() })), + /** + * Emitted when the browser URL-focus shortcut (browser.focusUrl, + * ⌘L by default) fires while the page itself has focus. Because the + * keystroke lands in the WebContentsView — outside the renderer + * document — it is caught by the main-process key interceptor + * (browser/keyboard.ts) and bounced back here so the renderer can + * focus the address bar. The other browser shortcuts act in main + * directly; only URL focus needs a renderer round-trip. + */ + focusUrl: listen(z.object({ tabId: z.string().uuid() })), }, }, diff --git a/src/shared/keybindings/commands.ts b/src/shared/keybindings/commands.ts index 579995bd..8360c62d 100644 --- a/src/shared/keybindings/commands.ts +++ b/src/shared/keybindings/commands.ts @@ -89,9 +89,29 @@ export const COMMANDS = { workbenchToggleFilesPanel: "workbench.toggleFilesPanel", workbenchToggleSidebar: "workbench.toggleSidebar", + // NOTE: DevTools has no app keybinding command. ⌘⌥I is owned by the + // Electron menu `toggleDevTools` role (app-window DevTools, standard + // behavior). A browser PAGE's DevTools is opened from the panel's + // toolbar button only — a reliable keyboard shortcut isn't feasible + // because the menu role preempts ⌘⌥I at the OS level for the focused + // window's webContents (the app), never the embedded page. + // Terminal terminalNew: "terminal.new", + // Browser tab (embedded WebContentsView). All are scoped via + // `when: "browserTabActive"` — they act on the active group's active + // tab when (and only when) that tab is a browser tab. Previously these + // were hardcoded capture-phase listeners inside browser-view.tsx, which + // (a) could not be customized or conflict-checked, and (b) lost the + // ⌘R/⌘⇧R race against the global dispatcher (files.refresh fired and + // stopImmediatePropagation'd before the component listener ran). + browserFocusUrl: "browser.focusUrl", + browserReload: "browser.reload", + browserHardReload: "browser.hardReload", + browserGoBack: "browser.goBack", + browserGoForward: "browser.goForward", + // Path actions on the active editor (mirrors VSCode's "when editor not focused") pathReveal: "path.reveal", pathCopy: "path.copy", diff --git a/src/shared/keybindings/conflicts.ts b/src/shared/keybindings/conflicts.ts new file mode 100644 index 00000000..72197015 --- /dev/null +++ b/src/shared/keybindings/conflicts.ts @@ -0,0 +1,252 @@ +/** + * Keybinding conflict engine. + * + * Evaluates a PROPOSED binding (what the user just recorded in the + * settings UI) against (a) the current effective binding table and + * (b) the reserved/built-in key catalog. Pure and synchronous — the + * recorder calls it on every keystroke for instant feedback. + * + * Conflict grades (UI policy in parentheses): + * - "blocking" — collides with another app command in an overlapping + * `when` scope, or makes a chord unreachable. (Refuse to save until + * the user unbinds the other side.) + * - "overlap" — same keystroke as another app command but both sides + * carry different non-empty `when` scopes. Scope disjointness is + * undecidable in general, so this is a warning, not a block. + * - "shadow" — collides with a built-in key (Monaco / terminal / + * Electron role). Saving is allowed but the user is told exactly + * what stops working. This is the ⌘/-bug class made visible. + * - "system" — OS-reserved (⌘Q, ⌘H, …). The recorder refuses the + * keystroke outright. + * + * Scope-overlap rule: two bindings on the same keystroke conflict as + * "blocking" when either side is unscoped (fires everywhere, including + * inside the other's scope) or both scopes are textually identical. + * Textual comparison is deliberate — a real implication solver over + * the when-grammar buys little for a 40-command catalog. + */ + +import type { AcceleratorString } from "./index"; +import { normalizeKeystroke } from "./keybinding-parse"; +import { findReservedKey, type ReservedKey } from "./reserved-keys"; + +export type KeybindingConflictKind = "blocking" | "overlap" | "shadow" | "system"; + +export interface KeybindingConflict { + kind: KeybindingConflictKind; + /** + * The colliding command id (blocking / overlap). A plain string so the + * engine serves both app dispatcher commands (CommandId) and editor + * (Monaco) command ids — the recorder maps it back to a label. + */ + command?: string; + /** The colliding reserved key (shadow / system). */ + reserved?: ReservedKey; +} + +/** + * Minimal shape the engine reads from each existing binding. Both the + * app `KeybindingDecl` and synthetic editor-command bindings satisfy it. + */ +export interface ConflictBinding { + command: string; + primary?: AcceleratorString; + chord?: readonly [AcceleratorString, AcceleratorString]; + when?: string; +} + +export interface ConflictQuery { + /** Command being (re)bound — its own declarations are skipped. */ + command: string; + primary?: AcceleratorString; + chord?: readonly [AcceleratorString, AcceleratorString]; + /** `when` scope the proposed binding will carry (inherited from defaults). */ + when?: string; + /** EFFECTIVE bindings (defaults + overrides already applied). */ + bindings: readonly ConflictBinding[]; + isMac: boolean; +} + +export function detectConflicts(q: ConflictQuery): KeybindingConflict[] { + const conflicts: KeybindingConflict[] = []; + const isMac = q.isMac; + + const proposedPrimary = q.primary !== undefined ? normalizeKeystroke(q.primary, isMac) : null; + const proposedLeader = q.chord !== undefined ? normalizeKeystroke(q.chord[0], isMac) : null; + const proposedSecondary = q.chord !== undefined ? normalizeKeystroke(q.chord[1], isMac) : null; + + for (const b of q.bindings) { + if (b.command === q.command) continue; + + // Two bindings whose `when` scopes are provably mutually exclusive + // (e.g. `browserTabActive` vs `!browserTabActive`) never fire on the + // same event, so they are NOT a conflict at all — skip every check + // for this pair. This kills the false-positive overlap the textual + // scopeGrade would otherwise report for our browser-routed defaults + // (⌘R, ⌘⇧R: files.refresh vs browser reload). + if (scopesProvablyDisjoint(q.when, b.when)) continue; + + const bPrimary = b.primary !== undefined ? normalizeKeystroke(b.primary, isMac) : null; + const bLeader = b.chord !== undefined ? normalizeKeystroke(b.chord[0], isMac) : null; + const bSecondary = b.chord !== undefined ? normalizeKeystroke(b.chord[1], isMac) : null; + + // primary vs primary — the classic duplicate. + if (proposedPrimary !== null && bPrimary !== null && proposedPrimary === bPrimary) { + conflicts.push({ kind: scopeGrade(q.when, b.when), command: b.command }); + } + + // primary vs another command's chord LEADER: the resolver matches + // singles before chord leaders, so the proposed primary would make + // that chord unreachable. Always blocking — `when` can't save a + // chord whose first half never arms. + if (proposedPrimary !== null && bLeader !== null && proposedPrimary === bLeader) { + conflicts.push({ kind: "blocking", command: b.command }); + } + + // chord LEADER vs another command's primary — mirror of the above: + // the existing primary fires first and the proposed chord never arms. + if (proposedLeader !== null && bPrimary !== null && proposedLeader === bPrimary) { + conflicts.push({ kind: "blocking", command: b.command }); + } + + // chord vs chord — same leader AND same secondary. + if ( + proposedLeader !== null && + bLeader !== null && + proposedLeader === bLeader && + proposedSecondary !== null && + bSecondary !== null && + proposedSecondary === bSecondary + ) { + conflicts.push({ kind: scopeGrade(q.when, b.when), command: b.command }); + } + } + + // Reserved / built-in collisions. Checked for the primary and the + // chord leader (a leader swallow is just as destructive); chord + // secondaries only fire during a pending chord, which no built-in + // can observe — skip them. + for (const accel of [q.primary, q.chord?.[0]]) { + if (accel === undefined) continue; + const reserved = findReservedKey(accel, isMac); + if (reserved !== undefined) { + conflicts.push({ kind: reserved.source === "system" ? "system" : "shadow", reserved }); + } + } + + return conflicts; +} + +/** + * Grade a same-keystroke collision by `when` scope: + * either side unscoped or identical scopes → blocking; both scoped + * differently → overlap (possibly disjoint — warn, don't block). + */ +function scopeGrade(a: string | undefined, b: string | undefined): KeybindingConflictKind { + if (a === undefined || b === undefined) return "blocking"; + if (a === b) return "blocking"; + return "overlap"; +} + +/** Top-level AND'd simple literals of a `when` clause, split into the + * context keys it requires true vs false. Parenthesised groups and `||` + * sub-expressions are opaque — only bare `key` / `!key` conjuncts count. + * `"!browserTabActive && (!terminalFocus || isMac)"` → pos:{}, neg:{browserTabActive}. */ +function topLevelLiterals(when: string): { pos: Set; neg: Set } { + const pos = new Set(); + const neg = new Set(); + // Split on `&&` at paren depth 0. + let depth = 0; + let start = 0; + const parts: string[] = []; + for (let i = 0; i < when.length; i++) { + const ch = when[i]; + if (ch === "(") depth++; + else if (ch === ")") depth--; + else if (depth === 0 && ch === "&" && when[i + 1] === "&") { + parts.push(when.slice(start, i)); + i++; + start = i + 1; + } + } + parts.push(when.slice(start)); + + for (const raw of parts) { + const tok = raw.trim(); + const neg1 = /^!\s*([A-Za-z][A-Za-z0-9]*)$/.exec(tok); + if (neg1 !== null) { + neg.add(neg1[1] as string); + continue; + } + const pos1 = /^([A-Za-z][A-Za-z0-9]*)$/.exec(tok); + if (pos1 !== null) pos.add(pos1[1] as string); + } + return { pos, neg }; +} + +/** + * True when two `when` scopes can be PROVEN mutually exclusive: one + * requires a context key true while the other requires the same key + * false (as top-level AND conjuncts). Conservative — returns false + * whenever it cannot prove disjointness, so it never hides a real + * conflict; it only suppresses the obvious `X` vs `!X` false positives + * (our browser-tab routing). A full boolean solver is unwarranted for a + * catalog this size. + */ +function scopesProvablyDisjoint(a: string | undefined, b: string | undefined): boolean { + if (a === undefined || b === undefined) return false; + const la = topLevelLiterals(a); + const lb = topLevelLiterals(b); + for (const k of la.pos) if (lb.neg.has(k)) return true; + for (const k of la.neg) if (lb.pos.has(k)) return true; + return false; +} + +/** + * Compute command-vs-command conflicts across an ENTIRE effective binding + * table, keyed by command id. Unlike {@link detectConflicts} (which + * evaluates one proposed binding at record time), this is the persistent + * view the settings table renders on every row — so a conflict created + * INDIRECTLY (resetting a command back to a default that now collides, or + * the *other* side being rebound onto your key) surfaces on both rows + * without anyone re-opening the recorder. + * + * Scope: command-vs-command only (blocking / overlap). Reserved/built-in + * SHADOW collisions are deliberately excluded here — an unchanged editor + * command sits on its own Monaco default by definition, so a shadow pass + * would flag every default against itself. Shadow/system warnings remain + * a record-time concern (the live recorder still reports them). + * + * Both sides of a collision receive an entry (the per-command run is + * symmetric), so every participating row can render a badge. + */ +export function detectTableConflicts( + bindings: readonly ConflictBinding[], + isMac: boolean, +): Map { + const out = new Map(); + const seen = new Set(); + + for (const b of bindings) { + if (seen.has(b.command)) continue; + seen.add(b.command); + + const prim = bindings.find((x) => x.command === b.command && x.primary !== undefined); + const ch = bindings.find((x) => x.command === b.command && x.chord !== undefined); + if (prim === undefined && ch === undefined) continue; + + const when = prim?.when ?? ch?.when; + const conflicts = detectConflicts({ + command: b.command, + ...(prim?.primary !== undefined ? { primary: prim.primary } : {}), + ...(ch?.chord !== undefined ? { chord: ch.chord } : {}), + ...(when !== undefined ? { when } : {}), + bindings, + isMac, + }).filter((c) => c.kind === "blocking" || c.kind === "overlap"); + + if (conflicts.length > 0) out.set(b.command, conflicts); + } + + return out; +} diff --git a/src/shared/keybindings/editor-commands.ts b/src/shared/keybindings/editor-commands.ts new file mode 100644 index 00000000..85cb4524 --- /dev/null +++ b/src/shared/keybindings/editor-commands.ts @@ -0,0 +1,81 @@ +/** + * Curated catalog of user-rebindable Monaco editor commands (Stage 3, + * "B안 큐레이션"). + * + * Why a curated list instead of mirroring Monaco's full ~600-command + * registry: Monaco exposes no clean enumeration API, its when-clause + * context system is its own, and 99% of those commands are never + * rebound. We surface the handful people actually remap (comment, move/ + * copy line, multi-cursor, format, …) plus an "advanced: bind any + * command id" escape hatch in the UI for the long tail. + * + * These commands are NOT part of the app dispatcher's KEYBINDINGS table — + * they live entirely inside Monaco's own keybinding service and only fire + * while the editor has focus. Customization is applied at the Monaco + * layer via `monaco.editor.addKeybindingRules` (see the renderer + * `services/editor/keybindings/apply.ts` reconciler), persisted as a + * separate `editorKeybindingOverrides` delta, and reuses the SAME + * override schema (command id + primary accelerator) as app keybindings. + * + * `defaultPrimary` records Monaco's built-in default in OUR accelerator + * format (CmdOrCtrl resolves per-platform, mirroring Monaco's CtrlCmd). + * It serves two jobs: showing "Default" in the UI, and telling the + * reconciler which keystroke to UNBIND when the user replaces it. A + * command that ships unbound (e.g. duplicateSelection) omits it. + * + * `slug` is the i18n label key suffix — flat (`editorCommand.`) + * because the raw ids contain dots that i18next would treat as nesting. + */ + +import type { AcceleratorString } from "./index"; + +export interface EditorCommandDef { + /** Monaco command id, passed verbatim to addKeybindingRules. */ + id: string; + /** i18n key suffix: `keybindings.editorCommand.`. */ + slug: string; + /** Monaco's built-in default in our accelerator format; absent = ships unbound. */ + defaultPrimary?: AcceleratorString; +} + +export const EDITOR_COMMANDS: readonly EditorCommandDef[] = [ + { id: "editor.action.commentLine", slug: "commentLine", defaultPrimary: "CmdOrCtrl+/" }, + { id: "editor.action.blockComment", slug: "blockComment", defaultPrimary: "Shift+Alt+A" }, + { + id: "editor.action.addSelectionToNextFindMatch", + slug: "addSelectionToNextFindMatch", + defaultPrimary: "CmdOrCtrl+D", + }, + { + id: "editor.action.copyLinesDownAction", + slug: "copyLinesDown", + defaultPrimary: "Shift+Alt+Down", + }, + { id: "editor.action.copyLinesUpAction", slug: "copyLinesUp", defaultPrimary: "Shift+Alt+Up" }, + { id: "editor.action.moveLinesDownAction", slug: "moveLinesDown", defaultPrimary: "Alt+Down" }, + { id: "editor.action.moveLinesUpAction", slug: "moveLinesUp", defaultPrimary: "Alt+Up" }, + { id: "editor.action.deleteLines", slug: "deleteLines", defaultPrimary: "CmdOrCtrl+Shift+K" }, + { + id: "editor.action.insertCursorBelow", + slug: "insertCursorBelow", + defaultPrimary: "CmdOrCtrl+Alt+Down", + }, + { + id: "editor.action.insertCursorAbove", + slug: "insertCursorAbove", + defaultPrimary: "CmdOrCtrl+Alt+Up", + }, + { id: "editor.action.formatDocument", slug: "formatDocument", defaultPrimary: "Shift+Alt+F" }, + { id: "editor.action.rename", slug: "rename", defaultPrimary: "F2" }, + // Ships unbound in Monaco standalone — a popular candidate for a user binding. + { id: "editor.action.duplicateSelection", slug: "duplicateSelection" }, +]; + +export const ALL_EDITOR_COMMAND_IDS: ReadonlySet = new Set( + EDITOR_COMMANDS.map((c) => c.id), +); + +/** Look up a curated command's declared default keystroke (or null). */ +export function editorCommandDefault(id: string): AcceleratorString | null { + return EDITOR_COMMANDS.find((c) => c.id === id)?.defaultPrimary ?? null; +} diff --git a/src/shared/keybindings/index.ts b/src/shared/keybindings/index.ts index fc3ddbaf..fdbf5ee8 100644 --- a/src/shared/keybindings/index.ts +++ b/src/shared/keybindings/index.ts @@ -59,17 +59,40 @@ export interface KeybindingDecl { * the dispatcher prefers single-keystroke matches before considering * chord leaders (handled in the dispatcher, not by table order). */ +/** + * Shell-key guard for Win/Linux. + * + * On Mac, `CmdOrCtrl` resolves to ⌘ exclusively, so bare ⌃-letter + * readline/job-control shortcuts (Ctrl+R reverse-i-search, Ctrl+W + * delete-word, Ctrl+T transpose, Ctrl+E end-of-line, Ctrl+O + * operate-and-get-next, Ctrl+N next-history, Ctrl+B backward-char, + * Ctrl+S XOFF, Ctrl+\ SIGQUIT, Ctrl+K kill-line) reach the terminal + * untouched. On Win/Linux, `CmdOrCtrl` IS Ctrl — without this guard + * those app bindings would shadow the shell whenever the terminal has + * focus. Applied to every binding whose key collides with a core shell + * key; shifted/alt'ed variants are left unguarded (shells don't bind + * them). + */ +const UNLESS_TERMINAL = "!terminalFocus || isMac"; + export const KEYBINDINGS: readonly KeybindingDecl[] = [ // File / editor // ⌘N opens a new untitled file (VSCode parity). The previous // ⌘N → Add Workspace binding has moved to ⌘⇧N (see below). - { command: COMMANDS.fileNew, primary: "CmdOrCtrl+N" }, - { command: COMMANDS.fileOpen, primary: "CmdOrCtrl+E" }, - { command: COMMANDS.fileOpen, primary: "CmdOrCtrl+O" }, - { command: COMMANDS.fileSave, primary: "CmdOrCtrl+S" }, - // Refresh blocks the page-level reload regardless of focus. - { command: COMMANDS.filesRefresh, primary: "CmdOrCtrl+R" }, - { command: COMMANDS.filesRefresh, primary: "CmdOrCtrl+Shift+R" }, + { command: COMMANDS.fileNew, primary: "CmdOrCtrl+N", when: UNLESS_TERMINAL }, + { command: COMMANDS.fileOpen, primary: "CmdOrCtrl+E", when: UNLESS_TERMINAL }, + { command: COMMANDS.fileOpen, primary: "CmdOrCtrl+O", when: UNLESS_TERMINAL }, + { command: COMMANDS.fileSave, primary: "CmdOrCtrl+S", when: UNLESS_TERMINAL }, + // Refresh blocks the page-level reload regardless of focus — except + // when the active tab is a browser tab (⌘R/⌘⇧R then mean Chrome-style + // page reload, see the Browser section below) or, on Win/Linux, when + // the terminal owns Ctrl+R (reverse-i-search). + { + command: COMMANDS.filesRefresh, + primary: "CmdOrCtrl+R", + when: `!browserTabActive && (${UNLESS_TERMINAL})`, + }, + { command: COMMANDS.filesRefresh, primary: "CmdOrCtrl+Shift+R", when: "!browserTabActive" }, // Open the active file-tree row in a side split. Scoped to the // tree so ⌘↵ inside a code editor still inserts a new line. @@ -107,14 +130,20 @@ export const KEYBINDINGS: readonly KeybindingDecl[] = [ }, // Tabs - { command: COMMANDS.tabClose, primary: "CmdOrCtrl+W" }, + { command: COMMANDS.tabClose, primary: "CmdOrCtrl+W", when: UNLESS_TERMINAL }, { command: COMMANDS.tabCloseOthers, primary: "CmdOrCtrl+Alt+T" }, // Chord-only commands (⌘K …). Cannot be Electron-registered; - // renderer handles entirely. - { command: COMMANDS.tabCloseSaved, chord: ["CmdOrCtrl+K", "U"] }, - { command: COMMANDS.tabCloseAll, chord: ["CmdOrCtrl+K", "CmdOrCtrl+W"] }, + // renderer handles entirely. The `when` guard applies to the *leader* + // match too (resolver checks it before arming the chord), so Ctrl+K + // keeps meaning kill-line in a Win/Linux terminal. + { command: COMMANDS.tabCloseSaved, chord: ["CmdOrCtrl+K", "U"], when: UNLESS_TERMINAL }, + { command: COMMANDS.tabCloseAll, chord: ["CmdOrCtrl+K", "CmdOrCtrl+W"], when: UNLESS_TERMINAL }, // VSCode's binding holds Cmd through the chord (⌘K ⌘⇧↵). - { command: COMMANDS.tabPinToggle, chord: ["CmdOrCtrl+K", "CmdOrCtrl+Shift+Enter"] }, + { + command: COMMANDS.tabPinToggle, + chord: ["CmdOrCtrl+K", "CmdOrCtrl+Shift+Enter"], + when: UNLESS_TERMINAL, + }, // Active-group tab cycling. Same Cmd+Ctrl modifier shape as // workspaceFocusPrev/Next (literal two-modifier combo, not CmdOrCtrl) // so Cmd-alone shortcuts inside Monaco aren't accidentally captured. @@ -125,7 +154,8 @@ export const KEYBINDINGS: readonly KeybindingDecl[] = [ // `\\` matches only the Backslash physical key. KeyboardEvent.code is // layout-independent, so Korean layouts (₩/\ key) work without extra // codes — see tokenToCodes in keybinding-parse.ts. - { command: COMMANDS.groupSplitRight, primary: "CmdOrCtrl+\\" }, + // Ctrl+\ sends SIGQUIT in a shell — guard on Win/Linux. + { command: COMMANDS.groupSplitRight, primary: "CmdOrCtrl+\\", when: UNLESS_TERMINAL }, { command: COMMANDS.groupSplitDown, primary: "CmdOrCtrl+Shift+\\" }, { command: COMMANDS.groupClose, primary: "CmdOrCtrl+Shift+W" }, { command: COMMANDS.groupFocusLeft, primary: "CmdOrCtrl+Alt+Left" }, @@ -149,11 +179,25 @@ export const KEYBINDINGS: readonly KeybindingDecl[] = [ { command: COMMANDS.settingsOpen, primary: "CmdOrCtrl+," }, // Workbench layout - { command: COMMANDS.workbenchToggleFilesPanel, primary: "CmdOrCtrl+B" }, + { command: COMMANDS.workbenchToggleFilesPanel, primary: "CmdOrCtrl+B", when: UNLESS_TERMINAL }, { command: COMMANDS.workbenchToggleSidebar, primary: "CmdOrCtrl+Shift+B" }, + // (No DevTools binding: ⌘⌥I is owned by the Electron menu toggleDevTools + // role for app-window DevTools. Browser page DevTools is button-only.) + // Terminal - { command: COMMANDS.terminalNew, primary: "CmdOrCtrl+T" }, + { command: COMMANDS.terminalNew, primary: "CmdOrCtrl+T", when: UNLESS_TERMINAL }, + + // Browser tab (Chrome parity). All scoped to `browserTabActive` — a + // STATE context key (active group's active tab is a browser tab), + // registered by the browser command domain. These never collide with + // the terminal guard above: terminal focus implies the active group's + // active tab is the terminal, so `browserTabActive` is false. + { command: COMMANDS.browserFocusUrl, primary: "CmdOrCtrl+L", when: "browserTabActive" }, + { command: COMMANDS.browserReload, primary: "CmdOrCtrl+R", when: "browserTabActive" }, + { command: COMMANDS.browserHardReload, primary: "CmdOrCtrl+Shift+R", when: "browserTabActive" }, + { command: COMMANDS.browserGoBack, primary: "CmdOrCtrl+[", when: "browserTabActive" }, + { command: COMMANDS.browserGoForward, primary: "CmdOrCtrl+]", when: "browserTabActive" }, // Path actions on the active editor { command: COMMANDS.pathReveal, primary: "CmdOrCtrl+Alt+R" }, diff --git a/src/shared/keybindings/keybinding-parse.ts b/src/shared/keybindings/keybinding-parse.ts index 1d0138b7..2649d3d3 100644 --- a/src/shared/keybindings/keybinding-parse.ts +++ b/src/shared/keybindings/keybinding-parse.ts @@ -184,31 +184,171 @@ function tokenToCodes(tok: string): readonly string[] { * platform — used by bindings that genuinely mean "Control". * - `!cmd && !ctrl`: neither modifier pressed. */ -export function matchesEvent(p: ParsedKeystroke, e: KeyboardEvent, isMac: boolean): boolean { - if (!p.codes.includes(e.code)) return false; +/** + * Platform-agnostic key state — the subset of modifiers + physical code + * that {@link matchesKeyState} needs. A DOM `KeyboardEvent` and an + * Electron `before-input-event` Input both reduce to this shape, so the + * single matcher serves the renderer dispatcher AND the main-process + * browser-view key interceptor (WebContentsView keystrokes never reach + * the renderer document — see main/features/browser/keyboard.ts). + */ +export interface KeyChordState { + code: string; + meta: boolean; + ctrl: boolean; + shift: boolean; + alt: boolean; +} + +/** Core matcher — see {@link matchesEvent} for the modifier matrix rationale. */ +export function matchesKeyState(p: ParsedKeystroke, s: KeyChordState, isMac: boolean): boolean { + if (!p.codes.includes(s.code)) return false; if (p.cmd && p.ctrl) { - if (!(e.metaKey && e.ctrlKey)) return false; + if (!(s.meta && s.ctrl)) return false; } else if (p.cmd) { // CmdOrCtrl shorthand: pick the platform's "primary" modifier and // require the other to be absent. The "absent" side matters most on // Mac, where bare ⌃-letter shortcuts belong to the terminal/shell. if (isMac) { - if (!e.metaKey || e.ctrlKey) return false; + if (!s.meta || s.ctrl) return false; } else { - if (!e.ctrlKey || e.metaKey) return false; + if (!s.ctrl || s.meta) return false; } } else if (p.ctrl) { - if (!e.ctrlKey || e.metaKey) return false; + if (!s.ctrl || s.meta) return false; } else { - if (e.metaKey || e.ctrlKey) return false; + if (s.meta || s.ctrl) return false; } - if (p.shift !== e.shiftKey) return false; - if (p.alt !== e.altKey) return false; + if (p.shift !== s.shift) return false; + if (p.alt !== s.alt) return false; return true; } +export function matchesEvent(p: ParsedKeystroke, e: KeyboardEvent, isMac: boolean): boolean { + return matchesKeyState( + p, + { code: e.code, meta: e.metaKey, ctrl: e.ctrlKey, shift: e.shiftKey, alt: e.altKey }, + isMac, + ); +} + +// --------------------------------------------------------------------------- +// Event → accelerator (the recorder's reverse function) +// --------------------------------------------------------------------------- + +/** Inverse of `tokenToCodes` for the codes we support. */ +const CODE_TO_TOKEN: Readonly> = { + Enter: "Enter", + Escape: "Escape", + Tab: "Tab", + Space: "Space", + Backspace: "Backspace", + Delete: "Delete", + ArrowUp: "Up", + ArrowDown: "Down", + ArrowLeft: "Left", + ArrowRight: "Right", + Backslash: "\\", + Slash: "/", + Comma: ",", + Period: "Period", + Semicolon: "Semicolon", + Quote: "Quote", + BracketLeft: "[", + BracketRight: "]", + Minus: "-", + Equal: "=", + Backquote: "`", +}; + +/** + * Convert a captured KeyboardEvent into the accelerator string that + * would match it — the keybinding recorder's reverse of + * `parseAccelerator` + `matchesEvent`. Round-trip invariant: + * `matchesEvent(parseAccelerator(eventToAccelerator(e)!), e, isMac)` + * is true whenever the result is non-null. + * + * Returns `null` when the event cannot form a valid binding: + * - modifier-only keydowns (the recorder shows them as "pending"); + * - physical keys outside our token table (media keys, NumPad, …); + * - the platform-foreign primary modifier (bare Win/Super key + * combos on non-Mac — `metaKey` there is not part of our + * accelerator vocabulary). + * + * Modifier mapping mirrors `matchesEvent`'s matrix: + * - Mac: meta → `CmdOrCtrl`, ctrl alone → `Ctrl` (literal), + * meta+ctrl → `Cmd+Ctrl` (literal two-modifier combo). + * - Win/Linux: ctrl → `CmdOrCtrl`; metaKey set → null. + */ +export function eventToAccelerator(e: KeyboardEvent, isMac: boolean): string | null { + const token = codeToToken(e.code); + if (token === null) return null; + + const mods: string[] = []; + if (isMac) { + if (e.metaKey && e.ctrlKey) { + mods.push("Cmd", "Ctrl"); + } else if (e.metaKey) { + mods.push("CmdOrCtrl"); + } else if (e.ctrlKey) { + mods.push("Ctrl"); + } + } else { + if (e.metaKey) return null; // Win/Super combos are not bindable + if (e.ctrlKey) mods.push("CmdOrCtrl"); + } + if (e.shiftKey) mods.push("Shift"); + if (e.altKey) mods.push("Alt"); + + return [...mods, token].join("+"); +} + +function codeToToken(code: string): string | null { + const letter = /^Key([A-Z])$/.exec(code); + if (letter !== null) return letter[1] as string; + const digit = /^Digit([0-9])$/.exec(code); + if (digit !== null) return digit[1] as string; + if (/^F[1-9]$|^F1[0-2]$/.test(code)) return code; + return CODE_TO_TOKEN[code] ?? null; +} + +/** True when `e.code` is itself a modifier key (⇧⌃⌥⌘ keydowns). */ +export function isModifierCode(code: string): boolean { + return /^(Shift|Control|Alt|Meta)(Left|Right)?$/.test(code) || code === "CapsLock"; +} + +/** + * Reduce an accelerator to a platform-resolved canonical key, e.g. + * `"meta+shift+KeyR"` (Mac) / `"ctrl+shift+KeyR"` (Win/Linux) for + * `"CmdOrCtrl+Shift+R"`. Two accelerators with the same normalized form + * match exactly the same KeyboardEvents under `matchesEvent` — this is + * the equality the conflict engine and reserved-key catalog compare on. + * + * Returns `null` for unparseable input instead of throwing, so callers + * probing user-typed strings don't need their own try/catch. + */ +export function normalizeKeystroke(accel: AcceleratorString, isMac: boolean): string | null { + let p: ParsedKeystroke; + try { + p = parseAccelerator(accel); + } catch { + return null; + } + const mods: string[] = []; + if (p.cmd && p.ctrl) { + mods.push("meta", "ctrl"); // literal two-modifier combo + } else if (p.cmd) { + mods.push(isMac ? "meta" : "ctrl"); // CmdOrCtrl shorthand resolution + } else if (p.ctrl) { + mods.push("ctrl"); + } + if (p.shift) mods.push("shift"); + if (p.alt) mods.push("alt"); + return [...mods, p.codes[0]].join("+"); +} + interface LabelOptions { isMac: boolean; } diff --git a/src/shared/keybindings/overrides.ts b/src/shared/keybindings/overrides.ts new file mode 100644 index 00000000..af18f9f7 --- /dev/null +++ b/src/shared/keybindings/overrides.ts @@ -0,0 +1,206 @@ +/** + * User keybinding overrides — schema + pure application. + * + * Persistence model: the DEFAULT table (`KEYBINDINGS`) is immutable + * code; user customization is stored as a DELTA (`KeybindingOverride[]` + * in appState). Effective bindings = `applyKeybindingOverrides(defaults, + * overrides)`. This keeps version migration trivial — when defaults + * change between app releases, overrides keyed by command id stay + * valid, and overrides for commands that no longer exist are silently + * dropped at apply time (never at parse time, so a stale state.json + * can't fail validation wholesale). + * + * Field semantics per override entry (at most one entry per command — + * later entries win): + * - `primary: "Accel"` → replace ALL default primary bindings of the + * command with this single accelerator. + * - `primary: null` → unbind (remove all default primaries). + * - `primary` absent → defaults untouched. + * - `chord` mirrors the same tri-state for chord bindings. + * + * The replaced binding INHERITS the `when` scope of the command's first + * default declaration. `when` is intentionally not user-editable in v1: + * scopes like `fileTreeFocus && !inputFocus` are data-loss guards + * (file.delete) and per-surface routers (browserTabActive) — letting a + * recorder UI silently strip them would reintroduce the exact bug + * classes the scopes exist to prevent. + */ + +import { z } from "zod"; +import type { CommandId } from "./commands"; +import type { KeybindingDecl } from "./index"; +import { parseAccelerator } from "./keybinding-parse"; + +// --------------------------------------------------------------------------- +// Schema +// --------------------------------------------------------------------------- + +/** + * An accelerator that our parser accepts. Validated with the real + * parser (not a regex) so the schema can never admit a string the + * dispatcher would later throw on at compile time. + */ +export const AcceleratorStringSchema = z + .string() + .min(1) + .max(64) + .refine( + (value) => { + try { + parseAccelerator(value); + return true; + } catch { + return false; + } + }, + { message: "unparseable accelerator string" }, + ); + +export const KeybindingOverrideSchema = z.object({ + // Plain string, NOT CommandIdSchema: an override written by a newer + // (or older) app version may reference a command this build doesn't + // know. Rejecting it here would fail the whole appState parse; we + // accept it and drop it at apply time instead. + command: z.string().min(1).max(128), + primary: AcceleratorStringSchema.nullable().optional(), + chord: z.tuple([AcceleratorStringSchema, AcceleratorStringSchema]).nullable().optional(), +}); + +export const KeybindingOverridesSchema = z.array(KeybindingOverrideSchema).max(256); + +export type KeybindingOverride = z.infer; + +// --------------------------------------------------------------------------- +// Application +// --------------------------------------------------------------------------- + +/** + * Compute the effective binding table from defaults + user overrides. + * + * Ordering is preserved — a replacement binding is emitted at the + * position of the command's FIRST default declaration of the same kind + * (primary/chord), because the resolver returns the first match in + * table order and that priority is part of the dispatch semantics + * (e.g. browser ⌘R before files.refresh ⌘R relies on disjoint `when`s, + * not order, but order remains the tiebreaker for true duplicates). + * Overrides for commands with no default declaration are appended at + * the end. + * + * Pure: never mutates inputs; safe to call on every store update. + */ +export function applyKeybindingOverrides( + defaults: readonly KeybindingDecl[], + overrides: readonly KeybindingOverride[] | undefined, + knownCommands: ReadonlySet, +): KeybindingDecl[] { + if (overrides === undefined || overrides.length === 0) return [...defaults]; + + // Last entry per command wins; unknown commands are dropped. + const byCommand = new Map(); + for (const o of overrides) { + if (knownCommands.has(o.command)) byCommand.set(o.command, o); + } + if (byCommand.size === 0) return [...defaults]; + + const out: KeybindingDecl[] = []; + const primaryEmitted = new Set(); + const chordEmitted = new Set(); + + for (const decl of defaults) { + const ov = byCommand.get(decl.command); + if (ov === undefined) { + out.push(decl); + continue; + } + + const next: KeybindingDecl = { command: decl.command }; + if (decl.when !== undefined) next.when = decl.when; + + if (decl.primary !== undefined) { + if (ov.primary === undefined) { + next.primary = decl.primary; // untouched + } else if (ov.primary !== null && !primaryEmitted.has(decl.command)) { + next.primary = ov.primary; // replacement, once, at first slot + primaryEmitted.add(decl.command); + } + // ov.primary === null → unbound; subsequent duplicates also dropped. + } + + if (decl.chord !== undefined) { + if (ov.chord === undefined) { + next.chord = decl.chord; + } else if (ov.chord !== null && !chordEmitted.has(decl.command)) { + next.chord = ov.chord; + chordEmitted.add(decl.command); + } + } + + if (next.primary !== undefined || next.chord !== undefined) { + out.push(next); + } + } + + // Overrides that add a binding to a command with no default decl of + // that kind (e.g. a chord-only command gaining a primary, or a + // command that ships unbound). + for (const [command, ov] of byCommand) { + const inheritedWhen = defaults.find((d) => d.command === command)?.when; + const extra: KeybindingDecl = { command: command as CommandId }; + if (inheritedWhen !== undefined) extra.when = inheritedWhen; + + let hasAny = false; + if (ov.primary != null && !primaryEmitted.has(command)) { + const hadDefaultPrimary = defaults.some( + (d) => d.command === command && d.primary !== undefined, + ); + if (!hadDefaultPrimary) { + extra.primary = ov.primary; + hasAny = true; + } + } + if (ov.chord != null && !chordEmitted.has(command)) { + const hadDefaultChord = defaults.some((d) => d.command === command && d.chord !== undefined); + if (!hadDefaultChord) { + extra.chord = ov.chord; + hasAny = true; + } + } + if (hasAny) out.push(extra); + } + + return out; +} + +// --------------------------------------------------------------------------- +// Override-list editing helpers (used by the renderer store / settings UI) +// --------------------------------------------------------------------------- + +/** + * Upsert one command's override into the list, merging field-wise with + * any existing entry. Passing `undefined` for a field leaves the + * existing value; `null` records an explicit unbind. Returns a new + * array (inputs untouched). An entry whose fields all end up + * `undefined` is removed entirely (back to defaults). + */ +export function upsertOverride( + overrides: readonly KeybindingOverride[], + patch: KeybindingOverride, +): KeybindingOverride[] { + const existing = overrides.find((o) => o.command === patch.command); + const merged: KeybindingOverride = { + command: patch.command, + primary: patch.primary !== undefined ? patch.primary : existing?.primary, + chord: patch.chord !== undefined ? patch.chord : existing?.chord, + }; + const rest = overrides.filter((o) => o.command !== patch.command); + if (merged.primary === undefined && merged.chord === undefined) return rest; + return [...rest, merged]; +} + +/** Remove a command's override (restore its defaults). Returns a new array. */ +export function removeOverride( + overrides: readonly KeybindingOverride[], + command: string, +): KeybindingOverride[] { + return overrides.filter((o) => o.command !== command); +} diff --git a/src/shared/keybindings/reserved-keys.ts b/src/shared/keybindings/reserved-keys.ts new file mode 100644 index 00000000..21c182eb --- /dev/null +++ b/src/shared/keybindings/reserved-keys.ts @@ -0,0 +1,123 @@ +/** + * Reserved / built-in key catalog — the data half of the keybinding + * conflict engine. + * + * Our dispatcher protects embedded components (Monaco, xterm, the + * browser tab) by ABSENCE: a key not in the KEYBINDINGS table simply + * passes through at capture phase. User customization can break that + * protection at any time (the ⌘/ → split-right bug was exactly this + * class of failure, introduced by code instead of by a user). This + * catalog makes the implicit reservation explicit data, so: + * - the conflict engine can warn "this will shadow Monaco's comment + * toggle" the moment a user records a colliding key; + * - the settings UI can render a read-only "keys used by built-ins" + * section; + * - regressions like ⌘/ are documented in one greppable place. + * + * `source` semantics: + * - "system" — OS/window-manager level (⌘Q, ⌘H, ⌘M). The recorder + * refuses these outright. + * - "electron" — Cocoa-registered role accelerators (Edit menu + * clipboard roles, zoom, host DevTools). Binding these + * may double-fire or never reach the renderer. + * - "monaco" — Monaco standalone editor defaults (fire when the + * editor has focus). Shadowing breaks editing UX. + * - "terminal" — readline / job-control keys the shell owns while + * the terminal has focus (plus app-level terminal + * keys like ⇧↵). Mostly literal Ctrl — relevant on + * Mac too, and doubly so on Win/Linux where + * CmdOrCtrl resolves to Ctrl. + * + * NOT exhaustive — entries earn their place by being likely collision + * targets, not by completeness. Extend freely. + */ + +import type { AcceleratorString } from "./index"; +import { normalizeKeystroke } from "./keybinding-parse"; + +export type ReservedKeySource = "system" | "electron" | "monaco" | "terminal"; + +export interface ReservedKey { + accelerator: AcceleratorString; + source: ReservedKeySource; + /** Short English description of what the key does in its owner. */ + note: string; + /** Restrict the reservation to one platform. Absent = both. */ + platform?: "mac" | "non-mac"; +} + +export const RESERVED_KEYS: readonly ReservedKey[] = [ + // ── system (recorder-blocked) ─────────────────────────────────────── + { accelerator: "Cmd+Q", source: "system", note: "Quit application", platform: "mac" }, + { accelerator: "Cmd+H", source: "system", note: "Hide application", platform: "mac" }, + { accelerator: "Cmd+Alt+H", source: "system", note: "Hide other applications", platform: "mac" }, + { accelerator: "Cmd+M", source: "system", note: "Minimize window", platform: "mac" }, + + // ── electron roles (Cocoa-registered accelerators) ────────────────── + { accelerator: "CmdOrCtrl+C", source: "electron", note: "Copy" }, + { accelerator: "CmdOrCtrl+X", source: "electron", note: "Cut" }, + { accelerator: "CmdOrCtrl+V", source: "electron", note: "Paste" }, + { accelerator: "CmdOrCtrl+A", source: "electron", note: "Select all" }, + { accelerator: "CmdOrCtrl+Z", source: "electron", note: "Undo" }, + { accelerator: "CmdOrCtrl+Shift+Z", source: "electron", note: "Redo" }, + { accelerator: "CmdOrCtrl+Alt+I", source: "electron", note: "Toggle window DevTools" }, + { accelerator: "CmdOrCtrl+0", source: "electron", note: "Reset zoom" }, + { accelerator: "CmdOrCtrl+-", source: "electron", note: "Zoom out" }, + { accelerator: "CmdOrCtrl+=", source: "electron", note: "Zoom in" }, + + // ── monaco editor defaults (editor focus) ─────────────────────────── + { accelerator: "CmdOrCtrl+/", source: "monaco", note: "Toggle line comment" }, + { accelerator: "CmdOrCtrl+D", source: "monaco", note: "Add selection to next find match" }, + { accelerator: "CmdOrCtrl+F", source: "monaco", note: "Find" }, + { accelerator: "CmdOrCtrl+Alt+F", source: "monaco", note: "Replace" }, + { accelerator: "CmdOrCtrl+G", source: "monaco", note: "Find next" }, + { accelerator: "CmdOrCtrl+U", source: "monaco", note: "Undo cursor position" }, + { accelerator: "CmdOrCtrl+[", source: "monaco", note: "Outdent line" }, + { accelerator: "CmdOrCtrl+]", source: "monaco", note: "Indent line" }, + { accelerator: "CmdOrCtrl+Shift+K", source: "monaco", note: "Delete line" }, + { accelerator: "CmdOrCtrl+Enter", source: "monaco", note: "Insert line below" }, + { accelerator: "Alt+Up", source: "monaco", note: "Move line up" }, + { accelerator: "Alt+Down", source: "monaco", note: "Move line down" }, + { accelerator: "Shift+Alt+Up", source: "monaco", note: "Copy line up" }, + { accelerator: "Shift+Alt+Down", source: "monaco", note: "Copy line down" }, + { accelerator: "F1", source: "monaco", note: "Editor command palette" }, + { accelerator: "F8", source: "monaco", note: "Go to next problem" }, + { accelerator: "F12", source: "monaco", note: "Go to definition" }, + { accelerator: "Shift+F12", source: "monaco", note: "Go to references" }, + + // ── terminal / shell (terminal focus) ─────────────────────────────── + { accelerator: "Ctrl+C", source: "terminal", note: "SIGINT (interrupt)" }, + { accelerator: "Ctrl+D", source: "terminal", note: "EOF / exit shell" }, + { accelerator: "Ctrl+Z", source: "terminal", note: "Suspend job (SIGTSTP)" }, + { accelerator: "Ctrl+R", source: "terminal", note: "Reverse history search" }, + { accelerator: "Ctrl+A", source: "terminal", note: "Beginning of line" }, + { accelerator: "Ctrl+E", source: "terminal", note: "End of line" }, + { accelerator: "Ctrl+W", source: "terminal", note: "Delete previous word" }, + { accelerator: "Ctrl+U", source: "terminal", note: "Kill line backward" }, + { accelerator: "Ctrl+K", source: "terminal", note: "Kill line forward" }, + { accelerator: "Ctrl+L", source: "terminal", note: "Clear screen" }, + { accelerator: "Ctrl+T", source: "terminal", note: "Transpose characters" }, + { accelerator: "Ctrl+N", source: "terminal", note: "Next history entry" }, + { accelerator: "Ctrl+P", source: "terminal", note: "Previous history entry" }, + { accelerator: "Ctrl+O", source: "terminal", note: "Operate and get next" }, + { accelerator: "Ctrl+S", source: "terminal", note: "Flow control stop (XOFF)" }, + { accelerator: "Ctrl+Q", source: "terminal", note: "Flow control resume (XON)" }, + { accelerator: "Ctrl+\\", source: "terminal", note: "SIGQUIT" }, + { accelerator: "Shift+Enter", source: "terminal", note: "Multi-line input (app)" }, +]; + +/** + * Find the reserved entry colliding with `accel` on the given platform, + * or `undefined`. Comparison uses {@link normalizeKeystroke}, i.e. the + * same equality `matchesEvent` implies — `CmdOrCtrl+R` collides with + * `Ctrl+R` on Win/Linux but not on Mac. + */ +export function findReservedKey(accel: AcceleratorString, isMac: boolean): ReservedKey | undefined { + const norm = normalizeKeystroke(accel, isMac); + if (norm === null) return undefined; + return RESERVED_KEYS.find((r) => { + if (r.platform === "mac" && !isMac) return false; + if (r.platform === "non-mac" && isMac) return false; + return normalizeKeystroke(r.accelerator, isMac) === norm; + }); +} diff --git a/src/shared/types/app-state.ts b/src/shared/types/app-state.ts index 66aa7c89..f6c042eb 100644 --- a/src/shared/types/app-state.ts +++ b/src/shared/types/app-state.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { THEME_SOURCES } from "../design-tokens"; +import { KeybindingOverridesSchema } from "../keybindings/overrides"; import { WorkspaceLayoutSnapshotSchema } from "./layout"; // ThemePreferenceSchema mirrors the registered ThemeId set. The "system" @@ -135,6 +136,23 @@ export const AppStateSchema = z.object({ // Renderer-only display setting; main process has no dedicated callback (generic patch // storage is sufficient). iconTheme: z.enum(["minimal", "material"]).optional(), + + // User keybinding overrides — a DELTA against the immutable default + // KEYBINDINGS table (see shared/keybindings/overrides.ts for the + // entry semantics: replace / null=unbind / absent=default). Absent = + // all defaults. Accelerator strings are validated with the real + // parser at this boundary; unknown command ids pass validation and + // are dropped at apply time (version-skew tolerance). + keybindingOverrides: KeybindingOverridesSchema.optional(), + + // User editor (Monaco) keybinding overrides — a DELTA against the + // curated EDITOR_COMMANDS catalog (see shared/keybindings/ + // editor-commands.ts). Separate from `keybindingOverrides` because + // these target Monaco's own keybinding service (applied via + // monaco.editor.addKeybindingRules), not the app dispatcher table. + // Same entry semantics (replace / null=unbind / absent=default); + // command ids outside the curated catalog are dropped at apply time. + editorKeybindingOverrides: KeybindingOverridesSchema.optional(), }); export type WindowBounds = z.infer; diff --git a/tests/unit/main/features/browser/browser-permission-ipc.test.ts b/tests/unit/main/features/browser/browser-permission-ipc.test.ts index aecdec42..a1b5f162 100644 --- a/tests/unit/main/features/browser/browser-permission-ipc.test.ts +++ b/tests/unit/main/features/browser/browser-permission-ipc.test.ts @@ -35,9 +35,7 @@ mock.module("../../../../../src/main/infra/ipc-router", () => ({ // Module under test — import after mock // --------------------------------------------------------------------------- -const { registerBrowserChannel } = await import( - "../../../../../src/main/features/browser/ipc" -); +const { registerBrowserChannel } = await import("../../../../../src/main/features/browser/ipc"); // --------------------------------------------------------------------------- // Fakes @@ -69,8 +67,12 @@ function makeFakeRegistry() { } as unknown as import("../../../../../src/main/features/browser/registry").BrowserTabRegistry; } -type RespondArgs = Parameters; -type CancelArgs = Parameters; +type RespondArgs = Parameters< + import("../../../../../src/main/features/browser/permission-prompt-manager").BrowserPermissionPromptManager["respond"] +>; +type CancelArgs = Parameters< + import("../../../../../src/main/features/browser/permission-prompt-manager").BrowserPermissionPromptManager["cancel"] +>; function makeFakePromptManager() { const respondCalls: RespondArgs[] = []; @@ -218,7 +220,9 @@ describe("browserPermission IPC — listRemembered", () => { }); const bpChannel = registeredChannels.get("browserPermission")!; - const result = bpChannel.call.listRemembered({ workspaceId: "unknown-ws" }) as { value: unknown[] }; + const result = bpChannel.call.listRemembered({ workspaceId: "unknown-ws" }) as { + value: unknown[]; + }; expect(result.value).toHaveLength(0); }); diff --git a/tests/unit/main/features/browser/keyboard.test.ts b/tests/unit/main/features/browser/keyboard.test.ts new file mode 100644 index 00000000..9c260ad4 --- /dev/null +++ b/tests/unit/main/features/browser/keyboard.test.ts @@ -0,0 +1,128 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import type { WebContents } from "electron"; +import { + installBrowserKeyInterceptor, + updateBrowserKeybindings, +} from "../../../../../src/main/features/browser/keyboard"; +import { COMMANDS } from "../../../../../src/shared/keybindings/commands"; + +// CmdOrCtrl resolves to ⌘ on macOS and Ctrl elsewhere — match the matcher's +// platform behaviour so the assertions hold on every CI host. +const IS_MAC = process.platform === "darwin"; + +interface FakeInput { + type: "keyDown" | "keyUp"; + code: string; + meta?: boolean; + control?: boolean; + shift?: boolean; + alt?: boolean; +} + +/** Build a before-input-event Input with the platform's primary modifier set. */ +function primaryMod(extra: Partial): Partial { + return IS_MAC ? { meta: true, ...extra } : { control: true, ...extra }; +} + +function fakeWc() { + let handler: ((event: { preventDefault: () => void }, input: FakeInput) => void) | null = null; + const wc = { + on(evt: string, cb: (event: { preventDefault: () => void }, input: FakeInput) => void) { + if (evt === "before-input-event") handler = cb; + return wc; + }, + } as unknown as WebContents; + return { + wc, + press(input: FakeInput): { prevented: boolean } { + let prevented = false; + handler?.( + { preventDefault: () => (prevented = true) }, + { + meta: false, + control: false, + shift: false, + alt: false, + ...input, + }, + ); + return { prevented }; + }, + }; +} + +describe("browser-view key interceptor", () => { + afterEach(() => { + // Restore default bindings for isolation. + updateBrowserKeybindings(undefined); + }); + + test("⌘R → reload, ⌘⇧R → hard reload (the reported dead shortcuts)", () => { + updateBrowserKeybindings(undefined); + const run = mock((_cmd: string, _tab: string) => {}); + const h = fakeWc(); + installBrowserKeyInterceptor(h.wc, "tab-1", run); + + const r = h.press({ type: "keyDown", code: "KeyR", ...primaryMod({}) }); + expect(r.prevented).toBe(true); + expect(run).toHaveBeenCalledWith(COMMANDS.browserReload, "tab-1"); + + const sr = h.press({ type: "keyDown", code: "KeyR", ...primaryMod({ shift: true }) }); + expect(sr.prevented).toBe(true); + expect(run).toHaveBeenCalledWith(COMMANDS.browserHardReload, "tab-1"); + }); + + test("back / forward / focus-url all route", () => { + updateBrowserKeybindings(undefined); + const run = mock((_cmd: string, _tab: string) => {}); + const h = fakeWc(); + installBrowserKeyInterceptor(h.wc, "t", run); + + h.press({ type: "keyDown", code: "BracketLeft", ...primaryMod({}) }); + h.press({ type: "keyDown", code: "BracketRight", ...primaryMod({}) }); + h.press({ type: "keyDown", code: "KeyL", ...primaryMod({}) }); + + const cmds = run.mock.calls.map((c) => c[0]); + expect(cmds).toContain(COMMANDS.browserGoBack); + expect(cmds).toContain(COMMANDS.browserGoForward); + expect(cmds).toContain(COMMANDS.browserFocusUrl); + }); + + test("⌘⌥I (DevTools) is NOT intercepted — left to the Electron menu role", () => { + updateBrowserKeybindings(undefined); + const run = mock((_cmd: string, _tab: string) => {}); + const h = fakeWc(); + installBrowserKeyInterceptor(h.wc, "t", run); + + const r = h.press({ type: "keyDown", code: "KeyI", ...primaryMod({ alt: true }) }); + expect(r.prevented).toBe(false); + expect(run).not.toHaveBeenCalled(); + }); + + test("keyUp is ignored; non-bound keys pass through untouched", () => { + updateBrowserKeybindings(undefined); + const run = mock((_cmd: string, _tab: string) => {}); + const h = fakeWc(); + installBrowserKeyInterceptor(h.wc, "t", run); + + expect(h.press({ type: "keyUp", code: "KeyR", ...primaryMod({}) }).prevented).toBe(false); + expect(h.press({ type: "keyDown", code: "KeyP", ...primaryMod({}) }).prevented).toBe(false); + expect(run).not.toHaveBeenCalled(); + }); + + test("user override is honoured: reload moved to ⌘⇧L, old ⌘R no longer reloads", () => { + updateBrowserKeybindings([{ command: COMMANDS.browserReload, primary: "CmdOrCtrl+Shift+L" }]); + const run = mock((_cmd: string, _tab: string) => {}); + const h = fakeWc(); + installBrowserKeyInterceptor(h.wc, "t", run); + + h.press({ type: "keyDown", code: "KeyL", ...primaryMod({ shift: true }) }); + expect(run).toHaveBeenCalledWith(COMMANDS.browserReload, "t"); + + run.mockClear(); + const r = h.press({ type: "keyDown", code: "KeyR", ...primaryMod({}) }); + // ⌘R is now unassigned among browser commands → no reload, no preventDefault. + expect(r.prevented).toBe(false); + expect(run).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/renderer/keybindings/dispatcher.test.ts b/tests/unit/renderer/keybindings/dispatcher.test.ts index 5b95bd31..e419ccb0 100644 --- a/tests/unit/renderer/keybindings/dispatcher.test.ts +++ b/tests/unit/renderer/keybindings/dispatcher.test.ts @@ -158,6 +158,26 @@ describe("handleGlobalKeyDown — refresh", () => { }); }); +describe("handleGlobalKeyDown — keybinding recorder pause", () => { + // The Settings → Keyboard Shortcuts recorder must receive raw + // keystrokes. While the keydown target sits inside a + // `[data-keybinding-recorder]` container the dispatcher must claim + // NOTHING — recording ⌘W would otherwise close the active tab. + it("does not claim ⌘W when the target is inside the recorder", () => { + const spies = setupCommandSpies(); + const target = { + tagName: "DIV", + isContentEditable: false, + closest: (sel: string) => (sel === "[data-keybinding-recorder]" ? ({} as HTMLElement) : null), + } as unknown as HTMLElement; + const e = makeEvent("w", { metaKey: true, code: "KeyW", target }); + const claimed = handleGlobalKeyDown(e as unknown as KeyboardEvent); + expect(claimed).toBe(false); + expect(spies[COMMANDS.tabClose]).not.toHaveBeenCalled(); + expect(e.defaultPrevented).toBe(false); + }); +}); + describe("handleGlobalKeyDown — fileOpen fires regardless of focus", () => { // Phase 3: VSCode-default behaviour — application-level shortcuts // fire even from inside an INPUT / editor unless their declaration diff --git a/tests/unit/renderer/keybindings/palette-command-keybinding.test.ts b/tests/unit/renderer/keybindings/palette-command-keybinding.test.ts index 9732bb61..8ca727b0 100644 --- a/tests/unit/renderer/keybindings/palette-command-keybinding.test.ts +++ b/tests/unit/renderer/keybindings/palette-command-keybinding.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import { registerPaletteCommands } from "../../../../src/renderer/commands/domains/palette"; import { __resetCommandsForTests, executeCommand, @@ -8,7 +9,6 @@ import { __resetWorkspaceSymbolPaletteStateForTests, isWorkspaceSymbolPaletteOpen, } from "../../../../src/renderer/components/symbol-palette/workspace-symbol-palette-state"; -import { registerPaletteCommands } from "../../../../src/renderer/commands/domains/palette"; import { evaluateContextKey } from "../../../../src/renderer/keybindings/context-keys"; import { __resetChordStateForTests, diff --git a/tests/unit/renderer/keybindings/resolver.test.ts b/tests/unit/renderer/keybindings/resolver.test.ts index a9ed0a0f..7405a314 100644 --- a/tests/unit/renderer/keybindings/resolver.test.ts +++ b/tests/unit/renderer/keybindings/resolver.test.ts @@ -1,6 +1,16 @@ -import { describe, expect, it } from "bun:test"; -import { resolveEvent } from "../../../../src/renderer/keybindings/resolver"; -import { COMMANDS } from "../../../../src/shared/keybindings/commands"; +import { afterEach, describe, expect, it } from "bun:test"; +import { + __resetContextProbesForTests, + registerContextProbe, +} from "../../../../src/renderer/keybindings/context-keys"; +import { + __resetActiveBindingsForTests, + resolveEvent, + setActiveBindings, +} from "../../../../src/renderer/keybindings/resolver"; +import { ALL_COMMAND_IDS, COMMANDS } from "../../../../src/shared/keybindings/commands"; +import { KEYBINDINGS } from "../../../../src/shared/keybindings/index"; +import { applyKeybindingOverrides } from "../../../../src/shared/keybindings/overrides"; interface MockKE { metaKey: boolean; @@ -42,7 +52,10 @@ describe("resolveEvent — no chord pending", () => { expect(r.kind).toBe("none"); }); - it("matches files.refresh on ⌘R regardless of focus (no when)", () => { + it("matches files.refresh on ⌘R outside browser tabs / Win-Linux terminals", () => { + // `when: "!browserTabActive && (!terminalFocus || isMac)"` — with no + // browser tab active and no terminal focus both guards pass, so ⌘R + // still refreshes files from any ordinary focus context. const r = resolveEvent(ev("KeyR", { metaKey: true }) as unknown as KeyboardEvent, null); expect(r.kind).toBe("single"); if (r.kind !== "single") throw new Error("unreachable"); @@ -88,6 +101,150 @@ describe("resolveEvent — when scoping", () => { }); }); +describe("resolveEvent — runtime recompilation (user overrides)", () => { + const KNOWN = new Set(ALL_COMMAND_IDS); + afterEach(() => { + __resetActiveBindingsForTests(); + }); + + it("a rebound command matches the new key and releases the old one", () => { + setActiveBindings( + applyKeybindingOverrides( + KEYBINDINGS, + [{ command: COMMANDS.tabClose, primary: "CmdOrCtrl+Shift+X" }], + KNOWN, + ), + ); + // old key released… + expect(resolveEvent(ev("KeyW", { metaKey: true }) as unknown as KeyboardEvent, null).kind).toBe( + "none", + ); + // …new key live, same command. + const r = resolveEvent( + ev("KeyX", { metaKey: true, shiftKey: true }) as unknown as KeyboardEvent, + null, + ); + if (r.kind !== "single") throw new Error("expected single match"); + expect(r.command).toBe(COMMANDS.tabClose); + }); + + it("an unbound command (primary: null) stops matching entirely", () => { + setActiveBindings( + applyKeybindingOverrides(KEYBINDINGS, [{ command: COMMANDS.tabClose, primary: null }], KNOWN), + ); + expect(resolveEvent(ev("KeyW", { metaKey: true }) as unknown as KeyboardEvent, null).kind).toBe( + "none", + ); + }); + + it("reset restores the default table", () => { + setActiveBindings( + applyKeybindingOverrides(KEYBINDINGS, [{ command: COMMANDS.tabClose, primary: null }], KNOWN), + ); + __resetActiveBindingsForTests(); + const r = resolveEvent(ev("KeyW", { metaKey: true }) as unknown as KeyboardEvent, null); + if (r.kind !== "single") throw new Error("expected single match"); + expect(r.command).toBe(COMMANDS.tabClose); + }); +}); + +describe("resolveEvent — browserTabActive state probe", () => { + afterEach(() => { + __resetContextProbesForTests(); + }); + + it("routes ⌘R to browser.reload when the active tab is a browser tab", () => { + registerContextProbe("browserTabActive", () => true); + const r = resolveEvent(ev("KeyR", { metaKey: true }) as unknown as KeyboardEvent, null); + if (r.kind !== "single") throw new Error("expected single match"); + expect(r.command).toBe(COMMANDS.browserReload); + }); + + it("routes ⌘⇧R to browser.hardReload when the active tab is a browser tab", () => { + registerContextProbe("browserTabActive", () => true); + const r = resolveEvent( + ev("KeyR", { metaKey: true, shiftKey: true }) as unknown as KeyboardEvent, + null, + ); + if (r.kind !== "single") throw new Error("expected single match"); + expect(r.command).toBe(COMMANDS.browserHardReload); + }); + + it("keeps ⌘R on files.refresh when the probe reports no browser tab", () => { + registerContextProbe("browserTabActive", () => false); + const r = resolveEvent(ev("KeyR", { metaKey: true }) as unknown as KeyboardEvent, null); + if (r.kind !== "single") throw new Error("expected single match"); + expect(r.command).toBe(COMMANDS.filesRefresh); + }); + + it("matches ⌘L / ⌘[ / ⌘] only while a browser tab is active", () => { + // Without the probe (or with it false) these must resolve to none — + // ⌘L etc. are not global app shortcuts. + expect(resolveEvent(ev("KeyL", { metaKey: true }) as unknown as KeyboardEvent, null).kind).toBe( + "none", + ); + registerContextProbe("browserTabActive", () => true); + const focusUrl = resolveEvent(ev("KeyL", { metaKey: true }) as unknown as KeyboardEvent, null); + if (focusUrl.kind !== "single") throw new Error("expected single match"); + expect(focusUrl.command).toBe(COMMANDS.browserFocusUrl); + const back = resolveEvent( + ev("BracketLeft", { metaKey: true }) as unknown as KeyboardEvent, + null, + ); + if (back.kind !== "single") throw new Error("expected single match"); + expect(back.command).toBe(COMMANDS.browserGoBack); + const fwd = resolveEvent( + ev("BracketRight", { metaKey: true }) as unknown as KeyboardEvent, + null, + ); + if (fwd.kind !== "single") throw new Error("expected single match"); + expect(fwd.command).toBe(COMMANDS.browserGoForward); + }); + + it("⌘⌥I is not in the table (owned by the Electron menu DevTools role)", () => { + registerContextProbe("browserTabActive", () => true); + const devtools = resolveEvent( + ev("KeyI", { metaKey: true, altKey: true }) as unknown as KeyboardEvent, + null, + ); + expect(devtools.kind).toBe("none"); + }); +}); + +describe("resolveEvent — terminal shell-key guard (Mac)", () => { + // On Mac (`isMac` true) the `!terminalFocus || isMac` guard is always + // satisfied — ⌘-letter shortcuts keep firing inside the terminal. The + // Win/Linux half (Ctrl+R etc. yielding to the shell) cannot be + // exercised here because the resolver's IS_MAC is fixed at module + // load; the guard expression itself is covered by keybinding-when + // tests. + function terminalTarget(code: string, mods: Partial>) { + const target = { + tagName: "DIV", + isContentEditable: false, + closest: (sel: string) => (sel === ".xterm" ? ({} as HTMLElement) : null), + }; + return { ...ev(code, mods), target } as unknown as KeyboardEvent; + } + + it("⌘R inside the terminal still refreshes files on Mac", () => { + const r = resolveEvent(terminalTarget("KeyR", { metaKey: true }), null); + if (r.kind !== "single") throw new Error("expected single match"); + expect(r.command).toBe(COMMANDS.filesRefresh); + }); + + it("⌘W inside the terminal still closes the tab on Mac", () => { + const r = resolveEvent(terminalTarget("KeyW", { metaKey: true }), null); + if (r.kind !== "single") throw new Error("expected single match"); + expect(r.command).toBe(COMMANDS.tabClose); + }); + + it("⌘K inside the terminal still arms the chord leader on Mac", () => { + const r = resolveEvent(terminalTarget("KeyK", { metaKey: true }), null); + expect(r.kind).toBe("chord-leader"); + }); +}); + describe("resolveEvent — chord pending", () => { const PENDING = "CmdOrCtrl+K"; diff --git a/tests/unit/renderer/services/editor/keybindings/apply.test.ts b/tests/unit/renderer/services/editor/keybindings/apply.test.ts new file mode 100644 index 00000000..73c07894 --- /dev/null +++ b/tests/unit/renderer/services/editor/keybindings/apply.test.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import type * as Monaco from "monaco-editor"; + +// --------------------------------------------------------------------------- +// Fake Monaco — captures addKeybindingRules calls and supplies the KeyMod / +// KeyCode constants the converter reads. Exact numeric values are arbitrary; +// the tests assert relationships (same command unbound/bound, idempotency), +// not absolute encodings. +// +// The monaco-singleton is stubbed via mock.module (bun mocks are global and +// another test file replaces requireMonaco with a thrower) — so we register +// OUR stub and dynamic-import the subject AFTER it, binding apply.ts to this +// fake regardless of cross-file evaluation order. +// --------------------------------------------------------------------------- + +interface Rule { + keybinding: number; + command: string; +} + +const calls: Rule[][] = []; + +const KeyMod = { CtrlCmd: 1 << 11, Shift: 1 << 10, Alt: 1 << 9, WinCtrl: 1 << 8 }; + +// Stable per-name code numbers for every member the converter may look up. +const KeyCode = new Proxy( + {}, + { + get(_t, prop: string) { + let h = 0; + for (let i = 0; i < prop.length; i++) h = (h * 31 + prop.charCodeAt(i)) & 0xff; + return h | 0x1000; // keep it a distinct, non-zero number + }, + }, +) as unknown as typeof Monaco.KeyCode; + +const fakeMonaco = { + KeyMod, + KeyCode, + editor: { + addKeybindingRules: (rules: Rule[]) => { + calls.push(rules); + }, + }, +} as unknown as typeof Monaco; + +mock.module("../../../../../../src/renderer/services/editor/runtime/monaco-singleton", () => ({ + initializeMonacoSingleton: () => {}, + isMonacoReady: () => true, + onMonacoReady: () => () => {}, + requireMonaco: () => fakeMonaco, +})); + +const { applyEditorKeybindingOverrides, __resetAppliedEditorBindingsForTests } = await import( + "../../../../../../src/renderer/services/editor/keybindings/apply" +); + +const COMMENT = "editor.action.commentLine"; + +function lastCall(): Rule[] { + return calls[calls.length - 1] as Rule[]; +} + +describe("applyEditorKeybindingOverrides reconciler", () => { + beforeEach(() => { + calls.length = 0; + __resetAppliedEditorBindingsForTests(); + }); + + test("fresh state with no overrides emits no rules (defaults already active)", () => { + applyEditorKeybindingOverrides([]); + expect(calls.length).toBe(0); + }); + + test("binding a user key unbinds the default and binds the new key — only for that command", () => { + applyEditorKeybindingOverrides([{ command: COMMENT, primary: "CmdOrCtrl+Shift+C" }]); + + expect(calls.length).toBe(1); + const rules = lastCall(); + // Exactly the changed command: one unbind (-id) + one bind (id). + expect(rules.some((r) => r.command === `-${COMMENT}`)).toBe(true); + expect(rules.some((r) => r.command === COMMENT)).toBe(true); + // No other command touched. + for (const r of rules) { + expect(r.command.replace(/^-/, "")).toBe(COMMENT); + } + }); + + test("re-applying the same override is a no-op (idempotent)", () => { + applyEditorKeybindingOverrides([{ command: COMMENT, primary: "CmdOrCtrl+Shift+C" }]); + calls.length = 0; + applyEditorKeybindingOverrides([{ command: COMMENT, primary: "CmdOrCtrl+Shift+C" }]); + expect(calls.length).toBe(0); + }); + + test("rebinding twice unbinds the PREVIOUS user key, not the original default", () => { + applyEditorKeybindingOverrides([{ command: COMMENT, primary: "CmdOrCtrl+Shift+C" }]); + const firstBound = lastCall().find((r) => r.command === COMMENT)?.keybinding; + expect(firstBound).toBeDefined(); + + calls.length = 0; + applyEditorKeybindingOverrides([{ command: COMMENT, primary: "CmdOrCtrl+Shift+J" }]); + const rules = lastCall(); + const unbind = rules.find((r) => r.command === `-${COMMENT}`); + // The unbind targets the keystroke we previously BOUND, so no stale key lingers. + expect(unbind?.keybinding).toBe(firstBound as number); + }); + + test("unbind (primary:null) removes the default and binds nothing", () => { + applyEditorKeybindingOverrides([{ command: COMMENT, primary: null }]); + const rules = lastCall(); + expect(rules.some((r) => r.command === `-${COMMENT}`)).toBe(true); + expect(rules.some((r) => r.command === COMMENT)).toBe(false); + }); + + test("reset (override removed) restores the default keystroke", () => { + applyEditorKeybindingOverrides([{ command: COMMENT, primary: "CmdOrCtrl+Shift+C" }]); + calls.length = 0; + applyEditorKeybindingOverrides([]); // back to defaults + const rules = lastCall(); + // Unbind the user key, re-bind the default to the command. + expect(rules.some((r) => r.command === `-${COMMENT}`)).toBe(true); + expect(rules.some((r) => r.command === COMMENT)).toBe(true); + }); + + test("overrides for unknown (non-curated) command ids are ignored", () => { + applyEditorKeybindingOverrides([ + { command: "editor.action.notCurated", primary: "CmdOrCtrl+J" }, + ]); + expect(calls.length).toBe(0); + }); +}); diff --git a/tests/unit/shared/conflicts-table.test.ts b/tests/unit/shared/conflicts-table.test.ts new file mode 100644 index 00000000..5bdc249b --- /dev/null +++ b/tests/unit/shared/conflicts-table.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test"; +import { + type ConflictBinding, + detectTableConflicts, +} from "../../../src/shared/keybindings/conflicts"; +import { KEYBINDINGS } from "../../../src/shared/keybindings/index"; + +describe("detectTableConflicts — persistent, table-wide view", () => { + test("no collisions → empty map", () => { + const bindings: ConflictBinding[] = [ + { command: "a", primary: "CmdOrCtrl+K" }, + { command: "b", primary: "CmdOrCtrl+J" }, + ]; + expect(detectTableConflicts(bindings, true).size).toBe(0); + }); + + test("two unscoped commands on the same key → BOTH rows flagged (blocking)", () => { + const bindings: ConflictBinding[] = [ + { command: "a", primary: "CmdOrCtrl+K" }, + { command: "b", primary: "CmdOrCtrl+K" }, + ]; + const map = detectTableConflicts(bindings, true); + // Symmetric: both participants get an entry so each row can badge. + expect(map.get("a")?.[0]?.kind).toBe("blocking"); + expect(map.get("a")?.[0]?.command).toBe("b"); + expect(map.get("b")?.[0]?.command).toBe("a"); + }); + + test("same key but differing (non-disjoint) scopes → overlap, not blocking", () => { + const bindings: ConflictBinding[] = [ + { command: "a", primary: "CmdOrCtrl+R", when: "fileTreeFocus" }, + { command: "b", primary: "CmdOrCtrl+R", when: "inputFocus" }, + ]; + const map = detectTableConflicts(bindings, true); + expect(map.get("a")?.[0]?.kind).toBe("overlap"); + }); + + test("reproduces the indirect-conflict case: resetting onto a now-taken key surfaces on both rows", () => { + // Scenario: command X was moved off ⌘K; command Y was set to ⌘K; then X + // is reset back to its ⌘K default. The effective table now has two ⌘K + // bindings — the table-wide pass flags BOTH, where the old record-time + // check (which only ran while editing) would have shown nothing. + const effectiveAfterReset: ConflictBinding[] = [ + { command: "x", primary: "CmdOrCtrl+K" }, // reset back to default + { command: "y", primary: "CmdOrCtrl+K" }, // user-assigned earlier + ]; + const map = detectTableConflicts(effectiveAfterReset, true); + expect(map.has("x")).toBe(true); + expect(map.has("y")).toBe(true); + }); + + test("reserved/shadow collisions are excluded from the table view (record-time only)", () => { + // ⌘/ shadows Monaco's comment toggle, but a single binding on it is not + // a command-vs-command conflict — the table view stays quiet. + const bindings: ConflictBinding[] = [{ command: "a", primary: "CmdOrCtrl+/" }]; + expect(detectTableConflicts(bindings, true).size).toBe(0); + }); + + test("provably-disjoint scopes (X vs !X) are NOT flagged — browser-tab routing", () => { + // The shape our ⌘R / ⌘⇧R defaults actually ship with: same key, but + // gated on browserTabActive vs !browserTabActive — mutually exclusive, + // so never a real collision. + const r: ConflictBinding[] = [ + { + command: "files.refresh", + primary: "CmdOrCtrl+R", + when: "!browserTabActive && (!terminalFocus || isMac)", + }, + { command: "browser.reload", primary: "CmdOrCtrl+R", when: "browserTabActive" }, + ]; + expect(detectTableConflicts(r, true).size).toBe(0); + + const shiftR: ConflictBinding[] = [ + { command: "files.refresh", primary: "CmdOrCtrl+Shift+R", when: "!browserTabActive" }, + { command: "browser.hardReload", primary: "CmdOrCtrl+Shift+R", when: "browserTabActive" }, + ]; + expect(detectTableConflicts(shiftR, true).size).toBe(0); + }); + + test("different-but-NOT-disjoint scopes still warn (overlap is conservative)", () => { + const bindings: ConflictBinding[] = [ + { command: "a", primary: "CmdOrCtrl+K", when: "fileTreeFocus" }, + { command: "b", primary: "CmdOrCtrl+K", when: "inputFocus" }, + ]; + // fileTreeFocus and inputFocus are not provably exclusive → overlap. + expect(detectTableConflicts(bindings, true).get("a")?.[0]?.kind).toBe("overlap"); + }); + + test("the shipped default KEYBINDINGS table has ZERO conflicts on both platforms", () => { + expect(detectTableConflicts(KEYBINDINGS, true).size).toBe(0); + expect(detectTableConflicts(KEYBINDINGS, false).size).toBe(0); + }); +}); diff --git a/tests/unit/shared/editor-commands.test.ts b/tests/unit/shared/editor-commands.test.ts new file mode 100644 index 00000000..c1d0a10a --- /dev/null +++ b/tests/unit/shared/editor-commands.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "bun:test"; +import { detectConflicts } from "../../../src/shared/keybindings/conflicts"; +import { + ALL_EDITOR_COMMAND_IDS, + EDITOR_COMMANDS, + editorCommandDefault, +} from "../../../src/shared/keybindings/editor-commands"; +import { parseAccelerator } from "../../../src/shared/keybindings/keybinding-parse"; + +describe("editor command catalog", () => { + test("command ids and slugs are unique", () => { + const ids = EDITOR_COMMANDS.map((c) => c.id); + const slugs = EDITOR_COMMANDS.map((c) => c.slug); + expect(new Set(ids).size).toBe(ids.length); + expect(new Set(slugs).size).toBe(slugs.length); + }); + + test("every declared default keystroke is a parseable accelerator", () => { + for (const c of EDITOR_COMMANDS) { + if (c.defaultPrimary === undefined) continue; + expect(() => parseAccelerator(c.defaultPrimary as string)).not.toThrow(); + } + }); + + test("ALL_EDITOR_COMMAND_IDS mirrors the catalog", () => { + expect(ALL_EDITOR_COMMAND_IDS.size).toBe(EDITOR_COMMANDS.length); + for (const c of EDITOR_COMMANDS) expect(ALL_EDITOR_COMMAND_IDS.has(c.id)).toBe(true); + }); + + test("editorCommandDefault returns the declared default, or null when unbound", () => { + expect(editorCommandDefault("editor.action.commentLine")).toBe("CmdOrCtrl+/"); + expect(editorCommandDefault("editor.action.duplicateSelection")).toBeNull(); + expect(editorCommandDefault("editor.action.nope")).toBeNull(); + }); +}); + +describe("conflict engine over editor (string-id) bindings", () => { + // The engine was generalized to plain string command ids so editor + // (Monaco) commands reuse it. A capture colliding with another editor + // command's key (both unscoped) is blocking. + const bindings = [ + { command: "editor.action.moveLinesUpAction", primary: "Alt+Up" }, + { command: "editor.action.moveLinesDownAction", primary: "Alt+Down" }, + ]; + + test("same keystroke as another editor command → blocking", () => { + const conflicts = detectConflicts({ + command: "editor.action.moveLinesDownAction", + primary: "Alt+Up", // collides with moveLinesUp + bindings, + isMac: true, + }); + expect( + conflicts.some( + (c) => c.kind === "blocking" && c.command === "editor.action.moveLinesUpAction", + ), + ).toBe(true); + }); + + test("rebinding a command to its own key is not a self-conflict", () => { + const conflicts = detectConflicts({ + command: "editor.action.moveLinesUpAction", + primary: "Alt+Up", + bindings, + isMac: true, + }); + // No blocking against itself; may still flag a reserved/shadow entry. + expect(conflicts.some((c) => c.kind === "blocking")).toBe(false); + }); +}); diff --git a/tests/unit/shared/keybinding-overrides.test.ts b/tests/unit/shared/keybinding-overrides.test.ts new file mode 100644 index 00000000..9762a901 --- /dev/null +++ b/tests/unit/shared/keybinding-overrides.test.ts @@ -0,0 +1,331 @@ +/** + * Pure tests for the user-override layer: schema validation, the + * defaults+delta merge, the conflict engine, and the recorder's + * event→accelerator reverse function. + */ +import { describe, expect, it } from "bun:test"; +import { detectConflicts } from "../../../src/shared/keybindings/conflicts"; +import type { KeybindingDecl } from "../../../src/shared/keybindings/index"; +import { + eventToAccelerator, + matchesEvent, + normalizeKeystroke, + parseAccelerator, +} from "../../../src/shared/keybindings/keybinding-parse"; +import { + applyKeybindingOverrides, + KeybindingOverridesSchema, + removeOverride, + upsertOverride, +} from "../../../src/shared/keybindings/overrides"; +import { findReservedKey } from "../../../src/shared/keybindings/reserved-keys"; + +const KNOWN = new Set(["tab.close", "file.open", "file.save", "tab.closeAll", "files.refresh"]); + +const DEFAULTS: readonly KeybindingDecl[] = [ + { command: "tab.close" as never, primary: "CmdOrCtrl+W", when: "!terminalFocus || isMac" }, + { command: "file.open" as never, primary: "CmdOrCtrl+E" }, + { command: "file.open" as never, primary: "CmdOrCtrl+O" }, + { command: "tab.closeAll" as never, chord: ["CmdOrCtrl+K", "CmdOrCtrl+W"] }, + { command: "files.refresh" as never, primary: "CmdOrCtrl+R", when: "!browserTabActive" }, +]; + +describe("KeybindingOverridesSchema", () => { + it("accepts replace / unbind / chord entries", () => { + const parsed = KeybindingOverridesSchema.parse([ + { command: "tab.close", primary: "CmdOrCtrl+Shift+X" }, + { command: "file.open", primary: null }, + { command: "tab.closeAll", chord: ["CmdOrCtrl+K", "A"] }, + ]); + expect(parsed).toHaveLength(3); + }); + + it("rejects unparseable accelerator strings at the boundary", () => { + expect(() => + KeybindingOverridesSchema.parse([{ command: "tab.close", primary: "Cmd+NotAKey" }]), + ).toThrow(); + expect(() => + KeybindingOverridesSchema.parse([{ command: "tab.close", primary: "CmdOrCtrl+W+S" }]), + ).toThrow(); + }); + + it("accepts unknown command ids (version-skew tolerance)", () => { + // Unknown ids must validate — they are dropped at APPLY time, not + // parse time, so a stale state.json never fails wholesale. + const parsed = KeybindingOverridesSchema.parse([ + { command: "future.command", primary: "CmdOrCtrl+9" }, + ]); + expect(parsed).toHaveLength(1); + }); +}); + +describe("applyKeybindingOverrides", () => { + it("returns defaults untouched for empty/undefined overrides", () => { + expect(applyKeybindingOverrides(DEFAULTS, undefined, KNOWN)).toEqual([...DEFAULTS]); + expect(applyKeybindingOverrides(DEFAULTS, [], KNOWN)).toEqual([...DEFAULTS]); + }); + + it("replaces a primary in place and inherits the default `when`", () => { + const out = applyKeybindingOverrides( + DEFAULTS, + [{ command: "tab.close", primary: "CmdOrCtrl+Shift+X" }], + KNOWN, + ); + const tabClose = out.filter((b) => b.command === "tab.close"); + expect(tabClose).toHaveLength(1); + expect(tabClose[0]?.primary).toBe("CmdOrCtrl+Shift+X"); + expect(tabClose[0]?.when).toBe("!terminalFocus || isMac"); + // position preserved: still first in the table + expect(out[0]?.command).toBe("tab.close"); + }); + + it("collapses multi-declaration commands to one replacement", () => { + const out = applyKeybindingOverrides( + DEFAULTS, + [{ command: "file.open", primary: "CmdOrCtrl+P" }], + KNOWN, + ); + const fileOpen = out.filter((b) => b.command === "file.open"); + expect(fileOpen).toHaveLength(1); + expect(fileOpen[0]?.primary).toBe("CmdOrCtrl+P"); + }); + + it("unbinds with null (drops every default primary)", () => { + const out = applyKeybindingOverrides( + DEFAULTS, + [{ command: "file.open", primary: null }], + KNOWN, + ); + expect(out.some((b) => b.command === "file.open")).toBe(false); + }); + + it("replaces a chord", () => { + const out = applyKeybindingOverrides( + DEFAULTS, + [{ command: "tab.closeAll", chord: ["CmdOrCtrl+K", "A"] }], + KNOWN, + ); + const decl = out.find((b) => b.command === "tab.closeAll"); + expect(decl?.chord).toEqual(["CmdOrCtrl+K", "A"]); + }); + + it("drops overrides for unknown commands", () => { + const out = applyKeybindingOverrides( + DEFAULTS, + [{ command: "ghost.command", primary: "CmdOrCtrl+9" }], + KNOWN, + ); + expect(out).toEqual([...DEFAULTS]); + }); + + it("adds a primary to a chord-only command (appended)", () => { + const out = applyKeybindingOverrides( + DEFAULTS, + [{ command: "tab.closeAll", primary: "CmdOrCtrl+9" }], + KNOWN, + ); + const decls = out.filter((b) => b.command === "tab.closeAll"); + // default chord retained + appended primary decl + expect(decls.some((d) => d.chord !== undefined)).toBe(true); + expect(decls.some((d) => d.primary === "CmdOrCtrl+9")).toBe(true); + }); + + it("last override entry per command wins", () => { + const out = applyKeybindingOverrides( + DEFAULTS, + [ + { command: "tab.close", primary: "CmdOrCtrl+1" }, + { command: "tab.close", primary: "CmdOrCtrl+2" }, + ], + KNOWN, + ); + expect(out.find((b) => b.command === "tab.close")?.primary).toBe("CmdOrCtrl+2"); + }); +}); + +describe("upsertOverride / removeOverride", () => { + it("merges field-wise and removes empty entries", () => { + let list = upsertOverride([], { command: "tab.close", primary: "CmdOrCtrl+1" }); + list = upsertOverride(list, { command: "tab.close", chord: ["CmdOrCtrl+K", "X"] }); + expect(list).toHaveLength(1); + expect(list[0]).toEqual({ + command: "tab.close", + primary: "CmdOrCtrl+1", + chord: ["CmdOrCtrl+K", "X"], + }); + expect(removeOverride(list, "tab.close")).toHaveLength(0); + }); +}); + +describe("normalizeKeystroke", () => { + it("resolves CmdOrCtrl per platform", () => { + expect(normalizeKeystroke("CmdOrCtrl+R", true)).toBe("meta+KeyR"); + expect(normalizeKeystroke("CmdOrCtrl+R", false)).toBe("ctrl+KeyR"); + expect(normalizeKeystroke("Ctrl+R", true)).toBe("ctrl+KeyR"); + expect(normalizeKeystroke("Cmd+Ctrl+Up", true)).toBe("meta+ctrl+ArrowUp"); + }); + + it("returns null for unparseable input", () => { + expect(normalizeKeystroke("Nope+X+Y", true)).toBeNull(); + }); + + it("collides CmdOrCtrl+R with Ctrl+R on Win/Linux but not Mac", () => { + expect(normalizeKeystroke("CmdOrCtrl+R", false)).toBe(normalizeKeystroke("Ctrl+R", false)); + expect(normalizeKeystroke("CmdOrCtrl+R", true)).not.toBe(normalizeKeystroke("Ctrl+R", true)); + }); +}); + +describe("findReservedKey", () => { + it("flags Monaco's comment toggle (the ⌘/ regression, as data)", () => { + const hit = findReservedKey("CmdOrCtrl+/", true); + expect(hit?.source).toBe("monaco"); + }); + + it("flags shell keys via the platform-resolved form", () => { + // CmdOrCtrl+R IS Ctrl+R on Win/Linux → terminal reservation hits… + expect(findReservedKey("CmdOrCtrl+R", false)?.source).toBe("terminal"); + // …but on Mac it's ⌘R, which no built-in owns. + expect(findReservedKey("CmdOrCtrl+R", true)).toBeUndefined(); + }); + + it("flags macOS system keys only on Mac", () => { + expect(findReservedKey("Cmd+Q", true)?.source).toBe("system"); + // On Win/Linux the mac-only system entry is skipped — but Cmd+Q + // normalizes to Ctrl+Q there, which the shell owns (XON). The + // platform filter correctly degrades the hit, not the protection. + expect(findReservedKey("Cmd+Q", false)?.source).toBe("terminal"); + }); +}); + +describe("detectConflicts", () => { + const isMac = true; + + it("blocks a duplicate keystroke when either side is unscoped", () => { + const conflicts = detectConflicts({ + command: "file.save" as never, + primary: "CmdOrCtrl+E", // file.open's key, file.open is unscoped + bindings: DEFAULTS, + isMac, + }); + expect(conflicts.some((c) => c.kind === "blocking" && c.command === "file.open")).toBe(true); + }); + + it("grades differing non-empty scopes as overlap, not blocking", () => { + const conflicts = detectConflicts({ + command: "file.save" as never, + primary: "CmdOrCtrl+R", + when: "editorFocus", + bindings: DEFAULTS, + isMac, + }); + const hit = conflicts.find((c) => c.command === ("files.refresh" as never)); + expect(hit?.kind).toBe("overlap"); + }); + + it("blocks a primary that equals another command's chord leader", () => { + const conflicts = detectConflicts({ + command: "file.save" as never, + primary: "CmdOrCtrl+K", + bindings: DEFAULTS, + isMac, + }); + expect(conflicts.some((c) => c.kind === "blocking" && c.command === "tab.closeAll")).toBe(true); + }); + + it("reports shadow for built-in keys and system for OS keys", () => { + const shadow = detectConflicts({ + command: "file.save" as never, + primary: "CmdOrCtrl+/", + bindings: DEFAULTS, + isMac, + }); + expect(shadow.some((c) => c.kind === "shadow" && c.reserved?.source === "monaco")).toBe(true); + + const system = detectConflicts({ + command: "file.save" as never, + primary: "Cmd+Q", + bindings: DEFAULTS, + isMac, + }); + expect(system.some((c) => c.kind === "system")).toBe(true); + }); + + it("returns nothing for a clean keystroke", () => { + const conflicts = detectConflicts({ + command: "file.save" as never, + primary: "CmdOrCtrl+Shift+9", + bindings: DEFAULTS, + isMac, + }); + expect(conflicts).toHaveLength(0); + }); + + it("ignores the command's own existing binding (self-conflict)", () => { + const conflicts = detectConflicts({ + command: "tab.close" as never, + primary: "CmdOrCtrl+W", + bindings: DEFAULTS, + isMac, + }); + expect(conflicts.some((c) => c.command === ("tab.close" as never))).toBe(false); + }); +}); + +describe("eventToAccelerator", () => { + interface MockKE { + metaKey: boolean; + ctrlKey: boolean; + shiftKey: boolean; + altKey: boolean; + code: string; + } + function ev(code: string, mods: Partial> = {}): KeyboardEvent { + return { + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + code, + ...mods, + } as unknown as KeyboardEvent; + } + + it("maps ⌘⇧X on Mac and Ctrl+Shift+X on Win/Linux", () => { + expect(eventToAccelerator(ev("KeyX", { metaKey: true, shiftKey: true }), true)).toBe( + "CmdOrCtrl+Shift+X", + ); + expect(eventToAccelerator(ev("KeyX", { ctrlKey: true, shiftKey: true }), false)).toBe( + "CmdOrCtrl+Shift+X", + ); + }); + + it("keeps literal Ctrl and Cmd+Ctrl distinct on Mac", () => { + expect(eventToAccelerator(ev("KeyR", { ctrlKey: true }), true)).toBe("Ctrl+R"); + expect(eventToAccelerator(ev("ArrowUp", { metaKey: true, ctrlKey: true }), true)).toBe( + "Cmd+Ctrl+Up", + ); + }); + + it("rejects modifier-only, unknown codes, and Win-key combos", () => { + expect(eventToAccelerator(ev("MetaLeft", { metaKey: true }), true)).toBeNull(); + expect(eventToAccelerator(ev("NumpadAdd", { metaKey: true }), true)).toBeNull(); + expect(eventToAccelerator(ev("KeyX", { metaKey: true }), false)).toBeNull(); + }); + + it("round-trips through parseAccelerator + matchesEvent", () => { + const cases: Array<[KeyboardEvent, boolean]> = [ + [ev("KeyW", { metaKey: true }), true], + [ev("Backslash", { metaKey: true, shiftKey: true }), true], + [ev("BracketLeft", { metaKey: true }), true], + [ev("F2"), true], + [ev("KeyR", { ctrlKey: true }), false], + [ev("Slash", { ctrlKey: true, altKey: true }), false], + ]; + for (const [event, isMac] of cases) { + const accel = eventToAccelerator(event, isMac); + expect(accel).not.toBeNull(); + if (accel === null) continue; + expect(matchesEvent(parseAccelerator(accel), event, isMac)).toBe(true); + } + }); +}); From 1b6c26031ca946bd100906350f0694932db0155e Mon Sep 17 00:00:00 2001 From: moreih29 Date: Wed, 10 Jun 2026 15:19:53 +0900 Subject: [PATCH 4/4] chore: bump version to 0.7.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b495e4fa..785c9f52 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nexus-code", "productName": "NexusCode", - "version": "0.6.2", + "version": "0.7.0", "description": "Multi-workspace VSCode-style editor for macOS. Monaco editor + terminal in one window.", "license": "MIT", "private": true,