711 lines · 23.4 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,  ResolvedKeybindingRule,  ResolvedKeybindingsConfig,  type ServerRemoveKeybindingInput,  type ServerUpsertKeybindingInput,  type ServerConfigIssue,} from "@t3tools/contracts";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";import {  DEFAULT_KEYBINDINGS,  DEFAULT_RESOLVED_KEYBINDINGS,  compileResolvedKeybindingRule,  compileResolvedKeybindingsConfig,  parseKeybindingShortcut,} from "@t3tools/shared/keybindings"; export {  DEFAULT_KEYBINDINGS,  compileResolvedKeybindingRule,  compileResolvedKeybindingsConfig,  parseKeybindingShortcut,}; 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 keybindingRuleFromUpsertInput(input: ServerUpsertKeybindingInput): KeybindingRule {  return input.when === undefined    ? { key: input.key, command: input.command }    : { key: input.key, command: input.command, when: input.when };} function replaceTargetFromUpsertInput(input: ServerUpsertKeybindingInput): KeybindingRule | null {  if (!input.replace) return null;  return input.replace.when === undefined    ? { key: input.replace.key, command: input.replace.command }    : { key: input.replace.key, command: input.replace.command, when: input.replace.when };} function keybindingRuleFromRemoveInput(input: ServerRemoveKeybindingInput): KeybindingRule {  return input.when === undefined    ? { key: input.key, command: input.command }    : { key: input.key, command: input.command, when: input.when };} 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 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.   */  readonly syncDefaultKeybindingsOnStartup: Effect.Effect<void, KeybindingsConfigError>;   /**   * Load runtime keybindings state along with non-fatal configuration issues.   */  readonly loadConfigState: Effect.Effect<KeybindingsConfigState, KeybindingsConfigError>;   /**   * Read the latest keybindings snapshot from cache/disk.   */  readonly getSnapshot: Effect.Effect<KeybindingsConfigState, KeybindingsConfigError>;   /**   * Stream of keybindings config change events.   */  readonly streamChanges: Stream.Stream<KeybindingsChangeEvent>;   /**   * Upsert a keybinding rule and persist the resulting configuration.   *   * Writes config atomically and enforces the max rule count by truncating   * oldest entries when needed.   */  readonly upsertKeybindingRule: (    input: ServerUpsertKeybindingInput,  ) => Effect.Effect<ResolvedKeybindingsConfig, KeybindingsConfigError>;   /**   * Remove a single persisted keybinding rule by exact key/command/when match.   */  readonly removeKeybindingRule: (    input: ServerRemoveKeybindingInput,  ) => Effect.Effect<ResolvedKeybindingsConfig, KeybindingsConfigError>;} /** * Keybindings - Service tag for keybinding configuration operations. */export class Keybindings extends Context.Service<Keybindings, KeybindingsShape>()(  "t3/keybindings",) {} const makeKeybindings = Effect.gen(function* () {  const { keybindingsConfigPath } = yield* ServerConfig;  const fs = yield* FileSystem.FileSystem;  const path = yield* Path.Path;  const upsertSemaphore = yield* Semaphore.make(1);  const resolvedConfigCacheKey = "resolved" as const;  const changesPubSub = yield* PubSub.unbounded<KeybindingsChangeEvent>();  const startedRef = yield* Ref.make(false);  const startedDeferred = yield* Deferred.make<void, KeybindingsConfigError>();  const watcherScope = yield* Scope.make("sequential");  yield* Effect.addFinalizer(() => Scope.close(watcherScope, Exit.void));  const emitChange = (configState: KeybindingsConfigState) =>    PubSub.publish(changesPubSub, configState).pipe(Effect.asVoid);   const readConfigExists = fs.exists(keybindingsConfigPath).pipe(    Effect.mapError(      (cause) =>        new KeybindingsConfigError({          configPath: keybindingsConfigPath,          detail: "failed to access keybindings config",          cause,        }),    ),  );   const readRawConfig = fs.readFileString(keybindingsConfigPath).pipe(    Effect.mapError(      (cause) =>        new KeybindingsConfigError({          configPath: keybindingsConfigPath,          detail: "failed to read keybindings config",          cause,        }),    ),  );   const loadWritableCustomKeybindingsConfig = Effect.fn(function* (): Effect.fn.Return<    readonly KeybindingRule[],    KeybindingsConfigError  > {    if (!(yield* readConfigExists)) {      return [];    }     const rawConfig = yield* readRawConfig.pipe(      Effect.flatMap(Schema.decodeEffect(RawKeybindingsEntries)),      Effect.mapError(        (cause) =>          new KeybindingsConfigError({            configPath: keybindingsConfigPath,            detail: "expected JSON array",            cause,          }),      ),    );     return yield* Effect.forEach(rawConfig, (entry) =>      Effect.gen(function* () {        const decodedRule = Schema.decodeUnknownExit(KeybindingRule)(entry);        if (decodedRule._tag === "Failure") {          yield* Effect.logWarning("ignoring invalid keybinding entry", {            path: keybindingsConfigPath,            entry,            error: Cause.pretty(decodedRule.cause),          });          return null;        }        const resolved = Schema.decodeExit(ResolvedKeybindingFromConfig)(decodedRule.value);        if (resolved._tag === "Failure") {          yield* Effect.logWarning("ignoring invalid keybinding entry", {            path: keybindingsConfigPath,            entry,            error: Cause.pretty(resolved.cause),          });          return null;        }        return decodedRule.value;      }),    ).pipe(Effect.map(Array.filter(Predicate.isNotNull)));  });   const loadRuntimeCustomKeybindingsConfig = Effect.fn(function* (): Effect.fn.Return<    {      readonly keybindings: readonly KeybindingRule[];      readonly issues: readonly ServerConfigIssue[];    },    KeybindingsConfigError  > {    if (!(yield* readConfigExists)) {      return { keybindings: [], issues: [] };    }     const rawConfig = yield* readRawConfig;    const decodedEntries = Schema.decodeUnknownExit(RawKeybindingsEntries)(rawConfig);    if (decodedEntries._tag === "Failure") {      const detail = `expected JSON array (${Cause.pretty(decodedEntries.cause)})`;      return {        keybindings: [],        issues: [malformedConfigIssue(detail)],      };    }     const keybindings: KeybindingRule[] = [];    const issues: ServerConfigIssue[] = [];    for (const [index, entry] of decodedEntries.value.entries()) {      const decodedRule = Schema.decodeUnknownExit(KeybindingRule)(entry);      if (decodedRule._tag === "Failure") {        const detail = Cause.pretty(decodedRule.cause);        issues.push(invalidEntryIssue(index, detail));        yield* Effect.logWarning("ignoring invalid keybinding entry", {          path: keybindingsConfigPath,          index,          entry,          error: detail,        });        continue;      }       const resolvedRule = Schema.decodeExit(ResolvedKeybindingFromConfig)(decodedRule.value);      if (resolvedRule._tag === "Failure") {        const detail = Cause.pretty(resolvedRule.cause);        issues.push(invalidEntryIssue(index, detail));        yield* Effect.logWarning("ignoring invalid keybinding entry", {          path: keybindingsConfigPath,          index,          entry,          error: detail,        });        continue;      }      keybindings.push(decodedRule.value);    }     return { keybindings, issues };  });   const writeConfigAtomically = (rules: readonly KeybindingRule[]) => {    return Schema.encodeEffect(KeybindingsConfigPrettyJson)(rules).pipe(      Effect.map((encoded) => `${encoded}\n`),      Effect.flatMap((encoded) =>        writeFileStringAtomically({          filePath: keybindingsConfigPath,          contents: encoded,        }).pipe(          Effect.provideService(FileSystem.FileSystem, fs),          Effect.provideService(Path.Path, path),        ),      ),      Effect.mapError(        (cause) =>          new KeybindingsConfigError({            configPath: keybindingsConfigPath,            detail: "failed to write keybindings config",            cause,          }),      ),    );  };   const loadConfigStateFromDisk = loadRuntimeCustomKeybindingsConfig().pipe(    Effect.map(({ keybindings, issues }) => ({      keybindings: mergeWithDefaultKeybindings(compileResolvedKeybindingsConfig(keybindings)),      issues,    })),  );   const resolvedConfigCache = yield* Cache.make<    typeof resolvedConfigCacheKey,    KeybindingsConfigState,    KeybindingsConfigError  >({    capacity: 1,    lookup: () => loadConfigStateFromDisk,  });   const loadConfigStateFromCacheOrDisk = Cache.get(resolvedConfigCache, resolvedConfigCacheKey);   const revalidateAndEmit = upsertSemaphore.withPermits(1)(    Effect.gen(function* () {      yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey);      const configState = yield* loadConfigStateFromCacheOrDisk;      yield* emitChange(configState);    }),  );   const syncDefaultKeybindingsOnStartup = upsertSemaphore.withPermits(1)(    Effect.gen(function* () {      const configExists = yield* readConfigExists;      if (!configExists) {        yield* writeConfigAtomically(DEFAULT_KEYBINDINGS);        yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey);        return;      }       const runtimeConfig = yield* loadRuntimeCustomKeybindingsConfig();      if (runtimeConfig.issues.length > 0) {        yield* Effect.logWarning(          "skipping startup keybindings default sync because config has issues",          {            path: keybindingsConfigPath,

Lines 1–500 of 711.