authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-15 13:51:37-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-15 15:51:05-08:00
log54fa15c72ed8e3dc8356f458c47462ee6c2f5745
tree8df198339177ddca3a689024ca6117bdc4056de3
parent370127760ac980eb790f347c172c2b51f6d3c501

Inline keybinding when AST type and update keybindings tests

- Move `KeybindingWhenNode` into `packages/contracts/src/keybindings.ts` - Remove deprecated `keybindingsWhen` module/export - Update web keybinding tests to build `whenAst` directly in fixtures

4 files changed, 56 insertions(+), 148 deletions(-)

apps/web/src/keybindings.test.ts+50-141
......@@ -1,8 +1,8 @@
11import { assert, describe, it } from "vitest";
22
33import {
4 type KeybindingCommand,
45 type KeybindingShortcut,
5 type KeybindingsConfig,
66 type KeybindingWhenNode,
77 type ResolvedKeybindingsConfig,
88} from "@t3tools/contracts";
......@@ -18,14 +18,6 @@ import {
1818 type ShortcutEventLike,
1919} from "./keybindings";
2020
21type WhenToken =
22 | { type: "identifier"; value: string }
23 | { type: "not" }
24 | { type: "and" }
25 | { type: "or" }
26 | { type: "lparen" }
27 | { type: "rparen" };
28
2921function event(overrides: Partial<ShortcutEventLike> = {}): ShortcutEventLike {
3022 return {
3123 key: "j",
......@@ -106,144 +98,37 @@ function shortcut(value: string): KeybindingShortcut {
10698 return parsed;
10799}
108100
109function tokenizeWhenExpression(expression: string): WhenToken[] | null {
110 const tokens: WhenToken[] = [];
111 let index = 0;
112
113 while (index < expression.length) {
114 const current = expression[index];
115 if (!current) break;
116
117 if (/\s/.test(current)) {
118 index += 1;
119 continue;
120 }
121 if (expression.startsWith("&&", index)) {
122 tokens.push({ type: "and" });
123 index += 2;
124 continue;
125 }
126 if (expression.startsWith("||", index)) {
127 tokens.push({ type: "or" });
128 index += 2;
129 continue;
130 }
131 if (current === "!") {
132 tokens.push({ type: "not" });
133 index += 1;
134 continue;
135 }
136 if (current === "(") {
137 tokens.push({ type: "lparen" });
138 index += 1;
139 continue;
140 }
141 if (current === ")") {
142 tokens.push({ type: "rparen" });
143 index += 1;
144 continue;
145 }
146
147 const identifier = /^[A-Za-z_][A-Za-z0-9_.-]*/.exec(expression.slice(index));
148 if (!identifier) return null;
149 tokens.push({ type: "identifier", value: identifier[0] });
150 index += identifier[0].length;
151 }
152
153 return tokens;
101function whenIdentifier(name: string): KeybindingWhenNode {
102 return { type: "identifier", name };
154103}
155104
156function parseWhenExpression(expression: string): KeybindingWhenNode | null {
157 const tokens = tokenizeWhenExpression(expression);
158 if (!tokens || tokens.length === 0) return null;
159 let index = 0;
160
161 const parsePrimary = (): KeybindingWhenNode | null => {
162 const token = tokens[index];
163 if (!token) return null;
164
165 if (token.type === "identifier") {
166 index += 1;
167 return { type: "identifier", name: token.value };
168 }
169
170 if (token.type === "lparen") {
171 index += 1;
172 const expressionNode = parseOr();
173 const closeToken = tokens[index];
174 if (!expressionNode || !closeToken || closeToken.type !== "rparen") {
175 return null;
176 }
177 index += 1;
178 return expressionNode;
179 }
180
181 return null;
182 };
183
184 const parseUnary = (): KeybindingWhenNode | null => {
185 const token = tokens[index];
186 if (token?.type === "not") {
187 index += 1;
188 const node = parseUnary();
189 if (!node) return null;
190 return { type: "not", node };
191 }
192 return parsePrimary();
193 };
194
195 const parseAnd = (): KeybindingWhenNode | null => {
196 let left = parseUnary();
197 if (!left) return null;
198
199 while (tokens[index]?.type === "and") {
200 index += 1;
201 const right = parseUnary();
202 if (!right) return null;
203 left = { type: "and", left, right };
204 }
205
206 return left;
207 };
208
209 const parseOr = (): KeybindingWhenNode | null => {
210 let left = parseAnd();
211 if (!left) return null;
212
213 while (tokens[index]?.type === "or") {
214 index += 1;
215 const right = parseAnd();
216 if (!right) return null;
217 left = { type: "or", left, right };
218 }
105function whenNot(node: KeybindingWhenNode): KeybindingWhenNode {
106 return { type: "not", node };
107}
219108
220 return left;
221 };
109function whenAnd(left: KeybindingWhenNode, right: KeybindingWhenNode): KeybindingWhenNode {
110 return { type: "and", left, right };
111}
222112
223 const ast = parseOr();
224 if (!ast || index !== tokens.length) return null;
225 return ast;
113interface TestBinding {
114 key: string;
115 command: KeybindingCommand;
116 whenAst?: KeybindingWhenNode;
226117}
227118
228function compile(bindings: KeybindingsConfig): ResolvedKeybindingsConfig {
119function compile(bindings: TestBinding[]): ResolvedKeybindingsConfig {
229120 const resolved: ResolvedKeybindingsConfig = [];
230121 for (const binding of bindings) {
231122 const parsedShortcut = parseShortcut(binding.key);
232123 if (!parsedShortcut) {
233124 throw new Error(`invalid shortcut in test fixture: ${binding.key}`);
234125 }
235 const when = binding.when?.trim();
236 if (when && when.length > 0) {
237 const whenAst = parseWhenExpression(when);
238 if (!whenAst) {
239 throw new Error(`invalid when expression in test fixture: ${when}`);
240 }
126 if (binding.whenAst) {
241127 resolved.push({
242128 key: binding.key.trim(),
243129 command: binding.command,
244 when,
245130 shortcut: parsedShortcut,
246 whenAst,
131 whenAst: binding.whenAst,
247132 });
248133 continue;
249134 }
......@@ -258,8 +143,16 @@ function compile(bindings: KeybindingsConfig): ResolvedKeybindingsConfig {
258143
259144const DEFAULT_BINDINGS = compile([
260145 { key: "mod+j", command: "terminal.toggle" },
261 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
262 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
146 {
147 key: "mod+d",
148 command: "terminal.split",
149 whenAst: whenIdentifier("terminalFocus"),
150 },
151 {
152 key: "mod+shift+d",
153 command: "terminal.new",
154 whenAst: whenIdentifier("terminalFocus"),
155 },
263156 { key: "mod+shift+o", command: "chat.new" },
264157 { key: "mod+o", command: "editor.openFavorite" },
265158]);
......@@ -314,12 +207,12 @@ describe("split/new terminal shortcuts", () => {
314207 {
315208 key: "mod+\\",
316209 command: "terminal.split",
317 when: "terminalOpen && !terminalFocus",
210 whenAst: whenAnd(whenIdentifier("terminalOpen"), whenNot(whenIdentifier("terminalFocus"))),
318211 },
319212 {
320213 key: "mod+shift+n",
321214 command: "terminal.new",
322 when: "terminalOpen && !terminalFocus",
215 whenAst: whenAnd(whenIdentifier("terminalOpen"), whenNot(whenIdentifier("terminalFocus"))),
323216 },
324217 { key: "mod+j", command: "terminal.toggle" },
325218 ]);
......@@ -345,8 +238,8 @@ describe("split/new terminal shortcuts", () => {
345238
346239 it("supports when boolean literals", () => {
347240 const keybindings = compile([
348 { key: "mod+n", command: "terminal.new", when: "true" },
349 { key: "mod+m", command: "terminal.new", when: "false" },
241 { key: "mod+n", command: "terminal.new", whenAst: whenIdentifier("true") },
242 { key: "mod+m", command: "terminal.new", whenAst: whenIdentifier("false") },
350243 ]);
351244
352245 assert.isTrue(
......@@ -365,8 +258,16 @@ describe("split/new terminal shortcuts", () => {
365258describe("shortcutLabelForCommand", () => {
366259 it("returns the most recent binding label", () => {
367260 const bindings = compile([
368 { key: "mod+\\", command: "terminal.split", when: "terminalFocus" },
369 { key: "mod+shift+\\", command: "terminal.split", when: "!terminalFocus" },
261 {
262 key: "mod+\\",
263 command: "terminal.split",
264 whenAst: whenIdentifier("terminalFocus"),
265 },
266 {
267 key: "mod+shift+\\",
268 command: "terminal.split",
269 whenAst: whenNot(whenIdentifier("terminalFocus")),
270 },
370271 ]);
371272 assert.strictEqual(shortcutLabelForCommand(bindings, "terminal.split", "Linux"), "Ctrl+Shift+\\");
372273 });
......@@ -412,7 +313,11 @@ describe("cross-command precedence", () => {
412313 it("uses when + order so a later focused rule overrides a global rule", () => {
413314 const keybindings = compile([
414315 { key: "mod+n", command: "chat.new" },
415 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },
316 {
317 key: "mod+n",
318 command: "terminal.new",
319 whenAst: whenIdentifier("terminalFocus"),
320 },
416321 ]);
417322
418323 assert.isTrue(
......@@ -443,7 +348,11 @@ describe("cross-command precedence", () => {
443348
444349 it("still lets a later global rule win when both rules match", () => {
445350 const keybindings = compile([
446 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },
351 {
352 key: "mod+n",
353 command: "terminal.new",
354 whenAst: whenIdentifier("terminalFocus"),
355 },
447356 { key: "mod+n", command: "chat.new" },
448357 ]);
449358
packages/contracts/src/index.ts-1
......@@ -6,7 +6,6 @@ export * from "./provider";
66export * from "./model";
77export * from "./ws";
88export * from "./keybindings";
9export * from "./keybindingsWhen";
109export * from "./server";
1110export * from "./project";
1211export * from "./git";
packages/contracts/src/keybindings.ts+6-1
......@@ -1,5 +1,4 @@
11import { z } from "zod";
2import type { KeybindingWhenNode } from "./keybindingsWhen";
32
43export const keybindingCommandSchema = z.enum([
54 "terminal.toggle",
......@@ -29,6 +28,12 @@ export const keybindingShortcutSchema = z.object({
2928 modKey: z.boolean(),
3029});
3130
31export type KeybindingWhenNode =
32 | { type: "identifier"; name: string }
33 | { type: "not"; node: KeybindingWhenNode }
34 | { type: "and"; left: KeybindingWhenNode; right: KeybindingWhenNode }
35 | { type: "or"; left: KeybindingWhenNode; right: KeybindingWhenNode };
36
3237export const keybindingWhenNodeSchema: z.ZodType<KeybindingWhenNode> = z.lazy(() =>
3338 z.union([
3439 z.object({
packages/contracts/src/keybindingsWhen.ts deleted-5
......@@ -1,5 +0,0 @@
1export type KeybindingWhenNode =
2 | { type: "identifier"; name: string }
3 | { type: "not"; node: KeybindingWhenNode }
4 | { type: "and"; left: KeybindingWhenNode; right: KeybindingWhenNode }
5 | { type: "or"; left: KeybindingWhenNode; right: KeybindingWhenNode };