347 lines · 11.4 KB · TypeScript
RawDownload
import {  STATIC_KEYBINDING_COMMANDS,  type KeybindingCommand,  type KeybindingShortcut,  type KeybindingWhenNode,  type ResolvedKeybindingRule,  type ResolvedKeybindingsConfig,} from "@t3tools/contracts";import {  DEFAULT_RESOLVED_KEYBINDINGS,  parseKeybindingWhenExpression,} from "@t3tools/shared/keybindings"; import { shortcutKeyFromEvent } from "../../keybindings";import { isMacPlatform } from "../../lib/utils"; export type KeybindingSource = "Default" | "Custom" | "Project"; export interface KeybindingRow {  readonly id: string;  readonly command: KeybindingCommand;  readonly key: string;  readonly when: string;  readonly source: KeybindingSource;  readonly defaultKey: string | null;  readonly defaultWhen: string;  readonly binding: ResolvedKeybindingRule;  readonly conflicts: ReadonlyArray<string>;} export type WhenVariableOption = string;export type KeybindingCommandOption = KeybindingCommand; const CORE_WHEN_VARIABLES = ["terminalFocus", "terminalOpen", "true", "false"] as const; const DEFAULT_WHEN_VARIABLES = new Set<string>(CORE_WHEN_VARIABLES);for (const binding of DEFAULT_RESOLVED_KEYBINDINGS) {  collectWhenIdentifiersFromNode(binding.whenAst, DEFAULT_WHEN_VARIABLES);} export const DEFAULT_WHEN_VARIABLE =  [...DEFAULT_WHEN_VARIABLES].find(    (identifier) => identifier !== "true" && identifier !== "false",  ) ?? "terminalFocus";const KNOWN_WHEN_VARIABLES = new Set(DEFAULT_WHEN_VARIABLES); export function shortcutToKeybindingInput(shortcut: KeybindingShortcut): string {  const parts: string[] = [];  if (shortcut.modKey) parts.push("mod");  if (shortcut.metaKey) parts.push("meta");  if (shortcut.ctrlKey) parts.push("ctrl");  if (shortcut.altKey) parts.push("alt");  if (shortcut.shiftKey) parts.push("shift");  parts.push(shortcut.key === " " ? "space" : shortcut.key === "escape" ? "esc" : shortcut.key);  return parts.join("+");} export function whenAstToExpression(node: KeybindingWhenNode | undefined): string {  if (!node) return "";  switch (node.type) {    case "identifier":      return node.name;    case "not":      return `!${wrapWhenExpression(node.node)}`;    case "and":      return `${wrapWhenExpression(node.left)} && ${wrapWhenExpression(node.right)}`;    case "or":      return `${wrapWhenExpression(node.left)} || ${wrapWhenExpression(node.right)}`;  }} export function whenNodeRemoveLabel(node: KeybindingWhenNode, depth: number): string {  if (depth === 0) return "Clear all conditions";  if (node.type === "identifier" || (node.type === "not" && node.node.type === "identifier")) {    return "Remove condition";  }  return "Remove group and its conditions";} function wrapWhenExpression(node: KeybindingWhenNode): string {  if (node.type === "identifier" || node.type === "not") return whenAstToExpression(node);  return `(${whenAstToExpression(node)})`;} export function parseWhenExpressionDraft(  expression: string,): { ok: true; value: KeybindingWhenNode | undefined } | { ok: false; message: string } {  const trimmed = expression.trim();  if (trimmed.length === 0) return { ok: true, value: undefined };   const ast = parseKeybindingWhenExpression(trimmed);  if (!ast) {    return {      ok: false,      message: "Use variables with !, &&, ||, and parentheses.",    };  }   return { ok: true, value: ast };} function sourceForBinding(binding: ResolvedKeybindingRule): KeybindingSource {  if (String(binding.command).startsWith("script.")) {    return "Project";  }   const bindingKey = shortcutToKeybindingInput(binding.shortcut);  const bindingWhen = whenAstToExpression(binding.whenAst);  const isDefault = DEFAULT_RESOLVED_KEYBINDINGS.some(    (entry) =>      entry.command === binding.command &&      shortcutToKeybindingInput(entry.shortcut) === bindingKey &&      whenAstToExpression(entry.whenAst) === bindingWhen,  );   return isDefault ? "Default" : "Custom";} function defaultBindingForBinding(  binding: ResolvedKeybindingRule,): ResolvedKeybindingRule | undefined {  const bindingKey = shortcutToKeybindingInput(binding.shortcut);  const bindingWhen = whenAstToExpression(binding.whenAst);   return (    DEFAULT_RESOLVED_KEYBINDINGS.find(      (entry) =>        entry.command === binding.command &&        shortcutToKeybindingInput(entry.shortcut) === bindingKey &&        whenAstToExpression(entry.whenAst) === bindingWhen,    ) ??    DEFAULT_RESOLVED_KEYBINDINGS.find(      (entry) =>        entry.command === binding.command && whenAstToExpression(entry.whenAst) === bindingWhen,    ) ??    DEFAULT_RESOLVED_KEYBINDINGS.find((entry) => entry.command === binding.command)  );} function keybindingRowId(command: KeybindingCommand, key: string, when: string): string {  return `${command}\u0000${key}\u0000${when}`;} function conflictsWithWhen(leftWhen: string, rightWhen: string): boolean {  return leftWhen.length === 0 || rightWhen.length === 0 || leftWhen === rightWhen;} export function keybindingConflictLabels(  rows: ReadonlyArray<KeybindingRow>,  input: { readonly rowId: string; readonly key: string; readonly when: string },): ReadonlyArray<string> {  if (input.key.trim().length === 0) return [];  const conflicts: Array<string> = [];  for (const candidate of rows) {    if (      candidate.id !== input.rowId &&      candidate.key === input.key &&      conflictsWithWhen(candidate.when, input.when)    ) {      conflicts.push(commandLabel(candidate.command));    }  }  return [...new Set(conflicts)].toSorted();} export function buildKeybindingRows(  keybindings: ResolvedKeybindingsConfig,  query: string,): ReadonlyArray<KeybindingRow> {  const normalizedQuery = query.trim().toLowerCase();  const rows = keybindings.map((binding, index) => {    const defaultBinding = defaultBindingForBinding(binding);    const key = shortcutToKeybindingInput(binding.shortcut);    const when = whenAstToExpression(binding.whenAst);    return {      id: `${keybindingRowId(binding.command, key, when)}\u0000${index}`,      command: binding.command,      key,      when,      source: sourceForBinding(binding),      defaultKey: defaultBinding ? shortcutToKeybindingInput(defaultBinding.shortcut) : null,      defaultWhen: whenAstToExpression(defaultBinding?.whenAst),      binding,      conflicts: [],    } satisfies KeybindingRow;  });   const rowsWithConflicts = rows.map((row) => {    const conflicts = keybindingConflictLabels(rows, {      rowId: row.id,      key: row.key,      when: row.when,    });    return conflicts.length > 0      ? Object.assign({}, row, { conflicts: [...new Set(conflicts)].toSorted() })      : row;  });   rowsWithConflicts.sort((left, right) => {    const commandCompare = left.command.localeCompare(right.command);    if (commandCompare !== 0) return commandCompare;    return left.key.localeCompare(right.key);  });   if (normalizedQuery.length === 0) {    return rowsWithConflicts;  }   return rowsWithConflicts.filter((row) => {    return (      row.command.toLowerCase().includes(normalizedQuery) ||      row.key.toLowerCase().includes(normalizedQuery) ||      row.when.toLowerCase().includes(normalizedQuery) ||      row.source.toLowerCase().includes(normalizedQuery)    );  });} function collectWhenIdentifiersFromNode(  node: KeybindingWhenNode | undefined,  identifiers: Set<string>,): void {  if (!node) return;  switch (node.type) {    case "identifier":      identifiers.add(node.name);      return;    case "not":      collectWhenIdentifiersFromNode(node.node, identifiers);      return;    case "and":    case "or":      collectWhenIdentifiersFromNode(node.left, identifiers);      collectWhenIdentifiersFromNode(node.right, identifiers);      return;  }} export function isKnownWhenVariable(identifier: string): boolean {  return KNOWN_WHEN_VARIABLES.has(identifier);} export function unknownWhenVariables(node: KeybindingWhenNode | undefined): ReadonlyArray<string> {  const identifiers = new Set<string>();  collectWhenIdentifiersFromNode(node, identifiers);  return [...identifiers].filter((identifier) => !isKnownWhenVariable(identifier)).toSorted();} export function buildWhenVariableOptions(): ReadonlyArray<WhenVariableOption> {  return [...KNOWN_WHEN_VARIABLES].toSorted((left, right) => {    const leftCoreIndex = CORE_WHEN_VARIABLES.indexOf(left as (typeof CORE_WHEN_VARIABLES)[number]);    const rightCoreIndex = CORE_WHEN_VARIABLES.indexOf(      right as (typeof CORE_WHEN_VARIABLES)[number],    );    if (leftCoreIndex !== -1 || rightCoreIndex !== -1) {      return (        (leftCoreIndex === -1 ? Number.MAX_SAFE_INTEGER : leftCoreIndex) -        (rightCoreIndex === -1 ? Number.MAX_SAFE_INTEGER : rightCoreIndex)      );    }    return left.localeCompare(right);  });} export function buildKeybindingCommandOptions(  keybindings: ResolvedKeybindingsConfig,): ReadonlyArray<KeybindingCommandOption> {  const commands = new Set<KeybindingCommand>(STATIC_KEYBINDING_COMMANDS);  for (const binding of keybindings) {    commands.add(binding.command);  }  return [...commands].toSorted((left, right) =>    commandLabel(left).localeCompare(commandLabel(right)),  );} export function commandLabel(command: KeybindingCommand): string {  const raw = String(command);  if (raw.startsWith("script.") && raw.endsWith(".run")) {    return `Run Script: ${titleCaseCommandSegment(raw.slice("script.".length, -".run".length))}`;  }  return raw.split(".").map(titleCaseCommandSegment).join(": ");} function titleCaseCommandSegment(segment: string): string {  const words: Array<string> = [];  for (const part of segment.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[-_\s]+/)) {    if (part.length > 0) {      words.push(part.slice(0, 1).toUpperCase() + part.slice(1));    }  }  return words.join(" ");} function normalizeShortcutKeyToken(key: string): string | null {  const normalized = key.toLowerCase();  if (    normalized === "meta" ||    normalized === "control" ||    normalized === "ctrl" ||    normalized === "shift" ||    normalized === "alt" ||    normalized === "option"  ) {    return null;  }  if (normalized === " ") return "space";  if (normalized === "escape") return "esc";  if (normalized === "arrowup") return "arrowup";  if (normalized === "arrowdown") return "arrowdown";  if (normalized === "arrowleft") return "arrowleft";  if (normalized === "arrowright") return "arrowright";  if (normalized.length === 1) return normalized;  if (/^f\d{1,2}$/.test(normalized)) return normalized;  if (normalized === "enter" || normalized === "tab" || normalized === "backspace") {    return normalized;  }  if (normalized === "delete" || normalized === "home" || normalized === "end") {    return normalized;  }  if (normalized === "pageup" || normalized === "pagedown") return normalized;  return null;} export function keybindingFromKeyboardEvent(  event: Pick<KeyboardEvent, "key" | "code" | "metaKey" | "ctrlKey" | "altKey" | "shiftKey">,  platform: string,): string | null {  const keyToken = normalizeShortcutKeyToken(shortcutKeyFromEvent(event));  if (!keyToken) return null;   const parts: string[] = [];  if (isMacPlatform(platform)) {    if (event.metaKey) parts.push("mod");    if (event.ctrlKey) parts.push("ctrl");  } else {    if (event.ctrlKey) parts.push("mod");    if (event.metaKey) parts.push("meta");  }  if (event.altKey) parts.push("alt");  if (event.shiftKey) parts.push("shift");  if (parts.length === 0) {    return null;  }  parts.push(keyToken);  return parts.join("+");}