| 1 | import { |
| 2 | STATIC_KEYBINDING_COMMANDS, |
| 3 | type KeybindingCommand, |
| 4 | type KeybindingShortcut, |
| 5 | type KeybindingWhenNode, |
| 6 | type ResolvedKeybindingRule, |
| 7 | type ResolvedKeybindingsConfig, |
| 8 | } from "@t3tools/contracts"; |
| 9 | import { |
| 10 | DEFAULT_RESOLVED_KEYBINDINGS, |
| 11 | parseKeybindingWhenExpression, |
| 12 | } from "@t3tools/shared/keybindings"; |
| 13 | |
| 14 | import { shortcutKeyFromEvent } from "../../keybindings"; |
| 15 | import { isMacPlatform } from "../../lib/utils"; |
| 16 | import { METRIC_OPTIONS, WINDOW_OPTIONS } from "../usage/usageShortcuts"; |
| 17 | |
| 18 | // Every usage.* command needs a rank. An unranked one falls back to the |
| 19 | // alphabetical compare, which makes the comparator inconsistent and the order |
| 20 | // depend on the input order. |
| 21 | const usageCommandOrder = new Map<KeybindingCommand, number>( |
| 22 | [ |
| 23 | "usage.open" as const, |
| 24 | ...[...METRIC_OPTIONS, ...WINDOW_OPTIONS].map((option) => option.command), |
| 25 | ].map((command, index) => [command, index]), |
| 26 | ); |
| 27 | |
| 28 | const firstUsageCommand = METRIC_OPTIONS[0].command; |
| 29 | |
| 30 | /** |
| 31 | * Orders commands by `key`, except Usage page commands, which sort as one |
| 32 | * block in page order where the first of them would sort. A total order, so |
| 33 | * adding a binding elsewhere cannot reshuffle the Usage rows. |
| 34 | */ |
| 35 | function compareCommands( |
| 36 | left: KeybindingCommand, |
| 37 | right: KeybindingCommand, |
| 38 | key: (command: KeybindingCommand) => string, |
| 39 | ): number { |
| 40 | const leftRank = usageCommandOrder.get(left); |
| 41 | const rightRank = usageCommandOrder.get(right); |
| 42 | if (leftRank !== undefined && rightRank !== undefined) return leftRank - rightRank; |
| 43 | return key(leftRank === undefined ? left : firstUsageCommand).localeCompare( |
| 44 | key(rightRank === undefined ? right : firstUsageCommand), |
| 45 | ); |
| 46 | } |
| 47 | |
| 48 | export type KeybindingSource = "Default" | "Custom" | "Project"; |
| 49 | |
| 50 | export interface KeybindingRow { |
| 51 | readonly id: string; |
| 52 | readonly command: KeybindingCommand; |
| 53 | readonly key: string; |
| 54 | readonly when: string; |
| 55 | readonly source: KeybindingSource; |
| 56 | readonly defaultKey: string | null; |
| 57 | readonly defaultWhen: string; |
| 58 | readonly binding: ResolvedKeybindingRule; |
| 59 | readonly conflicts: ReadonlyArray<string>; |
| 60 | } |
| 61 | |
| 62 | export type WhenVariableOption = string; |
| 63 | export type KeybindingCommandOption = KeybindingCommand; |
| 64 | |
| 65 | const CORE_WHEN_VARIABLES = [ |
| 66 | "terminalFocus", |
| 67 | "terminalOpen", |
| 68 | "isWeb", |
| 69 | "isDesktop", |
| 70 | "true", |
| 71 | "false", |
| 72 | ] as const; |
| 73 | |
| 74 | const DEFAULT_WHEN_VARIABLES = new Set<string>(CORE_WHEN_VARIABLES); |
| 75 | for (const binding of DEFAULT_RESOLVED_KEYBINDINGS) { |
| 76 | collectWhenIdentifiersFromNode(binding.whenAst, DEFAULT_WHEN_VARIABLES); |
| 77 | } |
| 78 | |
| 79 | export const DEFAULT_WHEN_VARIABLE = |
| 80 | [...DEFAULT_WHEN_VARIABLES].find( |
| 81 | (identifier) => identifier !== "true" && identifier !== "false", |
| 82 | ) ?? "terminalFocus"; |
| 83 | const KNOWN_WHEN_VARIABLES = new Set(DEFAULT_WHEN_VARIABLES); |
| 84 | |
| 85 | export function shortcutToKeybindingInput(shortcut: KeybindingShortcut): string { |
| 86 | const parts: string[] = []; |
| 87 | if (shortcut.modKey) parts.push("mod"); |
| 88 | if (shortcut.metaKey) parts.push("meta"); |
| 89 | if (shortcut.ctrlKey) parts.push("ctrl"); |
| 90 | if (shortcut.altKey) parts.push("alt"); |
| 91 | if (shortcut.shiftKey) parts.push("shift"); |
| 92 | parts.push(shortcut.key === " " ? "space" : shortcut.key === "escape" ? "esc" : shortcut.key); |
| 93 | return parts.join("+"); |
| 94 | } |
| 95 | |
| 96 | export function whenAstToExpression(node: KeybindingWhenNode | undefined): string { |
| 97 | if (!node) return ""; |
| 98 | switch (node.type) { |
| 99 | case "identifier": |
| 100 | return node.name; |
| 101 | case "not": |
| 102 | return `!${wrapWhenExpression(node.node)}`; |
| 103 | case "and": |
| 104 | return `${wrapWhenExpression(node.left)} && ${wrapWhenExpression(node.right)}`; |
| 105 | case "or": |
| 106 | return `${wrapWhenExpression(node.left)} || ${wrapWhenExpression(node.right)}`; |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | export function whenNodeRemoveLabel(node: KeybindingWhenNode, depth: number): string { |
| 111 | if (depth === 0) return "Clear all conditions"; |
| 112 | if (node.type === "identifier" || (node.type === "not" && node.node.type === "identifier")) { |
| 113 | return "Remove condition"; |
| 114 | } |
| 115 | return "Remove group and its conditions"; |
| 116 | } |
| 117 | |
| 118 | function wrapWhenExpression(node: KeybindingWhenNode): string { |
| 119 | if (node.type === "identifier" || node.type === "not") return whenAstToExpression(node); |
| 120 | return `(${whenAstToExpression(node)})`; |
| 121 | } |
| 122 | |
| 123 | export function parseWhenExpressionDraft( |
| 124 | expression: string, |
| 125 | ): { ok: true; value: KeybindingWhenNode | undefined } | { ok: false; message: string } { |
| 126 | const trimmed = expression.trim(); |
| 127 | if (trimmed.length === 0) return { ok: true, value: undefined }; |
| 128 | |
| 129 | const ast = parseKeybindingWhenExpression(trimmed); |
| 130 | if (!ast) { |
| 131 | return { |
| 132 | ok: false, |
| 133 | message: "Use variables with !, &&, ||, and parentheses.", |
| 134 | }; |
| 135 | } |
| 136 | |
| 137 | return { ok: true, value: ast }; |
| 138 | } |
| 139 | |
| 140 | function sourceForBinding(binding: ResolvedKeybindingRule): KeybindingSource { |
| 141 | if (String(binding.command).startsWith("script.")) { |
| 142 | return "Project"; |
| 143 | } |
| 144 | |
| 145 | const bindingKey = shortcutToKeybindingInput(binding.shortcut); |
| 146 | const bindingWhen = whenAstToExpression(binding.whenAst); |
| 147 | const isDefault = DEFAULT_RESOLVED_KEYBINDINGS.some( |
| 148 | (entry) => |
| 149 | entry.command === binding.command && |
| 150 | shortcutToKeybindingInput(entry.shortcut) === bindingKey && |
| 151 | whenAstToExpression(entry.whenAst) === bindingWhen, |
| 152 | ); |
| 153 | |
| 154 | return isDefault ? "Default" : "Custom"; |
| 155 | } |
| 156 | |
| 157 | function defaultBindingForBinding( |
| 158 | binding: ResolvedKeybindingRule, |
| 159 | ): ResolvedKeybindingRule | undefined { |
| 160 | const bindingKey = shortcutToKeybindingInput(binding.shortcut); |
| 161 | const bindingWhen = whenAstToExpression(binding.whenAst); |
| 162 | |
| 163 | return ( |
| 164 | DEFAULT_RESOLVED_KEYBINDINGS.find( |
| 165 | (entry) => |
| 166 | entry.command === binding.command && |
| 167 | shortcutToKeybindingInput(entry.shortcut) === bindingKey && |
| 168 | whenAstToExpression(entry.whenAst) === bindingWhen, |
| 169 | ) ?? |
| 170 | DEFAULT_RESOLVED_KEYBINDINGS.find( |
| 171 | (entry) => |
| 172 | entry.command === binding.command && whenAstToExpression(entry.whenAst) === bindingWhen, |
| 173 | ) ?? |
| 174 | DEFAULT_RESOLVED_KEYBINDINGS.find((entry) => entry.command === binding.command) |
| 175 | ); |
| 176 | } |
| 177 | |
| 178 | function keybindingRowId(command: KeybindingCommand, key: string, when: string): string { |
| 179 | return `${command}\u0000${key}\u0000${when}`; |
| 180 | } |
| 181 | |
| 182 | function conflictsWithWhen(leftWhen: string, rightWhen: string): boolean { |
| 183 | return leftWhen.length === 0 || rightWhen.length === 0 || leftWhen === rightWhen; |
| 184 | } |
| 185 | |
| 186 | export function keybindingConflictLabels( |
| 187 | rows: ReadonlyArray<KeybindingRow>, |
| 188 | input: { readonly rowId: string; readonly key: string; readonly when: string }, |
| 189 | ): ReadonlyArray<string> { |
| 190 | if (input.key.trim().length === 0) return []; |
| 191 | const conflicts: Array<string> = []; |
| 192 | for (const candidate of rows) { |
| 193 | if ( |
| 194 | candidate.id !== input.rowId && |
| 195 | candidate.key === input.key && |
| 196 | conflictsWithWhen(candidate.when, input.when) |
| 197 | ) { |
| 198 | conflicts.push(commandLabel(candidate.command)); |
| 199 | } |
| 200 | } |
| 201 | return [...new Set(conflicts)].toSorted(); |
| 202 | } |
| 203 | |
| 204 | export interface KeybindingGroup { |
| 205 | readonly id: string; |
| 206 | readonly title: string; |
| 207 | readonly rows: ReadonlyArray<KeybindingRow>; |
| 208 | } |
| 209 | |
| 210 | /** Page sections in display order; a command joins the first group listing its prefix. */ |
| 211 | const KEYBINDING_GROUPS = [ |
| 212 | { |
| 213 | id: "navigation", |
| 214 | title: "Navigation", |
| 215 | prefixes: ["sidebar", "rightPanel", "commandPalette", "filePicker", "projectSearch", "editor"], |
| 216 | }, |
| 217 | { id: "threads", title: "Threads", prefixes: ["thread", "chat", "pullRequest"] }, |
| 218 | { id: "composer", title: "Composer", prefixes: ["composer", "modelPicker"] }, |
| 219 | { id: "terminal", title: "Terminal", prefixes: ["terminal"] }, |
| 220 | { id: "preview", title: "Preview & diff", prefixes: ["preview", "diff"] }, |
| 221 | { id: "appearance", title: "Appearance", prefixes: ["theme", "appearance", "themeEditor"] }, |
| 222 | { id: "scripts", title: "Project scripts", prefixes: ["script"] }, |
| 223 | ] as const; |
| 224 | const OTHER_KEYBINDING_GROUP = { id: "other", title: "Other" } as const; |
| 225 | |
| 226 | function keybindingGroupFor(command: KeybindingCommand): { id: string; title: string } { |
| 227 | const prefix = String(command).split(".")[0] ?? ""; |
| 228 | return ( |
| 229 | KEYBINDING_GROUPS.find((group) => (group.prefixes as ReadonlyArray<string>).includes(prefix)) ?? |
| 230 | OTHER_KEYBINDING_GROUP |
| 231 | ); |
| 232 | } |
| 233 | |
| 234 | /** Splits sorted rows into the page's sections, dropping sections with no rows. */ |
| 235 | export function groupKeybindingRows( |
| 236 | rows: ReadonlyArray<KeybindingRow>, |
| 237 | ): ReadonlyArray<KeybindingGroup> { |
| 238 | const rowsByGroup = new Map<string, Array<KeybindingRow>>(); |
| 239 | for (const row of rows) { |
| 240 | const group = keybindingGroupFor(row.command); |
| 241 | const bucket = rowsByGroup.get(group.id); |
| 242 | if (bucket) bucket.push(row); |
| 243 | else rowsByGroup.set(group.id, [row]); |
| 244 | } |
| 245 | return [...KEYBINDING_GROUPS, OTHER_KEYBINDING_GROUP].flatMap((group) => { |
| 246 | const groupRows = rowsByGroup.get(group.id); |
| 247 | return groupRows ? [{ id: group.id, title: group.title, rows: groupRows }] : []; |
| 248 | }); |
| 249 | } |
| 250 | |
| 251 | export function buildKeybindingRows( |
| 252 | keybindings: ResolvedKeybindingsConfig, |
| 253 | query: string, |
| 254 | ): ReadonlyArray<KeybindingRow> { |
| 255 | const normalizedQuery = query.trim().toLowerCase(); |
| 256 | const rows = keybindings.flatMap((binding, index) => { |
| 257 | if (binding.command === "usage.limits") return []; |
| 258 | |
| 259 | const defaultBinding = defaultBindingForBinding(binding); |
| 260 | const key = shortcutToKeybindingInput(binding.shortcut); |
| 261 | const when = whenAstToExpression(binding.whenAst); |
| 262 | return [ |
| 263 | { |
| 264 | id: `${keybindingRowId(binding.command, key, when)}\u0000${index}`, |
| 265 | command: binding.command, |
| 266 | key, |
| 267 | when, |
| 268 | source: sourceForBinding(binding), |
| 269 | defaultKey: defaultBinding ? shortcutToKeybindingInput(defaultBinding.shortcut) : null, |
| 270 | defaultWhen: whenAstToExpression(defaultBinding?.whenAst), |
| 271 | binding, |
| 272 | conflicts: [], |
| 273 | } satisfies KeybindingRow, |
| 274 | ]; |
| 275 | }); |
| 276 | |
| 277 | const rowsWithConflicts = rows.map((row) => { |
| 278 | const conflicts = keybindingConflictLabels(rows, { |
| 279 | rowId: row.id, |
| 280 | key: row.key, |
| 281 | when: row.when, |
| 282 | }); |
| 283 | return conflicts.length > 0 |
| 284 | ? Object.assign({}, row, { conflicts: [...new Set(conflicts)].toSorted() }) |
| 285 | : row; |
| 286 | }); |
| 287 | |
| 288 | rowsWithConflicts.sort((left, right) => { |
| 289 | const commandCompare = compareCommands(left.command, right.command, (command) => command); |
| 290 | if (commandCompare !== 0) return commandCompare; |
| 291 | return left.key.localeCompare(right.key); |
| 292 | }); |
| 293 | |
| 294 | if (normalizedQuery.length === 0) { |
| 295 | return rowsWithConflicts; |
| 296 | } |
| 297 | |
| 298 | return rowsWithConflicts.filter((row) => { |
| 299 | return ( |
| 300 | row.command.toLowerCase().includes(normalizedQuery) || |
| 301 | commandLabel(row.command).toLowerCase().includes(normalizedQuery) || |
| 302 | row.key.toLowerCase().includes(normalizedQuery) || |
| 303 | row.when.toLowerCase().includes(normalizedQuery) || |
| 304 | row.source.toLowerCase().includes(normalizedQuery) |
| 305 | ); |
| 306 | }); |
| 307 | } |
| 308 | |
| 309 | function collectWhenIdentifiersFromNode( |
| 310 | node: KeybindingWhenNode | undefined, |
| 311 | identifiers: Set<string>, |
| 312 | ): void { |
| 313 | if (!node) return; |
| 314 | switch (node.type) { |
| 315 | case "identifier": |
| 316 | identifiers.add(node.name); |
| 317 | return; |
| 318 | case "not": |
| 319 | collectWhenIdentifiersFromNode(node.node, identifiers); |
| 320 | return; |
| 321 | case "and": |
| 322 | case "or": |
| 323 | collectWhenIdentifiersFromNode(node.left, identifiers); |
| 324 | collectWhenIdentifiersFromNode(node.right, identifiers); |
| 325 | return; |
| 326 | } |
| 327 | } |
| 328 | |
| 329 | export function isKnownWhenVariable(identifier: string): boolean { |
| 330 | return KNOWN_WHEN_VARIABLES.has(identifier); |
| 331 | } |
| 332 | |
| 333 | export function unknownWhenVariables(node: KeybindingWhenNode | undefined): ReadonlyArray<string> { |
| 334 | const identifiers = new Set<string>(); |
| 335 | collectWhenIdentifiersFromNode(node, identifiers); |
| 336 | return [...identifiers].filter((identifier) => !isKnownWhenVariable(identifier)).toSorted(); |
| 337 | } |
| 338 | |
| 339 | export function buildWhenVariableOptions(): ReadonlyArray<WhenVariableOption> { |
| 340 | return [...KNOWN_WHEN_VARIABLES].toSorted((left, right) => { |
| 341 | const leftCoreIndex = CORE_WHEN_VARIABLES.indexOf(left as (typeof CORE_WHEN_VARIABLES)[number]); |
| 342 | const rightCoreIndex = CORE_WHEN_VARIABLES.indexOf( |
| 343 | right as (typeof CORE_WHEN_VARIABLES)[number], |
| 344 | ); |
| 345 | if (leftCoreIndex !== -1 || rightCoreIndex !== -1) { |
| 346 | return ( |
| 347 | (leftCoreIndex === -1 ? Number.MAX_SAFE_INTEGER : leftCoreIndex) - |
| 348 | (rightCoreIndex === -1 ? Number.MAX_SAFE_INTEGER : rightCoreIndex) |
| 349 | ); |
| 350 | } |
| 351 | return left.localeCompare(right); |
| 352 | }); |
| 353 | } |
| 354 | |
| 355 | export function buildKeybindingCommandOptions( |
| 356 | keybindings: ResolvedKeybindingsConfig, |
| 357 | ): ReadonlyArray<KeybindingCommandOption> { |
| 358 | const commands = new Set<KeybindingCommand>( |
| 359 | STATIC_KEYBINDING_COMMANDS.filter((command) => command !== "usage.limits"), |
| 360 | ); |
| 361 | for (const binding of keybindings) { |
| 362 | if (binding.command !== "usage.limits") commands.add(binding.command); |
| 363 | } |
| 364 | return [...commands].toSorted((left, right) => compareCommands(left, right, commandLabel)); |
| 365 | } |
| 366 | |
| 367 | export function commandLabel(command: KeybindingCommand): string { |
| 368 | if (command === "composer.sendAlternate") return "Composer: Opposite Queue or Steer Action"; |
| 369 | if (command === "composer.sendBackground") return "Composer: Start in Background"; |
| 370 | if (command === "composer.sendAndNewThread") return "Composer: Send and Start New Thread"; |
| 371 | if (command === "thread.steerQueuedMessage") return "Queue: Send First Queued Message as Steer"; |
| 372 | if (command === "thread.editQueuedMessage") return "Queue: Edit Last Queued Message"; |
| 373 | if (command === "thread.copyReference") return "Pull Request: Copy Link or Thread ID"; |
| 374 | const usageMetric = METRIC_OPTIONS.find((option) => option.command === command); |
| 375 | if (usageMetric) return `Usage: ${usageMetric.label}`; |
| 376 | const usagePeriod = WINDOW_OPTIONS.find((option) => option.command === command); |
| 377 | if (usagePeriod) return `Usage: Period: ${usagePeriod.label}`; |
| 378 | if (command === "view.reopenClosed") return "Reopen Closed Tab"; |
| 379 | if (command === "preview.focusUrl") return "Preview: Focus URL"; |
| 380 | const raw = String(command); |
| 381 | if (raw.startsWith("script.") && raw.endsWith(".run")) { |
| 382 | return `Run Script: ${titleCaseCommandSegment(raw.slice("script.".length, -".run".length))}`; |
| 383 | } |
| 384 | return raw.split(".").map(titleCaseCommandSegment).join(": "); |
| 385 | } |
| 386 | |
| 387 | function titleCaseCommandSegment(segment: string): string { |
| 388 | const words: Array<string> = []; |
| 389 | for (const part of segment.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[-_\s]+/)) { |
| 390 | if (part.length > 0) { |
| 391 | words.push(part.slice(0, 1).toUpperCase() + part.slice(1)); |
| 392 | } |
| 393 | } |
| 394 | return words.join(" "); |
| 395 | } |
| 396 | |
| 397 | function normalizeShortcutKeyToken(key: string): string | null { |
| 398 | const normalized = key.toLowerCase(); |
| 399 | if ( |
| 400 | normalized === "meta" || |
| 401 | normalized === "control" || |
| 402 | normalized === "ctrl" || |
| 403 | normalized === "shift" || |
| 404 | normalized === "alt" || |
| 405 | normalized === "option" |
| 406 | ) { |
| 407 | return null; |
| 408 | } |
| 409 | if (normalized === " ") return "space"; |
| 410 | if (normalized === "escape") return "esc"; |
| 411 | if (normalized === "arrowup") return "arrowup"; |
| 412 | if (normalized === "arrowdown") return "arrowdown"; |
| 413 | if (normalized === "arrowleft") return "arrowleft"; |
| 414 | if (normalized === "arrowright") return "arrowright"; |
| 415 | if (normalized.length === 1) return normalized; |
| 416 | if (/^f\d{1,2}$/.test(normalized)) return normalized; |
| 417 | if (normalized === "enter" || normalized === "tab" || normalized === "backspace") { |
| 418 | return normalized; |
| 419 | } |
| 420 | if (normalized === "delete" || normalized === "home" || normalized === "end") { |
| 421 | return normalized; |
| 422 | } |
| 423 | if (normalized === "pageup" || normalized === "pagedown") return normalized; |
| 424 | return null; |
| 425 | } |
| 426 | |
| 427 | /** Turns a keydown into a binding such as `mod+shift+k` or `tab`. Null for modifier-only presses. */ |
| 428 | export function keybindingFromKeyboardEvent( |
| 429 | event: Pick<KeyboardEvent, "key" | "code" | "metaKey" | "ctrlKey" | "altKey" | "shiftKey">, |
| 430 | platform: string, |
| 431 | ): string | null { |
| 432 | const keyToken = normalizeShortcutKeyToken(shortcutKeyFromEvent(event)); |
| 433 | if (!keyToken) return null; |
| 434 | |
| 435 | const parts: string[] = []; |
| 436 | if (isMacPlatform(platform)) { |
| 437 | if (event.metaKey) parts.push("mod"); |
| 438 | if (event.ctrlKey) parts.push("ctrl"); |
| 439 | } else { |
| 440 | if (event.ctrlKey) parts.push("mod"); |
| 441 | if (event.metaKey) parts.push("meta"); |
| 442 | } |
| 443 | if (event.altKey) parts.push("alt"); |
| 444 | if (event.shiftKey) parts.push("shift"); |
| 445 | parts.push(keyToken); |
| 446 | return parts.join("+"); |
| 447 | } |