424 lines · 11.5 KB · TypeScript
RawDownload
import fs from "node:fs";import os from "node:os";import path from "node:path"; import type {  KeybindingRule,  KeybindingsConfig,  KeybindingShortcut,  KeybindingWhenNode,  ResolvedKeybindingRule,  ResolvedKeybindingsConfig,} from "@t3tools/contracts";import { keybindingRuleSchema } from "@t3tools/contracts"; interface KeybindingsLogger {  warn(message: string, context?: Record<string, unknown>): void;} type WhenToken =  | { type: "identifier"; value: string }  | { type: "not" }  | { type: "and" }  | { type: "or" }  | { type: "lparen" }  | { type: "rparen" }; export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [  { key: "mod+j", command: "terminal.toggle" },  { key: "mod+d", command: "terminal.split", when: "terminalFocus" },  { key: "mod+n", command: "terminal.new", when: "terminalFocus" },  { key: "mod+w", command: "terminal.close", when: "terminalFocus" },  { key: "mod+n", command: "chat.new", when: "!terminalFocus" },  { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },  { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },  { key: "mod+o", command: "editor.openFavorite" },]; function normalizeKeyToken(token: string): string {  if (token === "space") return " ";  if (token === "esc") return "escape";  return token;} export function parseKeybindingShortcut(value: string): KeybindingShortcut | null {  const rawTokens = value    .toLowerCase()    .split("+")    .map((token) => token.trim());  const tokens = [...rawTokens];  let trailingEmptyCount = 0;  while (tokens[tokens.length - 1] === "") {    trailingEmptyCount += 1;    tokens.pop();  }  if (trailingEmptyCount > 0) {    tokens.push("+");  }  if (tokens.some((token) => token.length === 0)) {    return null;  }  if (tokens.length === 0) return null;   let key: string | null = null;  let metaKey = false;  let ctrlKey = false;  let shiftKey = false;  let altKey = false;  let modKey = false;   for (const token of tokens) {    switch (token) {      case "cmd":      case "meta":        metaKey = true;        break;      case "ctrl":      case "control":        ctrlKey = true;        break;      case "shift":        shiftKey = true;        break;      case "alt":      case "option":        altKey = true;        break;      case "mod":        modKey = true;        break;      default: {        if (key !== null) return null;        key = normalizeKeyToken(token);      }    }  }   if (key === null) return null;  return {    key,    metaKey,    ctrlKey,    shiftKey,    altKey,    modKey,  };} function tokenizeWhenExpression(expression: string): WhenToken[] | null {  const tokens: WhenToken[] = [];  let index = 0;   while (index < expression.length) {    const current = expression[index];    if (!current) break;     if (/\s/.test(current)) {      index += 1;      continue;    }    if (expression.startsWith("&&", index)) {      tokens.push({ type: "and" });      index += 2;      continue;    }    if (expression.startsWith("||", index)) {      tokens.push({ type: "or" });      index += 2;      continue;    }    if (current === "!") {      tokens.push({ type: "not" });      index += 1;      continue;    }    if (current === "(") {      tokens.push({ type: "lparen" });      index += 1;      continue;    }    if (current === ")") {      tokens.push({ type: "rparen" });      index += 1;      continue;    }     const identifier = /^[A-Za-z_][A-Za-z0-9_.-]*/.exec(expression.slice(index));    if (!identifier) {      return null;    }    tokens.push({ type: "identifier", value: identifier[0] });    index += identifier[0].length;  }   return tokens;} export function parseKeybindingWhenExpression(expression: string): KeybindingWhenNode | null {  const tokens = tokenizeWhenExpression(expression);  if (!tokens || tokens.length === 0) return null;  let index = 0;   const parsePrimary = (): KeybindingWhenNode | null => {    const token = tokens[index];    if (!token) return null;     if (token.type === "identifier") {      index += 1;      return { type: "identifier", name: token.value };    }     if (token.type === "lparen") {      index += 1;      const expressionNode = parseOr();      const closeToken = tokens[index];      if (!expressionNode || !closeToken || closeToken.type !== "rparen") {        return null;      }      index += 1;      return expressionNode;    }     return null;  };   const parseUnary = (): KeybindingWhenNode | null => {    const token = tokens[index];    if (token?.type === "not") {      index += 1;      const node = parseUnary();      if (!node) return null;      return { type: "not", node };    }    return parsePrimary();  };   const parseAnd = (): KeybindingWhenNode | null => {    let left = parseUnary();    if (!left) return null;     while (tokens[index]?.type === "and") {      index += 1;      const right = parseUnary();      if (!right) return null;      left = { type: "and", left, right };    }     return left;  };   const parseOr = (): KeybindingWhenNode | null => {    let left = parseAnd();    if (!left) return null;     while (tokens[index]?.type === "or") {      index += 1;      const right = parseAnd();      if (!right) return null;      left = { type: "or", left, right };    }     return left;  };   const ast = parseOr();  if (!ast || index !== tokens.length) return null;  return ast;} export function compileResolvedKeybindingRule(rule: KeybindingRule): ResolvedKeybindingRule | null {  const key = rule.key.trim();  if (key.length === 0) return null;  const shortcut = parseKeybindingShortcut(key);  if (!shortcut) return null;   const when = rule.when?.trim();  if (when && when.length > 0) {    const whenAst = parseKeybindingWhenExpression(when);    if (!whenAst) return null;    return {      command: rule.command,      shortcut,      whenAst,    };  }   return {    command: rule.command,    shortcut,  };} export function compileResolvedKeybindingsConfig(  config: KeybindingsConfig,): ResolvedKeybindingsConfig {  const compiled: ResolvedKeybindingsConfig = [];  for (const rule of config) {    const resolved = compileResolvedKeybindingRule(rule);    if (!resolved) continue;    compiled.push(resolved);  }  return compiled;} function compileDefaultKeybindings(): ResolvedKeybindingsConfig {  const resolved: ResolvedKeybindingsConfig = [];  for (const rule of DEFAULT_KEYBINDINGS) {    const compiled = compileResolvedKeybindingRule(rule);    if (!compiled) {      throw new Error(`Invalid default keybinding: ${rule.command} (${rule.key})`);    }    resolved.push(compiled);  }  return resolved;} const DEFAULT_RESOLVED_KEYBINDINGS = compileDefaultKeybindings();const KEYBINDINGS_CONFIG_PATH = path.join(".t3", "keybindings.json");const MAX_KEYBINDINGS = 256; function resolveKeybindingsConfigPath(): string {  return path.join(os.homedir(), KEYBINDINGS_CONFIG_PATH);} function loadCustomKeybindingsConfig(  logger: KeybindingsLogger,  options?: {    throwOnUnreadableConfig?: boolean;  },): KeybindingsConfig {  const configPath = resolveKeybindingsConfigPath();  try {    const raw = fs.readFileSync(configPath, "utf8");    const parsed = JSON.parse(raw) as unknown;    if (!Array.isArray(parsed)) {      if (options?.throwOnUnreadableConfig) {        throw new Error(`Unable to parse keybindings config at ${configPath}: expected JSON array`);      }      logger.warn("ignoring keybindings config with unsupported format; expected array", {        path: configPath,      });      return [];    }     const sanitized: KeybindingsConfig = [];    let invalidEntries = 0;    for (const entry of parsed) {      const result = keybindingRuleSchema.safeParse(entry);      if (result.success) {        if (!compileResolvedKeybindingRule(result.data)) {          invalidEntries += 1;          continue;        }        sanitized.push(result.data);        continue;      }      invalidEntries += 1;    }    if (invalidEntries > 0) {      logger.warn("ignoring invalid keybinding entries", {        path: configPath,        invalidEntries,        totalEntries: parsed.length,      });    }    return sanitized;  } catch (error) {    if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {      return [];    }    if (options?.throwOnUnreadableConfig) {      throw new Error(        `Unable to parse keybindings config at ${configPath}: ${          error instanceof Error ? error.message : String(error)        }`,        { cause: error },      );    }    logger.warn("ignoring malformed keybindings config", {      path: configPath,      error: error instanceof Error ? error.message : String(error),    });    return [];  }} function writeConfigAtomically(configPath: string, config: KeybindingsConfig): void {  const tempPath = `${configPath}.${process.pid}.${Date.now()}.tmp`;  try {    fs.writeFileSync(tempPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");    fs.renameSync(tempPath, configPath);  } catch (error) {    fs.rmSync(tempPath, { force: true });    throw error;  }} function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig {  if (custom.length === 0) {    return [...DEFAULT_RESOLVED_KEYBINDINGS];  }   const overriddenCommands = new Set(custom.map((binding) => binding.command));  const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter(    (binding) => !overriddenCommands.has(binding.command),  );  const merged = [...retainedDefaults, ...custom];   if (merged.length <= MAX_KEYBINDINGS) {    return merged;  }   // Keep the latest rules when the config exceeds max size; later rules have higher precedence.  return merged.slice(-MAX_KEYBINDINGS);} export function loadResolvedKeybindingsConfig(  logger: KeybindingsLogger,): ResolvedKeybindingsConfig {  const customConfig = loadCustomKeybindingsConfig(logger);  const compiledCustomConfig = compileResolvedKeybindingsConfig(customConfig);  const overriddenCommands = new Set(compiledCustomConfig.map((entry) => entry.command));  const mergedBeforeCapLength =    DEFAULT_RESOLVED_KEYBINDINGS.filter((binding) => !overriddenCommands.has(binding.command))      .length + compiledCustomConfig.length;  const merged = mergeWithDefaultKeybindings(compiledCustomConfig);  if (mergedBeforeCapLength > MAX_KEYBINDINGS) {    logger.warn("truncating merged keybindings config to max entries", {      path: resolveKeybindingsConfigPath(),      maxEntries: MAX_KEYBINDINGS,    });  }  return merged;} export function upsertKeybindingRule(  logger: KeybindingsLogger,  rawRule: unknown,): ResolvedKeybindingsConfig {  const rule = keybindingRuleSchema.parse(rawRule);  if (!compileResolvedKeybindingRule(rule)) {    throw new Error(`Invalid keybinding shortcut: "${rule.key}"`);  }   const configPath = resolveKeybindingsConfigPath();  const customConfig = loadCustomKeybindingsConfig(logger, {    throwOnUnreadableConfig: true,  });  const nextConfig = [...customConfig.filter((entry) => entry.command !== rule.command), rule];  const cappedConfig =    nextConfig.length > MAX_KEYBINDINGS ? nextConfig.slice(-MAX_KEYBINDINGS) : nextConfig;   if (nextConfig.length > MAX_KEYBINDINGS) {    logger.warn("truncating keybindings config to max entries", {      path: configPath,      maxEntries: MAX_KEYBINDINGS,    });  }   fs.mkdirSync(path.dirname(configPath), { recursive: true });  writeConfigAtomically(configPath, cappedConfig);   const compiledCustomConfig = compileResolvedKeybindingsConfig(cappedConfig);  return mergeWithDefaultKeybindings(compiledCustomConfig);}