diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index dd3667a60b05f7fe8aa597f587bf68fb1a1892fc..35b99b011f143bd666da24d0e2c770f583681efd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -58,7 +58,7 @@ import { isTerminalToggleShortcut, resolveTerminalKeybindings, shortcutLabelForCommand, -} from "../terminal-shortcuts"; +} from "../keybindings"; import ChatMarkdown from "./ChatMarkdown"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { useNativeApi } from "../hooks/useNativeApi"; diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 3373f913662c1ce58f88da2abbbd17fe6e700aec..d9e544ab94debfb92999ead1bb34469706e2f4ee 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -18,7 +18,7 @@ import { preferredTerminalEditor, resolvePathLinkTarget, } from "../terminal-links"; -import { isTerminalClearShortcut } from "../terminal-shortcuts"; +import { isTerminalClearShortcut } from "../keybindings"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f71cb8f09b42a4c203bf962cf37518bbd3fada2e --- /dev/null +++ b/apps/web/src/keybindings.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_TERMINAL_KEYBINDINGS, + formatShortcutLabel, + isTerminalClearShortcut, + isTerminalNewShortcut, + isTerminalSplitShortcut, + isTerminalToggleShortcut, + resolveTerminalKeybindings, + shortcutLabelForCommand, + type ShortcutEventLike, +} from "./keybindings"; + +function event(overrides: Partial = {}): ShortcutEventLike { + return { + key: "j", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + ...overrides, + }; +} + +describe("isTerminalToggleShortcut", () => { + it("matches Cmd+J on macOS", () => { + expect( + isTerminalToggleShortcut(event({ metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, { + platform: "MacIntel", + }), + ).toBe(true); + }); + + it("matches Ctrl+J on non-macOS", () => { + expect( + isTerminalToggleShortcut(event({ ctrlKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, { + platform: "Win32", + }), + ).toBe(true); + }); +}); + +describe("split/new terminal shortcuts", () => { + it("requires terminalFocus for default split/new bindings", () => { + expect( + isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + ).toBe(false); + expect( + isTerminalNewShortcut( + event({ key: "d", ctrlKey: true, shiftKey: true }), + DEFAULT_TERMINAL_KEYBINDINGS, + { + platform: "Linux", + context: { terminalFocus: false }, + }, + ), + ).toBe(false); + }); + + it("matches split/new when terminalFocus is true", () => { + expect( + isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, { + platform: "MacIntel", + context: { terminalFocus: true }, + }), + ).toBe(true); + expect( + isTerminalNewShortcut( + event({ key: "d", ctrlKey: true, shiftKey: true }), + DEFAULT_TERMINAL_KEYBINDINGS, + { + platform: "Linux", + context: { terminalFocus: true }, + }, + ), + ).toBe(true); + }); + + it("supports configured overrides with when expressions", () => { + const keybindings = resolveTerminalKeybindings([ + { + key: "mod+\\", + command: "terminal.split", + when: "terminalOpen && !terminalFocus", + }, + { + key: "mod+shift+n", + command: "terminal.new", + when: "terminalOpen && !terminalFocus", + }, + ]); + expect( + isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, { + platform: "Win32", + context: { terminalOpen: true, terminalFocus: false }, + }), + ).toBe(true); + expect( + isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, { + platform: "Win32", + context: { terminalOpen: false, terminalFocus: false }, + }), + ).toBe(false); + expect( + isTerminalNewShortcut(event({ key: "n", ctrlKey: true, shiftKey: true }), keybindings, { + platform: "Win32", + context: { terminalOpen: true, terminalFocus: false }, + }), + ).toBe(true); + }); +}); + +describe("resolveTerminalKeybindings", () => { + it("uses defaults when config is empty", () => { + expect(resolveTerminalKeybindings(undefined)).toEqual(DEFAULT_TERMINAL_KEYBINDINGS); + }); + + it("replaces defaults for commands configured by user", () => { + const resolved = resolveTerminalKeybindings([ + { key: "mod+\\", command: "terminal.split", when: "terminalFocus" }, + ]); + + expect(resolved).toEqual([ + { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, + { key: "mod+\\", command: "terminal.split", when: "terminalFocus" }, + ]); + }); + + it("ignores invalid configured values", () => { + expect( + resolveTerminalKeybindings([ + { key: " ", command: "terminal.toggle" }, + { key: "mod+d+f", command: "terminal.split" }, + ]), + ).toEqual(DEFAULT_TERMINAL_KEYBINDINGS); + }); +}); + +describe("shortcutLabelForCommand", () => { + it("returns the most recent binding label", () => { + const resolved = resolveTerminalKeybindings([ + { key: "mod+\\", command: "terminal.split", when: "terminalFocus" }, + { key: "mod+shift+\\", command: "terminal.split", when: "!terminalFocus" }, + ]); + + expect(shortcutLabelForCommand(resolved, "terminal.split", "Linux")).toBe("Ctrl+Shift+\\"); + }); +}); + +describe("formatShortcutLabel", () => { + it("formats labels for macOS", () => { + expect(formatShortcutLabel("mod+shift+d", "MacIntel")).toBe("⇧⌘D"); + }); + + it("formats labels for non-macOS", () => { + expect(formatShortcutLabel("mod+shift+d", "Linux")).toBe("Ctrl+Shift+D"); + }); +}); + +describe("isTerminalClearShortcut", () => { + it("matches Ctrl+L on all platforms", () => { + expect(isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "Linux")).toBe(true); + expect(isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "MacIntel")).toBe(true); + }); + + it("matches Cmd+K on macOS", () => { + expect(isTerminalClearShortcut(event({ key: "k", metaKey: true }), "MacIntel")).toBe(true); + }); +}); diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts new file mode 100644 index 0000000000000000000000000000000000000000..c876f8bc81c56fa46448db2fbd6387117e993b9b --- /dev/null +++ b/apps/web/src/keybindings.ts @@ -0,0 +1,461 @@ +import type { KeybindingCommand, KeybindingsConfig } from "@t3tools/contracts"; + +export interface ShortcutEventLike { + key: string; + metaKey: boolean; + ctrlKey: boolean; + shiftKey: boolean; + altKey: boolean; +} + +export interface ShortcutMatchContext { + terminalFocus: boolean; + terminalOpen: boolean; + [key: string]: boolean; +} + +export interface ResolvedTerminalKeybinding { + key: string; + command: KeybindingCommand; + when?: string | undefined; +} + +export type ResolvedTerminalKeybindings = ResolvedTerminalKeybinding[]; + +export const DEFAULT_TERMINAL_KEYBINDINGS: ResolvedTerminalKeybindings = [ + { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, + { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, +]; + +interface ParsedShortcut { + key: string; + metaKey: boolean; + ctrlKey: boolean; + shiftKey: boolean; + altKey: boolean; + modKey: boolean; +} + +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 Map(); + +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 tokens = value + .toLowerCase() + .split("+") + .map((token) => token.trim()) + .filter((token) => token.length > 0); + 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( + event: ShortcutEventLike, + shortcutValue: string, + platform = navigator.platform, +): boolean { + const parsed = parseShortcutValue(shortcutValue); + if (!parsed) return false; + + const key = normalizeEventKey(event.key); + if (key !== parsed.key) return false; + + const useMetaForMod = isMacPlatform(platform); + const expectedMeta = parsed.metaKey || (parsed.modKey && useMetaForMod); + const expectedCtrl = parsed.ctrlKey || (parsed.modKey && !useMetaForMod); + return ( + event.metaKey === expectedMeta && + event.ctrlKey === expectedCtrl && + event.shiftKey === parsed.shiftKey && + event.altKey === parsed.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 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(); + }; + + 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 { + switch (node.type) { + case "identifier": + return Boolean(context[node.name]); + case "not": + return !evaluateWhenNode(node.node, context); + case "and": + return evaluateWhenNode(node.left, context) && evaluateWhenNode(node.right, context); + case "or": + return evaluateWhenNode(node.left, context) || evaluateWhenNode(node.right, context); + } +} + +function matchesWhenExpression(when: string | undefined, context: ShortcutMatchContext): boolean { + if (!when) return true; + const normalized = when.trim(); + if (normalized.length === 0) return true; + + if (!WHEN_AST_CACHE.has(normalized)) { + WHEN_AST_CACHE.set(normalized, parseWhenExpression(normalized)); + } + const ast = WHEN_AST_CACHE.get(normalized); + if (!ast) return false; + return evaluateWhenNode(ast, 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 normalizeConfiguredKeybinding( + keybinding: KeybindingsConfig[number], +): ResolvedTerminalKeybinding | null { + const key = keybinding.key.trim(); + if (key.length === 0) return null; + if (!parseShortcutValue(key)) return null; + + const when = keybinding.when?.trim(); + return { + key, + command: keybinding.command, + ...(when ? { when } : {}), + }; +} + +function matchesCommandShortcut( + event: ShortcutEventLike, + keybindings: ResolvedTerminalKeybindings, + command: KeybindingCommand, + options?: ShortcutMatchOptions, +): boolean { + const platform = resolvePlatform(options); + const context = resolveContext(options); + + for (let index = keybindings.length - 1; index >= 0; index -= 1) { + const binding = keybindings[index]; + if (!binding || binding.command !== command) continue; + if (!matchesWhenExpression(binding.when, context)) continue; + if (!matchesShortcutValue(event, binding.key, platform)) continue; + return true; + } + return false; +} + +export function resolveTerminalKeybindings( + input: KeybindingsConfig | null | undefined, +): ResolvedTerminalKeybindings { + if (!input || input.length === 0) { + return [...DEFAULT_TERMINAL_KEYBINDINGS]; + } + + const normalizedInput = input + .map((binding) => normalizeConfiguredKeybinding(binding)) + .filter((binding): binding is ResolvedTerminalKeybinding => binding !== null); + if (normalizedInput.length === 0) { + return [...DEFAULT_TERMINAL_KEYBINDINGS]; + } + + const overriddenCommands = new Set(normalizedInput.map((binding) => binding.command)); + const retainedDefaults = DEFAULT_TERMINAL_KEYBINDINGS.filter( + (binding) => !overriddenCommands.has(binding.command), + ); + return [...retainedDefaults, ...normalizedInput]; +} + +function formatShortcutKeyLabel(key: string): string { + if (key === " ") return "Space"; + if (key.length === 1) return key.toUpperCase(); + if (key === "escape") return "Esc"; + if (key === "arrowup") return "Up"; + if (key === "arrowdown") return "Down"; + if (key === "arrowleft") return "Left"; + if (key === "arrowright") return "Right"; + 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); + 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; + + if (useMetaForMod) { + return `${showCtrl ? "\u2303" : ""}${showAlt ? "\u2325" : ""}${showShift ? "\u21e7" : ""}${showMeta ? "\u2318" : ""}${keyLabel}`; + } + + const parts: string[] = []; + if (showCtrl) parts.push("Ctrl"); + if (showAlt) parts.push("Alt"); + if (showShift) parts.push("Shift"); + if (showMeta) parts.push("Meta"); + parts.push(keyLabel); + return parts.join("+"); +} + +export function shortcutLabelForCommand( + keybindings: ResolvedTerminalKeybindings, + command: KeybindingCommand, + platform = navigator.platform, +): string | null { + 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 null; +} + +export function isTerminalToggleShortcut( + event: ShortcutEventLike, + keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "terminal.toggle", options); +} + +export function isTerminalSplitShortcut( + event: ShortcutEventLike, + keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "terminal.split", options); +} + +export function isTerminalNewShortcut( + event: ShortcutEventLike, + keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "terminal.new", options); +} + +export function isTerminalClearShortcut( + event: ShortcutEventLike, + platform = navigator.platform, +): boolean { + const key = event.key.toLowerCase(); + + if (key === "l" && event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) { + return true; + } + + return ( + isMacPlatform(platform) && + key === "k" && + event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey + ); +} diff --git a/apps/web/src/terminal-shortcuts.test.ts b/apps/web/src/terminal-shortcuts.test.ts deleted file mode 100644 index 6de3d641a8e070fb855d1d674e54642f1782b187..0000000000000000000000000000000000000000 --- a/apps/web/src/terminal-shortcuts.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - DEFAULT_TERMINAL_KEYBINDINGS, - formatShortcutLabel, - isTerminalClearShortcut, - isTerminalNewShortcut, - isTerminalSplitShortcut, - isTerminalToggleShortcut, - resolveTerminalKeybindings, - shortcutLabelForCommand, - type ShortcutEventLike, -} from "./terminal-shortcuts"; - -function event(overrides: Partial = {}): ShortcutEventLike { - return { - key: "j", - metaKey: false, - ctrlKey: false, - shiftKey: false, - altKey: false, - ...overrides, - }; -} - -describe("isTerminalToggleShortcut", () => { - it("matches Cmd+J on macOS", () => { - expect( - isTerminalToggleShortcut(event({ metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, { - platform: "MacIntel", - }), - ).toBe(true); - }); - - it("matches Ctrl+J on non-macOS", () => { - expect( - isTerminalToggleShortcut(event({ ctrlKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, { - platform: "Win32", - }), - ).toBe(true); - }); -}); - -describe("split/new terminal shortcuts", () => { - it("requires terminalFocus for default split/new bindings", () => { - expect( - isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, { - platform: "MacIntel", - context: { terminalFocus: false }, - }), - ).toBe(false); - expect( - isTerminalNewShortcut( - event({ key: "d", ctrlKey: true, shiftKey: true }), - DEFAULT_TERMINAL_KEYBINDINGS, - { - platform: "Linux", - context: { terminalFocus: false }, - }, - ), - ).toBe(false); - }); - - it("matches split/new when terminalFocus is true", () => { - expect( - isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, { - platform: "MacIntel", - context: { terminalFocus: true }, - }), - ).toBe(true); - expect( - isTerminalNewShortcut( - event({ key: "d", ctrlKey: true, shiftKey: true }), - DEFAULT_TERMINAL_KEYBINDINGS, - { - platform: "Linux", - context: { terminalFocus: true }, - }, - ), - ).toBe(true); - }); - - it("supports configured overrides with when expressions", () => { - const keybindings = resolveTerminalKeybindings([ - { - key: "mod+\\", - command: "terminal.split", - when: "terminalOpen && !terminalFocus", - }, - { - key: "mod+shift+n", - command: "terminal.new", - when: "terminalOpen && !terminalFocus", - }, - ]); - expect( - isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, { - platform: "Win32", - context: { terminalOpen: true, terminalFocus: false }, - }), - ).toBe(true); - expect( - isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, { - platform: "Win32", - context: { terminalOpen: false, terminalFocus: false }, - }), - ).toBe(false); - expect( - isTerminalNewShortcut(event({ key: "n", ctrlKey: true, shiftKey: true }), keybindings, { - platform: "Win32", - context: { terminalOpen: true, terminalFocus: false }, - }), - ).toBe(true); - }); -}); - -describe("resolveTerminalKeybindings", () => { - it("uses defaults when config is empty", () => { - expect(resolveTerminalKeybindings(undefined)).toEqual(DEFAULT_TERMINAL_KEYBINDINGS); - }); - - it("replaces defaults for commands configured by user", () => { - const resolved = resolveTerminalKeybindings([ - { key: "mod+\\", command: "terminal.split", when: "terminalFocus" }, - ]); - - expect(resolved).toEqual([ - { key: "mod+j", command: "terminal.toggle" }, - { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, - { key: "mod+\\", command: "terminal.split", when: "terminalFocus" }, - ]); - }); - - it("ignores invalid configured values", () => { - expect( - resolveTerminalKeybindings([ - { key: " ", command: "terminal.toggle" }, - { key: "mod+d+f", command: "terminal.split" }, - ]), - ).toEqual(DEFAULT_TERMINAL_KEYBINDINGS); - }); -}); - -describe("shortcutLabelForCommand", () => { - it("returns the most recent binding label", () => { - const resolved = resolveTerminalKeybindings([ - { key: "mod+\\", command: "terminal.split", when: "terminalFocus" }, - { key: "mod+shift+\\", command: "terminal.split", when: "!terminalFocus" }, - ]); - - expect(shortcutLabelForCommand(resolved, "terminal.split", "Linux")).toBe("Ctrl+Shift+\\"); - }); -}); - -describe("formatShortcutLabel", () => { - it("formats labels for macOS", () => { - expect(formatShortcutLabel("mod+shift+d", "MacIntel")).toBe("⇧⌘D"); - }); - - it("formats labels for non-macOS", () => { - expect(formatShortcutLabel("mod+shift+d", "Linux")).toBe("Ctrl+Shift+D"); - }); -}); - -describe("isTerminalClearShortcut", () => { - it("matches Ctrl+L on all platforms", () => { - expect(isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "Linux")).toBe(true); - expect(isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "MacIntel")).toBe(true); - }); - - it("matches Cmd+K on macOS", () => { - expect(isTerminalClearShortcut(event({ key: "k", metaKey: true }), "MacIntel")).toBe(true); - }); -}); diff --git a/apps/web/src/terminal-shortcuts.ts b/apps/web/src/terminal-shortcuts.ts deleted file mode 100644 index c876f8bc81c56fa46448db2fbd6387117e993b9b..0000000000000000000000000000000000000000 --- a/apps/web/src/terminal-shortcuts.ts +++ /dev/null @@ -1,461 +0,0 @@ -import type { KeybindingCommand, KeybindingsConfig } from "@t3tools/contracts"; - -export interface ShortcutEventLike { - key: string; - metaKey: boolean; - ctrlKey: boolean; - shiftKey: boolean; - altKey: boolean; -} - -export interface ShortcutMatchContext { - terminalFocus: boolean; - terminalOpen: boolean; - [key: string]: boolean; -} - -export interface ResolvedTerminalKeybinding { - key: string; - command: KeybindingCommand; - when?: string | undefined; -} - -export type ResolvedTerminalKeybindings = ResolvedTerminalKeybinding[]; - -export const DEFAULT_TERMINAL_KEYBINDINGS: ResolvedTerminalKeybindings = [ - { key: "mod+j", command: "terminal.toggle" }, - { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, - { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, -]; - -interface ParsedShortcut { - key: string; - metaKey: boolean; - ctrlKey: boolean; - shiftKey: boolean; - altKey: boolean; - modKey: boolean; -} - -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 Map(); - -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 tokens = value - .toLowerCase() - .split("+") - .map((token) => token.trim()) - .filter((token) => token.length > 0); - 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( - event: ShortcutEventLike, - shortcutValue: string, - platform = navigator.platform, -): boolean { - const parsed = parseShortcutValue(shortcutValue); - if (!parsed) return false; - - const key = normalizeEventKey(event.key); - if (key !== parsed.key) return false; - - const useMetaForMod = isMacPlatform(platform); - const expectedMeta = parsed.metaKey || (parsed.modKey && useMetaForMod); - const expectedCtrl = parsed.ctrlKey || (parsed.modKey && !useMetaForMod); - return ( - event.metaKey === expectedMeta && - event.ctrlKey === expectedCtrl && - event.shiftKey === parsed.shiftKey && - event.altKey === parsed.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 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(); - }; - - 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 { - switch (node.type) { - case "identifier": - return Boolean(context[node.name]); - case "not": - return !evaluateWhenNode(node.node, context); - case "and": - return evaluateWhenNode(node.left, context) && evaluateWhenNode(node.right, context); - case "or": - return evaluateWhenNode(node.left, context) || evaluateWhenNode(node.right, context); - } -} - -function matchesWhenExpression(when: string | undefined, context: ShortcutMatchContext): boolean { - if (!when) return true; - const normalized = when.trim(); - if (normalized.length === 0) return true; - - if (!WHEN_AST_CACHE.has(normalized)) { - WHEN_AST_CACHE.set(normalized, parseWhenExpression(normalized)); - } - const ast = WHEN_AST_CACHE.get(normalized); - if (!ast) return false; - return evaluateWhenNode(ast, 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 normalizeConfiguredKeybinding( - keybinding: KeybindingsConfig[number], -): ResolvedTerminalKeybinding | null { - const key = keybinding.key.trim(); - if (key.length === 0) return null; - if (!parseShortcutValue(key)) return null; - - const when = keybinding.when?.trim(); - return { - key, - command: keybinding.command, - ...(when ? { when } : {}), - }; -} - -function matchesCommandShortcut( - event: ShortcutEventLike, - keybindings: ResolvedTerminalKeybindings, - command: KeybindingCommand, - options?: ShortcutMatchOptions, -): boolean { - const platform = resolvePlatform(options); - const context = resolveContext(options); - - for (let index = keybindings.length - 1; index >= 0; index -= 1) { - const binding = keybindings[index]; - if (!binding || binding.command !== command) continue; - if (!matchesWhenExpression(binding.when, context)) continue; - if (!matchesShortcutValue(event, binding.key, platform)) continue; - return true; - } - return false; -} - -export function resolveTerminalKeybindings( - input: KeybindingsConfig | null | undefined, -): ResolvedTerminalKeybindings { - if (!input || input.length === 0) { - return [...DEFAULT_TERMINAL_KEYBINDINGS]; - } - - const normalizedInput = input - .map((binding) => normalizeConfiguredKeybinding(binding)) - .filter((binding): binding is ResolvedTerminalKeybinding => binding !== null); - if (normalizedInput.length === 0) { - return [...DEFAULT_TERMINAL_KEYBINDINGS]; - } - - const overriddenCommands = new Set(normalizedInput.map((binding) => binding.command)); - const retainedDefaults = DEFAULT_TERMINAL_KEYBINDINGS.filter( - (binding) => !overriddenCommands.has(binding.command), - ); - return [...retainedDefaults, ...normalizedInput]; -} - -function formatShortcutKeyLabel(key: string): string { - if (key === " ") return "Space"; - if (key.length === 1) return key.toUpperCase(); - if (key === "escape") return "Esc"; - if (key === "arrowup") return "Up"; - if (key === "arrowdown") return "Down"; - if (key === "arrowleft") return "Left"; - if (key === "arrowright") return "Right"; - 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); - 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; - - if (useMetaForMod) { - return `${showCtrl ? "\u2303" : ""}${showAlt ? "\u2325" : ""}${showShift ? "\u21e7" : ""}${showMeta ? "\u2318" : ""}${keyLabel}`; - } - - const parts: string[] = []; - if (showCtrl) parts.push("Ctrl"); - if (showAlt) parts.push("Alt"); - if (showShift) parts.push("Shift"); - if (showMeta) parts.push("Meta"); - parts.push(keyLabel); - return parts.join("+"); -} - -export function shortcutLabelForCommand( - keybindings: ResolvedTerminalKeybindings, - command: KeybindingCommand, - platform = navigator.platform, -): string | null { - 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 null; -} - -export function isTerminalToggleShortcut( - event: ShortcutEventLike, - keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS, - options?: ShortcutMatchOptions, -): boolean { - return matchesCommandShortcut(event, keybindings, "terminal.toggle", options); -} - -export function isTerminalSplitShortcut( - event: ShortcutEventLike, - keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS, - options?: ShortcutMatchOptions, -): boolean { - return matchesCommandShortcut(event, keybindings, "terminal.split", options); -} - -export function isTerminalNewShortcut( - event: ShortcutEventLike, - keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS, - options?: ShortcutMatchOptions, -): boolean { - return matchesCommandShortcut(event, keybindings, "terminal.new", options); -} - -export function isTerminalClearShortcut( - event: ShortcutEventLike, - platform = navigator.platform, -): boolean { - const key = event.key.toLowerCase(); - - if (key === "l" && event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) { - return true; - } - - return ( - isMacPlatform(platform) && - key === "k" && - event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey - ); -}