| author | |
| committer | |
| log | f1cd76c17af2fa9496729ef08056ae69e1f27742 |
| tree | 07667147cacd92bd25766c607d58750e3238ad96 |
| parent | 31aa32b83bb234ef7f2bdad6a898d06ea1d0552e |
- Move default keybinding resolution/override merging into `server.getConfig`
- Return server-resolved keybindings to the web client instead of resolving in UI
- Update docs and tests to reflect server-side defaults, overrides, and truncation behavior6 files changed, 108 insertions(+), 134 deletions(-)
KEYBINDINGS.md+11-2| ... | @@ -4,6 +4,15 @@ T3 Code reads keybindings from: | ... | @@ -4,6 +4,15 @@ T3 Code reads keybindings from: |
| 4 | 4 | ||
| 5 | - `~/.t3/keybindings.json` | 5 | - `~/.t3/keybindings.json` |
| 6 | 6 | ||
| 7 | Schema source of truth: | ||
| 8 | |||
| 9 | - `packages/contracts/src/keybindings.ts` | ||
| 10 | - `packages/contracts/src/server.ts` | ||
| 11 | |||
| 12 | Server-side default resolution/merging: | ||
| 13 | |||
| 14 | - `apps/server/src/wsServer.ts` | ||
| 15 | |||
| 7 | The file must be a JSON array of rules: | 16 | The file must be a JSON array of rules: |
| 8 | 17 | ||
| 9 | ```json | 18 | ```json |
| ... | @@ -71,13 +80,13 @@ Unknown condition keys evaluate to `false`. | ... | @@ -71,13 +80,13 @@ Unknown condition keys evaluate to `false`. |
| 71 | 80 | ||
| 72 | ## Defaults | 81 | ## Defaults |
| 73 | 82 | ||
| 74 | Built-in defaults: | 83 | Built-in defaults are resolved on the server and sent to the web client via `server.getConfig`: |
| 75 | 84 | ||
| 76 | - `terminal.toggle`: `mod+j` | 85 | - `terminal.toggle`: `mod+j` |
| 77 | - `terminal.split`: `mod+d` when `terminalFocus` | 86 | - `terminal.split`: `mod+d` when `terminalFocus` |
| 78 | - `terminal.new`: `mod+shift+d` when `terminalFocus` | 87 | - `terminal.new`: `mod+shift+d` when `terminalFocus` |
| 79 | 88 | ||
| 80 | If you define any rules for a command, the default rule for that command is removed. | 89 | If you define any rules for a command, the default rule for that command is removed on the server. |
| 81 | 90 | ||
| 82 | ## Precedence | 91 | ## Precedence |
| 83 | 92 |
apps/server/src/wsServer.test.ts+20-4| ... | @@ -309,7 +309,11 @@ describe("WebSocket Server", () => { | ... | @@ -309,7 +309,11 @@ describe("WebSocket Server", () => { |
| 309 | expect(response.error).toBeUndefined(); | 309 | expect(response.error).toBeUndefined(); |
| 310 | expect(response.result).toEqual({ | 310 | expect(response.result).toEqual({ |
| 311 | cwd: "/my/workspace", | 311 | cwd: "/my/workspace", |
| 312 | keybindings: [], | 312 | keybindings: [ |
| 313 | { key: "mod+j", command: "terminal.toggle" }, | ||
| 314 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, | ||
| 315 | { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, | ||
| 316 | ], | ||
| 313 | }); | 317 | }); |
| 314 | }); | 318 | }); |
| 315 | 319 | ||
| ... | @@ -379,7 +383,11 @@ describe("WebSocket Server", () => { | ... | @@ -379,7 +383,11 @@ describe("WebSocket Server", () => { |
| 379 | expect(response.error).toBeUndefined(); | 383 | expect(response.error).toBeUndefined(); |
| 380 | expect(response.result).toEqual({ | 384 | expect(response.result).toEqual({ |
| 381 | cwd: "/my/workspace", | 385 | cwd: "/my/workspace", |
| 382 | keybindings: [{ key: "mod+j", command: "terminal.toggle" }], | 386 | keybindings: [ |
| 387 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, | ||
| 388 | { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, | ||
| 389 | { key: "mod+j", command: "terminal.toggle" }, | ||
| 390 | ], | ||
| 383 | }); | 391 | }); |
| 384 | expect( | 392 | expect( |
| 385 | warnSpy.mock.calls.some(([message]) => | 393 | warnSpy.mock.calls.some(([message]) => |
| ... | @@ -414,7 +422,11 @@ describe("WebSocket Server", () => { | ... | @@ -414,7 +422,11 @@ describe("WebSocket Server", () => { |
| 414 | expect(response.error).toBeUndefined(); | 422 | expect(response.error).toBeUndefined(); |
| 415 | expect(response.result).toEqual({ | 423 | expect(response.result).toEqual({ |
| 416 | cwd: "/my/workspace", | 424 | cwd: "/my/workspace", |
| 417 | keybindings: [], | 425 | keybindings: [ |
| 426 | { key: "mod+j", command: "terminal.toggle" }, | ||
| 427 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, | ||
| 428 | { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, | ||
| 429 | ], | ||
| 418 | }); | 430 | }); |
| 419 | expect( | 431 | expect( |
| 420 | warnSpy.mock.calls.some(([message]) => | 432 | warnSpy.mock.calls.some(([message]) => |
| ... | @@ -445,7 +457,11 @@ describe("WebSocket Server", () => { | ... | @@ -445,7 +457,11 @@ describe("WebSocket Server", () => { |
| 445 | expect(response.error).toBeUndefined(); | 457 | expect(response.error).toBeUndefined(); |
| 446 | expect(response.result).toEqual({ | 458 | expect(response.result).toEqual({ |
| 447 | cwd: "/my/workspace", | 459 | cwd: "/my/workspace", |
| 448 | keybindings: [], | 460 | keybindings: [ |
| 461 | { key: "mod+j", command: "terminal.toggle" }, | ||
| 462 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, | ||
| 463 | { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, | ||
| 464 | ], | ||
| 449 | }); | 465 | }); |
| 450 | expect( | 466 | expect( |
| 451 | warnSpy.mock.calls.some(([message]) => | 467 | warnSpy.mock.calls.some(([message]) => |
apps/server/src/wsServer.ts+42-11| ... | @@ -70,6 +70,31 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined { | ... | @@ -70,6 +70,31 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined { |
| 70 | return undefined; | 70 | return undefined; |
| 71 | } | 71 | } |
| 72 | 72 | ||
| 73 | const DEFAULT_KEYBINDINGS: KeybindingsConfig = [ | ||
| 74 | { key: "mod+j", command: "terminal.toggle" }, | ||
| 75 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, | ||
| 76 | { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, | ||
| 77 | ]; | ||
| 78 | |||
| 79 | function mergeWithDefaultKeybindings(custom: KeybindingsConfig): KeybindingsConfig { | ||
| 80 | if (custom.length === 0) { | ||
| 81 | return [...DEFAULT_KEYBINDINGS]; | ||
| 82 | } | ||
| 83 | |||
| 84 | const overriddenCommands = new Set(custom.map((binding) => binding.command)); | ||
| 85 | const retainedDefaults = DEFAULT_KEYBINDINGS.filter( | ||
| 86 | (binding) => !overriddenCommands.has(binding.command), | ||
| 87 | ); | ||
| 88 | const merged = [...retainedDefaults, ...custom]; | ||
| 89 | |||
| 90 | if (merged.length <= 256) { | ||
| 91 | return merged; | ||
| 92 | } | ||
| 93 | |||
| 94 | // Keep the latest rules when the config exceeds max size; later rules have higher precedence. | ||
| 95 | return merged.slice(-256); | ||
| 96 | } | ||
| 97 | |||
| 73 | function readKeybindingsConfig(logger: ReturnType<typeof createLogger>): KeybindingsConfig { | 98 | function readKeybindingsConfig(logger: ReturnType<typeof createLogger>): KeybindingsConfig { |
| 74 | const configPath = path.join(os.homedir(), ".t3", "keybindings.json"); | 99 | const configPath = path.join(os.homedir(), ".t3", "keybindings.json"); |
| 75 | try { | 100 | try { |
| ... | @@ -79,7 +104,7 @@ function readKeybindingsConfig(logger: ReturnType<typeof createLogger>): Keybind | ... | @@ -79,7 +104,7 @@ function readKeybindingsConfig(logger: ReturnType<typeof createLogger>): Keybind |
| 79 | logger.warn("ignoring keybindings config with unsupported format; expected array", { | 104 | logger.warn("ignoring keybindings config with unsupported format; expected array", { |
| 80 | path: configPath, | 105 | path: configPath, |
| 81 | }); | 106 | }); |
| 82 | return []; | 107 | return [...DEFAULT_KEYBINDINGS]; |
| 83 | } | 108 | } |
| 84 | 109 | ||
| 85 | const sanitized: KeybindingsConfig = []; | 110 | const sanitized: KeybindingsConfig = []; |
| ... | @@ -88,13 +113,6 @@ function readKeybindingsConfig(logger: ReturnType<typeof createLogger>): Keybind | ... | @@ -88,13 +113,6 @@ function readKeybindingsConfig(logger: ReturnType<typeof createLogger>): Keybind |
| 88 | const result = keybindingRuleSchema.safeParse(entry); | 113 | const result = keybindingRuleSchema.safeParse(entry); |
| 89 | if (result.success) { | 114 | if (result.success) { |
| 90 | sanitized.push(result.data); | 115 | sanitized.push(result.data); |
| 91 | if (sanitized.length >= 256) { | ||
| 92 | logger.warn("truncating keybindings config to max entries", { | ||
| 93 | path: configPath, | ||
| 94 | maxEntries: 256, | ||
| 95 | }); | ||
| 96 | break; | ||
| 97 | } | ||
| 98 | continue; | 116 | continue; |
| 99 | } | 117 | } |
| 100 | invalidEntries += 1; | 118 | invalidEntries += 1; |
| ... | @@ -106,17 +124,30 @@ function readKeybindingsConfig(logger: ReturnType<typeof createLogger>): Keybind | ... | @@ -106,17 +124,30 @@ function readKeybindingsConfig(logger: ReturnType<typeof createLogger>): Keybind |
| 106 | totalEntries: parsed.length, | 124 | totalEntries: parsed.length, |
| 107 | }); | 125 | }); |
| 108 | } | 126 | } |
| 109 | return sanitized; | 127 | const mergedBeforeCap = [ |
| 128 | ...DEFAULT_KEYBINDINGS.filter( | ||
| 129 | (binding) => !new Set(sanitized.map((entry) => entry.command)).has(binding.command), | ||
| 130 | ), | ||
| 131 | ...sanitized, | ||
| 132 | ]; | ||
| 133 | const merged = mergeWithDefaultKeybindings(sanitized); | ||
| 134 | if (mergedBeforeCap.length > 256) { | ||
| 135 | logger.warn("truncating merged keybindings config to max entries", { | ||
| 136 | path: configPath, | ||
| 137 | maxEntries: 256, | ||
| 138 | }); | ||
| 139 | } | ||
| 140 | return merged; | ||
| 110 | } catch (error) { | 141 | } catch (error) { |
| 111 | if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { | 142 | if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { |
| 112 | return []; | 143 | return [...DEFAULT_KEYBINDINGS]; |
| 113 | } | 144 | } |
| 114 | logger.warn("ignoring malformed keybindings config", { | 145 | logger.warn("ignoring malformed keybindings config", { |
| 115 | path: configPath, | 146 | path: configPath, |
| 116 | error: error instanceof Error ? error.message : String(error), | 147 | error: error instanceof Error ? error.message : String(error), |
| 117 | }); | 148 | }); |
| 118 | } | 149 | } |
| 119 | return []; | 150 | return [...DEFAULT_KEYBINDINGS]; |
| 120 | } | 151 | } |
| 121 | 152 | ||
| 122 | export function createServer(options: ServerOptions) { | 153 | export function createServer(options: ServerOptions) { |
apps/web/src/components/ChatView.tsx+5-6| ... | @@ -52,11 +52,10 @@ import type { ChatImageAttachment } from "../types"; | ... | @@ -52,11 +52,10 @@ import type { ChatImageAttachment } from "../types"; |
| 52 | import BranchToolbar from "./BranchToolbar"; | 52 | import BranchToolbar from "./BranchToolbar"; |
| 53 | import GitActionsControl from "./GitActionsControl"; | 53 | import GitActionsControl from "./GitActionsControl"; |
| 54 | import { | 54 | import { |
| 55 | DEFAULT_TERMINAL_KEYBINDINGS, | ||
| 56 | isTerminalNewShortcut, | 55 | isTerminalNewShortcut, |
| 57 | isTerminalSplitShortcut, | 56 | isTerminalSplitShortcut, |
| 58 | isTerminalToggleShortcut, | 57 | isTerminalToggleShortcut, |
| 59 | resolveTerminalKeybindings, | 58 | type ResolvedTerminalKeybindings, |
| 60 | shortcutLabelForCommand, | 59 | shortcutLabelForCommand, |
| 61 | } from "../keybindings"; | 60 | } from "../keybindings"; |
| 62 | import ChatMarkdown from "./ChatMarkdown"; | 61 | import ChatMarkdown from "./ChatMarkdown"; |
| ... | @@ -152,7 +151,7 @@ export default function ChatView() { | ... | @@ -152,7 +151,7 @@ export default function ChatView() { |
| 152 | const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({}); | 151 | const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({}); |
| 153 | const [nowTick, setNowTick] = useState(() => Date.now()); | 152 | const [nowTick, setNowTick] = useState(() => Date.now()); |
| 154 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); | 153 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); |
| 155 | const [terminalKeybindings, setTerminalKeybindings] = useState(DEFAULT_TERMINAL_KEYBINDINGS); | 154 | const [terminalKeybindings, setTerminalKeybindings] = useState<ResolvedTerminalKeybindings>([]); |
| 156 | const messagesScrollRef = useRef<HTMLDivElement>(null); | 155 | const messagesScrollRef = useRef<HTMLDivElement>(null); |
| 157 | const messagesEndRef = useRef<HTMLDivElement>(null); | 156 | const messagesEndRef = useRef<HTMLDivElement>(null); |
| 158 | const shouldAutoScrollRef = useRef(true); | 157 | const shouldAutoScrollRef = useRef(true); |
| ... | @@ -282,7 +281,7 @@ export default function ChatView() { | ... | @@ -282,7 +281,7 @@ export default function ChatView() { |
| 282 | 281 | ||
| 283 | useEffect(() => { | 282 | useEffect(() => { |
| 284 | if (!api || !api.server || typeof api.server.getConfig !== "function") { | 283 | if (!api || !api.server || typeof api.server.getConfig !== "function") { |
| 285 | setTerminalKeybindings(DEFAULT_TERMINAL_KEYBINDINGS); | 284 | setTerminalKeybindings([]); |
| 286 | return; | 285 | return; |
| 287 | } | 286 | } |
| 288 | 287 | ||
| ... | @@ -292,11 +291,11 @@ export default function ChatView() { | ... | @@ -292,11 +291,11 @@ export default function ChatView() { |
| 292 | .getConfig() | 291 | .getConfig() |
| 293 | .then((config) => { | 292 | .then((config) => { |
| 294 | if (disposed) return; | 293 | if (disposed) return; |
| 295 | setTerminalKeybindings(resolveTerminalKeybindings(config.keybindings)); | 294 | setTerminalKeybindings(config.keybindings); |
| 296 | }) | 295 | }) |
| 297 | .catch(() => { | 296 | .catch(() => { |
| 298 | if (disposed) return; | 297 | if (disposed) return; |
| 299 | setTerminalKeybindings(DEFAULT_TERMINAL_KEYBINDINGS); | 298 | setTerminalKeybindings([]); |
| 300 | }); | 299 | }); |
| 301 | 300 | ||
| 302 | return () => { | 301 | return () => { |
apps/web/src/keybindings.test.ts+26-64| ... | @@ -1,13 +1,12 @@ | ... | @@ -1,13 +1,12 @@ |
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | ||
| 3 | import type { KeybindingsConfig } from "@t3tools/contracts"; | ||
| 3 | import { | 4 | import { |
| 4 | DEFAULT_TERMINAL_KEYBINDINGS, | ||
| 5 | formatShortcutLabel, | 5 | formatShortcutLabel, |
| 6 | isTerminalClearShortcut, | 6 | isTerminalClearShortcut, |
| 7 | isTerminalNewShortcut, | 7 | isTerminalNewShortcut, |
| 8 | isTerminalSplitShortcut, | 8 | isTerminalSplitShortcut, |
| 9 | isTerminalToggleShortcut, | 9 | isTerminalToggleShortcut, |
| 10 | resolveTerminalKeybindings, | ||
| 11 | shortcutLabelForCommand, | 10 | shortcutLabelForCommand, |
| 12 | type ShortcutEventLike, | 11 | type ShortcutEventLike, |
| 13 | } from "./keybindings"; | 12 | } from "./keybindings"; |
| ... | @@ -23,65 +22,55 @@ function event(overrides: Partial<ShortcutEventLike> = {}): ShortcutEventLike { | ... | @@ -23,65 +22,55 @@ function event(overrides: Partial<ShortcutEventLike> = {}): ShortcutEventLike { |
| 23 | }; | 22 | }; |
| 24 | } | 23 | } |
| 25 | 24 | ||
| 25 | const DEFAULT_BINDINGS: KeybindingsConfig = [ | ||
| 26 | { key: "mod+j", command: "terminal.toggle" }, | ||
| 27 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, | ||
| 28 | { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, | ||
| 29 | ]; | ||
| 30 | |||
| 26 | describe("isTerminalToggleShortcut", () => { | 31 | describe("isTerminalToggleShortcut", () => { |
| 27 | it("matches Cmd+J on macOS", () => { | 32 | it("matches Cmd+J on macOS", () => { |
| 28 | expect( | 33 | expect(isTerminalToggleShortcut(event({ metaKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel" })).toBe(true); |
| 29 | isTerminalToggleShortcut(event({ metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, { | ||
| 30 | platform: "MacIntel", | ||
| 31 | }), | ||
| 32 | ).toBe(true); | ||
| 33 | }); | 34 | }); |
| 34 | 35 | ||
| 35 | it("matches Ctrl+J on non-macOS", () => { | 36 | it("matches Ctrl+J on non-macOS", () => { |
| 36 | expect( | 37 | expect(isTerminalToggleShortcut(event({ ctrlKey: true }), DEFAULT_BINDINGS, { platform: "Win32" })).toBe(true); |
| 37 | isTerminalToggleShortcut(event({ ctrlKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, { | ||
| 38 | platform: "Win32", | ||
| 39 | }), | ||
| 40 | ).toBe(true); | ||
| 41 | }); | 38 | }); |
| 42 | }); | 39 | }); |
| 43 | 40 | ||
| 44 | describe("split/new terminal shortcuts", () => { | 41 | describe("split/new terminal shortcuts", () => { |
| 45 | it("requires terminalFocus for default split/new bindings", () => { | 42 | it("requires terminalFocus for default split/new bindings", () => { |
| 46 | expect( | 43 | expect( |
| 47 | isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, { | 44 | isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, { |
| 48 | platform: "MacIntel", | 45 | platform: "MacIntel", |
| 49 | context: { terminalFocus: false }, | 46 | context: { terminalFocus: false }, |
| 50 | }), | 47 | }), |
| 51 | ).toBe(false); | 48 | ).toBe(false); |
| 52 | expect( | 49 | expect( |
| 53 | isTerminalNewShortcut( | 50 | isTerminalNewShortcut(event({ key: "d", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { |
| 54 | event({ key: "d", ctrlKey: true, shiftKey: true }), | 51 | platform: "Linux", |
| 55 | DEFAULT_TERMINAL_KEYBINDINGS, | 52 | context: { terminalFocus: false }, |
| 56 | { | 53 | }), |
| 57 | platform: "Linux", | ||
| 58 | context: { terminalFocus: false }, | ||
| 59 | }, | ||
| 60 | ), | ||
| 61 | ).toBe(false); | 54 | ).toBe(false); |
| 62 | }); | 55 | }); |
| 63 | 56 | ||
| 64 | it("matches split/new when terminalFocus is true", () => { | 57 | it("matches split/new when terminalFocus is true", () => { |
| 65 | expect( | 58 | expect( |
| 66 | isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, { | 59 | isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, { |
| 67 | platform: "MacIntel", | 60 | platform: "MacIntel", |
| 68 | context: { terminalFocus: true }, | 61 | context: { terminalFocus: true }, |
| 69 | }), | 62 | }), |
| 70 | ).toBe(true); | 63 | ).toBe(true); |
| 71 | expect( | 64 | expect( |
| 72 | isTerminalNewShortcut( | 65 | isTerminalNewShortcut(event({ key: "d", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { |
| 73 | event({ key: "d", ctrlKey: true, shiftKey: true }), | 66 | platform: "Linux", |
| 74 | DEFAULT_TERMINAL_KEYBINDINGS, | 67 | context: { terminalFocus: true }, |
| 75 | { | 68 | }), |
| 76 | platform: "Linux", | ||
| 77 | context: { terminalFocus: true }, | ||
| 78 | }, | ||
| 79 | ), | ||
| 80 | ).toBe(true); | 69 | ).toBe(true); |
| 81 | }); | 70 | }); |
| 82 | 71 | ||
| 83 | it("supports configured overrides with when expressions", () => { | 72 | it("supports when expressions", () => { |
| 84 | const keybindings = resolveTerminalKeybindings([ | 73 | const keybindings: KeybindingsConfig = [ |
| 85 | { | 74 | { |
| 86 | key: "mod+\\", | 75 | key: "mod+\\", |
| 87 | command: "terminal.split", | 76 | command: "terminal.split", |
| ... | @@ -92,7 +81,8 @@ describe("split/new terminal shortcuts", () => { | ... | @@ -92,7 +81,8 @@ describe("split/new terminal shortcuts", () => { |
| 92 | command: "terminal.new", | 81 | command: "terminal.new", |
| 93 | when: "terminalOpen && !terminalFocus", | 82 | when: "terminalOpen && !terminalFocus", |
| 94 | }, | 83 | }, |
| 95 | ]); | 84 | { key: "mod+j", command: "terminal.toggle" }, |
| 85 | ]; | ||
| 96 | expect( | 86 | expect( |
| 97 | isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, { | 87 | isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, { |
| 98 | platform: "Win32", | 88 | platform: "Win32", |
| ... | @@ -114,41 +104,13 @@ describe("split/new terminal shortcuts", () => { | ... | @@ -114,41 +104,13 @@ describe("split/new terminal shortcuts", () => { |
| 114 | }); | 104 | }); |
| 115 | }); | 105 | }); |
| 116 | 106 | ||
| 117 | describe("resolveTerminalKeybindings", () => { | ||
| 118 | it("uses defaults when config is empty", () => { | ||
| 119 | expect(resolveTerminalKeybindings(undefined)).toEqual(DEFAULT_TERMINAL_KEYBINDINGS); | ||
| 120 | }); | ||
| 121 | |||
| 122 | it("replaces defaults for commands configured by user", () => { | ||
| 123 | const resolved = resolveTerminalKeybindings([ | ||
| 124 | { key: "mod+\\", command: "terminal.split", when: "terminalFocus" }, | ||
| 125 | ]); | ||
| 126 | |||
| 127 | expect(resolved).toEqual([ | ||
| 128 | { key: "mod+j", command: "terminal.toggle" }, | ||
| 129 | { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, | ||
| 130 | { key: "mod+\\", command: "terminal.split", when: "terminalFocus" }, | ||
| 131 | ]); | ||
| 132 | }); | ||
| 133 | |||
| 134 | it("ignores invalid configured values", () => { | ||
| 135 | expect( | ||
| 136 | resolveTerminalKeybindings([ | ||
| 137 | { key: " ", command: "terminal.toggle" }, | ||
| 138 | { key: "mod+d+f", command: "terminal.split" }, | ||
| 139 | ]), | ||
| 140 | ).toEqual(DEFAULT_TERMINAL_KEYBINDINGS); | ||
| 141 | }); | ||
| 142 | }); | ||
| 143 | |||
| 144 | describe("shortcutLabelForCommand", () => { | 107 | describe("shortcutLabelForCommand", () => { |
| 145 | it("returns the most recent binding label", () => { | 108 | it("returns the most recent binding label", () => { |
| 146 | const resolved = resolveTerminalKeybindings([ | 109 | const bindings: KeybindingsConfig = [ |
| 147 | { key: "mod+\\", command: "terminal.split", when: "terminalFocus" }, | 110 | { key: "mod+\\", command: "terminal.split", when: "terminalFocus" }, |
| 148 | { key: "mod+shift+\\", command: "terminal.split", when: "!terminalFocus" }, | 111 | { key: "mod+shift+\\", command: "terminal.split", when: "!terminalFocus" }, |
| 149 | ]); | 112 | ]; |
| 150 | 113 | expect(shortcutLabelForCommand(bindings, "terminal.split", "Linux")).toBe("Ctrl+Shift+\\"); | |
| 151 | expect(shortcutLabelForCommand(resolved, "terminal.split", "Linux")).toBe("Ctrl+Shift+\\"); | ||
| 152 | }); | 114 | }); |
| 153 | }); | 115 | }); |
| 154 | 116 |
apps/web/src/keybindings.ts+4-47| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { KeybindingCommand, KeybindingRule, KeybindingsConfig } from "@t3tools/contracts"; | 1 | import type { KeybindingCommand, KeybindingsConfig } from "@t3tools/contracts"; |
| 2 | 2 | ||
| 3 | export interface ShortcutEventLike { | 3 | export interface ShortcutEventLike { |
| 4 | key: string; | 4 | key: string; |
| ... | @@ -14,15 +14,8 @@ export interface ShortcutMatchContext { | ... | @@ -14,15 +14,8 @@ export interface ShortcutMatchContext { |
| 14 | [key: string]: boolean; | 14 | [key: string]: boolean; |
| 15 | } | 15 | } |
| 16 | 16 | ||
| 17 | export type ResolvedTerminalKeybinding = KeybindingRule; | ||
| 18 | export type ResolvedTerminalKeybindings = KeybindingsConfig; | 17 | export type ResolvedTerminalKeybindings = KeybindingsConfig; |
| 19 | 18 | ||
| 20 | export const DEFAULT_TERMINAL_KEYBINDINGS: ResolvedTerminalKeybindings = [ | ||
| 21 | { key: "mod+j", command: "terminal.toggle" }, | ||
| 22 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, | ||
| 23 | { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, | ||
| 24 | ]; | ||
| 25 | |||
| 26 | interface ParsedShortcut { | 19 | interface ParsedShortcut { |
| 27 | key: string; | 20 | key: string; |
| 28 | metaKey: boolean; | 21 | metaKey: boolean; |
| ... | @@ -306,21 +299,6 @@ function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatc | ... | @@ -306,21 +299,6 @@ function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatc |
| 306 | }; | 299 | }; |
| 307 | } | 300 | } |
| 308 | 301 | ||
| 309 | function normalizeConfiguredKeybinding( | ||
| 310 | keybinding: KeybindingRule, | ||
| 311 | ): ResolvedTerminalKeybinding | null { | ||
| 312 | const key = keybinding.key.trim(); | ||
| 313 | if (key.length === 0) return null; | ||
| 314 | if (!parseShortcutValue(key)) return null; | ||
| 315 | |||
| 316 | const when = keybinding.when?.trim(); | ||
| 317 | return { | ||
| 318 | key, | ||
| 319 | command: keybinding.command, | ||
| 320 | ...(when ? { when } : {}), | ||
| 321 | }; | ||
| 322 | } | ||
| 323 | |||
| 324 | function matchesCommandShortcut( | 302 | function matchesCommandShortcut( |
| 325 | event: ShortcutEventLike, | 303 | event: ShortcutEventLike, |
| 326 | keybindings: ResolvedTerminalKeybindings, | 304 | keybindings: ResolvedTerminalKeybindings, |
| ... | @@ -340,27 +318,6 @@ function matchesCommandShortcut( | ... | @@ -340,27 +318,6 @@ function matchesCommandShortcut( |
| 340 | return false; | 318 | return false; |
| 341 | } | 319 | } |
| 342 | 320 | ||
| 343 | export function resolveTerminalKeybindings( | ||
| 344 | input: KeybindingsConfig | null | undefined, | ||
| 345 | ): ResolvedTerminalKeybindings { | ||
| 346 | if (!input || input.length === 0) { | ||
| 347 | return [...DEFAULT_TERMINAL_KEYBINDINGS]; | ||
| 348 | } | ||
| 349 | |||
| 350 | const normalizedInput = input | ||
| 351 | .map((binding) => normalizeConfiguredKeybinding(binding)) | ||
| 352 | .filter((binding): binding is ResolvedTerminalKeybinding => binding !== null); | ||
| 353 | if (normalizedInput.length === 0) { | ||
| 354 | return [...DEFAULT_TERMINAL_KEYBINDINGS]; | ||
| 355 | } | ||
| 356 | |||
| 357 | const overriddenCommands = new Set(normalizedInput.map((binding) => binding.command)); | ||
| 358 | const retainedDefaults = DEFAULT_TERMINAL_KEYBINDINGS.filter( | ||
| 359 | (binding) => !overriddenCommands.has(binding.command), | ||
| 360 | ); | ||
| 361 | return [...retainedDefaults, ...normalizedInput]; | ||
| 362 | } | ||
| 363 | |||
| 364 | function formatShortcutKeyLabel(key: string): string { | 321 | function formatShortcutKeyLabel(key: string): string { |
| 365 | if (key === " ") return "Space"; | 322 | if (key === " ") return "Space"; |
| 366 | if (key.length === 1) return key.toUpperCase(); | 323 | if (key.length === 1) return key.toUpperCase(); |
| ... | @@ -413,7 +370,7 @@ export function shortcutLabelForCommand( | ... | @@ -413,7 +370,7 @@ export function shortcutLabelForCommand( |
| 413 | 370 | ||
| 414 | export function isTerminalToggleShortcut( | 371 | export function isTerminalToggleShortcut( |
| 415 | event: ShortcutEventLike, | 372 | event: ShortcutEventLike, |
| 416 | keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS, | 373 | keybindings: ResolvedTerminalKeybindings, |
| 417 | options?: ShortcutMatchOptions, | 374 | options?: ShortcutMatchOptions, |
| 418 | ): boolean { | 375 | ): boolean { |
| 419 | return matchesCommandShortcut(event, keybindings, "terminal.toggle", options); | 376 | return matchesCommandShortcut(event, keybindings, "terminal.toggle", options); |
| ... | @@ -421,7 +378,7 @@ export function isTerminalToggleShortcut( | ... | @@ -421,7 +378,7 @@ export function isTerminalToggleShortcut( |
| 421 | 378 | ||
| 422 | export function isTerminalSplitShortcut( | 379 | export function isTerminalSplitShortcut( |
| 423 | event: ShortcutEventLike, | 380 | event: ShortcutEventLike, |
| 424 | keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS, | 381 | keybindings: ResolvedTerminalKeybindings, |
| 425 | options?: ShortcutMatchOptions, | 382 | options?: ShortcutMatchOptions, |
| 426 | ): boolean { | 383 | ): boolean { |
| 427 | return matchesCommandShortcut(event, keybindings, "terminal.split", options); | 384 | return matchesCommandShortcut(event, keybindings, "terminal.split", options); |
| ... | @@ -429,7 +386,7 @@ export function isTerminalSplitShortcut( | ... | @@ -429,7 +386,7 @@ export function isTerminalSplitShortcut( |
| 429 | 386 | ||
| 430 | export function isTerminalNewShortcut( | 387 | export function isTerminalNewShortcut( |
| 431 | event: ShortcutEventLike, | 388 | event: ShortcutEventLike, |
| 432 | keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS, | 389 | keybindings: ResolvedTerminalKeybindings, |
| 433 | options?: ShortcutMatchOptions, | 390 | options?: ShortcutMatchOptions, |
| 434 | ): boolean { | 391 | ): boolean { |
| 435 | return matchesCommandShortcut(event, keybindings, "terminal.new", options); | 392 | return matchesCommandShortcut(event, keybindings, "terminal.new", options); |