diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index dd52603a3b6ac0809596166c6d53d197f93b5295..84cbfe090a242d4e303b04a2851540225caeae5b 100644 --- a/KEYBINDINGS.md +++ b/KEYBINDINGS.md @@ -9,8 +9,9 @@ Schema source of truth: - [`packages/contracts/src/keybindings.ts`](packages/contracts/src/keybindings.ts) - [`packages/contracts/src/server.ts`](packages/contracts/src/server.ts) -Server-side default resolution/merging: +Server-side parsing/default resolution/merging: +- [`apps/server/src/keybindings.ts`](apps/server/src/keybindings.ts) - [`apps/server/src/wsServer.ts`](apps/server/src/wsServer.ts) The file must be a JSON array of rules: @@ -94,8 +95,18 @@ Built-in defaults are resolved on the server and sent to the web client via `ser If you define any rules for a command, the default rule for that command is removed on the server. +## Server-Resolved Payload + +`~/.t3/keybindings.json` is parsed and compiled on the server. The web client receives resolved rules (including parsed shortcut/when fields) and does not parse raw keybinding strings. + +See: + +- [`packages/contracts/src/keybindings.ts`](packages/contracts/src/keybindings.ts) +- [`apps/server/src/keybindings.ts`](apps/server/src/keybindings.ts) +- [`apps/server/src/wsServer.ts`](apps/server/src/wsServer.ts) + ## Precedence - Rules are evaluated in array order. -- For a key event, the server/client resolves the last rule where both `key` matches and `when` evaluates to `true`. +- For a key event, the last rule where both `key` matches and `when` evaluates to `true` wins. - That means precedence is across commands, not only within the same command. diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..72c7e45d800cea594bd34a2b1c0a0a62c1be599f --- /dev/null +++ b/apps/server/src/keybindings.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; + +import { compileResolvedKeybindingRule, parseKeybindingShortcut } from "./keybindings"; + +describe("server keybindings", () => { + it("parses shortcuts including plus key", () => { + expect(parseKeybindingShortcut("mod+j")).toEqual({ + key: "j", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }); + expect(parseKeybindingShortcut("mod++")).toEqual({ + key: "+", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }); + }); + + it("compiles valid rule with parsed when AST", () => { + const compiled = compileResolvedKeybindingRule({ + key: "mod+d", + command: "terminal.split", + when: "terminalOpen && !terminalFocus", + }); + + expect(compiled).toEqual({ + key: "mod+d", + command: "terminal.split", + when: "terminalOpen && !terminalFocus", + shortcut: { + key: "d", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "and", + left: { type: "identifier", name: "terminalOpen" }, + right: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + }); + }); + + it("rejects invalid rules", () => { + expect( + compileResolvedKeybindingRule({ + key: "mod+shift+d+o", + command: "terminal.new", + }), + ).toBeNull(); + + expect( + compileResolvedKeybindingRule({ + key: "mod+d", + command: "terminal.split", + when: "terminalFocus && (", + }), + ).toBeNull(); + }); +}); diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts new file mode 100644 index 0000000000000000000000000000000000000000..2a56e00e25fb6e404d03dc1aefc646baa281469c --- /dev/null +++ b/apps/server/src/keybindings.ts @@ -0,0 +1,244 @@ +import type { + KeybindingRule, + KeybindingsConfig, + KeybindingShortcut, + KeybindingWhenNode, + ResolvedKeybindingRule, + ResolvedKeybindingsConfig, +} from "@t3tools/contracts"; + +type WhenToken = + | { type: "identifier"; value: string } + | { type: "not" } + | { type: "and" } + | { type: "or" } + | { type: "lparen" } + | { type: "rparen" }; + +function normalizeKeyToken(token: string): string { + if (token === "space") return " "; + if (token === "esc") return "escape"; + return token; +} + +export function parseKeybindingShortcut(value: string): KeybindingShortcut | null { + const rawTokens = value.toLowerCase().split("+").map((token) => token.trim()); + const tokens = [...rawTokens]; + let trailingEmptyCount = 0; + while (tokens[tokens.length - 1] === "") { + trailingEmptyCount += 1; + tokens.pop(); + } + if (trailingEmptyCount > 0) { + tokens.push("+"); + } + if (tokens.some((token) => token.length === 0)) { + return null; + } + if (tokens.length === 0) return null; + + let key: string | null = null; + let metaKey = false; + let ctrlKey = false; + let shiftKey = false; + let altKey = false; + let modKey = false; + + for (const token of tokens) { + switch (token) { + case "cmd": + case "meta": + metaKey = true; + break; + case "ctrl": + case "control": + ctrlKey = true; + break; + case "shift": + shiftKey = true; + break; + case "alt": + case "option": + altKey = true; + break; + case "mod": + modKey = true; + break; + default: { + if (key !== null) return null; + key = normalizeKeyToken(token); + } + } + } + + if (key === null) return null; + return { + key, + metaKey, + ctrlKey, + shiftKey, + altKey, + modKey, + }; +} + +function tokenizeWhenExpression(expression: string): WhenToken[] | null { + const tokens: WhenToken[] = []; + let index = 0; + + while (index < expression.length) { + const current = expression[index]; + if (!current) break; + + if (/\s/.test(current)) { + index += 1; + continue; + } + if (expression.startsWith("&&", index)) { + tokens.push({ type: "and" }); + index += 2; + continue; + } + if (expression.startsWith("||", index)) { + tokens.push({ type: "or" }); + index += 2; + continue; + } + if (current === "!") { + tokens.push({ type: "not" }); + index += 1; + continue; + } + if (current === "(") { + tokens.push({ type: "lparen" }); + index += 1; + continue; + } + if (current === ")") { + tokens.push({ type: "rparen" }); + index += 1; + continue; + } + + const identifier = /^[A-Za-z_][A-Za-z0-9_.-]*/.exec(expression.slice(index)); + if (!identifier) { + return null; + } + tokens.push({ type: "identifier", value: identifier[0] }); + index += identifier[0].length; + } + + return tokens; +} + +export function parseKeybindingWhenExpression(expression: string): KeybindingWhenNode | null { + const tokens = tokenizeWhenExpression(expression); + if (!tokens || tokens.length === 0) return null; + let index = 0; + + const parsePrimary = (): KeybindingWhenNode | null => { + const token = tokens[index]; + if (!token) return null; + + if (token.type === "identifier") { + index += 1; + return { type: "identifier", name: token.value }; + } + + if (token.type === "lparen") { + index += 1; + const expressionNode = parseOr(); + const closeToken = tokens[index]; + if (!expressionNode || !closeToken || closeToken.type !== "rparen") { + return null; + } + index += 1; + return expressionNode; + } + + return null; + }; + + const parseUnary = (): KeybindingWhenNode | null => { + const token = tokens[index]; + if (token?.type === "not") { + index += 1; + const node = parseUnary(); + if (!node) return null; + return { type: "not", node }; + } + return parsePrimary(); + }; + + const parseAnd = (): KeybindingWhenNode | null => { + let left = parseUnary(); + if (!left) return null; + + while (tokens[index]?.type === "and") { + index += 1; + const right = parseUnary(); + if (!right) return null; + left = { type: "and", left, right }; + } + + return left; + }; + + const parseOr = (): KeybindingWhenNode | null => { + let left = parseAnd(); + if (!left) return null; + + while (tokens[index]?.type === "or") { + index += 1; + const right = parseAnd(); + if (!right) return null; + left = { type: "or", left, right }; + } + + return left; + }; + + const ast = parseOr(); + if (!ast || index !== tokens.length) return null; + return ast; +} + +export function compileResolvedKeybindingRule( + rule: KeybindingRule, +): ResolvedKeybindingRule | null { + const key = rule.key.trim(); + if (key.length === 0) return null; + const shortcut = parseKeybindingShortcut(key); + if (!shortcut) return null; + + const when = rule.when?.trim(); + if (when && when.length > 0) { + const whenAst = parseKeybindingWhenExpression(when); + if (!whenAst) return null; + return { + key, + command: rule.command, + when, + shortcut, + whenAst, + }; + } + + return { + key, + command: rule.command, + shortcut, + }; +} + +export function compileResolvedKeybindingsConfig( + config: KeybindingsConfig, +): ResolvedKeybindingsConfig { + const compiled: ResolvedKeybindingsConfig = []; + for (const rule of config) { + const resolved = compileResolvedKeybindingRule(rule); + if (!resolved) continue; + compiled.push(resolved); + } + return compiled; +} diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 6eaa7fd73a2cea476c128ad7d8177e705da26f01..d4bc7d993d61d8c965b3f1490b9097acd379d1e5 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -11,10 +11,13 @@ import { DEFAULT_TERMINAL_ID, WS_CHANNELS, WS_METHODS, + type KeybindingsConfig, + type ResolvedKeybindingsConfig, type WsPush, type WsResponse, } from "@t3tools/contracts"; import { ProjectRegistry } from "./projectRegistry"; +import { compileResolvedKeybindingRule } from "./keybindings"; import type { TerminalClearInput, TerminalCloseInput, @@ -192,6 +195,18 @@ async function sendRequest(ws: WebSocket, method: string, params?: unknown): Pro } } +function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsConfig { + const resolved: ResolvedKeybindingsConfig = []; + for (const binding of bindings) { + const compiled = compileResolvedKeybindingRule(binding); + if (!compiled) { + throw new Error(`Unexpected invalid keybinding in test setup: ${binding.command}`); + } + resolved.push(compiled); + } + return resolved; +} + describe("WebSocket Server", () => { let server: ReturnType | null = null; const connections: WebSocket[] = []; @@ -309,13 +324,13 @@ describe("WebSocket Server", () => { expect(response.error).toBeUndefined(); expect(response.result).toEqual({ cwd: "/my/workspace", - keybindings: [ + keybindings: compileKeybindings([ { key: "mod+j", command: "terminal.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, { key: "mod+shift+o", command: "chat.new" }, { key: "mod+o", command: "editor.openFavorite" }, - ], + ]), }); }); @@ -348,13 +363,13 @@ describe("WebSocket Server", () => { expect(response.error).toBeUndefined(); expect(response.result).toEqual({ cwd: "/my/workspace", - keybindings: [ + keybindings: compileKeybindings([ { key: "mod+shift+o", command: "chat.new" }, { key: "mod+o", command: "editor.openFavorite" }, { key: "cmd+j", command: "terminal.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, - ], + ]), }); }); @@ -387,13 +402,13 @@ describe("WebSocket Server", () => { expect(response.error).toBeUndefined(); expect(response.result).toEqual({ cwd: "/my/workspace", - keybindings: [ + keybindings: compileKeybindings([ { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, { key: "mod+shift+o", command: "chat.new" }, { key: "mod+o", command: "editor.openFavorite" }, { key: "mod+j", command: "terminal.toggle" }, - ], + ]), }); expect( warnSpy.mock.calls.some(([message]) => @@ -428,13 +443,13 @@ describe("WebSocket Server", () => { expect(response.error).toBeUndefined(); expect(response.result).toEqual({ cwd: "/my/workspace", - keybindings: [ + keybindings: compileKeybindings([ { key: "mod+j", command: "terminal.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, { key: "mod+shift+o", command: "chat.new" }, { key: "mod+o", command: "editor.openFavorite" }, - ], + ]), }); expect( warnSpy.mock.calls.some(([message]) => @@ -468,13 +483,13 @@ describe("WebSocket Server", () => { expect(firstResponse.error).toBeUndefined(); expect(firstResponse.result).toEqual({ cwd: "/my/workspace", - keybindings: [ + keybindings: compileKeybindings([ { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, { key: "mod+shift+o", command: "chat.new" }, { key: "mod+o", command: "editor.openFavorite" }, { key: "cmd+j", command: "terminal.toggle" }, - ], + ]), }); fs.writeFileSync( @@ -514,13 +529,13 @@ describe("WebSocket Server", () => { expect(response.error).toBeUndefined(); expect(response.result).toEqual({ cwd: "/my/workspace", - keybindings: [ + keybindings: compileKeybindings([ { key: "mod+j", command: "terminal.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, { key: "mod+shift+o", command: "chat.new" }, { key: "mod+o", command: "editor.openFavorite" }, - ], + ]), }); expect( warnSpy.mock.calls.some(([message]) => @@ -551,13 +566,13 @@ describe("WebSocket Server", () => { expect(response.error).toBeUndefined(); expect(response.result).toEqual({ cwd: "/my/workspace", - keybindings: [ + keybindings: compileKeybindings([ { key: "mod+j", command: "terminal.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, { key: "mod+shift+o", command: "chat.new" }, { key: "mod+o", command: "editor.openFavorite" }, - ], + ]), }); expect( warnSpy.mock.calls.some(([message]) => diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 8b9d7e76c2643341a7109781f857686fcd604da2..d6ea286981f3b8cd7a4f74f989e4f8369d4ee374 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -11,6 +11,7 @@ import { WS_METHODS, keybindingRuleSchema, type KeybindingsConfig, + type ResolvedKeybindingsConfig, type TerminalEvent, type WsPush, type WsRequest, @@ -33,6 +34,7 @@ import { removeGitWorktree, } from "./git"; import { TerminalManager } from "./terminalManager"; +import { compileResolvedKeybindingRule } from "./keybindings"; const MIME_TYPES: Record = { ".html": "text/html; charset=utf-8", @@ -78,129 +80,29 @@ const DEFAULT_KEYBINDINGS: KeybindingsConfig = [ { key: "mod+o", command: "editor.openFavorite" }, ]; -type WhenTokenType = "identifier" | "not" | "and" | "or" | "lparen" | "rparen"; - -interface WhenToken { - type: WhenTokenType; -} - -function tokenizeWhenExpression(expression: string): WhenToken[] | null { - const tokens: WhenToken[] = []; - let index = 0; - - while (index < expression.length) { - const current = expression[index]; - if (!current) break; - - if (/\s/.test(current)) { - index += 1; - continue; - } - if (expression.startsWith("&&", index)) { - tokens.push({ type: "and" }); - index += 2; - continue; - } - if (expression.startsWith("||", index)) { - tokens.push({ type: "or" }); - index += 2; - continue; - } - if (current === "!") { - tokens.push({ type: "not" }); - index += 1; - continue; - } - if (current === "(") { - tokens.push({ type: "lparen" }); - index += 1; - continue; - } - if (current === ")") { - tokens.push({ type: "rparen" }); - index += 1; - continue; - } - - const identifier = /^[A-Za-z_][A-Za-z0-9_.-]*/.exec(expression.slice(index)); - if (!identifier) { - return null; - } - tokens.push({ type: "identifier" }); - index += identifier[0].length; +function compileDefaultKeybindings(): ResolvedKeybindingsConfig { + const resolved: ResolvedKeybindingsConfig = []; + for (const rule of DEFAULT_KEYBINDINGS) { + const compiled = compileResolvedKeybindingRule(rule); + if (!compiled) { + throw new Error(`Invalid default keybinding: ${rule.command} (${rule.key})`); + } + resolved.push(compiled); } - - return tokens; + return resolved; } -function isValidWhenExpression(when: string): boolean { - const tokens = tokenizeWhenExpression(when); - if (!tokens || tokens.length === 0) return false; - let index = 0; - - const parsePrimary = (): boolean => { - const token = tokens[index]; - if (!token) return false; - - if (token.type === "identifier") { - index += 1; - return true; - } - - if (token.type === "lparen") { - index += 1; - const parsed = parseOr(); - const close = tokens[index]; - if (!parsed || !close || close.type !== "rparen") return false; - index += 1; - return true; - } - - return false; - }; - - const parseUnary = (): boolean => { - const token = tokens[index]; - if (token?.type === "not") { - index += 1; - return parseUnary(); - } - return parsePrimary(); - }; - - const parseAnd = (): boolean => { - if (!parseUnary()) return false; - - while (tokens[index]?.type === "and") { - index += 1; - if (!parseUnary()) return false; - } - - return true; - }; - - const parseOr = (): boolean => { - if (!parseAnd()) return false; - - while (tokens[index]?.type === "or") { - index += 1; - if (!parseAnd()) return false; - } - - return true; - }; - - if (!parseOr()) return false; - return index === tokens.length; -} +const DEFAULT_RESOLVED_KEYBINDINGS = compileDefaultKeybindings(); -function mergeWithDefaultKeybindings(custom: KeybindingsConfig): KeybindingsConfig { +function mergeWithDefaultKeybindings( + custom: ResolvedKeybindingsConfig, +): ResolvedKeybindingsConfig { if (custom.length === 0) { - return [...DEFAULT_KEYBINDINGS]; + return [...DEFAULT_RESOLVED_KEYBINDINGS]; } const overriddenCommands = new Set(custom.map((binding) => binding.command)); - const retainedDefaults = DEFAULT_KEYBINDINGS.filter( + const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter( (binding) => !overriddenCommands.has(binding.command), ); const merged = [...retainedDefaults, ...custom]; @@ -213,7 +115,9 @@ function mergeWithDefaultKeybindings(custom: KeybindingsConfig): KeybindingsConf return merged.slice(-256); } -function readKeybindingsConfig(logger: ReturnType): KeybindingsConfig { +function readKeybindingsConfig( + logger: ReturnType, +): ResolvedKeybindingsConfig { const configPath = path.join(os.homedir(), ".t3", "keybindings.json"); try { const raw = fs.readFileSync(configPath, "utf8"); @@ -222,19 +126,20 @@ function readKeybindingsConfig(logger: ReturnType): Keybind logger.warn("ignoring keybindings config with unsupported format; expected array", { path: configPath, }); - return [...DEFAULT_KEYBINDINGS]; + return [...DEFAULT_RESOLVED_KEYBINDINGS]; } - const sanitized: KeybindingsConfig = []; + const sanitized: ResolvedKeybindingsConfig = []; let invalidEntries = 0; for (const entry of parsed) { const result = keybindingRuleSchema.safeParse(entry); if (result.success) { - if (result.data.when && !isValidWhenExpression(result.data.when)) { + const compiled = compileResolvedKeybindingRule(result.data); + if (!compiled) { invalidEntries += 1; continue; } - sanitized.push(result.data); + sanitized.push(compiled); continue; } invalidEntries += 1; @@ -246,14 +151,12 @@ function readKeybindingsConfig(logger: ReturnType): Keybind totalEntries: parsed.length, }); } - const mergedBeforeCap = [ - ...DEFAULT_KEYBINDINGS.filter( - (binding) => !new Set(sanitized.map((entry) => entry.command)).has(binding.command), - ), - ...sanitized, - ]; + const overriddenCommands = new Set(sanitized.map((entry) => entry.command)); + const mergedBeforeCapLength = + DEFAULT_RESOLVED_KEYBINDINGS.filter((binding) => !overriddenCommands.has(binding.command)) + .length + sanitized.length; const merged = mergeWithDefaultKeybindings(sanitized); - if (mergedBeforeCap.length > 256) { + if (mergedBeforeCapLength > 256) { logger.warn("truncating merged keybindings config to max entries", { path: configPath, maxEntries: 256, @@ -262,14 +165,14 @@ function readKeybindingsConfig(logger: ReturnType): Keybind return merged; } catch (error) { if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { - return [...DEFAULT_KEYBINDINGS]; + return [...DEFAULT_RESOLVED_KEYBINDINGS]; } logger.warn("ignoring malformed keybindings config", { path: configPath, error: error instanceof Error ? error.message : String(error), }); } - return [...DEFAULT_KEYBINDINGS]; + return [...DEFAULT_RESOLVED_KEYBINDINGS]; } export function createServer(options: ServerOptions) { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 61b60f60508a22376f780800caa22d8d7901fde2..0e9038b94caf054072cb9778b7d78f9cf5333cb3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5,9 +5,9 @@ import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, + type ResolvedKeybindingsConfig, type ProviderApprovalDecision, type ProviderSendTurnAttachmentInput, - type KeybindingsConfig, } from "@t3tools/contracts"; import { type ClipboardEvent, @@ -92,7 +92,7 @@ const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; -const EMPTY_KEYBINDINGS: KeybindingsConfig = []; +const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; @@ -1520,7 +1520,7 @@ function ReasoningEffortPicker(props: { ); } -function OpenInPicker({ keybindings }: { keybindings: KeybindingsConfig }) { +function OpenInPicker({ keybindings }: { keybindings: ResolvedKeybindingsConfig }) { const [lastEditor, setLastEditor] = useState(() => { const stored = localStorage.getItem(LAST_EDITOR_KEY); return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index e30bfc0c8eb675cf1d1dd70bb93447ba88bbab15..ce427ab462464259c2149e4d046376eef81d75ce 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,5 +1,6 @@ import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; +import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; @@ -19,6 +20,7 @@ import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const; +const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; function formatRelativeTime(iso: string): string { const diff = Date.now() - new Date(iso).getTime(); @@ -115,7 +117,7 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null export default function Sidebar() { const { state, dispatch } = useStore(); const api = useNativeApi(); - const { data: keybindings = [] } = useQuery({ + const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({ ...serverConfigQueryOptions(api), select: (config) => config.keybindings, }); diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index d6f99c2f98ef069dc24d9b7ccec05c779fa09056..5e2a90bad50e61fb7831c117e685afebcff6102f 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -1,6 +1,11 @@ import { assert, describe, it } from "vitest"; -import type { KeybindingsConfig } from "@t3tools/contracts"; +import { + type KeybindingShortcut, + type KeybindingsConfig, + type KeybindingWhenNode, + type ResolvedKeybindingsConfig, +} from "@t3tools/contracts"; import { formatShortcutLabel, isChatNewShortcut, @@ -13,6 +18,14 @@ import { type ShortcutEventLike, } from "./keybindings"; +type WhenToken = + | { type: "identifier"; value: string } + | { type: "not" } + | { type: "and" } + | { type: "or" } + | { type: "lparen" } + | { type: "rparen" }; + function event(overrides: Partial = {}): ShortcutEventLike { return { key: "j", @@ -24,13 +37,232 @@ function event(overrides: Partial = {}): ShortcutEventLike { }; } -const DEFAULT_BINDINGS: KeybindingsConfig = [ +function parseShortcut(value: string): KeybindingShortcut | null { + const rawTokens = value.toLowerCase().split("+").map((token) => token.trim()); + const tokens = [...rawTokens]; + let trailingEmptyCount = 0; + while (tokens[tokens.length - 1] === "") { + trailingEmptyCount += 1; + tokens.pop(); + } + if (trailingEmptyCount > 0) { + tokens.push("+"); + } + if (tokens.some((token) => token.length === 0)) { + return null; + } + if (tokens.length === 0) return null; + + let key: string | null = null; + let metaKey = false; + let ctrlKey = false; + let shiftKey = false; + let altKey = false; + let modKey = false; + + for (const token of tokens) { + switch (token) { + case "cmd": + case "meta": + metaKey = true; + break; + case "ctrl": + case "control": + ctrlKey = true; + break; + case "shift": + shiftKey = true; + break; + case "alt": + case "option": + altKey = true; + break; + case "mod": + modKey = true; + break; + default: { + if (key !== null) return null; + key = token; + } + } + } + + if (key === null) return null; + return { + key, + metaKey, + ctrlKey, + shiftKey, + altKey, + modKey, + }; +} + +function shortcut(value: string): KeybindingShortcut { + const parsed = parseShortcut(value); + if (!parsed) { + throw new Error(`invalid shortcut in test fixture: ${value}`); + } + return parsed; +} + +function tokenizeWhenExpression(expression: string): WhenToken[] | null { + const tokens: WhenToken[] = []; + let index = 0; + + while (index < expression.length) { + const current = expression[index]; + if (!current) break; + + if (/\s/.test(current)) { + index += 1; + continue; + } + if (expression.startsWith("&&", index)) { + tokens.push({ type: "and" }); + index += 2; + continue; + } + if (expression.startsWith("||", index)) { + tokens.push({ type: "or" }); + index += 2; + continue; + } + if (current === "!") { + tokens.push({ type: "not" }); + index += 1; + continue; + } + if (current === "(") { + tokens.push({ type: "lparen" }); + index += 1; + continue; + } + if (current === ")") { + tokens.push({ type: "rparen" }); + index += 1; + continue; + } + + const identifier = /^[A-Za-z_][A-Za-z0-9_.-]*/.exec(expression.slice(index)); + if (!identifier) return null; + tokens.push({ type: "identifier", value: identifier[0] }); + index += identifier[0].length; + } + + return tokens; +} + +function parseWhenExpression(expression: string): KeybindingWhenNode | null { + const tokens = tokenizeWhenExpression(expression); + if (!tokens || tokens.length === 0) return null; + let index = 0; + + const parsePrimary = (): KeybindingWhenNode | null => { + const token = tokens[index]; + if (!token) return null; + + if (token.type === "identifier") { + index += 1; + return { type: "identifier", name: token.value }; + } + + if (token.type === "lparen") { + index += 1; + const expressionNode = parseOr(); + const closeToken = tokens[index]; + if (!expressionNode || !closeToken || closeToken.type !== "rparen") { + return null; + } + index += 1; + return expressionNode; + } + + return null; + }; + + const parseUnary = (): KeybindingWhenNode | null => { + const token = tokens[index]; + if (token?.type === "not") { + index += 1; + const node = parseUnary(); + if (!node) return null; + return { type: "not", node }; + } + return parsePrimary(); + }; + + const parseAnd = (): KeybindingWhenNode | null => { + let left = parseUnary(); + if (!left) return null; + + while (tokens[index]?.type === "and") { + index += 1; + const right = parseUnary(); + if (!right) return null; + left = { type: "and", left, right }; + } + + return left; + }; + + const parseOr = (): KeybindingWhenNode | null => { + let left = parseAnd(); + if (!left) return null; + + while (tokens[index]?.type === "or") { + index += 1; + const right = parseAnd(); + if (!right) return null; + left = { type: "or", left, right }; + } + + return left; + }; + + const ast = parseOr(); + if (!ast || index !== tokens.length) return null; + return ast; +} + +function compile(bindings: KeybindingsConfig): ResolvedKeybindingsConfig { + const resolved: ResolvedKeybindingsConfig = []; + for (const binding of bindings) { + const parsedShortcut = parseShortcut(binding.key); + if (!parsedShortcut) { + throw new Error(`invalid shortcut in test fixture: ${binding.key}`); + } + const when = binding.when?.trim(); + if (when && when.length > 0) { + const whenAst = parseWhenExpression(when); + if (!whenAst) { + throw new Error(`invalid when expression in test fixture: ${when}`); + } + resolved.push({ + key: binding.key.trim(), + command: binding.command, + when, + shortcut: parsedShortcut, + whenAst, + }); + continue; + } + resolved.push({ + key: binding.key.trim(), + command: binding.command, + shortcut: parsedShortcut, + }); + } + return resolved; +} + +const DEFAULT_BINDINGS = compile([ { key: "mod+j", command: "terminal.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, { key: "mod+shift+o", command: "chat.new" }, { key: "mod+o", command: "editor.openFavorite" }, -]; +]); describe("isTerminalToggleShortcut", () => { it("matches Cmd+J on macOS", () => { @@ -78,7 +310,7 @@ describe("split/new terminal shortcuts", () => { }); it("supports when expressions", () => { - const keybindings: KeybindingsConfig = [ + const keybindings = compile([ { key: "mod+\\", command: "terminal.split", @@ -90,7 +322,7 @@ describe("split/new terminal shortcuts", () => { when: "terminalOpen && !terminalFocus", }, { key: "mod+j", command: "terminal.toggle" }, - ]; + ]); assert.isTrue( isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, { platform: "Win32", @@ -112,10 +344,10 @@ describe("split/new terminal shortcuts", () => { }); it("supports when boolean literals", () => { - const keybindings: KeybindingsConfig = [ + const keybindings = compile([ { key: "mod+n", command: "terminal.new", when: "true" }, { key: "mod+m", command: "terminal.new", when: "false" }, - ]; + ]); assert.isTrue( isTerminalNewShortcut(event({ key: "n", ctrlKey: true }), keybindings, { @@ -132,10 +364,10 @@ describe("split/new terminal shortcuts", () => { describe("shortcutLabelForCommand", () => { it("returns the most recent binding label", () => { - const bindings: KeybindingsConfig = [ + const bindings = compile([ { key: "mod+\\", command: "terminal.split", when: "terminalFocus" }, { key: "mod+shift+\\", command: "terminal.split", when: "!terminalFocus" }, - ]; + ]); assert.strictEqual(shortcutLabelForCommand(bindings, "terminal.split", "Linux"), "Ctrl+Shift+\\"); }); @@ -178,10 +410,10 @@ describe("chat/editor shortcuts", () => { describe("cross-command precedence", () => { it("uses when + order so a later focused rule overrides a global rule", () => { - const keybindings: KeybindingsConfig = [ + const keybindings = compile([ { key: "mod+n", command: "chat.new" }, { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, - ]; + ]); assert.isTrue( isTerminalNewShortcut(event({ key: "n", metaKey: true }), keybindings, { @@ -210,10 +442,10 @@ describe("cross-command precedence", () => { }); it("still lets a later global rule win when both rules match", () => { - const keybindings: KeybindingsConfig = [ + const keybindings = compile([ { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, { key: "mod+n", command: "chat.new" }, - ]; + ]); assert.isFalse( isTerminalNewShortcut(event({ key: "n", ctrlKey: true }), keybindings, { @@ -232,16 +464,16 @@ describe("cross-command precedence", () => { describe("formatShortcutLabel", () => { it("formats labels for macOS", () => { - assert.strictEqual(formatShortcutLabel("mod+shift+d", "MacIntel"), "⇧⌘D"); + assert.strictEqual(formatShortcutLabel(shortcut("mod+shift+d"), "MacIntel"), "⇧⌘D"); }); it("formats labels for non-macOS", () => { - assert.strictEqual(formatShortcutLabel("mod+shift+d", "Linux"), "Ctrl+Shift+D"); + assert.strictEqual(formatShortcutLabel(shortcut("mod+shift+d"), "Linux"), "Ctrl+Shift+D"); }); it("formats labels for plus key", () => { - assert.strictEqual(formatShortcutLabel("mod++", "MacIntel"), "⌘+"); - assert.strictEqual(formatShortcutLabel("mod++", "Linux"), "Ctrl++"); + assert.strictEqual(formatShortcutLabel(shortcut("mod++"), "MacIntel"), "⌘+"); + assert.strictEqual(formatShortcutLabel(shortcut("mod++"), "Linux"), "Ctrl++"); }); }); @@ -258,7 +490,7 @@ describe("isTerminalClearShortcut", () => { describe("plus key parsing", () => { it("matches the plus key shortcut", () => { - const plusBindings: KeybindingsConfig = [{ key: "mod++", command: "terminal.toggle" }]; + const plusBindings = compile([{ key: "mod++", command: "terminal.toggle" }]); assert.isTrue( isTerminalToggleShortcut(event({ key: "+", metaKey: true }), plusBindings, { platform: "MacIntel", diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 70217185c5672a9627608b3d4e579110a3cdfbd8..da376c5772f3477bb9f726fbd2cf8de8441be7eb 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -1,5 +1,9 @@ -import type { KeybindingCommand, KeybindingsConfig } from "@t3tools/contracts"; -import { LruCache } from "./lib/lru"; +import { + type KeybindingCommand, + type KeybindingShortcut, + type KeybindingWhenNode, + type ResolvedKeybindingsConfig, +} from "@t3tools/contracts"; export interface ShortcutEventLike { key: string; @@ -15,262 +19,55 @@ export interface ShortcutMatchContext { [key: string]: boolean; } -export type ResolvedKeybindings = KeybindingsConfig; - -interface ParsedShortcut { - key: string; - metaKey: boolean; - ctrlKey: boolean; - shiftKey: boolean; - altKey: boolean; - modKey: boolean; -} +export type ResolvedKeybindings = ResolvedKeybindingsConfig; interface ShortcutMatchOptions { platform?: string; context?: Partial; } -type WhenNode = - | { type: "identifier"; name: string } - | { type: "not"; node: WhenNode } - | { type: "and"; left: WhenNode; right: WhenNode } - | { type: "or"; left: WhenNode; right: WhenNode }; - -type WhenToken = - | { type: "identifier"; value: string } - | { type: "not" } - | { type: "and" } - | { type: "or" } - | { type: "lparen" } - | { type: "rparen" }; - -const WHEN_AST_CACHE = new LruCache(512); - function isMacPlatform(platform: string): boolean { return /mac|iphone|ipad|ipod/i.test(platform); } -function normalizeKeyToken(token: string): string { - if (token === "space") return " "; - if (token === "esc") return "escape"; - return token; -} - -function parseShortcutValue(value: string): ParsedShortcut | null { - const rawTokens = value.toLowerCase().split("+").map((token) => token.trim()); - const tokens = [...rawTokens]; - let trailingEmptyCount = 0; - while (tokens[tokens.length - 1] === "") { - trailingEmptyCount += 1; - tokens.pop(); - } - if (trailingEmptyCount > 0) { - tokens.push("+"); - } - if (tokens.some((token) => token.length === 0)) { - return null; - } - if (tokens.length === 0) return null; - - let key: string | null = null; - let metaKey = false; - let ctrlKey = false; - let shiftKey = false; - let altKey = false; - let modKey = false; - - for (const token of tokens) { - switch (token) { - case "cmd": - case "meta": - metaKey = true; - break; - case "ctrl": - case "control": - ctrlKey = true; - break; - case "shift": - shiftKey = true; - break; - case "alt": - case "option": - altKey = true; - break; - case "mod": - modKey = true; - break; - default: { - if (key !== null) { - return null; - } - key = normalizeKeyToken(token); - } - } - } - - if (key === null) return null; - - return { - key, - metaKey, - ctrlKey, - shiftKey, - altKey, - modKey, - }; -} - function normalizeEventKey(key: string): string { const normalized = key.toLowerCase(); if (normalized === "esc") return "escape"; return normalized; } -function matchesShortcutValue( +function matchesShortcut( event: ShortcutEventLike, - shortcutValue: string, + shortcut: KeybindingShortcut, platform = navigator.platform, ): boolean { - const parsed = parseShortcutValue(shortcutValue); - if (!parsed) return false; - const key = normalizeEventKey(event.key); - if (key !== parsed.key) return false; + if (key !== shortcut.key) return false; const useMetaForMod = isMacPlatform(platform); - const expectedMeta = parsed.metaKey || (parsed.modKey && useMetaForMod); - const expectedCtrl = parsed.ctrlKey || (parsed.modKey && !useMetaForMod); + const expectedMeta = shortcut.metaKey || (shortcut.modKey && useMetaForMod); + const expectedCtrl = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod); return ( event.metaKey === expectedMeta && event.ctrlKey === expectedCtrl && - event.shiftKey === parsed.shiftKey && - event.altKey === parsed.altKey + event.shiftKey === shortcut.shiftKey && + event.altKey === shortcut.altKey ); } -function tokenizeWhenExpression(expression: string): WhenToken[] | null { - const tokens: WhenToken[] = []; - let index = 0; - - while (index < expression.length) { - const current = expression[index]; - if (!current) break; - - if (/\s/.test(current)) { - index += 1; - continue; - } - if (expression.startsWith("&&", index)) { - tokens.push({ type: "and" }); - index += 2; - continue; - } - if (expression.startsWith("||", index)) { - tokens.push({ type: "or" }); - index += 2; - continue; - } - if (current === "!") { - tokens.push({ type: "not" }); - index += 1; - continue; - } - if (current === "(") { - tokens.push({ type: "lparen" }); - index += 1; - continue; - } - if (current === ")") { - tokens.push({ type: "rparen" }); - index += 1; - continue; - } - - const identifier = /^[A-Za-z_][A-Za-z0-9_.-]*/.exec(expression.slice(index)); - if (!identifier) { - return null; - } - tokens.push({ type: "identifier", value: identifier[0] }); - index += identifier[0].length; - } - - return tokens; +function resolvePlatform(options: ShortcutMatchOptions | undefined): string { + return options?.platform ?? navigator.platform; } -function parseWhenExpression(expression: string): WhenNode | null { - const tokens = tokenizeWhenExpression(expression); - if (!tokens || tokens.length === 0) return null; - let index = 0; - - const parsePrimary = (): WhenNode | null => { - const token = tokens[index]; - if (!token) return null; - - if (token.type === "identifier") { - index += 1; - return { type: "identifier", name: token.value }; - } - - if (token.type === "lparen") { - index += 1; - const expressionNode = parseOr(); - const closeToken = tokens[index]; - if (!expressionNode || !closeToken || closeToken.type !== "rparen") { - return null; - } - index += 1; - return expressionNode; - } - - return null; - }; - - const parseUnary = (): WhenNode | null => { - const token = tokens[index]; - if (token?.type === "not") { - index += 1; - const node = parseUnary(); - if (!node) return null; - return { type: "not", node }; - } - return parsePrimary(); +function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatchContext { + return { + terminalFocus: false, + terminalOpen: false, + ...options?.context, }; - - const parseAnd = (): WhenNode | null => { - let left = parseUnary(); - if (!left) return null; - - while (tokens[index]?.type === "and") { - index += 1; - const right = parseUnary(); - if (!right) return null; - left = { type: "and", left, right }; - } - - return left; - }; - - const parseOr = (): WhenNode | null => { - let left = parseAnd(); - if (!left) return null; - - while (tokens[index]?.type === "or") { - index += 1; - const right = parseAnd(); - if (!right) return null; - left = { type: "or", left, right }; - } - - return left; - }; - - const ast = parseOr(); - if (!ast || index !== tokens.length) return null; - return ast; } -function evaluateWhenNode(node: WhenNode, context: ShortcutMatchContext): boolean { +function evaluateWhenNode(node: KeybindingWhenNode, context: ShortcutMatchContext): boolean { switch (node.type) { case "identifier": if (node.name === "true") return true; @@ -285,32 +82,12 @@ function evaluateWhenNode(node: WhenNode, context: ShortcutMatchContext): boolea } } -function matchesWhenExpression(when: string | undefined, context: ShortcutMatchContext): boolean { - if (!when) return true; - const normalized = when.trim(); - if (normalized.length === 0) return true; - - const cached = WHEN_AST_CACHE.get(normalized); - if (cached === undefined) { - const parsed = parseWhenExpression(normalized); - WHEN_AST_CACHE.set(normalized, parsed); - if (!parsed) return false; - return evaluateWhenNode(parsed, context); - } - if (!cached) return false; - return evaluateWhenNode(cached, context); -} - -function resolvePlatform(options: ShortcutMatchOptions | undefined): string { - return options?.platform ?? navigator.platform; -} - -function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatchContext { - return { - terminalFocus: false, - terminalOpen: false, - ...options?.context, - }; +function matchesWhenClause( + whenAst: ResolvedKeybindings[number]["whenAst"], + context: ShortcutMatchContext, +): boolean { + if (!whenAst) return true; + return evaluateWhenNode(whenAst, context); } function matchesCommandShortcut( @@ -333,8 +110,8 @@ function resolveShortcutCommand( for (let index = keybindings.length - 1; index >= 0; index -= 1) { const binding = keybindings[index]; if (!binding) continue; - if (!matchesWhenExpression(binding.when, context)) continue; - if (!matchesShortcutValue(event, binding.key, platform)) continue; + if (!matchesWhenClause(binding.whenAst, context)) continue; + if (!matchesShortcut(event, binding.shortcut, platform)) continue; return binding.command; } return null; @@ -351,18 +128,13 @@ function formatShortcutKeyLabel(key: string): string { return key.slice(0, 1).toUpperCase() + key.slice(1); } -export function formatShortcutLabel(shortcutValue: string, platform = navigator.platform): string { - const parsed = parseShortcutValue(shortcutValue); - if (!parsed) { - return shortcutValue; - } - - const keyLabel = formatShortcutKeyLabel(parsed.key); +export function formatShortcutLabel(shortcut: KeybindingShortcut, platform = navigator.platform): string { + const keyLabel = formatShortcutKeyLabel(shortcut.key); const useMetaForMod = isMacPlatform(platform); - const showMeta = parsed.metaKey || (parsed.modKey && useMetaForMod); - const showCtrl = parsed.ctrlKey || (parsed.modKey && !useMetaForMod); - const showAlt = parsed.altKey; - const showShift = parsed.shiftKey; + const showMeta = shortcut.metaKey || (shortcut.modKey && useMetaForMod); + const showCtrl = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod); + const showAlt = shortcut.altKey; + const showShift = shortcut.shiftKey; if (useMetaForMod) { return `${showCtrl ? "\u2303" : ""}${showAlt ? "\u2325" : ""}${showShift ? "\u21e7" : ""}${showMeta ? "\u2318" : ""}${keyLabel}`; @@ -385,7 +157,7 @@ export function shortcutLabelForCommand( for (let index = keybindings.length - 1; index >= 0; index -= 1) { const binding = keybindings[index]; if (!binding || binding.command !== command) continue; - return formatShortcutLabel(binding.key, platform); + return formatShortcutLabel(binding.shortcut, platform); } return null; } diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 5e705e418f4a6cb350162409f0308cc90d62ce4f..7e3b58d14fa92f32689be7fd050b50ff707c307e 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -6,6 +6,7 @@ export * from "./provider"; export * from "./model"; export * from "./ws"; export * from "./keybindings"; +export * from "./keybindingsWhen"; export * from "./server"; export * from "./project"; export * from "./git"; diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a68ffb7b14505585ce9bf6c803bdfe5d92372e5a --- /dev/null +++ b/packages/contracts/src/keybindings.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; + +import { + keybindingRuleSchema, + keybindingsConfigSchema, + resolvedKeybindingRuleSchema, + resolvedKeybindingsConfigSchema, +} from "./keybindings"; + +describe("keybindings contracts", () => { + it("parses keybinding rules", () => { + const parsed = keybindingRuleSchema.parse({ + key: "mod+j", + command: "terminal.toggle", + }); + expect(parsed.command).toBe("terminal.toggle"); + }); + + it("rejects invalid command values", () => { + expect(() => + keybindingRuleSchema.parse({ + key: "mod+j", + command: "invalid.command" as unknown as "terminal.toggle", + }), + ).toThrow(); + }); + + it("parses keybindings array payload", () => { + const parsed = keybindingsConfigSchema.parse([ + { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, + ]); + expect(parsed).toHaveLength(2); + }); + + it("parses resolved keybinding rules", () => { + const parsed = resolvedKeybindingRuleSchema.parse({ + key: "mod+d", + command: "terminal.split", + when: "terminalOpen && !terminalFocus", + shortcut: { + key: "d", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "and", + left: { type: "identifier", name: "terminalOpen" }, + right: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + }); + expect(parsed.shortcut.key).toBe("d"); + }); + + it("parses resolved keybindings arrays", () => { + const parsed = resolvedKeybindingsConfigSchema.parse([ + { + key: "mod+j", + command: "terminal.toggle", + shortcut: { + key: "j", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + }, + ]); + expect(parsed).toHaveLength(1); + }); +}); diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 4576d79fb4f17f9b35cfedc930875beded80a649..43334ecc9b95f14adbaa9ba2fe2f556012444fc5 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import type { KeybindingWhenNode } from "./keybindingsWhen"; export const keybindingCommandSchema = z.enum([ "terminal.toggle", @@ -19,6 +20,51 @@ export const keybindingRuleSchema = z.object({ export const keybindingsConfigSchema = z.array(keybindingRuleSchema).max(256); +export const keybindingShortcutSchema = z.object({ + key: z.string().trim().min(1).max(32), + metaKey: z.boolean(), + ctrlKey: z.boolean(), + shiftKey: z.boolean(), + altKey: z.boolean(), + modKey: z.boolean(), +}); + +export const keybindingWhenNodeSchema: z.ZodType = z.lazy(() => + z.union([ + z.object({ + type: z.literal("identifier"), + name: z.string().min(1), + }), + z.object({ + type: z.literal("not"), + node: keybindingWhenNodeSchema, + }), + z.object({ + type: z.literal("and"), + left: keybindingWhenNodeSchema, + right: keybindingWhenNodeSchema, + }), + z.object({ + type: z.literal("or"), + left: keybindingWhenNodeSchema, + right: keybindingWhenNodeSchema, + }), + ]), +); + +export const resolvedKeybindingRuleSchema = z.object({ + key: keybindingValueSchema, + command: keybindingCommandSchema, + when: keybindingWhenSchema.optional(), + shortcut: keybindingShortcutSchema, + whenAst: keybindingWhenNodeSchema.optional(), +}); + +export const resolvedKeybindingsConfigSchema = z.array(resolvedKeybindingRuleSchema).max(256); + export type KeybindingCommand = z.infer; export type KeybindingRule = z.infer; export type KeybindingsConfig = z.infer; +export type KeybindingShortcut = z.infer; +export type ResolvedKeybindingRule = z.infer; +export type ResolvedKeybindingsConfig = z.infer; diff --git a/packages/contracts/src/keybindingsWhen.ts b/packages/contracts/src/keybindingsWhen.ts new file mode 100644 index 0000000000000000000000000000000000000000..e182d19035ab15dcf809483ff57af19cd0623369 --- /dev/null +++ b/packages/contracts/src/keybindingsWhen.ts @@ -0,0 +1,5 @@ +export type KeybindingWhenNode = + | { type: "identifier"; name: string } + | { type: "not"; node: KeybindingWhenNode } + | { type: "and"; left: KeybindingWhenNode; right: KeybindingWhenNode } + | { type: "or"; left: KeybindingWhenNode; right: KeybindingWhenNode }; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index d3ed929fbafaa1dad1924862fb304c769dd1d4ec..ebf3f1334e41441a8f8dc5eb7b8cc5ad5f1ba8bf 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,9 +1,9 @@ import { z } from "zod"; -import { keybindingsConfigSchema } from "./keybindings"; +import { resolvedKeybindingsConfigSchema } from "./keybindings"; export const serverConfigSchema = z.object({ cwd: z.string().min(1), - keybindings: keybindingsConfigSchema.default([]), + keybindings: resolvedKeybindingsConfigSchema.default([]), }); export type ServerConfig = z.infer;