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