704 lines · 24.3 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 * as Array from "effect/Array";import * as Cache from "effect/Cache";import * as Cause from "effect/Cause";import * as Deferred from "effect/Deferred";import * as Duration from "effect/Duration";import * as Effect from "effect/Effect";import * as Exit from "effect/Exit";import * as FileSystem from "effect/FileSystem";import * as Path from "effect/Path";import * as Layer from "effect/Layer";import * as Predicate from "effect/Predicate";import * as PubSub from "effect/PubSub";import * as Result from "effect/Result";import * as Schema from "effect/Schema";import * as SchemaIssue from "effect/SchemaIssue";import * as SchemaTransformation from "effect/SchemaTransformation";import * as Ref from "effect/Ref";import * as Context from "effect/Context";import * as Scope from "effect/Scope";import * as Stream from "effect/Stream";import * as Semaphore from "effect/Semaphore";import * as ServerConfig from "./config.ts";import { writeFileStringAtomically } from "./atomicWrite.ts";import { fromJsonStringPretty, 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({                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({                message: "Resolved shortcut cannot be encoded to key string",              }),            );          }           const when = resolved.whenAst ? encodeWhenAst(resolved.whenAst) : undefined;          return {            key,            command: resolved.command,            when,          };        }),    }),  ),); 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 KeybindingsConfigPrettyJson = fromJsonStringPretty(KeybindingsConfig);const decodeKeybindingRuleExit = Schema.decodeUnknownExit(KeybindingRule);const decodeResolvedKeybindingFromConfigExit = Schema.decodeExit(ResolvedKeybindingFromConfig);const decodeRawKeybindingsEntriesExit = Schema.decodeUnknownExit(RawKeybindingsEntries);const encodeKeybindingsConfigPrettyJson = Schema.encodeEffect(KeybindingsConfigPrettyJson); 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);} /** * Keybindings - Service tag for keybinding configuration operations. */export class Keybindings extends Context.Service<  Keybindings,  {    /**     * 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>;  }>()("t3/keybindings") {} const make = Effect.gen(function* () {  const { keybindingsConfigPath } = yield* ServerConfig.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 = decodeKeybindingRuleExit(entry);        if (decodedRule._tag === "Failure") {          yield* Effect.logWarning("ignoring invalid keybinding entry", {            path: keybindingsConfigPath,            entry,            error: Cause.pretty(decodedRule.cause),          });          return null;        }        const resolved = decodeResolvedKeybindingFromConfigExit(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 = decodeRawKeybindingsEntriesExit(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 = decodeKeybindingRuleExit(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 = decodeResolvedKeybindingFromConfigExit(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 encodeKeybindingsConfigPrettyJson(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,            issues: runtimeConfig.issues,          },        );        yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey);        return;      }      const customConfig = runtimeConfig.keybindings;      const existingCommands = new Set(customConfig.map((entry) => entry.command));      const missingDefaults: KeybindingRule[] = [];      const shortcutConflictWarnings: Array<{        defaultCommand: KeybindingRule["command"];        conflictingCommand: KeybindingRule["command"];        key: string;        when: string | null;      }> = [];      for (const defaultRule of DEFAULT_KEYBINDINGS) {

Lines 1–500 of 704.