919 lines · 27.8 KB · TypeScript
RawDownload
/** * Keybindings - Keybinding configuration service definitions. * * Owns parsing, validation, merge, and persistence of user keybinding * configuration consumed by the server runtime. * * @module Keybindings */import {  KeybindingRule,  KeybindingsConfig,  KeybindingsConfigError,  KeybindingShortcut,  KeybindingWhenNode,  MAX_KEYBINDINGS_COUNT,  MODEL_PICKER_JUMP_KEYBINDING_COMMANDS,  MAX_WHEN_EXPRESSION_DEPTH,  ResolvedKeybindingRule,  ResolvedKeybindingsConfig,  THREAD_JUMP_KEYBINDING_COMMANDS,  type ServerConfigIssue,} from "@t3tools/contracts";import type { Mutable } from "effect/Types";import {  Array,  Cache,  Cause,  Deferred,  Duration,  Effect,  Exit,  FileSystem,  Path,  Layer,  Option,  Predicate,  PubSub,  Schema,  SchemaGetter,  SchemaIssue,  SchemaTransformation,  Ref,  Context,  Scope,  Stream,} from "effect";import * as Semaphore from "effect/Semaphore";import { ServerConfig } from "./config.ts";import { writeFileStringAtomically } from "./atomicWrite.ts";import { fromLenientJson } from "@t3tools/shared/schemaJson"; 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+d", command: "diff.toggle", when: "!terminalFocus" },  { key: "mod+k", command: "commandPalette.toggle", 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+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" },  { key: "mod+o", command: "editor.openFavorite" },  { key: "mod+shift+[", command: "thread.previous" },  { key: "mod+shift+]", command: "thread.next" },  ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({    key: `mod+${index + 1}`,    command,  })),  ...MODEL_PICKER_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({    key: `mod+${index + 1}`,    command,    when: "modelPickerOpen",  })),]; function normalizeKeyToken(token: string): string {  if (token === "space") return " ";  if (token === "esc") return "escape";  return token;} /** @internal - Exported for testing */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;} function parseKeybindingWhenExpression(expression: string): KeybindingWhenNode | null {  const tokens = tokenizeWhenExpression(expression);  if (!tokens || tokens.length === 0) return null;  let index = 0;   const parsePrimary = (depth: number): KeybindingWhenNode | null => {    if (depth > MAX_WHEN_EXPRESSION_DEPTH) {      return 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(depth + 1);      const closeToken = tokens[index];      if (!expressionNode || !closeToken || closeToken.type !== "rparen") {        return null;      }      index += 1;      return expressionNode;    }     return null;  };   const parseUnary = (depth: number): KeybindingWhenNode | null => {    let notCount = 0;    while (tokens[index]?.type === "not") {      index += 1;      notCount += 1;      if (notCount > MAX_WHEN_EXPRESSION_DEPTH) {        return null;      }    }     let node = parsePrimary(depth);    if (!node) return null;     while (notCount > 0) {      node = { type: "not", node };      notCount -= 1;    }     return node;  };   const parseAnd = (depth: number): KeybindingWhenNode | null => {    let left = parseUnary(depth);    if (!left) return null;     while (tokens[index]?.type === "and") {      index += 1;      const right = parseUnary(depth);      if (!right) return null;      left = { type: "and", left, right };    }     return left;  };   const parseOr = (depth: number): KeybindingWhenNode | null => {    let left = parseAnd(depth);    if (!left) return null;     while (tokens[index]?.type === "or") {      index += 1;      const right = parseAnd(depth);      if (!right) return null;      left = { type: "or", left, right };    }     return left;  };   const ast = parseOr(0);  if (!ast || index !== tokens.length) return null;  return ast;} /** @internal - Exported for testing */export function compileResolvedKeybindingRule(rule: KeybindingRule): ResolvedKeybindingRule | null {  const shortcut = parseKeybindingShortcut(rule.key);  if (!shortcut) return null;   if (rule.when !== undefined) {    const whenAst = parseKeybindingWhenExpression(rule.when);    if (!whenAst) return null;    return {      command: rule.command,      shortcut,      whenAst,    };  }   return {    command: rule.command,    shortcut,  };} export function compileResolvedKeybindingsConfig(  config: KeybindingsConfig,): ResolvedKeybindingsConfig {  const compiled: Mutable<ResolvedKeybindingsConfig> = [];  for (const rule of config) {    const result = Schema.decodeExit(ResolvedKeybindingFromConfig)(rule);    if (result._tag === "Success") {      compiled.push(result.value);    }  }  return compiled;} export const ResolvedKeybindingFromConfig = KeybindingRule.pipe(  Schema.decodeTo(    Schema.toType(ResolvedKeybindingRule),    SchemaTransformation.transformOrFail({      decode: (rule) =>        Effect.succeed(compileResolvedKeybindingRule(rule)).pipe(          Effect.filterOrFail(            Predicate.isNotNull,            () =>              new SchemaIssue.InvalidValue(Option.some(rule), {                message: "Invalid keybinding rule",              }),          ),          Effect.map((resolved) => resolved),        ),       encode: (resolved) =>        Effect.gen(function* () {          const key = encodeShortcut(resolved.shortcut);          if (!key) {            return yield* Effect.fail(              new SchemaIssue.InvalidValue(Option.some(resolved), {                message: "Resolved shortcut cannot be encoded to key string",              }),            );          }           const when = resolved.whenAst ? encodeWhenAst(resolved.whenAst) : undefined;          return {            key,            command: resolved.command,            when,          };        }),    }),  ),); export const ResolvedKeybindingsFromConfig = Schema.Array(ResolvedKeybindingFromConfig).check(  Schema.isMaxLength(MAX_KEYBINDINGS_COUNT),); function isSameKeybindingRule(left: KeybindingRule, right: KeybindingRule): boolean {  return (    left.command === right.command &&    left.key === right.key &&    (left.when ?? undefined) === (right.when ?? undefined)  );} function keybindingShortcutContext(rule: KeybindingRule): string | null {  const parsed = parseKeybindingShortcut(rule.key);  if (!parsed) return null;  const encoded = encodeShortcut(parsed);  if (!encoded) return null;  return `${encoded}\u0000${rule.when ?? ""}`;} function hasSameShortcutContext(left: KeybindingRule, right: KeybindingRule): boolean {  const leftContext = keybindingShortcutContext(left);  const rightContext = keybindingShortcutContext(right);  if (!leftContext || !rightContext) return false;  return leftContext === rightContext;} function encodeShortcut(shortcut: KeybindingShortcut): string | null {  const modifiers: string[] = [];  if (shortcut.modKey) modifiers.push("mod");  if (shortcut.metaKey) modifiers.push("meta");  if (shortcut.ctrlKey) modifiers.push("ctrl");  if (shortcut.altKey) modifiers.push("alt");  if (shortcut.shiftKey) modifiers.push("shift");  if (!shortcut.key) return null;  if (shortcut.key !== "+" && shortcut.key.includes("+")) return null;  const key = shortcut.key === " " ? "space" : shortcut.key;  return [...modifiers, key].join("+");} function encodeWhenAst(node: KeybindingWhenNode): string {  switch (node.type) {    case "identifier":      return node.name;    case "not":      return `!(${encodeWhenAst(node.node)})`;    case "and":      return `(${encodeWhenAst(node.left)} && ${encodeWhenAst(node.right)})`;    case "or":      return `(${encodeWhenAst(node.left)} || ${encodeWhenAst(node.right)})`;  }} const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS); const RawKeybindingsEntries = fromLenientJson(Schema.Array(Schema.Unknown));const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig);const PrettyJsonString = SchemaGetter.parseJson<string>().compose(  SchemaGetter.stringifyJson({ space: 2 }),);const KeybindingsConfigPrettyJson = KeybindingsConfigJson.pipe(  Schema.encode({    decode: PrettyJsonString,    encode: PrettyJsonString,  }),); export interface KeybindingsConfigState {  readonly keybindings: ResolvedKeybindingsConfig;  readonly issues: readonly ServerConfigIssue[];} export interface KeybindingsChangeEvent {  readonly keybindings: ResolvedKeybindingsConfig;  readonly issues: readonly ServerConfigIssue[];} function trimIssueMessage(message: string): string {  const trimmed = message.trim();  return trimmed.length > 0 ? trimmed : "Invalid keybindings configuration.";} function malformedConfigIssue(detail: string): ServerConfigIssue {  return {    kind: "keybindings.malformed-config",    message: trimIssueMessage(detail),  };} function invalidEntryIssue(index: number, detail: string): ServerConfigIssue {  return {    kind: "keybindings.invalid-entry",    index,    message: trimIssueMessage(detail),  };} 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_COUNT) {    return merged;  }   // Keep the latest rules when the config exceeds max size; later rules have higher precedence.  return merged.slice(-MAX_KEYBINDINGS_COUNT);} /** * KeybindingsShape - Service API for keybinding configuration operations. */export interface KeybindingsShape {  /**   * Start the keybindings runtime and attach file watching.   *   * Safe to call multiple times. The first successful call establishes the   * runtime; later calls await the same startup.   */  readonly start: Effect.Effect<void, KeybindingsConfigError>;   /**   * Await keybindings runtime readiness.   *   * Readiness means the config directory exists, the watcher is attached, the   * startup sync has completed, and the current snapshot has been loaded.   */  readonly ready: Effect.Effect<void, KeybindingsConfigError>;   /**   * Ensure the on-disk keybindings file exists and includes all default   * commands so newly-added defaults are backfilled on startup.

Lines 1–500 of 919.