| author | |
| committer | |
| log | ace58035818652ebf400fe71e102de050950e3af |
| tree | 4c2f9c452e575400864863cafd46aff04dd5a143 |
| parent | 645b037c9e994ca408bec4ab658db2966eac7a25 |
- Rename `terminal-shortcuts.ts` and its test to `keybindings`
- Update ChatView and ThreadTerminalDrawer imports to use new module path6 files changed, 637 insertions(+), 637 deletions(-)
apps/web/src/components/ChatView.tsx+1-1| ... | ... | @@ -58,7 +58,7 @@ import { |
| 58 | 58 | isTerminalToggleShortcut, |
| 59 | 59 | resolveTerminalKeybindings, |
| 60 | 60 | shortcutLabelForCommand, |
| 61 | } from "../terminal-shortcuts"; | |
| 61 | } from "../keybindings"; | |
| 62 | 62 | import ChatMarkdown from "./ChatMarkdown"; |
| 63 | 63 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| 64 | 64 | import { useNativeApi } from "../hooks/useNativeApi"; |
apps/web/src/components/ThreadTerminalDrawer.tsx+1-1| ... | ... | @@ -18,7 +18,7 @@ import { |
| 18 | 18 | preferredTerminalEditor, |
| 19 | 19 | resolvePathLinkTarget, |
| 20 | 20 | } from "../terminal-links"; |
| 21 | import { isTerminalClearShortcut } from "../terminal-shortcuts"; | |
| 21 | import { isTerminalClearShortcut } from "../keybindings"; | |
| 22 | 22 | import { |
| 23 | 23 | DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 24 | 24 | DEFAULT_THREAD_TERMINAL_ID, |
apps/web/src/keybindings.test.ts created+174| ... | ... | @@ -0,0 +1,174 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | DEFAULT_TERMINAL_KEYBINDINGS, | |
| 5 | formatShortcutLabel, | |
| 6 | isTerminalClearShortcut, | |
| 7 | isTerminalNewShortcut, | |
| 8 | isTerminalSplitShortcut, | |
| 9 | isTerminalToggleShortcut, | |
| 10 | resolveTerminalKeybindings, | |
| 11 | shortcutLabelForCommand, | |
| 12 | type ShortcutEventLike, | |
| 13 | } from "./keybindings"; | |
| 14 | ||
| 15 | function event(overrides: Partial<ShortcutEventLike> = {}): ShortcutEventLike { | |
| 16 | return { | |
| 17 | key: "j", | |
| 18 | metaKey: false, | |
| 19 | ctrlKey: false, | |
| 20 | shiftKey: false, | |
| 21 | altKey: false, | |
| 22 | ...overrides, | |
| 23 | }; | |
| 24 | } | |
| 25 | ||
| 26 | describe("isTerminalToggleShortcut", () => { | |
| 27 | it("matches Cmd+J on macOS", () => { | |
| 28 | expect( | |
| 29 | isTerminalToggleShortcut(event({ metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, { | |
| 30 | platform: "MacIntel", | |
| 31 | }), | |
| 32 | ).toBe(true); | |
| 33 | }); | |
| 34 | ||
| 35 | it("matches Ctrl+J on non-macOS", () => { | |
| 36 | expect( | |
| 37 | isTerminalToggleShortcut(event({ ctrlKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, { | |
| 38 | platform: "Win32", | |
| 39 | }), | |
| 40 | ).toBe(true); | |
| 41 | }); | |
| 42 | }); | |
| 43 | ||
| 44 | describe("split/new terminal shortcuts", () => { | |
| 45 | it("requires terminalFocus for default split/new bindings", () => { | |
| 46 | expect( | |
| 47 | isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, { | |
| 48 | platform: "MacIntel", | |
| 49 | context: { terminalFocus: false }, | |
| 50 | }), | |
| 51 | ).toBe(false); | |
| 52 | expect( | |
| 53 | isTerminalNewShortcut( | |
| 54 | event({ key: "d", ctrlKey: true, shiftKey: true }), | |
| 55 | DEFAULT_TERMINAL_KEYBINDINGS, | |
| 56 | { | |
| 57 | platform: "Linux", | |
| 58 | context: { terminalFocus: false }, | |
| 59 | }, | |
| 60 | ), | |
| 61 | ).toBe(false); | |
| 62 | }); | |
| 63 | ||
| 64 | it("matches split/new when terminalFocus is true", () => { | |
| 65 | expect( | |
| 66 | isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, { | |
| 67 | platform: "MacIntel", | |
| 68 | context: { terminalFocus: true }, | |
| 69 | }), | |
| 70 | ).toBe(true); | |
| 71 | expect( | |
| 72 | isTerminalNewShortcut( | |
| 73 | event({ key: "d", ctrlKey: true, shiftKey: true }), | |
| 74 | DEFAULT_TERMINAL_KEYBINDINGS, | |
| 75 | { | |
| 76 | platform: "Linux", | |
| 77 | context: { terminalFocus: true }, | |
| 78 | }, | |
| 79 | ), | |
| 80 | ).toBe(true); | |
| 81 | }); | |
| 82 | ||
| 83 | it("supports configured overrides with when expressions", () => { | |
| 84 | const keybindings = resolveTerminalKeybindings([ | |
| 85 | { | |
| 86 | key: "mod+\\", | |
| 87 | command: "terminal.split", | |
| 88 | when: "terminalOpen && !terminalFocus", | |
| 89 | }, | |
| 90 | { | |
| 91 | key: "mod+shift+n", | |
| 92 | command: "terminal.new", | |
| 93 | when: "terminalOpen && !terminalFocus", | |
| 94 | }, | |
| 95 | ]); | |
| 96 | expect( | |
| 97 | isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, { | |
| 98 | platform: "Win32", | |
| 99 | context: { terminalOpen: true, terminalFocus: false }, | |
| 100 | }), | |
| 101 | ).toBe(true); | |
| 102 | expect( | |
| 103 | isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, { | |
| 104 | platform: "Win32", | |
| 105 | context: { terminalOpen: false, terminalFocus: false }, | |
| 106 | }), | |
| 107 | ).toBe(false); | |
| 108 | expect( | |
| 109 | isTerminalNewShortcut(event({ key: "n", ctrlKey: true, shiftKey: true }), keybindings, { | |
| 110 | platform: "Win32", | |
| 111 | context: { terminalOpen: true, terminalFocus: false }, | |
| 112 | }), | |
| 113 | ).toBe(true); | |
| 114 | }); | |
| 115 | }); | |
| 116 | ||
| 117 | describe("resolveTerminalKeybindings", () => { | |
| 118 | it("uses defaults when config is empty", () => { | |
| 119 | expect(resolveTerminalKeybindings(undefined)).toEqual(DEFAULT_TERMINAL_KEYBINDINGS); | |
| 120 | }); | |
| 121 | ||
| 122 | it("replaces defaults for commands configured by user", () => { | |
| 123 | const resolved = resolveTerminalKeybindings([ | |
| 124 | { key: "mod+\\", command: "terminal.split", when: "terminalFocus" }, | |
| 125 | ]); | |
| 126 | ||
| 127 | expect(resolved).toEqual([ | |
| 128 | { key: "mod+j", command: "terminal.toggle" }, | |
| 129 | { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, | |
| 130 | { key: "mod+\\", command: "terminal.split", when: "terminalFocus" }, | |
| 131 | ]); | |
| 132 | }); | |
| 133 | ||
| 134 | it("ignores invalid configured values", () => { | |
| 135 | expect( | |
| 136 | resolveTerminalKeybindings([ | |
| 137 | { key: " ", command: "terminal.toggle" }, | |
| 138 | { key: "mod+d+f", command: "terminal.split" }, | |
| 139 | ]), | |
| 140 | ).toEqual(DEFAULT_TERMINAL_KEYBINDINGS); | |
| 141 | }); | |
| 142 | }); | |
| 143 | ||
| 144 | describe("shortcutLabelForCommand", () => { | |
| 145 | it("returns the most recent binding label", () => { | |
| 146 | const resolved = resolveTerminalKeybindings([ | |
| 147 | { key: "mod+\\", command: "terminal.split", when: "terminalFocus" }, | |
| 148 | { key: "mod+shift+\\", command: "terminal.split", when: "!terminalFocus" }, | |
| 149 | ]); | |
| 150 | ||
| 151 | expect(shortcutLabelForCommand(resolved, "terminal.split", "Linux")).toBe("Ctrl+Shift+\\"); | |
| 152 | }); | |
| 153 | }); | |
| 154 | ||
| 155 | describe("formatShortcutLabel", () => { | |
| 156 | it("formats labels for macOS", () => { | |
| 157 | expect(formatShortcutLabel("mod+shift+d", "MacIntel")).toBe("⇧⌘D"); | |
| 158 | }); | |
| 159 | ||
| 160 | it("formats labels for non-macOS", () => { | |
| 161 | expect(formatShortcutLabel("mod+shift+d", "Linux")).toBe("Ctrl+Shift+D"); | |
| 162 | }); | |
| 163 | }); | |
| 164 | ||
| 165 | describe("isTerminalClearShortcut", () => { | |
| 166 | it("matches Ctrl+L on all platforms", () => { | |
| 167 | expect(isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "Linux")).toBe(true); | |
| 168 | expect(isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "MacIntel")).toBe(true); | |
| 169 | }); | |
| 170 | ||
| 171 | it("matches Cmd+K on macOS", () => { | |
| 172 | expect(isTerminalClearShortcut(event({ key: "k", metaKey: true }), "MacIntel")).toBe(true); | |
| 173 | }); | |
| 174 | }); |
apps/web/src/keybindings.ts created+461| ... | ... | @@ -0,0 +1,461 @@ |
| 1 | import type { KeybindingCommand, KeybindingsConfig } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | export interface ShortcutEventLike { | |
| 4 | key: string; | |
| 5 | metaKey: boolean; | |
| 6 | ctrlKey: boolean; | |
| 7 | shiftKey: boolean; | |
| 8 | altKey: boolean; | |
| 9 | } | |
| 10 | ||
| 11 | export interface ShortcutMatchContext { | |
| 12 | terminalFocus: boolean; | |
| 13 | terminalOpen: boolean; | |
| 14 | [key: string]: boolean; | |
| 15 | } | |
| 16 | ||
| 17 | export interface ResolvedTerminalKeybinding { | |
| 18 | key: string; | |
| 19 | command: KeybindingCommand; | |
| 20 | when?: string | undefined; | |
| 21 | } | |
| 22 | ||
| 23 | export type ResolvedTerminalKeybindings = ResolvedTerminalKeybinding[]; | |
| 24 | ||
| 25 | export const DEFAULT_TERMINAL_KEYBINDINGS: ResolvedTerminalKeybindings = [ | |
| 26 | { key: "mod+j", command: "terminal.toggle" }, | |
| 27 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, | |
| 28 | { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, | |
| 29 | ]; | |
| 30 | ||
| 31 | interface ParsedShortcut { | |
| 32 | key: string; | |
| 33 | metaKey: boolean; | |
| 34 | ctrlKey: boolean; | |
| 35 | shiftKey: boolean; | |
| 36 | altKey: boolean; | |
| 37 | modKey: boolean; | |
| 38 | } | |
| 39 | ||
| 40 | interface ShortcutMatchOptions { | |
| 41 | platform?: string; | |
| 42 | context?: Partial<ShortcutMatchContext>; | |
| 43 | } | |
| 44 | ||
| 45 | type WhenNode = | |
| 46 | | { type: "identifier"; name: string } | |
| 47 | | { type: "not"; node: WhenNode } | |
| 48 | | { type: "and"; left: WhenNode; right: WhenNode } | |
| 49 | | { type: "or"; left: WhenNode; right: WhenNode }; | |
| 50 | ||
| 51 | type WhenToken = | |
| 52 | | { type: "identifier"; value: string } | |
| 53 | | { type: "not" } | |
| 54 | | { type: "and" } | |
| 55 | | { type: "or" } | |
| 56 | | { type: "lparen" } | |
| 57 | | { type: "rparen" }; | |
| 58 | ||
| 59 | const WHEN_AST_CACHE = new Map<string, WhenNode | null>(); | |
| 60 | ||
| 61 | function isMacPlatform(platform: string): boolean { | |
| 62 | return /mac|iphone|ipad|ipod/i.test(platform); | |
| 63 | } | |
| 64 | ||
| 65 | function normalizeKeyToken(token: string): string { | |
| 66 | if (token === "space") return " "; | |
| 67 | if (token === "esc") return "escape"; | |
| 68 | return token; | |
| 69 | } | |
| 70 | ||
| 71 | function parseShortcutValue(value: string): ParsedShortcut | null { | |
| 72 | const tokens = value | |
| 73 | .toLowerCase() | |
| 74 | .split("+") | |
| 75 | .map((token) => token.trim()) | |
| 76 | .filter((token) => token.length > 0); | |
| 77 | if (tokens.length === 0) return null; | |
| 78 | ||
| 79 | let key: string | null = null; | |
| 80 | let metaKey = false; | |
| 81 | let ctrlKey = false; | |
| 82 | let shiftKey = false; | |
| 83 | let altKey = false; | |
| 84 | let modKey = false; | |
| 85 | ||
| 86 | for (const token of tokens) { | |
| 87 | switch (token) { | |
| 88 | case "cmd": | |
| 89 | case "meta": | |
| 90 | metaKey = true; | |
| 91 | break; | |
| 92 | case "ctrl": | |
| 93 | case "control": | |
| 94 | ctrlKey = true; | |
| 95 | break; | |
| 96 | case "shift": | |
| 97 | shiftKey = true; | |
| 98 | break; | |
| 99 | case "alt": | |
| 100 | case "option": | |
| 101 | altKey = true; | |
| 102 | break; | |
| 103 | case "mod": | |
| 104 | modKey = true; | |
| 105 | break; | |
| 106 | default: { | |
| 107 | if (key !== null) { | |
| 108 | return null; | |
| 109 | } | |
| 110 | key = normalizeKeyToken(token); | |
| 111 | } | |
| 112 | } | |
| 113 | } | |
| 114 | ||
| 115 | if (key === null) return null; | |
| 116 | ||
| 117 | return { | |
| 118 | key, | |
| 119 | metaKey, | |
| 120 | ctrlKey, | |
| 121 | shiftKey, | |
| 122 | altKey, | |
| 123 | modKey, | |
| 124 | }; | |
| 125 | } | |
| 126 | ||
| 127 | function normalizeEventKey(key: string): string { | |
| 128 | const normalized = key.toLowerCase(); | |
| 129 | if (normalized === "esc") return "escape"; | |
| 130 | return normalized; | |
| 131 | } | |
| 132 | ||
| 133 | function matchesShortcutValue( | |
| 134 | event: ShortcutEventLike, | |
| 135 | shortcutValue: string, | |
| 136 | platform = navigator.platform, | |
| 137 | ): boolean { | |
| 138 | const parsed = parseShortcutValue(shortcutValue); | |
| 139 | if (!parsed) return false; | |
| 140 | ||
| 141 | const key = normalizeEventKey(event.key); | |
| 142 | if (key !== parsed.key) return false; | |
| 143 | ||
| 144 | const useMetaForMod = isMacPlatform(platform); | |
| 145 | const expectedMeta = parsed.metaKey || (parsed.modKey && useMetaForMod); | |
| 146 | const expectedCtrl = parsed.ctrlKey || (parsed.modKey && !useMetaForMod); | |
| 147 | return ( | |
| 148 | event.metaKey === expectedMeta && | |
| 149 | event.ctrlKey === expectedCtrl && | |
| 150 | event.shiftKey === parsed.shiftKey && | |
| 151 | event.altKey === parsed.altKey | |
| 152 | ); | |
| 153 | } | |
| 154 | ||
| 155 | function tokenizeWhenExpression(expression: string): WhenToken[] | null { | |
| 156 | const tokens: WhenToken[] = []; | |
| 157 | let index = 0; | |
| 158 | ||
| 159 | while (index < expression.length) { | |
| 160 | const current = expression[index]; | |
| 161 | if (!current) break; | |
| 162 | ||
| 163 | if (/\s/.test(current)) { | |
| 164 | index += 1; | |
| 165 | continue; | |
| 166 | } | |
| 167 | if (expression.startsWith("&&", index)) { | |
| 168 | tokens.push({ type: "and" }); | |
| 169 | index += 2; | |
| 170 | continue; | |
| 171 | } | |
| 172 | if (expression.startsWith("||", index)) { | |
| 173 | tokens.push({ type: "or" }); | |
| 174 | index += 2; | |
| 175 | continue; | |
| 176 | } | |
| 177 | if (current === "!") { | |
| 178 | tokens.push({ type: "not" }); | |
| 179 | index += 1; | |
| 180 | continue; | |
| 181 | } | |
| 182 | if (current === "(") { | |
| 183 | tokens.push({ type: "lparen" }); | |
| 184 | index += 1; | |
| 185 | continue; | |
| 186 | } | |
| 187 | if (current === ")") { | |
| 188 | tokens.push({ type: "rparen" }); | |
| 189 | index += 1; | |
| 190 | continue; | |
| 191 | } | |
| 192 | ||
| 193 | const identifier = /^[A-Za-z_][A-Za-z0-9_.-]*/.exec(expression.slice(index)); | |
| 194 | if (!identifier) { | |
| 195 | return null; | |
| 196 | } | |
| 197 | tokens.push({ type: "identifier", value: identifier[0] }); | |
| 198 | index += identifier[0].length; | |
| 199 | } | |
| 200 | ||
| 201 | return tokens; | |
| 202 | } | |
| 203 | ||
| 204 | function parseWhenExpression(expression: string): WhenNode | null { | |
| 205 | const tokens = tokenizeWhenExpression(expression); | |
| 206 | if (!tokens || tokens.length === 0) return null; | |
| 207 | let index = 0; | |
| 208 | ||
| 209 | const parsePrimary = (): WhenNode | null => { | |
| 210 | const token = tokens[index]; | |
| 211 | if (!token) return null; | |
| 212 | ||
| 213 | if (token.type === "identifier") { | |
| 214 | index += 1; | |
| 215 | return { type: "identifier", name: token.value }; | |
| 216 | } | |
| 217 | ||
| 218 | if (token.type === "lparen") { | |
| 219 | index += 1; | |
| 220 | const expressionNode = parseOr(); | |
| 221 | const closeToken = tokens[index]; | |
| 222 | if (!expressionNode || !closeToken || closeToken.type !== "rparen") { | |
| 223 | return null; | |
| 224 | } | |
| 225 | index += 1; | |
| 226 | return expressionNode; | |
| 227 | } | |
| 228 | ||
| 229 | return null; | |
| 230 | }; | |
| 231 | ||
| 232 | const parseUnary = (): WhenNode | null => { | |
| 233 | const token = tokens[index]; | |
| 234 | if (token?.type === "not") { | |
| 235 | index += 1; | |
| 236 | const node = parseUnary(); | |
| 237 | if (!node) return null; | |
| 238 | return { type: "not", node }; | |
| 239 | } | |
| 240 | return parsePrimary(); | |
| 241 | }; | |
| 242 | ||
| 243 | const parseAnd = (): WhenNode | null => { | |
| 244 | let left = parseUnary(); | |
| 245 | if (!left) return null; | |
| 246 | ||
| 247 | while (tokens[index]?.type === "and") { | |
| 248 | index += 1; | |
| 249 | const right = parseUnary(); | |
| 250 | if (!right) return null; | |
| 251 | left = { type: "and", left, right }; | |
| 252 | } | |
| 253 | ||
| 254 | return left; | |
| 255 | }; | |
| 256 | ||
| 257 | const parseOr = (): WhenNode | null => { | |
| 258 | let left = parseAnd(); | |
| 259 | if (!left) return null; | |
| 260 | ||
| 261 | while (tokens[index]?.type === "or") { | |
| 262 | index += 1; | |
| 263 | const right = parseAnd(); | |
| 264 | if (!right) return null; | |
| 265 | left = { type: "or", left, right }; | |
| 266 | } | |
| 267 | ||
| 268 | return left; | |
| 269 | }; | |
| 270 | ||
| 271 | const ast = parseOr(); | |
| 272 | if (!ast || index !== tokens.length) return null; | |
| 273 | return ast; | |
| 274 | } | |
| 275 | ||
| 276 | function evaluateWhenNode(node: WhenNode, context: ShortcutMatchContext): boolean { | |
| 277 | switch (node.type) { | |
| 278 | case "identifier": | |
| 279 | return Boolean(context[node.name]); | |
| 280 | case "not": | |
| 281 | return !evaluateWhenNode(node.node, context); | |
| 282 | case "and": | |
| 283 | return evaluateWhenNode(node.left, context) && evaluateWhenNode(node.right, context); | |
| 284 | case "or": | |
| 285 | return evaluateWhenNode(node.left, context) || evaluateWhenNode(node.right, context); | |
| 286 | } | |
| 287 | } | |
| 288 | ||
| 289 | function matchesWhenExpression(when: string | undefined, context: ShortcutMatchContext): boolean { | |
| 290 | if (!when) return true; | |
| 291 | const normalized = when.trim(); | |
| 292 | if (normalized.length === 0) return true; | |
| 293 | ||
| 294 | if (!WHEN_AST_CACHE.has(normalized)) { | |
| 295 | WHEN_AST_CACHE.set(normalized, parseWhenExpression(normalized)); | |
| 296 | } | |
| 297 | const ast = WHEN_AST_CACHE.get(normalized); | |
| 298 | if (!ast) return false; | |
| 299 | return evaluateWhenNode(ast, context); | |
| 300 | } | |
| 301 | ||
| 302 | function resolvePlatform(options: ShortcutMatchOptions | undefined): string { | |
| 303 | return options?.platform ?? navigator.platform; | |
| 304 | } | |
| 305 | ||
| 306 | function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatchContext { | |
| 307 | return { | |
| 308 | terminalFocus: false, | |
| 309 | terminalOpen: false, | |
| 310 | ...options?.context, | |
| 311 | }; | |
| 312 | } | |
| 313 | ||
| 314 | function normalizeConfiguredKeybinding( | |
| 315 | keybinding: KeybindingsConfig[number], | |
| 316 | ): ResolvedTerminalKeybinding | null { | |
| 317 | const key = keybinding.key.trim(); | |
| 318 | if (key.length === 0) return null; | |
| 319 | if (!parseShortcutValue(key)) return null; | |
| 320 | ||
| 321 | const when = keybinding.when?.trim(); | |
| 322 | return { | |
| 323 | key, | |
| 324 | command: keybinding.command, | |
| 325 | ...(when ? { when } : {}), | |
| 326 | }; | |
| 327 | } | |
| 328 | ||
| 329 | function matchesCommandShortcut( | |
| 330 | event: ShortcutEventLike, | |
| 331 | keybindings: ResolvedTerminalKeybindings, | |
| 332 | command: KeybindingCommand, | |
| 333 | options?: ShortcutMatchOptions, | |
| 334 | ): boolean { | |
| 335 | const platform = resolvePlatform(options); | |
| 336 | const context = resolveContext(options); | |
| 337 | ||
| 338 | for (let index = keybindings.length - 1; index >= 0; index -= 1) { | |
| 339 | const binding = keybindings[index]; | |
| 340 | if (!binding || binding.command !== command) continue; | |
| 341 | if (!matchesWhenExpression(binding.when, context)) continue; | |
| 342 | if (!matchesShortcutValue(event, binding.key, platform)) continue; | |
| 343 | return true; | |
| 344 | } | |
| 345 | return false; | |
| 346 | } | |
| 347 | ||
| 348 | export function resolveTerminalKeybindings( | |
| 349 | input: KeybindingsConfig | null | undefined, | |
| 350 | ): ResolvedTerminalKeybindings { | |
| 351 | if (!input || input.length === 0) { | |
| 352 | return [...DEFAULT_TERMINAL_KEYBINDINGS]; | |
| 353 | } | |
| 354 | ||
| 355 | const normalizedInput = input | |
| 356 | .map((binding) => normalizeConfiguredKeybinding(binding)) | |
| 357 | .filter((binding): binding is ResolvedTerminalKeybinding => binding !== null); | |
| 358 | if (normalizedInput.length === 0) { | |
| 359 | return [...DEFAULT_TERMINAL_KEYBINDINGS]; | |
| 360 | } | |
| 361 | ||
| 362 | const overriddenCommands = new Set(normalizedInput.map((binding) => binding.command)); | |
| 363 | const retainedDefaults = DEFAULT_TERMINAL_KEYBINDINGS.filter( | |
| 364 | (binding) => !overriddenCommands.has(binding.command), | |
| 365 | ); | |
| 366 | return [...retainedDefaults, ...normalizedInput]; | |
| 367 | } | |
| 368 | ||
| 369 | function formatShortcutKeyLabel(key: string): string { | |
| 370 | if (key === " ") return "Space"; | |
| 371 | if (key.length === 1) return key.toUpperCase(); | |
| 372 | if (key === "escape") return "Esc"; | |
| 373 | if (key === "arrowup") return "Up"; | |
| 374 | if (key === "arrowdown") return "Down"; | |
| 375 | if (key === "arrowleft") return "Left"; | |
| 376 | if (key === "arrowright") return "Right"; | |
| 377 | return key.slice(0, 1).toUpperCase() + key.slice(1); | |
| 378 | } | |
| 379 | ||
| 380 | export function formatShortcutLabel(shortcutValue: string, platform = navigator.platform): string { | |
| 381 | const parsed = parseShortcutValue(shortcutValue); | |
| 382 | if (!parsed) { | |
| 383 | return shortcutValue; | |
| 384 | } | |
| 385 | ||
| 386 | const keyLabel = formatShortcutKeyLabel(parsed.key); | |
| 387 | const useMetaForMod = isMacPlatform(platform); | |
| 388 | const showMeta = parsed.metaKey || (parsed.modKey && useMetaForMod); | |
| 389 | const showCtrl = parsed.ctrlKey || (parsed.modKey && !useMetaForMod); | |
| 390 | const showAlt = parsed.altKey; | |
| 391 | const showShift = parsed.shiftKey; | |
| 392 | ||
| 393 | if (useMetaForMod) { | |
| 394 | return `${showCtrl ? "\u2303" : ""}${showAlt ? "\u2325" : ""}${showShift ? "\u21e7" : ""}${showMeta ? "\u2318" : ""}${keyLabel}`; | |
| 395 | } | |
| 396 | ||
| 397 | const parts: string[] = []; | |
| 398 | if (showCtrl) parts.push("Ctrl"); | |
| 399 | if (showAlt) parts.push("Alt"); | |
| 400 | if (showShift) parts.push("Shift"); | |
| 401 | if (showMeta) parts.push("Meta"); | |
| 402 | parts.push(keyLabel); | |
| 403 | return parts.join("+"); | |
| 404 | } | |
| 405 | ||
| 406 | export function shortcutLabelForCommand( | |
| 407 | keybindings: ResolvedTerminalKeybindings, | |
| 408 | command: KeybindingCommand, | |
| 409 | platform = navigator.platform, | |
| 410 | ): string | null { | |
| 411 | for (let index = keybindings.length - 1; index >= 0; index -= 1) { | |
| 412 | const binding = keybindings[index]; | |
| 413 | if (!binding || binding.command !== command) continue; | |
| 414 | return formatShortcutLabel(binding.key, platform); | |
| 415 | } | |
| 416 | return null; | |
| 417 | } | |
| 418 | ||
| 419 | export function isTerminalToggleShortcut( | |
| 420 | event: ShortcutEventLike, | |
| 421 | keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS, | |
| 422 | options?: ShortcutMatchOptions, | |
| 423 | ): boolean { | |
| 424 | return matchesCommandShortcut(event, keybindings, "terminal.toggle", options); | |
| 425 | } | |
| 426 | ||
| 427 | export function isTerminalSplitShortcut( | |
| 428 | event: ShortcutEventLike, | |
| 429 | keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS, | |
| 430 | options?: ShortcutMatchOptions, | |
| 431 | ): boolean { | |
| 432 | return matchesCommandShortcut(event, keybindings, "terminal.split", options); | |
| 433 | } | |
| 434 | ||
| 435 | export function isTerminalNewShortcut( | |
| 436 | event: ShortcutEventLike, | |
| 437 | keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS, | |
| 438 | options?: ShortcutMatchOptions, | |
| 439 | ): boolean { | |
| 440 | return matchesCommandShortcut(event, keybindings, "terminal.new", options); | |
| 441 | } | |
| 442 | ||
| 443 | export function isTerminalClearShortcut( | |
| 444 | event: ShortcutEventLike, | |
| 445 | platform = navigator.platform, | |
| 446 | ): boolean { | |
| 447 | const key = event.key.toLowerCase(); | |
| 448 | ||
| 449 | if (key === "l" && event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) { | |
| 450 | return true; | |
| 451 | } | |
| 452 | ||
| 453 | return ( | |
| 454 | isMacPlatform(platform) && | |
| 455 | key === "k" && | |
| 456 | event.metaKey && | |
| 457 | !event.ctrlKey && | |
| 458 | !event.altKey && | |
| 459 | !event.shiftKey | |
| 460 | ); | |
| 461 | } |
apps/web/src/terminal-shortcuts.test.ts deleted-174| ... | ... | @@ -1,174 +0,0 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | DEFAULT_TERMINAL_KEYBINDINGS, | |
| 5 | formatShortcutLabel, | |
| 6 | isTerminalClearShortcut, | |
| 7 | isTerminalNewShortcut, | |
| 8 | isTerminalSplitShortcut, | |
| 9 | isTerminalToggleShortcut, | |
| 10 | resolveTerminalKeybindings, | |
| 11 | shortcutLabelForCommand, | |
| 12 | type ShortcutEventLike, | |
| 13 | } from "./terminal-shortcuts"; | |
| 14 | ||
| 15 | function event(overrides: Partial<ShortcutEventLike> = {}): ShortcutEventLike { | |
| 16 | return { | |
| 17 | key: "j", | |
| 18 | metaKey: false, | |
| 19 | ctrlKey: false, | |
| 20 | shiftKey: false, | |
| 21 | altKey: false, | |
| 22 | ...overrides, | |
| 23 | }; | |
| 24 | } | |
| 25 | ||
| 26 | describe("isTerminalToggleShortcut", () => { | |
| 27 | it("matches Cmd+J on macOS", () => { | |
| 28 | expect( | |
| 29 | isTerminalToggleShortcut(event({ metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, { | |
| 30 | platform: "MacIntel", | |
| 31 | }), | |
| 32 | ).toBe(true); | |
| 33 | }); | |
| 34 | ||
| 35 | it("matches Ctrl+J on non-macOS", () => { | |
| 36 | expect( | |
| 37 | isTerminalToggleShortcut(event({ ctrlKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, { | |
| 38 | platform: "Win32", | |
| 39 | }), | |
| 40 | ).toBe(true); | |
| 41 | }); | |
| 42 | }); | |
| 43 | ||
| 44 | describe("split/new terminal shortcuts", () => { | |
| 45 | it("requires terminalFocus for default split/new bindings", () => { | |
| 46 | expect( | |
| 47 | isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, { | |
| 48 | platform: "MacIntel", | |
| 49 | context: { terminalFocus: false }, | |
| 50 | }), | |
| 51 | ).toBe(false); | |
| 52 | expect( | |
| 53 | isTerminalNewShortcut( | |
| 54 | event({ key: "d", ctrlKey: true, shiftKey: true }), | |
| 55 | DEFAULT_TERMINAL_KEYBINDINGS, | |
| 56 | { | |
| 57 | platform: "Linux", | |
| 58 | context: { terminalFocus: false }, | |
| 59 | }, | |
| 60 | ), | |
| 61 | ).toBe(false); | |
| 62 | }); | |
| 63 | ||
| 64 | it("matches split/new when terminalFocus is true", () => { | |
| 65 | expect( | |
| 66 | isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, { | |
| 67 | platform: "MacIntel", | |
| 68 | context: { terminalFocus: true }, | |
| 69 | }), | |
| 70 | ).toBe(true); | |
| 71 | expect( | |
| 72 | isTerminalNewShortcut( | |
| 73 | event({ key: "d", ctrlKey: true, shiftKey: true }), | |
| 74 | DEFAULT_TERMINAL_KEYBINDINGS, | |
| 75 | { | |
| 76 | platform: "Linux", | |
| 77 | context: { terminalFocus: true }, | |
| 78 | }, | |
| 79 | ), | |
| 80 | ).toBe(true); | |
| 81 | }); | |
| 82 | ||
| 83 | it("supports configured overrides with when expressions", () => { | |
| 84 | const keybindings = resolveTerminalKeybindings([ | |
| 85 | { | |
| 86 | key: "mod+\\", | |
| 87 | command: "terminal.split", | |
| 88 | when: "terminalOpen && !terminalFocus", | |
| 89 | }, | |
| 90 | { | |
| 91 | key: "mod+shift+n", | |
| 92 | command: "terminal.new", | |
| 93 | when: "terminalOpen && !terminalFocus", | |
| 94 | }, | |
| 95 | ]); | |
| 96 | expect( | |
| 97 | isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, { | |
| 98 | platform: "Win32", | |
| 99 | context: { terminalOpen: true, terminalFocus: false }, | |
| 100 | }), | |
| 101 | ).toBe(true); | |
| 102 | expect( | |
| 103 | isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, { | |
| 104 | platform: "Win32", | |
| 105 | context: { terminalOpen: false, terminalFocus: false }, | |
| 106 | }), | |
| 107 | ).toBe(false); | |
| 108 | expect( | |
| 109 | isTerminalNewShortcut(event({ key: "n", ctrlKey: true, shiftKey: true }), keybindings, { | |
| 110 | platform: "Win32", | |
| 111 | context: { terminalOpen: true, terminalFocus: false }, | |
| 112 | }), | |
| 113 | ).toBe(true); | |
| 114 | }); | |
| 115 | }); | |
| 116 | ||
| 117 | describe("resolveTerminalKeybindings", () => { | |
| 118 | it("uses defaults when config is empty", () => { | |
| 119 | expect(resolveTerminalKeybindings(undefined)).toEqual(DEFAULT_TERMINAL_KEYBINDINGS); | |
| 120 | }); | |
| 121 | ||
| 122 | it("replaces defaults for commands configured by user", () => { | |
| 123 | const resolved = resolveTerminalKeybindings([ | |
| 124 | { key: "mod+\\", command: "terminal.split", when: "terminalFocus" }, | |
| 125 | ]); | |
| 126 | ||
| 127 | expect(resolved).toEqual([ | |
| 128 | { key: "mod+j", command: "terminal.toggle" }, | |
| 129 | { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, | |
| 130 | { key: "mod+\\", command: "terminal.split", when: "terminalFocus" }, | |
| 131 | ]); | |
| 132 | }); | |
| 133 | ||
| 134 | it("ignores invalid configured values", () => { | |
| 135 | expect( | |
| 136 | resolveTerminalKeybindings([ | |
| 137 | { key: " ", command: "terminal.toggle" }, | |
| 138 | { key: "mod+d+f", command: "terminal.split" }, | |
| 139 | ]), | |
| 140 | ).toEqual(DEFAULT_TERMINAL_KEYBINDINGS); | |
| 141 | }); | |
| 142 | }); | |
| 143 | ||
| 144 | describe("shortcutLabelForCommand", () => { | |
| 145 | it("returns the most recent binding label", () => { | |
| 146 | const resolved = resolveTerminalKeybindings([ | |
| 147 | { key: "mod+\\", command: "terminal.split", when: "terminalFocus" }, | |
| 148 | { key: "mod+shift+\\", command: "terminal.split", when: "!terminalFocus" }, | |
| 149 | ]); | |
| 150 | ||
| 151 | expect(shortcutLabelForCommand(resolved, "terminal.split", "Linux")).toBe("Ctrl+Shift+\\"); | |
| 152 | }); | |
| 153 | }); | |
| 154 | ||
| 155 | describe("formatShortcutLabel", () => { | |
| 156 | it("formats labels for macOS", () => { | |
| 157 | expect(formatShortcutLabel("mod+shift+d", "MacIntel")).toBe("⇧⌘D"); | |
| 158 | }); | |
| 159 | ||
| 160 | it("formats labels for non-macOS", () => { | |
| 161 | expect(formatShortcutLabel("mod+shift+d", "Linux")).toBe("Ctrl+Shift+D"); | |
| 162 | }); | |
| 163 | }); | |
| 164 | ||
| 165 | describe("isTerminalClearShortcut", () => { | |
| 166 | it("matches Ctrl+L on all platforms", () => { | |
| 167 | expect(isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "Linux")).toBe(true); | |
| 168 | expect(isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "MacIntel")).toBe(true); | |
| 169 | }); | |
| 170 | ||
| 171 | it("matches Cmd+K on macOS", () => { | |
| 172 | expect(isTerminalClearShortcut(event({ key: "k", metaKey: true }), "MacIntel")).toBe(true); | |
| 173 | }); | |
| 174 | }); |
apps/web/src/terminal-shortcuts.ts deleted-461| ... | ... | @@ -1,461 +0,0 @@ |
| 1 | import type { KeybindingCommand, KeybindingsConfig } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | export interface ShortcutEventLike { | |
| 4 | key: string; | |
| 5 | metaKey: boolean; | |
| 6 | ctrlKey: boolean; | |
| 7 | shiftKey: boolean; | |
| 8 | altKey: boolean; | |
| 9 | } | |
| 10 | ||
| 11 | export interface ShortcutMatchContext { | |
| 12 | terminalFocus: boolean; | |
| 13 | terminalOpen: boolean; | |
| 14 | [key: string]: boolean; | |
| 15 | } | |
| 16 | ||
| 17 | export interface ResolvedTerminalKeybinding { | |
| 18 | key: string; | |
| 19 | command: KeybindingCommand; | |
| 20 | when?: string | undefined; | |
| 21 | } | |
| 22 | ||
| 23 | export type ResolvedTerminalKeybindings = ResolvedTerminalKeybinding[]; | |
| 24 | ||
| 25 | export const DEFAULT_TERMINAL_KEYBINDINGS: ResolvedTerminalKeybindings = [ | |
| 26 | { key: "mod+j", command: "terminal.toggle" }, | |
| 27 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, | |
| 28 | { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, | |
| 29 | ]; | |
| 30 | ||
| 31 | interface ParsedShortcut { | |
| 32 | key: string; | |
| 33 | metaKey: boolean; | |
| 34 | ctrlKey: boolean; | |
| 35 | shiftKey: boolean; | |
| 36 | altKey: boolean; | |
| 37 | modKey: boolean; | |
| 38 | } | |
| 39 | ||
| 40 | interface ShortcutMatchOptions { | |
| 41 | platform?: string; | |
| 42 | context?: Partial<ShortcutMatchContext>; | |
| 43 | } | |
| 44 | ||
| 45 | type WhenNode = | |
| 46 | | { type: "identifier"; name: string } | |
| 47 | | { type: "not"; node: WhenNode } | |
| 48 | | { type: "and"; left: WhenNode; right: WhenNode } | |
| 49 | | { type: "or"; left: WhenNode; right: WhenNode }; | |
| 50 | ||
| 51 | type WhenToken = | |
| 52 | | { type: "identifier"; value: string } | |
| 53 | | { type: "not" } | |
| 54 | | { type: "and" } | |
| 55 | | { type: "or" } | |
| 56 | | { type: "lparen" } | |
| 57 | | { type: "rparen" }; | |
| 58 | ||
| 59 | const WHEN_AST_CACHE = new Map<string, WhenNode | null>(); | |
| 60 | ||
| 61 | function isMacPlatform(platform: string): boolean { | |
| 62 | return /mac|iphone|ipad|ipod/i.test(platform); | |
| 63 | } | |
| 64 | ||
| 65 | function normalizeKeyToken(token: string): string { | |
| 66 | if (token === "space") return " "; | |
| 67 | if (token === "esc") return "escape"; | |
| 68 | return token; | |
| 69 | } | |
| 70 | ||
| 71 | function parseShortcutValue(value: string): ParsedShortcut | null { | |
| 72 | const tokens = value | |
| 73 | .toLowerCase() | |
| 74 | .split("+") | |
| 75 | .map((token) => token.trim()) | |
| 76 | .filter((token) => token.length > 0); | |
| 77 | if (tokens.length === 0) return null; | |
| 78 | ||
| 79 | let key: string | null = null; | |
| 80 | let metaKey = false; | |
| 81 | let ctrlKey = false; | |
| 82 | let shiftKey = false; | |
| 83 | let altKey = false; | |
| 84 | let modKey = false; | |
| 85 | ||
| 86 | for (const token of tokens) { | |
| 87 | switch (token) { | |
| 88 | case "cmd": | |
| 89 | case "meta": | |
| 90 | metaKey = true; | |
| 91 | break; | |
| 92 | case "ctrl": | |
| 93 | case "control": | |
| 94 | ctrlKey = true; | |
| 95 | break; | |
| 96 | case "shift": | |
| 97 | shiftKey = true; | |
| 98 | break; | |
| 99 | case "alt": | |
| 100 | case "option": | |
| 101 | altKey = true; | |
| 102 | break; | |
| 103 | case "mod": | |
| 104 | modKey = true; | |
| 105 | break; | |
| 106 | default: { | |
| 107 | if (key !== null) { | |
| 108 | return null; | |
| 109 | } | |
| 110 | key = normalizeKeyToken(token); | |
| 111 | } | |
| 112 | } | |
| 113 | } | |
| 114 | ||
| 115 | if (key === null) return null; | |
| 116 | ||
| 117 | return { | |
| 118 | key, | |
| 119 | metaKey, | |
| 120 | ctrlKey, | |
| 121 | shiftKey, | |
| 122 | altKey, | |
| 123 | modKey, | |
| 124 | }; | |
| 125 | } | |
| 126 | ||
| 127 | function normalizeEventKey(key: string): string { | |
| 128 | const normalized = key.toLowerCase(); | |
| 129 | if (normalized === "esc") return "escape"; | |
| 130 | return normalized; | |
| 131 | } | |
| 132 | ||
| 133 | function matchesShortcutValue( | |
| 134 | event: ShortcutEventLike, | |
| 135 | shortcutValue: string, | |
| 136 | platform = navigator.platform, | |
| 137 | ): boolean { | |
| 138 | const parsed = parseShortcutValue(shortcutValue); | |
| 139 | if (!parsed) return false; | |
| 140 | ||
| 141 | const key = normalizeEventKey(event.key); | |
| 142 | if (key !== parsed.key) return false; | |
| 143 | ||
| 144 | const useMetaForMod = isMacPlatform(platform); | |
| 145 | const expectedMeta = parsed.metaKey || (parsed.modKey && useMetaForMod); | |
| 146 | const expectedCtrl = parsed.ctrlKey || (parsed.modKey && !useMetaForMod); | |
| 147 | return ( | |
| 148 | event.metaKey === expectedMeta && | |
| 149 | event.ctrlKey === expectedCtrl && | |
| 150 | event.shiftKey === parsed.shiftKey && | |
| 151 | event.altKey === parsed.altKey | |
| 152 | ); | |
| 153 | } | |
| 154 | ||
| 155 | function tokenizeWhenExpression(expression: string): WhenToken[] | null { | |
| 156 | const tokens: WhenToken[] = []; | |
| 157 | let index = 0; | |
| 158 | ||
| 159 | while (index < expression.length) { | |
| 160 | const current = expression[index]; | |
| 161 | if (!current) break; | |
| 162 | ||
| 163 | if (/\s/.test(current)) { | |
| 164 | index += 1; | |
| 165 | continue; | |
| 166 | } | |
| 167 | if (expression.startsWith("&&", index)) { | |
| 168 | tokens.push({ type: "and" }); | |
| 169 | index += 2; | |
| 170 | continue; | |
| 171 | } | |
| 172 | if (expression.startsWith("||", index)) { | |
| 173 | tokens.push({ type: "or" }); | |
| 174 | index += 2; | |
| 175 | continue; | |
| 176 | } | |
| 177 | if (current === "!") { | |
| 178 | tokens.push({ type: "not" }); | |
| 179 | index += 1; | |
| 180 | continue; | |
| 181 | } | |
| 182 | if (current === "(") { | |
| 183 | tokens.push({ type: "lparen" }); | |
| 184 | index += 1; | |
| 185 | continue; | |
| 186 | } | |
| 187 | if (current === ")") { | |
| 188 | tokens.push({ type: "rparen" }); | |
| 189 | index += 1; | |
| 190 | continue; | |
| 191 | } | |
| 192 | ||
| 193 | const identifier = /^[A-Za-z_][A-Za-z0-9_.-]*/.exec(expression.slice(index)); | |
| 194 | if (!identifier) { | |
| 195 | return null; | |
| 196 | } | |
| 197 | tokens.push({ type: "identifier", value: identifier[0] }); | |
| 198 | index += identifier[0].length; | |
| 199 | } | |
| 200 | ||
| 201 | return tokens; | |
| 202 | } | |
| 203 | ||
| 204 | function parseWhenExpression(expression: string): WhenNode | null { | |
| 205 | const tokens = tokenizeWhenExpression(expression); | |
| 206 | if (!tokens || tokens.length === 0) return null; | |
| 207 | let index = 0; | |
| 208 | ||
| 209 | const parsePrimary = (): WhenNode | null => { | |
| 210 | const token = tokens[index]; | |
| 211 | if (!token) return null; | |
| 212 | ||
| 213 | if (token.type === "identifier") { | |
| 214 | index += 1; | |
| 215 | return { type: "identifier", name: token.value }; | |
| 216 | } | |
| 217 | ||
| 218 | if (token.type === "lparen") { | |
| 219 | index += 1; | |
| 220 | const expressionNode = parseOr(); | |
| 221 | const closeToken = tokens[index]; | |
| 222 | if (!expressionNode || !closeToken || closeToken.type !== "rparen") { | |
| 223 | return null; | |
| 224 | } | |
| 225 | index += 1; | |
| 226 | return expressionNode; | |
| 227 | } | |
| 228 | ||
| 229 | return null; | |
| 230 | }; | |
| 231 | ||
| 232 | const parseUnary = (): WhenNode | null => { | |
| 233 | const token = tokens[index]; | |
| 234 | if (token?.type === "not") { | |
| 235 | index += 1; | |
| 236 | const node = parseUnary(); | |
| 237 | if (!node) return null; | |
| 238 | return { type: "not", node }; | |
| 239 | } | |
| 240 | return parsePrimary(); | |
| 241 | }; | |
| 242 | ||
| 243 | const parseAnd = (): WhenNode | null => { | |
| 244 | let left = parseUnary(); | |
| 245 | if (!left) return null; | |
| 246 | ||
| 247 | while (tokens[index]?.type === "and") { | |
| 248 | index += 1; | |
| 249 | const right = parseUnary(); | |
| 250 | if (!right) return null; | |
| 251 | left = { type: "and", left, right }; | |
| 252 | } | |
| 253 | ||
| 254 | return left; | |
| 255 | }; | |
| 256 | ||
| 257 | const parseOr = (): WhenNode | null => { | |
| 258 | let left = parseAnd(); | |
| 259 | if (!left) return null; | |
| 260 | ||
| 261 | while (tokens[index]?.type === "or") { | |
| 262 | index += 1; | |
| 263 | const right = parseAnd(); | |
| 264 | if (!right) return null; | |
| 265 | left = { type: "or", left, right }; | |
| 266 | } | |
| 267 | ||
| 268 | return left; | |
| 269 | }; | |
| 270 | ||
| 271 | const ast = parseOr(); | |
| 272 | if (!ast || index !== tokens.length) return null; | |
| 273 | return ast; | |
| 274 | } | |
| 275 | ||
| 276 | function evaluateWhenNode(node: WhenNode, context: ShortcutMatchContext): boolean { | |
| 277 | switch (node.type) { | |
| 278 | case "identifier": | |
| 279 | return Boolean(context[node.name]); | |
| 280 | case "not": | |
| 281 | return !evaluateWhenNode(node.node, context); | |
| 282 | case "and": | |
| 283 | return evaluateWhenNode(node.left, context) && evaluateWhenNode(node.right, context); | |
| 284 | case "or": | |
| 285 | return evaluateWhenNode(node.left, context) || evaluateWhenNode(node.right, context); | |
| 286 | } | |
| 287 | } | |
| 288 | ||
| 289 | function matchesWhenExpression(when: string | undefined, context: ShortcutMatchContext): boolean { | |
| 290 | if (!when) return true; | |
| 291 | const normalized = when.trim(); | |
| 292 | if (normalized.length === 0) return true; | |
| 293 | ||
| 294 | if (!WHEN_AST_CACHE.has(normalized)) { | |
| 295 | WHEN_AST_CACHE.set(normalized, parseWhenExpression(normalized)); | |
| 296 | } | |
| 297 | const ast = WHEN_AST_CACHE.get(normalized); | |
| 298 | if (!ast) return false; | |
| 299 | return evaluateWhenNode(ast, context); | |
| 300 | } | |
| 301 | ||
| 302 | function resolvePlatform(options: ShortcutMatchOptions | undefined): string { | |
| 303 | return options?.platform ?? navigator.platform; | |
| 304 | } | |
| 305 | ||
| 306 | function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatchContext { | |
| 307 | return { | |
| 308 | terminalFocus: false, | |
| 309 | terminalOpen: false, | |
| 310 | ...options?.context, | |
| 311 | }; | |
| 312 | } | |
| 313 | ||
| 314 | function normalizeConfiguredKeybinding( | |
| 315 | keybinding: KeybindingsConfig[number], | |
| 316 | ): ResolvedTerminalKeybinding | null { | |
| 317 | const key = keybinding.key.trim(); | |
| 318 | if (key.length === 0) return null; | |
| 319 | if (!parseShortcutValue(key)) return null; | |
| 320 | ||
| 321 | const when = keybinding.when?.trim(); | |
| 322 | return { | |
| 323 | key, | |
| 324 | command: keybinding.command, | |
| 325 | ...(when ? { when } : {}), | |
| 326 | }; | |
| 327 | } | |
| 328 | ||
| 329 | function matchesCommandShortcut( | |
| 330 | event: ShortcutEventLike, | |
| 331 | keybindings: ResolvedTerminalKeybindings, | |
| 332 | command: KeybindingCommand, | |
| 333 | options?: ShortcutMatchOptions, | |
| 334 | ): boolean { | |
| 335 | const platform = resolvePlatform(options); | |
| 336 | const context = resolveContext(options); | |
| 337 | ||
| 338 | for (let index = keybindings.length - 1; index >= 0; index -= 1) { | |
| 339 | const binding = keybindings[index]; | |
| 340 | if (!binding || binding.command !== command) continue; | |
| 341 | if (!matchesWhenExpression(binding.when, context)) continue; | |
| 342 | if (!matchesShortcutValue(event, binding.key, platform)) continue; | |
| 343 | return true; | |
| 344 | } | |
| 345 | return false; | |
| 346 | } | |
| 347 | ||
| 348 | export function resolveTerminalKeybindings( | |
| 349 | input: KeybindingsConfig | null | undefined, | |
| 350 | ): ResolvedTerminalKeybindings { | |
| 351 | if (!input || input.length === 0) { | |
| 352 | return [...DEFAULT_TERMINAL_KEYBINDINGS]; | |
| 353 | } | |
| 354 | ||
| 355 | const normalizedInput = input | |
| 356 | .map((binding) => normalizeConfiguredKeybinding(binding)) | |
| 357 | .filter((binding): binding is ResolvedTerminalKeybinding => binding !== null); | |
| 358 | if (normalizedInput.length === 0) { | |
| 359 | return [...DEFAULT_TERMINAL_KEYBINDINGS]; | |
| 360 | } | |
| 361 | ||
| 362 | const overriddenCommands = new Set(normalizedInput.map((binding) => binding.command)); | |
| 363 | const retainedDefaults = DEFAULT_TERMINAL_KEYBINDINGS.filter( | |
| 364 | (binding) => !overriddenCommands.has(binding.command), | |
| 365 | ); | |
| 366 | return [...retainedDefaults, ...normalizedInput]; | |
| 367 | } | |
| 368 | ||
| 369 | function formatShortcutKeyLabel(key: string): string { | |
| 370 | if (key === " ") return "Space"; | |
| 371 | if (key.length === 1) return key.toUpperCase(); | |
| 372 | if (key === "escape") return "Esc"; | |
| 373 | if (key === "arrowup") return "Up"; | |
| 374 | if (key === "arrowdown") return "Down"; | |
| 375 | if (key === "arrowleft") return "Left"; | |
| 376 | if (key === "arrowright") return "Right"; | |
| 377 | return key.slice(0, 1).toUpperCase() + key.slice(1); | |
| 378 | } | |
| 379 | ||
| 380 | export function formatShortcutLabel(shortcutValue: string, platform = navigator.platform): string { | |
| 381 | const parsed = parseShortcutValue(shortcutValue); | |
| 382 | if (!parsed) { | |
| 383 | return shortcutValue; | |
| 384 | } | |
| 385 | ||
| 386 | const keyLabel = formatShortcutKeyLabel(parsed.key); | |
| 387 | const useMetaForMod = isMacPlatform(platform); | |
| 388 | const showMeta = parsed.metaKey || (parsed.modKey && useMetaForMod); | |
| 389 | const showCtrl = parsed.ctrlKey || (parsed.modKey && !useMetaForMod); | |
| 390 | const showAlt = parsed.altKey; | |
| 391 | const showShift = parsed.shiftKey; | |
| 392 | ||
| 393 | if (useMetaForMod) { | |
| 394 | return `${showCtrl ? "\u2303" : ""}${showAlt ? "\u2325" : ""}${showShift ? "\u21e7" : ""}${showMeta ? "\u2318" : ""}${keyLabel}`; | |
| 395 | } | |
| 396 | ||
| 397 | const parts: string[] = []; | |
| 398 | if (showCtrl) parts.push("Ctrl"); | |
| 399 | if (showAlt) parts.push("Alt"); | |
| 400 | if (showShift) parts.push("Shift"); | |
| 401 | if (showMeta) parts.push("Meta"); | |
| 402 | parts.push(keyLabel); | |
| 403 | return parts.join("+"); | |
| 404 | } | |
| 405 | ||
| 406 | export function shortcutLabelForCommand( | |
| 407 | keybindings: ResolvedTerminalKeybindings, | |
| 408 | command: KeybindingCommand, | |
| 409 | platform = navigator.platform, | |
| 410 | ): string | null { | |
| 411 | for (let index = keybindings.length - 1; index >= 0; index -= 1) { | |
| 412 | const binding = keybindings[index]; | |
| 413 | if (!binding || binding.command !== command) continue; | |
| 414 | return formatShortcutLabel(binding.key, platform); | |
| 415 | } | |
| 416 | return null; | |
| 417 | } | |
| 418 | ||
| 419 | export function isTerminalToggleShortcut( | |
| 420 | event: ShortcutEventLike, | |
| 421 | keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS, | |
| 422 | options?: ShortcutMatchOptions, | |
| 423 | ): boolean { | |
| 424 | return matchesCommandShortcut(event, keybindings, "terminal.toggle", options); | |
| 425 | } | |
| 426 | ||
| 427 | export function isTerminalSplitShortcut( | |
| 428 | event: ShortcutEventLike, | |
| 429 | keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS, | |
| 430 | options?: ShortcutMatchOptions, | |
| 431 | ): boolean { | |
| 432 | return matchesCommandShortcut(event, keybindings, "terminal.split", options); | |
| 433 | } | |
| 434 | ||
| 435 | export function isTerminalNewShortcut( | |
| 436 | event: ShortcutEventLike, | |
| 437 | keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS, | |
| 438 | options?: ShortcutMatchOptions, | |
| 439 | ): boolean { | |
| 440 | return matchesCommandShortcut(event, keybindings, "terminal.new", options); | |
| 441 | } | |
| 442 | ||
| 443 | export function isTerminalClearShortcut( | |
| 444 | event: ShortcutEventLike, | |
| 445 | platform = navigator.platform, | |
| 446 | ): boolean { | |
| 447 | const key = event.key.toLowerCase(); | |
| 448 | ||
| 449 | if (key === "l" && event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) { | |
| 450 | return true; | |
| 451 | } | |
| 452 | ||
| 453 | return ( | |
| 454 | isMacPlatform(platform) && | |
| 455 | key === "k" && | |
| 456 | event.metaKey && | |
| 457 | !event.ctrlKey && | |
| 458 | !event.altKey && | |
| 459 | !event.shiftKey | |
| 460 | ); | |
| 461 | } |