1/**
2 * Keybindings - Keybinding configuration service definitions.
3 *
4 * Owns parsing, validation, merge, and persistence of user keybinding
5 * configuration consumed by the server runtime.
6 *
7 * @module Keybindings
8 */
9import {
10 KeybindingRule,
11 KeybindingsConfig,
12 KeybindingsConfigError,
13 KeybindingShortcut,
14 KeybindingWhenNode,
15 MAX_KEYBINDINGS_COUNT,
16 ResolvedKeybindingRule,
17 ResolvedKeybindingsConfig,
18 type ServerRemoveKeybindingInput,
19 type ServerUpsertKeybindingInput,
20 type ServerConfigIssue,
21} from "@t3tools/contracts";
22import * as Array from "effect/Array";
23import * as Cache from "effect/Cache";
24import * as Cause from "effect/Cause";
25import * as Deferred from "effect/Deferred";
26import * as Duration from "effect/Duration";
27import * as Effect from "effect/Effect";
28import * as Exit from "effect/Exit";
29import * as FileSystem from "effect/FileSystem";
30import * as Path from "effect/Path";
31import * as Layer from "effect/Layer";
32import * as Predicate from "effect/Predicate";
33import * as PubSub from "effect/PubSub";
34import * as Result from "effect/Result";
35import * as Schema from "effect/Schema";
36import * as SchemaIssue from "effect/SchemaIssue";
37import * as SchemaTransformation from "effect/SchemaTransformation";
38import * as Ref from "effect/Ref";
39import * as Context from "effect/Context";
40import * as Scope from "effect/Scope";
41import * as Stream from "effect/Stream";
42import * as Semaphore from "effect/Semaphore";
43import * as ServerConfig from "./config.ts";
44import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
45import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson";
46import {
47 DEFAULT_KEYBINDINGS,
48 mergeWithDefaultKeybindings,
49 compileResolvedKeybindingRule,
50 compileResolvedKeybindingsConfig,
51 parseKeybindingShortcut,
52} from "@t3tools/shared/keybindings";
53
54export {
55 DEFAULT_KEYBINDINGS,
56 compileResolvedKeybindingRule,
57 compileResolvedKeybindingsConfig,
58 parseKeybindingShortcut,
59};
60
61export const ResolvedKeybindingFromConfig = KeybindingRule.pipe(
62 Schema.decodeTo(
63 Schema.toType(ResolvedKeybindingRule),
64 SchemaTransformation.transformEffect({
65 decode: (rule) =>
66 Effect.succeed(compileResolvedKeybindingRule(rule)).pipe(
67 Effect.filterOrFail(
68 Predicate.isNotNull,
69 () =>
70 new SchemaIssue.InvalidValue({
71 message: "Invalid keybinding rule",
72 }),
73 ),
74 Effect.map((resolved) => resolved),
75 ),
76
77 encode: (resolved) =>
78 Effect.gen(function* () {
79 const key = encodeShortcut(resolved.shortcut);
80 if (!key) {
81 return yield* Effect.fail(
82 new SchemaIssue.InvalidValue({
83 message: "Resolved shortcut cannot be encoded to key string",
84 }),
85 );
86 }
87
88 const when = resolved.whenAst ? encodeWhenAst(resolved.whenAst) : undefined;
89 return {
90 key,
91 command: resolved.command,
92 when,
93 };
94 }),
95 }),
96 ),
97);
98
99function isSameKeybindingRule(left: KeybindingRule, right: KeybindingRule): boolean {
100 return (
101 left.command === right.command &&
102 left.key === right.key &&
103 (left.when ?? undefined) === (right.when ?? undefined)
104 );
105}
106
107// Default rules added to a command after startup sync had already persisted
108// that command. Backfill skips commands a config already has, so startup adds
109// each rule once per config, only next to the untouched earlier default
110// (`alongside`), and records its id. Customized commands and later removals
111// stay as the user set them.
112const LATE_DEFAULT_KEYBINDINGS: ReadonlyArray<{
113 readonly id: string;
114 readonly alongside: KeybindingRule;
115 readonly rule: KeybindingRule;
116}> = [
117 {
118 id: "composer.sendBackground:mod+enter",
119 alongside: {
120 key: "mod+alt+enter",
121 command: "composer.sendBackground",
122 when: "composerFocus && draftThreadRoute",
123 },
124 rule: {
125 key: "mod+enter",
126 command: "composer.sendBackground",
127 when: "composerFocus && draftThreadRoute",
128 },
129 },
130];
131
132function keybindingShortcutContext(rule: KeybindingRule): string | null {
133 const parsed = parseKeybindingShortcut(rule.key);
134 if (!parsed) return null;
135 const encoded = encodeShortcut(parsed);
136 if (!encoded) return null;
137 return `${encoded}\u0000${rule.when ?? ""}`;
138}
139
140function hasSameShortcutContext(left: KeybindingRule, right: KeybindingRule): boolean {
141 const leftContext = keybindingShortcutContext(left);
142 const rightContext = keybindingShortcutContext(right);
143 if (!leftContext || !rightContext) return false;
144 return leftContext === rightContext;
145}
146
147function keybindingRuleFromUpsertInput(input: ServerUpsertKeybindingInput): KeybindingRule {
148 return input.when === undefined
149 ? { key: input.key, command: input.command }
150 : { key: input.key, command: input.command, when: input.when };
151}
152
153function replaceTargetFromUpsertInput(input: ServerUpsertKeybindingInput): KeybindingRule | null {
154 if (!input.replace) return null;
155 return input.replace.when === undefined
156 ? { key: input.replace.key, command: input.replace.command }
157 : { key: input.replace.key, command: input.replace.command, when: input.replace.when };
158}
159
160function keybindingRuleFromRemoveInput(input: ServerRemoveKeybindingInput): KeybindingRule {
161 return input.when === undefined
162 ? { key: input.key, command: input.command }
163 : { key: input.key, command: input.command, when: input.when };
164}
165
166function encodeShortcut(shortcut: KeybindingShortcut): string | null {
167 const modifiers: string[] = [];
168 if (shortcut.modKey) modifiers.push("mod");
169 if (shortcut.metaKey) modifiers.push("meta");
170 if (shortcut.ctrlKey) modifiers.push("ctrl");
171 if (shortcut.altKey) modifiers.push("alt");
172 if (shortcut.shiftKey) modifiers.push("shift");
173 if (!shortcut.key) return null;
174 if (shortcut.key !== "+" && shortcut.key.includes("+")) return null;
175 const key = shortcut.key === " " ? "space" : shortcut.key;
176 return [...modifiers, key].join("+");
177}
178
179function encodeWhenAst(node: KeybindingWhenNode): string {
180 switch (node.type) {
181 case "identifier":
182 return node.name;
183 case "not":
184 return `!(${encodeWhenAst(node.node)})`;
185 case "and":
186 return `(${encodeWhenAst(node.left)} && ${encodeWhenAst(node.right)})`;
187 case "or":
188 return `(${encodeWhenAst(node.left)} || ${encodeWhenAst(node.right)})`;
189 }
190}
191
192const RawKeybindingsEntries = fromLenientJson(Schema.Array(Schema.Unknown));
193const KeybindingsConfigPrettyJson = fromJsonStringPretty(KeybindingsConfig);
194const decodeKeybindingRuleExit = Schema.decodeUnknownExit(KeybindingRule);
195const decodeResolvedKeybindingFromConfigExit = Schema.decodeExit(ResolvedKeybindingFromConfig);
196const decodeRawKeybindingsEntriesExit = Schema.decodeUnknownExit(RawKeybindingsEntries);
197const encodeKeybindingsConfigPrettyJson = Schema.encodeEffect(KeybindingsConfigPrettyJson);
198
199export interface KeybindingsConfigState {
200 readonly keybindings: ResolvedKeybindingsConfig;
201 readonly issues: readonly ServerConfigIssue[];
202}
203
204export interface KeybindingsChangeEvent {
205 readonly keybindings: ResolvedKeybindingsConfig;
206 readonly issues: readonly ServerConfigIssue[];
207}
208
209function trimIssueMessage(message: string): string {
210 const trimmed = message.trim();
211 return trimmed.length > 0 ? trimmed : "Invalid keybindings configuration.";
212}
213
214function malformedConfigIssue(detail: string): ServerConfigIssue {
215 return {
216 kind: "keybindings.malformed-config",
217 message: trimIssueMessage(detail),
218 };
219}
220
221function invalidEntryIssue(index: number, detail: string): ServerConfigIssue {
222 return {
223 kind: "keybindings.invalid-entry",
224 index,
225 message: trimIssueMessage(detail),
226 };
227}
228
229/**
230 * Keybindings - Service tag for keybinding configuration operations.
231 */
232export class Keybindings extends Context.Service<
233 Keybindings,
234 {
235 /**
236 * Start the keybindings runtime and attach file watching.
237 *
238 * Safe to call multiple times. The first successful call establishes the
239 * runtime; later calls await the same startup.
240 */
241 readonly start: Effect.Effect<void, KeybindingsConfigError>;
242
243 /**
244 * Await keybindings runtime readiness.
245 *
246 * Readiness means the config directory exists, the watcher is attached, the
247 * startup sync has completed, and the current snapshot has been loaded.
248 */
249 readonly ready: Effect.Effect<void, KeybindingsConfigError>;
250
251 /**
252 * Ensure the on-disk keybindings file exists and includes all default
253 * commands so newly-added defaults are backfilled on startup.
254 */
255 readonly syncDefaultKeybindingsOnStartup: Effect.Effect<void, KeybindingsConfigError>;
256
257 /**
258 * Load runtime keybindings state along with non-fatal configuration issues.
259 */
260 readonly loadConfigState: Effect.Effect<KeybindingsConfigState, KeybindingsConfigError>;
261
262 /**
263 * Read the latest keybindings snapshot from cache/disk.
264 */
265 readonly getSnapshot: Effect.Effect<KeybindingsConfigState, KeybindingsConfigError>;
266
267 /**
268 * Stream of keybindings config change events.
269 */
270 readonly streamChanges: Stream.Stream<KeybindingsChangeEvent>;
271
272 /**
273 * Upsert a keybinding rule and persist the resulting configuration.
274 *
275 * Writes config atomically and enforces the max rule count by truncating
276 * oldest entries when needed.
277 */
278 readonly upsertKeybindingRule: (
279 input: ServerUpsertKeybindingInput,
280 ) => Effect.Effect<ResolvedKeybindingsConfig, KeybindingsConfigError>;
281
282 /**
283 * Remove a single persisted keybinding rule by exact key/command/when match.
284 */
285 readonly removeKeybindingRule: (
286 input: ServerRemoveKeybindingInput,
287 ) => Effect.Effect<ResolvedKeybindingsConfig, KeybindingsConfigError>;
288 }
289>()("t3/keybindings") {}
290
291const make = Effect.gen(function* () {
292 const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig;
293 const fs = yield* FileSystem.FileSystem;
294 const path = yield* Path.Path;
295 const upsertSemaphore = yield* Semaphore.make(1);
296 const resolvedConfigCacheKey = "resolved" as const;
297 const changesPubSub = yield* PubSub.unbounded<KeybindingsChangeEvent>();
298 const startedRef = yield* Ref.make(false);
299 const startedDeferred = yield* Deferred.make<void, KeybindingsConfigError>();
300 const watcherScope = yield* Scope.make("sequential");
301 yield* Effect.addFinalizer(() => Scope.close(watcherScope, Exit.void));
302 const emitChange = (configState: KeybindingsConfigState) =>
303 PubSub.publish(changesPubSub, configState).pipe(Effect.asVoid);
304
305 const readConfigExists = fs.exists(keybindingsConfigPath).pipe(
306 Effect.mapError(
307 (cause) =>
308 new KeybindingsConfigError({
309 configPath: keybindingsConfigPath,
310 detail: "failed to access keybindings config",
311 cause,
312 }),
313 ),
314 );
315
316 const readRawConfig = fs.readFileString(keybindingsConfigPath).pipe(
317 Effect.mapError(
318 (cause) =>
319 new KeybindingsConfigError({
320 configPath: keybindingsConfigPath,
321 detail: "failed to read keybindings config",
322 cause,
323 }),
324 ),
325 );
326
327 const loadWritableCustomKeybindingsConfig = Effect.fn(function* (): Effect.fn.Return<
328 readonly KeybindingRule[],
329 KeybindingsConfigError
330 > {
331 if (!(yield* readConfigExists)) {
332 return [];
333 }
334
335 const rawConfig = yield* readRawConfig.pipe(
336 Effect.flatMap(Schema.decodeEffect(RawKeybindingsEntries)),
337 Effect.mapError(
338 (cause) =>
339 new KeybindingsConfigError({
340 configPath: keybindingsConfigPath,
341 detail: "expected JSON array",
342 cause,
343 }),
344 ),
345 );
346
347 return yield* Effect.forEach(rawConfig, (entry) =>
348 Effect.gen(function* () {
349 const decodedRule = decodeKeybindingRuleExit(entry);
350 if (decodedRule._tag === "Failure") {
351 yield* Effect.logWarning("ignoring invalid keybinding entry", {
352 path: keybindingsConfigPath,
353 entry,
354 error: Cause.pretty(decodedRule.cause),
355 });
356 return null;
357 }
358 const resolved = decodeResolvedKeybindingFromConfigExit(decodedRule.value);
359 if (resolved._tag === "Failure") {
360 yield* Effect.logWarning("ignoring invalid keybinding entry", {
361 path: keybindingsConfigPath,
362 entry,
363 error: Cause.pretty(resolved.cause),
364 });
365 return null;
366 }
367 return decodedRule.value;
368 }),
369 ).pipe(Effect.map(Array.filter(Predicate.isNotNull)));
370 });
371
372 const loadRuntimeCustomKeybindingsConfig = Effect.fn(function* (): Effect.fn.Return<
373 {
374 readonly keybindings: readonly KeybindingRule[];
375 readonly issues: readonly ServerConfigIssue[];
376 },
377 KeybindingsConfigError
378 > {
379 if (!(yield* readConfigExists)) {
380 return { keybindings: [], issues: [] };
381 }
382
383 const rawConfig = yield* readRawConfig;
384 const decodedEntries = decodeRawKeybindingsEntriesExit(rawConfig);
385 if (decodedEntries._tag === "Failure") {
386 const detail = `expected JSON array (${Cause.pretty(decodedEntries.cause)})`;
387 return {
388 keybindings: [],
389 issues: [malformedConfigIssue(detail)],
390 };
391 }
392
393 const keybindings: KeybindingRule[] = [];
394 const issues: ServerConfigIssue[] = [];
395 for (const [index, entry] of decodedEntries.value.entries()) {
396 const decodedRule = decodeKeybindingRuleExit(entry);
397 if (decodedRule._tag === "Failure") {
398 const detail = Cause.pretty(decodedRule.cause);
399 issues.push(invalidEntryIssue(index, detail));
400 yield* Effect.logWarning("ignoring invalid keybinding entry", {
401 path: keybindingsConfigPath,
402 index,
403 entry,
404 error: detail,
405 });
406 continue;
407 }
408
409 const resolvedRule = decodeResolvedKeybindingFromConfigExit(decodedRule.value);
410 if (resolvedRule._tag === "Failure") {
411 const detail = Cause.pretty(resolvedRule.cause);
412 issues.push(invalidEntryIssue(index, detail));
413 yield* Effect.logWarning("ignoring invalid keybinding entry", {
414 path: keybindingsConfigPath,
415 index,
416 entry,
417 error: detail,
418 });
419 continue;
420 }
421 keybindings.push(decodedRule.value);
422 }
423
424 return { keybindings, issues };
425 });
426
427 // One applied migration id per line, next to the config file.
428 const appliedMigrationsPath = path.join(
429 path.dirname(keybindingsConfigPath),
430 "keybindings-migrations",
431 );
432 const readAppliedMigrationIds = fs.readFileString(appliedMigrationsPath).pipe(
433 Effect.map((contents) => new Set(contents.split("\n").filter((line) => line.length > 0))),
434 Effect.orElseSucceed(() => new Set<string>()),
435 );
436 const recordAppliedMigrations = (ids: Iterable<string>) =>
437 writeFileStringAtomically({
438 filePath: appliedMigrationsPath,
439 contents: `${[...new Set(ids)].join("\n")}\n`,
440 }).pipe(
441 Effect.provideService(FileSystem.FileSystem, fs),
442 Effect.provideService(Path.Path, path),
443 Effect.mapError(
444 (cause) =>
445 new KeybindingsConfigError({
446 configPath: keybindingsConfigPath,
447 detail: "failed to record keybinding migrations",
448 cause,
449 }),
450 ),
451 );
452
453 const writeConfigAtomically = (rules: readonly KeybindingRule[]) => {
454 return encodeKeybindingsConfigPrettyJson(rules).pipe(
455 Effect.map((encoded) => `${encoded}\n`),
456 Effect.flatMap((encoded) =>
457 writeFileStringAtomically({
458 filePath: keybindingsConfigPath,
459 contents: encoded,
460 }).pipe(
461 Effect.provideService(FileSystem.FileSystem, fs),
462 Effect.provideService(Path.Path, path),
463 ),
464 ),
465 Effect.mapError(
466 (cause) =>
467 new KeybindingsConfigError({
468 configPath: keybindingsConfigPath,
469 detail: "failed to write keybindings config",
470 cause,
471 }),
472 ),
473 );
474 };
475
476 const loadConfigStateFromDisk = loadRuntimeCustomKeybindingsConfig().pipe(
477 Effect.map(({ keybindings, issues }) => ({
478 keybindings: mergeWithDefaultKeybindings(compileResolvedKeybindingsConfig(keybindings)),
479 issues,
480 })),
481 );
482
483 // A failed read is not kept: the next read retries instead of replaying the failure.
484 const resolvedConfigCache = yield* Cache.makeWith<
485 typeof resolvedConfigCacheKey,
486 KeybindingsConfigState,
487 KeybindingsConfigError
488 >(() => loadConfigStateFromDisk, {
489 capacity: 1,
490 timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero),
491 });
492
493 const loadConfigStateFromCacheOrDisk = Cache.get(resolvedConfigCache, resolvedConfigCacheKey);
494
495 const revalidateAndEmit = upsertSemaphore.withPermits(1)(
496 Effect.gen(function* () {
497 yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey);
498 const configState = yield* loadConfigStateFromCacheOrDisk;
499 yield* emitChange(configState);
500 }),
501 );
502
503 const syncDefaultKeybindingsOnStartup = upsertSemaphore.withPermits(1)(
504 Effect.gen(function* () {
505 const appliedMigrationIds = yield* readAppliedMigrationIds;
506 const pendingLateDefaults = LATE_DEFAULT_KEYBINDINGS.filter(
507 (late) => !appliedMigrationIds.has(late.id),
508 );
509 const configExists = yield* readConfigExists;
510 if (!configExists) {
511 yield* writeConfigAtomically(DEFAULT_KEYBINDINGS);
512 if (pendingLateDefaults.length > 0) {
513 yield* recordAppliedMigrations([
514 ...appliedMigrationIds,
515 ...LATE_DEFAULT_KEYBINDINGS.map((late) => late.id),
516 ]);
517 }
518 yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey);
519 return;
520 }
521
522 const runtimeConfig = yield* loadRuntimeCustomKeybindingsConfig();
523 if (runtimeConfig.issues.length > 0) {
524 yield* Effect.logWarning(
525 "skipping startup keybindings default sync because config has issues",
526 {
527 path: keybindingsConfigPath,
528 issues: runtimeConfig.issues,
529 },
530 );
531 yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey);
532 return;
533 }
534 const customConfig = runtimeConfig.keybindings;
535 const existingCommands = new Set(customConfig.map((entry) => entry.command));
536 const missingDefaults: KeybindingRule[] = [];
537 const shortcutConflictWarnings: Array<{
538 defaultCommand: KeybindingRule["command"];
539 conflictingCommand: KeybindingRule["command"];
540 key: string;
541 when: string | null;
542 }> = [];
543 for (const defaultRule of DEFAULT_KEYBINDINGS) {
544 if (existingCommands.has(defaultRule.command)) {
545 continue;
546 }
547 const conflictingEntry = customConfig.find((entry) =>
548 hasSameShortcutContext(entry, defaultRule),
549 );
550 if (conflictingEntry) {
551 shortcutConflictWarnings.push({
552 defaultCommand: defaultRule.command,
553 conflictingCommand: conflictingEntry.command,
554 key: defaultRule.key,
555 when: defaultRule.when ?? null,
556 });
557 continue;
558 }
559 missingDefaults.push(defaultRule);
560 }
561 // The loop above backfills whole missing commands. Late defaults join
562 // an untouched earlier default, when their shortcut is still free.
563 for (const { alongside, rule } of pendingLateDefaults) {
564 if (
565 customConfig.some((entry) => isSameKeybindingRule(entry, alongside)) &&
566 !customConfig.some((entry) => hasSameShortcutContext(entry, rule))
567 ) {
568 missingDefaults.push(rule);
569 }
570 }
571 for (const conflict of shortcutConflictWarnings) {
572 yield* Effect.logWarning("skipping default keybinding due to shortcut conflict", {
573 path: keybindingsConfigPath,
574 defaultCommand: conflict.defaultCommand,
575 conflictingCommand: conflict.conflictingCommand,
576 key: conflict.key,
577 when: conflict.when,
578 reason: "shortcut context already used by existing rule",
579 });
580 }
581 if (missingDefaults.length > 0) {
582 const matchingDefaults = Array.filterMap(DEFAULT_KEYBINDINGS, (defaultRule) =>
583 customConfig.some((entry) => isSameKeybindingRule(entry, defaultRule))
584 ? Result.succeed(defaultRule.command)
585 : Result.failVoid,
586 );
587 if (matchingDefaults.length > 0) {
588 yield* Effect.logWarning("default keybinding rule already defined in user config", {
589 path: keybindingsConfigPath,
590 commands: matchingDefaults,
591 });
592 }
593 }
594
595 // Keep only defaults that fit, before persisted rules so existing
596 // custom shortcuts retain priority when their conditions overlap.
597 const availableSlots = Math.max(0, MAX_KEYBINDINGS_COUNT - customConfig.length);
598 const defaultsToAppend = missingDefaults.slice(0, availableSlots);
599 const skippedDefaults = missingDefaults.slice(availableSlots);
600 if (skippedDefaults.length > 0) {
601 yield* Effect.logWarning("skipping default keybinding backfill at max entries", {
602 path: keybindingsConfigPath,
603 maxEntries: MAX_KEYBINDINGS_COUNT,
604 commands: skippedDefaults.map((rule) => rule.command),
605 });
606 }
607 if (defaultsToAppend.length > 0) {
608 yield* writeConfigAtomically([...defaultsToAppend, ...customConfig]);
609 }
610 // A late default skipped at max entries stays pending for a later start.
611 const settledLateDefaults = pendingLateDefaults.filter(
612 (late) => !skippedDefaults.includes(late.rule),
613 );
614 if (settledLateDefaults.length > 0) {
615 yield* recordAppliedMigrations([
616 ...appliedMigrationIds,
617 ...settledLateDefaults.map((late) => late.id),
618 ]);
619 }
620 yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey);
621 }),
622 );
623
624 const startWatcher = Effect.gen(function* () {
625 const keybindingsConfigDir = path.dirname(keybindingsConfigPath);
626 const keybindingsConfigFile = path.basename(keybindingsConfigPath);
627 const keybindingsConfigPathResolved = path.resolve(keybindingsConfigPath);
628
629 yield* fs.makeDirectory(keybindingsConfigDir, { recursive: true }).pipe(
630 Effect.mapError(
631 (cause) =>
632 new KeybindingsConfigError({
633 configPath: keybindingsConfigPath,
634 detail: "failed to prepare keybindings config directory",
635 cause,
636 }),
637 ),
638 );
639
640 const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true }));
641
642 // Debounce watch events so the file is fully written before we read it.
643 // Editors emit multiple events per save (truncate, write, rename) and
644 // `fs.watch` can fire before the content has been flushed to disk.
645 const debouncedKeybindingsEvents = fs.watch(keybindingsConfigDir).pipe(
646 Stream.filter((event) => {
647 return (
648 event.path === keybindingsConfigFile ||
649 event.path === keybindingsConfigPath ||
650 path.resolve(keybindingsConfigDir, event.path) === keybindingsConfigPathResolved
651 );
652 }),
653 Stream.debounce(Duration.millis(100)),
654 );
655
656 yield* Stream.runForEach(debouncedKeybindingsEvents, () => revalidateAndEmitSafely).pipe(
657 Effect.ignoreCause({ log: true }),
658 Effect.forkIn(watcherScope),
659 Effect.asVoid,
660 );
661 });
662
663 const start = Effect.gen(function* () {
664 const alreadyStarted = yield* Ref.get(startedRef);
665 if (alreadyStarted) {
666 return yield* Deferred.await(startedDeferred);
667 }
668
669 yield* Ref.set(startedRef, true);
670 const startup = Effect.gen(function* () {
671 yield* startWatcher;
672 yield* syncDefaultKeybindingsOnStartup;
673 yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey);
674 yield* loadConfigStateFromCacheOrDisk;
675 });
676
677 const startupExit = yield* Effect.exit(startup);
678 if (startupExit._tag === "Failure") {
679 yield* Deferred.failCause(startedDeferred, startupExit.cause).pipe(Effect.orDie);
680 return yield* Effect.failCause(startupExit.cause);
681 }
682
683 yield* Deferred.succeed(startedDeferred, undefined).pipe(Effect.orDie);
684 });
685
686 return {
687 start,
688 ready: Deferred.await(startedDeferred),
689 syncDefaultKeybindingsOnStartup,
690 loadConfigState: loadConfigStateFromCacheOrDisk,
691 getSnapshot: loadConfigStateFromCacheOrDisk,
692 get streamChanges() {
693 return Stream.fromPubSub(changesPubSub);
694 },
695 upsertKeybindingRule: (input) =>
696 upsertSemaphore.withPermits(1)(
697 Effect.gen(function* () {
698 const customConfig = yield* loadWritableCustomKeybindingsConfig();
699 const rule = keybindingRuleFromUpsertInput(input);
700 const replaceTarget = replaceTargetFromUpsertInput(input);
701 const nextConfig = [
702 ...customConfig.filter((entry) => {
703 if (replaceTarget) {
704 return (
705 !isSameKeybindingRule(entry, replaceTarget) && !isSameKeybindingRule(entry, rule)
706 );
707 }
708 return !isSameKeybindingRule(entry, rule);
709 }),
710 rule,
711 ];
712 const cappedConfig =
713 nextConfig.length > MAX_KEYBINDINGS_COUNT
714 ? nextConfig.slice(-MAX_KEYBINDINGS_COUNT)
715 : nextConfig;
716 if (nextConfig.length > MAX_KEYBINDINGS_COUNT) {
717 yield* Effect.logWarning("truncating keybindings config to max entries", {
718 path: keybindingsConfigPath,
719 maxEntries: MAX_KEYBINDINGS_COUNT,
720 });
721 }
722 yield* writeConfigAtomically(cappedConfig);
723 const nextResolved = mergeWithDefaultKeybindings(
724 compileResolvedKeybindingsConfig(cappedConfig),
725 );
726 yield* Cache.set(resolvedConfigCache, resolvedConfigCacheKey, {
727 keybindings: nextResolved,
728 issues: [],
729 });
730 yield* emitChange({
731 keybindings: nextResolved,
732 issues: [],
733 });
734 return nextResolved;
735 }),
736 ),
737 removeKeybindingRule: (input) =>
738 upsertSemaphore.withPermits(1)(
739 Effect.gen(function* () {
740 const customConfig = yield* loadWritableCustomKeybindingsConfig();
741 const target = keybindingRuleFromRemoveInput(input);
742 const nextConfig = customConfig.filter((entry) => !isSameKeybindingRule(entry, target));
743 yield* writeConfigAtomically(nextConfig);
744 const nextResolved = mergeWithDefaultKeybindings(
745 compileResolvedKeybindingsConfig(nextConfig),
746 );
747 yield* Cache.set(resolvedConfigCache, resolvedConfigCacheKey, {
748 keybindings: nextResolved,
749 issues: [],
750 });
751 yield* emitChange({
752 keybindings: nextResolved,
753 issues: [],
754 });
755 return nextResolved;
756 }),
757 ),
758 } satisfies Keybindings["Service"];
759});
760
761export const layer = Layer.effect(Keybindings, make);