authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-15 14:20:50-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-15 15:51:05-08:00
log6667992753c3995fb591356ce8784624c6d4bd05
tree6d15a4eeeaad60a5367fd428566db66573d7b002
parent41ba1ec7a8965b42a7a7bf48065e5a800d46dc9e

Harden keybinding schemas and align tests with server defaults

- make resolved keybinding/shortcut schemas strict to reject unknown fields - export server `DEFAULT_KEYBINDINGS` as source of truth and reuse it in ws tests - simplify keybinding test fixtures and update docs to reference server defaults

6 files changed, 82 insertions(+), 162 deletions(-)

KEYBINDINGS.md+3-10
...@@ -21,11 +21,8 @@ The file must be a JSON array of rules:...@@ -21,11 +21,8 @@ The file must be a JSON array of rules:
2121
22```json22```json
23[23[
24 { "key": "mod+j", "command": "terminal.toggle" },24 { "key": "mod+g", "command": "terminal.toggle" },
25 { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" },25 { "key": "mod+shift+g", "command": "terminal.new", "when": "terminalFocus" }
26 { "key": "mod+shift+d", "command": "terminal.new", "when": "terminalFocus" },
27 { "key": "mod+shift+o", "command": "chat.new" },
28 { "key": "mod+o", "command": "editor.openFavorite" }
29]26]
30```27```
3128
...@@ -90,11 +87,7 @@ Unknown condition keys evaluate to `false`....@@ -90,11 +87,7 @@ Unknown condition keys evaluate to `false`.
9087
91Built-in defaults are resolved on the server and sent to the web client via `server.getConfig`:88Built-in defaults are resolved on the server and sent to the web client via `server.getConfig`:
9289
93- `terminal.toggle`: `mod+j`90- Source of truth: [`DEFAULT_KEYBINDINGS` in `apps/server/src/keybindings.ts`](apps/server/src/keybindings.ts)
94- `terminal.split`: `mod+d` when `terminalFocus`
95- `terminal.new`: `mod+shift+d` when `terminalFocus`
96- `chat.new`: `mod+shift+o`
97- `editor.openFavorite`: `mod+o`
9891
99If you define any rules for a command, the default rule for that command is removed on the server.92If you define any rules for a command, the default rule for that command is removed on the server.
10093
apps/server/src/keybindings.ts+1-1
...@@ -24,7 +24,7 @@ type WhenToken =...@@ -24,7 +24,7 @@ type WhenToken =
24 | { type: "lparen" }24 | { type: "lparen" }
25 | { type: "rparen" };25 | { type: "rparen" };
2626
27const DEFAULT_KEYBINDINGS: KeybindingsConfig = [27export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
28 { key: "mod+j", command: "terminal.toggle" },28 { key: "mod+j", command: "terminal.toggle" },
29 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },29 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
30 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },30 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
apps/server/src/wsServer.test.ts+22-42
...@@ -17,7 +17,7 @@ import {...@@ -17,7 +17,7 @@ import {
17 type WsResponse,17 type WsResponse,
18} from "@t3tools/contracts";18} from "@t3tools/contracts";
19import { ProjectRegistry } from "./projectRegistry";19import { ProjectRegistry } from "./projectRegistry";
20import { compileResolvedKeybindingRule } from "./keybindings";20import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings";
21import type {21import type {
22 TerminalClearInput,22 TerminalClearInput,
23 TerminalCloseInput,23 TerminalCloseInput,
...@@ -207,6 +207,20 @@ function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsCon...@@ -207,6 +207,20 @@ function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsCon
207 return resolved;207 return resolved;
208}208}
209209
210const DEFAULT_RESOLVED_KEYBINDINGS = compileKeybindings([...DEFAULT_KEYBINDINGS]);
211
212function mergeWithDefaultsForTest(custom: KeybindingsConfig): ResolvedKeybindingsConfig {
213 if (custom.length === 0) {
214 return DEFAULT_RESOLVED_KEYBINDINGS;
215 }
216
217 const overriddenCommands = new Set(custom.map((binding) => binding.command));
218 const retainedDefaults = DEFAULT_KEYBINDINGS.filter(
219 (binding) => !overriddenCommands.has(binding.command),
220 );
221 return compileKeybindings([...retainedDefaults, ...custom].slice(-256));
222}
223
210describe("WebSocket Server", () => {224describe("WebSocket Server", () => {
211 let server: ReturnType<typeof createServer> | null = null;225 let server: ReturnType<typeof createServer> | null = null;
212 const connections: WebSocket[] = [];226 const connections: WebSocket[] = [];
...@@ -324,13 +338,7 @@ describe("WebSocket Server", () => {...@@ -324,13 +338,7 @@ describe("WebSocket Server", () => {
324 expect(response.error).toBeUndefined();338 expect(response.error).toBeUndefined();
325 expect(response.result).toEqual({339 expect(response.result).toEqual({
326 cwd: "/my/workspace",340 cwd: "/my/workspace",
327 keybindings: compileKeybindings([341 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
328 { key: "mod+j", command: "terminal.toggle" },
329 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
330 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
331 { key: "mod+shift+o", command: "chat.new" },
332 { key: "mod+o", command: "editor.openFavorite" },
333 ]),
334 });342 });
335 });343 });
336344
...@@ -363,9 +371,7 @@ describe("WebSocket Server", () => {...@@ -363,9 +371,7 @@ describe("WebSocket Server", () => {
363 expect(response.error).toBeUndefined();371 expect(response.error).toBeUndefined();
364 expect(response.result).toEqual({372 expect(response.result).toEqual({
365 cwd: "/my/workspace",373 cwd: "/my/workspace",
366 keybindings: compileKeybindings([374 keybindings: mergeWithDefaultsForTest([
367 { key: "mod+shift+o", command: "chat.new" },
368 { key: "mod+o", command: "editor.openFavorite" },
369 { key: "cmd+j", command: "terminal.toggle" },375 { key: "cmd+j", command: "terminal.toggle" },
370 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },376 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
371 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },377 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
...@@ -402,11 +408,7 @@ describe("WebSocket Server", () => {...@@ -402,11 +408,7 @@ describe("WebSocket Server", () => {
402 expect(response.error).toBeUndefined();408 expect(response.error).toBeUndefined();
403 expect(response.result).toEqual({409 expect(response.result).toEqual({
404 cwd: "/my/workspace",410 cwd: "/my/workspace",
405 keybindings: compileKeybindings([411 keybindings: mergeWithDefaultsForTest([
406 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
407 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
408 { key: "mod+shift+o", command: "chat.new" },
409 { key: "mod+o", command: "editor.openFavorite" },
410 { key: "mod+j", command: "terminal.toggle" },412 { key: "mod+j", command: "terminal.toggle" },
411 ]),413 ]),
412 });414 });
...@@ -443,13 +445,7 @@ describe("WebSocket Server", () => {...@@ -443,13 +445,7 @@ describe("WebSocket Server", () => {
443 expect(response.error).toBeUndefined();445 expect(response.error).toBeUndefined();
444 expect(response.result).toEqual({446 expect(response.result).toEqual({
445 cwd: "/my/workspace",447 cwd: "/my/workspace",
446 keybindings: compileKeybindings([448 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
447 { key: "mod+j", command: "terminal.toggle" },
448 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
449 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
450 { key: "mod+shift+o", command: "chat.new" },
451 { key: "mod+o", command: "editor.openFavorite" },
452 ]),
453 });449 });
454 expect(450 expect(
455 warnSpy.mock.calls.some(([message]) =>451 warnSpy.mock.calls.some(([message]) =>
...@@ -483,11 +479,7 @@ describe("WebSocket Server", () => {...@@ -483,11 +479,7 @@ describe("WebSocket Server", () => {
483 expect(firstResponse.error).toBeUndefined();479 expect(firstResponse.error).toBeUndefined();
484 expect(firstResponse.result).toEqual({480 expect(firstResponse.result).toEqual({
485 cwd: "/my/workspace",481 cwd: "/my/workspace",
486 keybindings: compileKeybindings([482 keybindings: mergeWithDefaultsForTest([
487 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
488 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
489 { key: "mod+shift+o", command: "chat.new" },
490 { key: "mod+o", command: "editor.openFavorite" },
491 { key: "cmd+j", command: "terminal.toggle" },483 { key: "cmd+j", command: "terminal.toggle" },
492 ]),484 ]),
493 });485 });
...@@ -529,13 +521,7 @@ describe("WebSocket Server", () => {...@@ -529,13 +521,7 @@ describe("WebSocket Server", () => {
529 expect(response.error).toBeUndefined();521 expect(response.error).toBeUndefined();
530 expect(response.result).toEqual({522 expect(response.result).toEqual({
531 cwd: "/my/workspace",523 cwd: "/my/workspace",
532 keybindings: compileKeybindings([524 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
533 { key: "mod+j", command: "terminal.toggle" },
534 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
535 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
536 { key: "mod+shift+o", command: "chat.new" },
537 { key: "mod+o", command: "editor.openFavorite" },
538 ]),
539 });525 });
540 expect(526 expect(
541 warnSpy.mock.calls.some(([message]) =>527 warnSpy.mock.calls.some(([message]) =>
...@@ -566,13 +552,7 @@ describe("WebSocket Server", () => {...@@ -566,13 +552,7 @@ describe("WebSocket Server", () => {
566 expect(response.error).toBeUndefined();552 expect(response.error).toBeUndefined();
567 expect(response.result).toEqual({553 expect(response.result).toEqual({
568 cwd: "/my/workspace",554 cwd: "/my/workspace",
569 keybindings: compileKeybindings([555 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
570 { key: "mod+j", command: "terminal.toggle" },
571 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
572 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
573 { key: "mod+shift+o", command: "chat.new" },
574 { key: "mod+o", command: "editor.openFavorite" },
575 ]),
576 });556 });
577 expect(557 expect(
578 warnSpy.mock.calls.some(([message]) =>558 warnSpy.mock.calls.some(([message]) =>
apps/web/src/keybindings.test.ts+37-106
...@@ -29,75 +29,21 @@ function event(overrides: Partial<ShortcutEventLike> = {}): ShortcutEventLike {...@@ -29,75 +29,21 @@ function event(overrides: Partial<ShortcutEventLike> = {}): ShortcutEventLike {
29 };29 };
30}30}
3131
32function parseShortcut(value: string): KeybindingShortcut | null {32function modShortcut(
33 const rawTokens = value.toLowerCase().split("+").map((token) => token.trim());33 key: string,
34 const tokens = [...rawTokens];34 overrides: Partial<Omit<KeybindingShortcut, "key">> = {},
35 let trailingEmptyCount = 0;35): KeybindingShortcut {
36 while (tokens[tokens.length - 1] === "") {
37 trailingEmptyCount += 1;
38 tokens.pop();
39 }
40 if (trailingEmptyCount > 0) {
41 tokens.push("+");
42 }
43 if (tokens.some((token) => token.length === 0)) {
44 return null;
45 }
46 if (tokens.length === 0) return null;
47
48 let key: string | null = null;
49 let metaKey = false;
50 let ctrlKey = false;
51 let shiftKey = false;
52 let altKey = false;
53 let modKey = false;
54
55 for (const token of tokens) {
56 switch (token) {
57 case "cmd":
58 case "meta":
59 metaKey = true;
60 break;
61 case "ctrl":
62 case "control":
63 ctrlKey = true;
64 break;
65 case "shift":
66 shiftKey = true;
67 break;
68 case "alt":
69 case "option":
70 altKey = true;
71 break;
72 case "mod":
73 modKey = true;
74 break;
75 default: {
76 if (key !== null) return null;
77 key = token;
78 }
79 }
80 }
81
82 if (key === null) return null;
83 return {36 return {
84 key,37 key,
85 metaKey,38 metaKey: false,
86 ctrlKey,39 ctrlKey: false,
87 shiftKey,40 shiftKey: false,
88 altKey,41 altKey: false,
89 modKey,42 modKey: true,
43 ...overrides,
90 };44 };
91}45}
9246
93function shortcut(value: string): KeybindingShortcut {
94 const parsed = parseShortcut(value);
95 if (!parsed) {
96 throw new Error(`invalid shortcut in test fixture: ${value}`);
97 }
98 return parsed;
99}
100
101function whenIdentifier(name: string): KeybindingWhenNode {47function whenIdentifier(name: string): KeybindingWhenNode {
102 return { type: "identifier", name };48 return { type: "identifier", name };
103}49}
...@@ -111,48 +57,33 @@ function whenAnd(left: KeybindingWhenNode, right: KeybindingWhenNode): Keybindin...@@ -111,48 +57,33 @@ function whenAnd(left: KeybindingWhenNode, right: KeybindingWhenNode): Keybindin
111}57}
11258
113interface TestBinding {59interface TestBinding {
114 key: string;60 shortcut: KeybindingShortcut;
115 command: KeybindingCommand;61 command: KeybindingCommand;
116 whenAst?: KeybindingWhenNode;62 whenAst?: KeybindingWhenNode;
117}63}
11864
119function compile(bindings: TestBinding[]): ResolvedKeybindingsConfig {65function compile(bindings: TestBinding[]): ResolvedKeybindingsConfig {
120 const resolved: ResolvedKeybindingsConfig = [];66 return bindings.map((binding) => ({
121 for (const binding of bindings) {67 command: binding.command,
122 const parsedShortcut = parseShortcut(binding.key);68 shortcut: binding.shortcut,
123 if (!parsedShortcut) {69 ...(binding.whenAst ? { whenAst: binding.whenAst } : {}),
124 throw new Error(`invalid shortcut in test fixture: ${binding.key}`);70 }));
125 }
126 if (binding.whenAst) {
127 resolved.push({
128 command: binding.command,
129 shortcut: parsedShortcut,
130 whenAst: binding.whenAst,
131 });
132 continue;
133 }
134 resolved.push({
135 command: binding.command,
136 shortcut: parsedShortcut,
137 });
138 }
139 return resolved;
140}71}
14172
142const DEFAULT_BINDINGS = compile([73const DEFAULT_BINDINGS = compile([
143 { key: "mod+j", command: "terminal.toggle" },74 { shortcut: modShortcut("j"), command: "terminal.toggle" },
144 {75 {
145 key: "mod+d",76 shortcut: modShortcut("d"),
146 command: "terminal.split",77 command: "terminal.split",
147 whenAst: whenIdentifier("terminalFocus"),78 whenAst: whenIdentifier("terminalFocus"),
148 },79 },
149 {80 {
150 key: "mod+shift+d",81 shortcut: modShortcut("d", { shiftKey: true }),
151 command: "terminal.new",82 command: "terminal.new",
152 whenAst: whenIdentifier("terminalFocus"),83 whenAst: whenIdentifier("terminalFocus"),
153 },84 },
154 { key: "mod+shift+o", command: "chat.new" },85 { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" },
155 { key: "mod+o", command: "editor.openFavorite" },86 { shortcut: modShortcut("o"), command: "editor.openFavorite" },
156]);87]);
15788
158describe("isTerminalToggleShortcut", () => {89describe("isTerminalToggleShortcut", () => {
...@@ -203,16 +134,16 @@ describe("split/new terminal shortcuts", () => {...@@ -203,16 +134,16 @@ describe("split/new terminal shortcuts", () => {
203 it("supports when expressions", () => {134 it("supports when expressions", () => {
204 const keybindings = compile([135 const keybindings = compile([
205 {136 {
206 key: "mod+\\",137 shortcut: modShortcut("\\"),
207 command: "terminal.split",138 command: "terminal.split",
208 whenAst: whenAnd(whenIdentifier("terminalOpen"), whenNot(whenIdentifier("terminalFocus"))),139 whenAst: whenAnd(whenIdentifier("terminalOpen"), whenNot(whenIdentifier("terminalFocus"))),
209 },140 },
210 {141 {
211 key: "mod+shift+n",142 shortcut: modShortcut("n", { shiftKey: true }),
212 command: "terminal.new",143 command: "terminal.new",
213 whenAst: whenAnd(whenIdentifier("terminalOpen"), whenNot(whenIdentifier("terminalFocus"))),144 whenAst: whenAnd(whenIdentifier("terminalOpen"), whenNot(whenIdentifier("terminalFocus"))),
214 },145 },
215 { key: "mod+j", command: "terminal.toggle" },146 { shortcut: modShortcut("j"), command: "terminal.toggle" },
216 ]);147 ]);
217 assert.isTrue(148 assert.isTrue(
218 isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, {149 isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, {
...@@ -236,8 +167,8 @@ describe("split/new terminal shortcuts", () => {...@@ -236,8 +167,8 @@ describe("split/new terminal shortcuts", () => {
236167
237 it("supports when boolean literals", () => {168 it("supports when boolean literals", () => {
238 const keybindings = compile([169 const keybindings = compile([
239 { key: "mod+n", command: "terminal.new", whenAst: whenIdentifier("true") },170 { shortcut: modShortcut("n"), command: "terminal.new", whenAst: whenIdentifier("true") },
240 { key: "mod+m", command: "terminal.new", whenAst: whenIdentifier("false") },171 { shortcut: modShortcut("m"), command: "terminal.new", whenAst: whenIdentifier("false") },
241 ]);172 ]);
242173
243 assert.isTrue(174 assert.isTrue(
...@@ -257,12 +188,12 @@ describe("shortcutLabelForCommand", () => {...@@ -257,12 +188,12 @@ describe("shortcutLabelForCommand", () => {
257 it("returns the most recent binding label", () => {188 it("returns the most recent binding label", () => {
258 const bindings = compile([189 const bindings = compile([
259 {190 {
260 key: "mod+\\",191 shortcut: modShortcut("\\"),
261 command: "terminal.split",192 command: "terminal.split",
262 whenAst: whenIdentifier("terminalFocus"),193 whenAst: whenIdentifier("terminalFocus"),
263 },194 },
264 {195 {
265 key: "mod+shift+\\",196 shortcut: modShortcut("\\", { shiftKey: true }),
266 command: "terminal.split",197 command: "terminal.split",
267 whenAst: whenNot(whenIdentifier("terminalFocus")),198 whenAst: whenNot(whenIdentifier("terminalFocus")),
268 },199 },
...@@ -310,9 +241,9 @@ describe("chat/editor shortcuts", () => {...@@ -310,9 +241,9 @@ describe("chat/editor shortcuts", () => {
310describe("cross-command precedence", () => {241describe("cross-command precedence", () => {
311 it("uses when + order so a later focused rule overrides a global rule", () => {242 it("uses when + order so a later focused rule overrides a global rule", () => {
312 const keybindings = compile([243 const keybindings = compile([
313 { key: "mod+n", command: "chat.new" },244 { shortcut: modShortcut("n"), command: "chat.new" },
314 {245 {
315 key: "mod+n",246 shortcut: modShortcut("n"),
316 command: "terminal.new",247 command: "terminal.new",
317 whenAst: whenIdentifier("terminalFocus"),248 whenAst: whenIdentifier("terminalFocus"),
318 },249 },
...@@ -347,11 +278,11 @@ describe("cross-command precedence", () => {...@@ -347,11 +278,11 @@ describe("cross-command precedence", () => {
347 it("still lets a later global rule win when both rules match", () => {278 it("still lets a later global rule win when both rules match", () => {
348 const keybindings = compile([279 const keybindings = compile([
349 {280 {
350 key: "mod+n",281 shortcut: modShortcut("n"),
351 command: "terminal.new",282 command: "terminal.new",
352 whenAst: whenIdentifier("terminalFocus"),283 whenAst: whenIdentifier("terminalFocus"),
353 },284 },
354 { key: "mod+n", command: "chat.new" },285 { shortcut: modShortcut("n"), command: "chat.new" },
355 ]);286 ]);
356287
357 assert.isFalse(288 assert.isFalse(
...@@ -371,16 +302,16 @@ describe("cross-command precedence", () => {...@@ -371,16 +302,16 @@ describe("cross-command precedence", () => {
371302
372describe("formatShortcutLabel", () => {303describe("formatShortcutLabel", () => {
373 it("formats labels for macOS", () => {304 it("formats labels for macOS", () => {
374 assert.strictEqual(formatShortcutLabel(shortcut("mod+shift+d"), "MacIntel"), "⇧⌘D");305 assert.strictEqual(formatShortcutLabel(modShortcut("d", { shiftKey: true }), "MacIntel"), "⇧⌘D");
375 });306 });
376307
377 it("formats labels for non-macOS", () => {308 it("formats labels for non-macOS", () => {
378 assert.strictEqual(formatShortcutLabel(shortcut("mod+shift+d"), "Linux"), "Ctrl+Shift+D");309 assert.strictEqual(formatShortcutLabel(modShortcut("d", { shiftKey: true }), "Linux"), "Ctrl+Shift+D");
379 });310 });
380311
381 it("formats labels for plus key", () => {312 it("formats labels for plus key", () => {
382 assert.strictEqual(formatShortcutLabel(shortcut("mod++"), "MacIntel"), "⌘+");313 assert.strictEqual(formatShortcutLabel(modShortcut("+"), "MacIntel"), "⌘+");
383 assert.strictEqual(formatShortcutLabel(shortcut("mod++"), "Linux"), "Ctrl++");314 assert.strictEqual(formatShortcutLabel(modShortcut("+"), "Linux"), "Ctrl++");
384 });315 });
385});316});
386317
...@@ -397,7 +328,7 @@ describe("isTerminalClearShortcut", () => {...@@ -397,7 +328,7 @@ describe("isTerminalClearShortcut", () => {
397328
398describe("plus key parsing", () => {329describe("plus key parsing", () => {
399 it("matches the plus key shortcut", () => {330 it("matches the plus key shortcut", () => {
400 const plusBindings = compile([{ key: "mod++", command: "terminal.toggle" }]);331 const plusBindings = compile([{ shortcut: modShortcut("+"), command: "terminal.toggle" }]);
401 assert.isTrue(332 assert.isTrue(
402 isTerminalToggleShortcut(event({ key: "+", metaKey: true }), plusBindings, {333 isTerminalToggleShortcut(event({ key: "+", metaKey: true }), plusBindings, {
403 platform: "MacIntel",334 platform: "MacIntel",
packages/contracts/src/keybindings.test.ts+17-1
...@@ -61,7 +61,6 @@ describe("keybindings contracts", () => {...@@ -61,7 +61,6 @@ describe("keybindings contracts", () => {
61 it("parses resolved keybindings arrays", () => {61 it("parses resolved keybindings arrays", () => {
62 const parsed = resolvedKeybindingsConfigSchema.parse([62 const parsed = resolvedKeybindingsConfigSchema.parse([
63 {63 {
64 key: "mod+j",
65 command: "terminal.toggle",64 command: "terminal.toggle",
66 shortcut: {65 shortcut: {
67 key: "j",66 key: "j",
...@@ -75,4 +74,21 @@ describe("keybindings contracts", () => {...@@ -75,4 +74,21 @@ describe("keybindings contracts", () => {
75 ]);74 ]);
76 assert.lengthOf(parsed, 1);75 assert.lengthOf(parsed, 1);
77 });76 });
77
78 it("rejects unknown fields in resolved keybinding rules", () => {
79 assert.throws(() =>
80 resolvedKeybindingRuleSchema.parse({
81 command: "terminal.toggle",
82 shortcut: {
83 key: "j",
84 metaKey: false,
85 ctrlKey: false,
86 shiftKey: false,
87 altKey: false,
88 modKey: true,
89 },
90 key: "mod+j",
91 }),
92 );
93 });
78});94});
packages/contracts/src/keybindings.ts+2-2
...@@ -26,7 +26,7 @@ export const keybindingShortcutSchema = z.object({...@@ -26,7 +26,7 @@ export const keybindingShortcutSchema = z.object({
26 shiftKey: z.boolean(),26 shiftKey: z.boolean(),
27 altKey: z.boolean(),27 altKey: z.boolean(),
28 modKey: z.boolean(),28 modKey: z.boolean(),
29});29}).strict();
3030
31export type KeybindingWhenNode =31export type KeybindingWhenNode =
32 | { type: "identifier"; name: string }32 | { type: "identifier"; name: string }
...@@ -61,7 +61,7 @@ export const resolvedKeybindingRuleSchema = z.object({...@@ -61,7 +61,7 @@ export const resolvedKeybindingRuleSchema = z.object({
61 command: keybindingCommandSchema,61 command: keybindingCommandSchema,
62 shortcut: keybindingShortcutSchema,62 shortcut: keybindingShortcutSchema,
63 whenAst: keybindingWhenNodeSchema.optional(),63 whenAst: keybindingWhenNodeSchema.optional(),
64});64}).strict();
6565
66export const resolvedKeybindingsConfigSchema = z.array(resolvedKeybindingRuleSchema).max(256);66export const resolvedKeybindingsConfigSchema = z.array(resolvedKeybindingRuleSchema).max(256);
6767