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 @@...@@ -1,8 +1,8 @@
1import { assert, describe, it } from "vitest";1import { assert, describe, it } from "vitest";
22
3import {3import {
4 type KeybindingCommand,
4 type KeybindingShortcut,5 type KeybindingShortcut,
5 type KeybindingsConfig,
6 type KeybindingWhenNode,6 type KeybindingWhenNode,
7 type ResolvedKeybindingsConfig,7 type ResolvedKeybindingsConfig,
8} from "@t3tools/contracts";8} from "@t3tools/contracts";
...@@ -18,14 +18,6 @@ import {...@@ -18,14 +18,6 @@ import {
18 type ShortcutEventLike,18 type ShortcutEventLike,
19} from "./keybindings";19} 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
29function event(overrides: Partial<ShortcutEventLike> = {}): ShortcutEventLike {21function event(overrides: Partial<ShortcutEventLike> = {}): ShortcutEventLike {
30 return {22 return {
31 key: "j",23 key: "j",
...@@ -106,144 +98,37 @@ function shortcut(value: string): KeybindingShortcut {...@@ -106,144 +98,37 @@ function shortcut(value: string): KeybindingShortcut {
106 return parsed;98 return parsed;
107}99}
108100
109function tokenizeWhenExpression(expression: string): WhenToken[] | null {101function whenIdentifier(name: string): KeybindingWhenNode {
110 const tokens: WhenToken[] = [];102 return { type: "identifier", name };
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;
154}103}
155104
156function parseWhenExpression(expression: string): KeybindingWhenNode | null {105function whenNot(node: KeybindingWhenNode): KeybindingWhenNode {
157 const tokens = tokenizeWhenExpression(expression);106 return { type: "not", node };
158 if (!tokens || tokens.length === 0) return null;107}
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 }
219108
220 return left;109function whenAnd(left: KeybindingWhenNode, right: KeybindingWhenNode): KeybindingWhenNode {
221 };110 return { type: "and", left, right };
111}
222112
223 const ast = parseOr();113interface TestBinding {
224 if (!ast || index !== tokens.length) return null;114 key: string;
225 return ast;115 command: KeybindingCommand;
116 whenAst?: KeybindingWhenNode;
226}117}
227118
228function compile(bindings: KeybindingsConfig): ResolvedKeybindingsConfig {119function compile(bindings: TestBinding[]): ResolvedKeybindingsConfig {
229 const resolved: ResolvedKeybindingsConfig = [];120 const resolved: ResolvedKeybindingsConfig = [];
230 for (const binding of bindings) {121 for (const binding of bindings) {
231 const parsedShortcut = parseShortcut(binding.key);122 const parsedShortcut = parseShortcut(binding.key);
232 if (!parsedShortcut) {123 if (!parsedShortcut) {
233 throw new Error(`invalid shortcut in test fixture: ${binding.key}`);124 throw new Error(`invalid shortcut in test fixture: ${binding.key}`);
234 }125 }
235 const when = binding.when?.trim();126 if (binding.whenAst) {
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 }
241 resolved.push({127 resolved.push({
242 key: binding.key.trim(),128 key: binding.key.trim(),
243 command: binding.command,129 command: binding.command,
244 when,
245 shortcut: parsedShortcut,130 shortcut: parsedShortcut,
246 whenAst,131 whenAst: binding.whenAst,
247 });132 });
248 continue;133 continue;
249 }134 }
...@@ -258,8 +143,16 @@ function compile(bindings: KeybindingsConfig): ResolvedKeybindingsConfig {...@@ -258,8 +143,16 @@ function compile(bindings: KeybindingsConfig): ResolvedKeybindingsConfig {
258143
259const DEFAULT_BINDINGS = compile([144const DEFAULT_BINDINGS = compile([
260 { key: "mod+j", command: "terminal.toggle" },145 { key: "mod+j", command: "terminal.toggle" },
261 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },146 {
262 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },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 },
263 { key: "mod+shift+o", command: "chat.new" },156 { key: "mod+shift+o", command: "chat.new" },
264 { key: "mod+o", command: "editor.openFavorite" },157 { key: "mod+o", command: "editor.openFavorite" },
265]);158]);
...@@ -314,12 +207,12 @@ describe("split/new terminal shortcuts", () => {...@@ -314,12 +207,12 @@ describe("split/new terminal shortcuts", () => {
314 {207 {
315 key: "mod+\\",208 key: "mod+\\",
316 command: "terminal.split",209 command: "terminal.split",
317 when: "terminalOpen && !terminalFocus",210 whenAst: whenAnd(whenIdentifier("terminalOpen"), whenNot(whenIdentifier("terminalFocus"))),
318 },211 },
319 {212 {
320 key: "mod+shift+n",213 key: "mod+shift+n",
321 command: "terminal.new",214 command: "terminal.new",
322 when: "terminalOpen && !terminalFocus",215 whenAst: whenAnd(whenIdentifier("terminalOpen"), whenNot(whenIdentifier("terminalFocus"))),
323 },216 },
324 { key: "mod+j", command: "terminal.toggle" },217 { key: "mod+j", command: "terminal.toggle" },
325 ]);218 ]);
...@@ -345,8 +238,8 @@ describe("split/new terminal shortcuts", () => {...@@ -345,8 +238,8 @@ describe("split/new terminal shortcuts", () => {
345238
346 it("supports when boolean literals", () => {239 it("supports when boolean literals", () => {
347 const keybindings = compile([240 const keybindings = compile([
348 { key: "mod+n", command: "terminal.new", when: "true" },241 { key: "mod+n", command: "terminal.new", whenAst: whenIdentifier("true") },
349 { key: "mod+m", command: "terminal.new", when: "false" },242 { key: "mod+m", command: "terminal.new", whenAst: whenIdentifier("false") },
350 ]);243 ]);
351244
352 assert.isTrue(245 assert.isTrue(
...@@ -365,8 +258,16 @@ describe("split/new terminal shortcuts", () => {...@@ -365,8 +258,16 @@ describe("split/new terminal shortcuts", () => {
365describe("shortcutLabelForCommand", () => {258describe("shortcutLabelForCommand", () => {
366 it("returns the most recent binding label", () => {259 it("returns the most recent binding label", () => {
367 const bindings = compile([260 const bindings = compile([
368 { key: "mod+\\", command: "terminal.split", when: "terminalFocus" },261 {
369 { key: "mod+shift+\\", command: "terminal.split", when: "!terminalFocus" },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 },
370 ]);271 ]);
371 assert.strictEqual(shortcutLabelForCommand(bindings, "terminal.split", "Linux"), "Ctrl+Shift+\\");272 assert.strictEqual(shortcutLabelForCommand(bindings, "terminal.split", "Linux"), "Ctrl+Shift+\\");
372 });273 });
...@@ -412,7 +313,11 @@ describe("cross-command precedence", () => {...@@ -412,7 +313,11 @@ describe("cross-command precedence", () => {
412 it("uses when + order so a later focused rule overrides a global rule", () => {313 it("uses when + order so a later focused rule overrides a global rule", () => {
413 const keybindings = compile([314 const keybindings = compile([
414 { key: "mod+n", command: "chat.new" },315 { 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 },
416 ]);321 ]);
417322
418 assert.isTrue(323 assert.isTrue(
...@@ -443,7 +348,11 @@ describe("cross-command precedence", () => {...@@ -443,7 +348,11 @@ describe("cross-command precedence", () => {
443348
444 it("still lets a later global rule win when both rules match", () => {349 it("still lets a later global rule win when both rules match", () => {
445 const keybindings = compile([350 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 },
447 { key: "mod+n", command: "chat.new" },356 { key: "mod+n", command: "chat.new" },
448 ]);357 ]);
449358
packages/contracts/src/index.ts-1
...@@ -6,7 +6,6 @@ export * from "./provider";...@@ -6,7 +6,6 @@ export * from "./provider";
6export * from "./model";6export * from "./model";
7export * from "./ws";7export * from "./ws";
8export * from "./keybindings";8export * from "./keybindings";
9export * from "./keybindingsWhen";
10export * from "./server";9export * from "./server";
11export * from "./project";10export * from "./project";
12export * from "./git";11export * from "./git";
packages/contracts/src/keybindings.ts+6-1
...@@ -1,5 +1,4 @@...@@ -1,5 +1,4 @@
1import { z } from "zod";1import { z } from "zod";
2import type { KeybindingWhenNode } from "./keybindingsWhen";
32
4export const keybindingCommandSchema = z.enum([3export const keybindingCommandSchema = z.enum([
5 "terminal.toggle",4 "terminal.toggle",
...@@ -29,6 +28,12 @@ export const keybindingShortcutSchema = z.object({...@@ -29,6 +28,12 @@ export const keybindingShortcutSchema = z.object({
29 modKey: z.boolean(),28 modKey: z.boolean(),
30});29});
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
32export const keybindingWhenNodeSchema: z.ZodType<KeybindingWhenNode> = z.lazy(() =>37export const keybindingWhenNodeSchema: z.ZodType<KeybindingWhenNode> = z.lazy(() =>
33 z.union([38 z.union([
34 z.object({39 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 };