1import { KeybindingCommand, KeybindingRule, KeybindingsConfig } from "@t3tools/contracts";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, it } from "@effect/vitest";
4import { assertFailure } from "@effect/vitest/utils";
5import * as Cause from "effect/Cause";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Logger from "effect/Logger";
10import * as Path from "effect/Path";
11import * as Schema from "effect/Schema";
12import * as ServerConfig from "./config.ts";
13import * as Keybindings from "./keybindings.ts";
14import { KeybindingsConfigError, MAX_KEYBINDINGS_COUNT } from "@t3tools/contracts";
15import * as HostProcess from "@t3tools/shared/HostProcess";
16
17const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig);
18const encodeKeybindingsConfigJson = Schema.encodeEffect(KeybindingsConfigJson);
19const decodeKeybindingsConfigJson = Schema.decodeUnknownEffect(KeybindingsConfigJson);
20const encodeResolvedKeybindingFromConfig = Schema.encodeEffect(
21 Keybindings.ResolvedKeybindingFromConfig,
22);
23const decodeResolvedKeybindingFromConfigExit = Schema.decodeUnknownExit(
24 Keybindings.ResolvedKeybindingFromConfig,
25);
26const layerKeybindings = () => {
27 return Keybindings.layer.pipe(
28 Layer.provideMerge(
29 Layer.fresh(
30 ServerConfig.layerTest(process.cwd(), {
31 prefix: "t3code-keybindings-test-",
32 }),
33 ),
34 ),
35 );
36};
37
38const toDetailResult = <A, R>(effect: Effect.Effect<A, KeybindingsConfigError, R>) =>
39 effect.pipe(
40 Effect.mapError((error) => error.detail),
41 Effect.result,
42 );
43
44const writeKeybindingsConfig = (configPath: string, rules: readonly KeybindingRule[]) =>
45 Effect.gen(function* () {
46 const fileSystem = yield* FileSystem.FileSystem;
47 const path = yield* Path.Path;
48 const encoded = yield* encodeKeybindingsConfigJson(rules);
49 yield* fileSystem.makeDirectory(path.dirname(configPath), { recursive: true });
50 yield* fileSystem.writeFileString(configPath, encoded);
51 });
52
53const readKeybindingsConfig = (configPath: string) =>
54 Effect.gen(function* () {
55 const fileSystem = yield* FileSystem.FileSystem;
56 const rawConfig = yield* fileSystem.readFileString(configPath);
57 return yield* decodeKeybindingsConfigJson(rawConfig);
58 });
59
60it.layer(NodeServices.layer)("keybindings", (it) => {
61 it.effect("parses shortcuts including plus key", () =>
62 Effect.sync(() => {
63 assert.deepEqual(Keybindings.parseKeybindingShortcut("mod+j"), {
64 key: "j",
65 metaKey: false,
66 ctrlKey: false,
67 shiftKey: false,
68 altKey: false,
69 modKey: true,
70 });
71 assert.deepEqual(Keybindings.parseKeybindingShortcut("mod++"), {
72 key: "+",
73 metaKey: false,
74 ctrlKey: false,
75 shiftKey: false,
76 altKey: false,
77 modKey: true,
78 });
79 }),
80 );
81
82 it.effect("compiles valid rule with parsed when AST", () =>
83 Effect.sync(() => {
84 const compiled = Keybindings.compileResolvedKeybindingRule({
85 key: "mod+d",
86 command: "terminal.split",
87 when: "terminalOpen && !terminalFocus",
88 });
89
90 assert.deepEqual(compiled, {
91 command: "terminal.split",
92 shortcut: {
93 key: "d",
94 metaKey: false,
95 ctrlKey: false,
96 shiftKey: false,
97 altKey: false,
98 modKey: true,
99 },
100 whenAst: {
101 type: "and",
102 left: { type: "identifier", name: "terminalOpen" },
103 right: {
104 type: "not",
105 node: { type: "identifier", name: "terminalFocus" },
106 },
107 },
108 });
109 }),
110 );
111
112 it.effect("encodes resolved plus-key shortcuts", () =>
113 Effect.gen(function* () {
114 const encoded = yield* encodeResolvedKeybindingFromConfig({
115 command: "terminal.toggle",
116 shortcut: {
117 key: "+",
118 metaKey: false,
119 ctrlKey: false,
120 shiftKey: false,
121 altKey: false,
122 modKey: true,
123 },
124 });
125
126 assert.equal(encoded.key, "mod++");
127 assert.equal(encoded.command, "terminal.toggle");
128 }),
129 );
130
131 it.effect("rejects invalid rules", () =>
132 Effect.sync(() => {
133 assert.isNull(
134 Keybindings.compileResolvedKeybindingRule({
135 key: "mod+shift+d+o",
136 command: "terminal.new",
137 }),
138 );
139
140 assert.isNull(
141 Keybindings.compileResolvedKeybindingRule({
142 key: "mod+d",
143 command: "terminal.split",
144 when: "terminalFocus && (",
145 }),
146 );
147
148 assert.isNull(
149 Keybindings.compileResolvedKeybindingRule({
150 key: "mod+d",
151 command: "terminal.split",
152 when: `${"!".repeat(300)}terminalFocus`,
153 }),
154 );
155 }),
156 );
157
158 it.effect("formats invalid resolved keybinding rules with the custom message", () =>
159 Effect.sync(() => {
160 const result = decodeResolvedKeybindingFromConfigExit({
161 key: "mod+shift+d+o",
162 command: "terminal.new",
163 });
164
165 if (result._tag !== "Failure") {
166 assert.fail("Expected invalid keybinding decode to fail");
167 }
168
169 const detail = Cause.pretty(result.cause);
170 assert.isTrue(detail.includes("Invalid keybinding rule"));
171 assert.isFalse(detail.includes("Invalid data"));
172 }),
173 );
174
175 it.effect("bootstraps default keybindings when config file is missing", () =>
176 Effect.gen(function* () {
177 const fs = yield* FileSystem.FileSystem;
178 const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig;
179 assert.isFalse(yield* fs.exists(keybindingsConfigPath));
180
181 yield* Effect.gen(function* () {
182 const keybindings = yield* Keybindings.Keybindings;
183 yield* keybindings.syncDefaultKeybindingsOnStartup;
184 });
185
186 const persisted = yield* readKeybindingsConfig(keybindingsConfigPath);
187 assert.deepEqual(persisted, Keybindings.DEFAULT_KEYBINDINGS);
188 }).pipe(Effect.provide(layerKeybindings())),
189 );
190
191 it.effect("uses defaults in runtime when config is malformed without overriding file", () =>
192 Effect.gen(function* () {
193 const fs = yield* FileSystem.FileSystem;
194 const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig;
195 yield* fs.writeFileString(keybindingsConfigPath, "{ not-json");
196
197 const configState = yield* Effect.gen(function* () {
198 const keybindings = yield* Keybindings.Keybindings;
199 return yield* keybindings.loadConfigState;
200 });
201
202 assert.deepEqual(
203 configState.keybindings,
204 Keybindings.compileResolvedKeybindingsConfig(Keybindings.DEFAULT_KEYBINDINGS),
205 );
206 assert.deepEqual(configState.issues, [
207 {
208 kind: "keybindings.malformed-config",
209 message: configState.issues[0]?.message ?? "",
210 },
211 ]);
212 assert.equal(yield* fs.readFileString(keybindingsConfigPath), "{ not-json");
213 }).pipe(Effect.provide(layerKeybindings())),
214 );
215
216 it.effect("ignores invalid entries in runtime and reports them as issues", () =>
217 Effect.gen(function* () {
218 const fs = yield* FileSystem.FileSystem;
219 const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig;
220 yield* fs.writeFileString(
221 keybindingsConfigPath,
222 JSON.stringify([
223 { key: "mod+j", command: "terminal.toggle" },
224 { key: "mod+shift+d+o", command: "terminal.new" },
225 { key: "mod+x", command: "invalid.command" },
226 ]),
227 );
228
229 const configState = yield* Effect.gen(function* () {
230 const keybindings = yield* Keybindings.Keybindings;
231 return yield* keybindings.loadConfigState;
232 });
233
234 assert.isTrue(configState.keybindings.some((entry) => entry.command === "terminal.toggle"));
235 assert.isFalse(
236 configState.keybindings.some((entry) => String(entry.command) === "invalid.command"),
237 );
238 assert.deepEqual(configState.issues, [
239 {
240 kind: "keybindings.invalid-entry",
241 index: 1,
242 message: configState.issues[0]?.message ?? "",
243 },
244 {
245 kind: "keybindings.invalid-entry",
246 index: 2,
247 message: configState.issues[1]?.message ?? "",
248 },
249 ]);
250 }).pipe(Effect.provide(layerKeybindings())),
251 );
252
253 it.effect(
254 "upserts missing default keybindings on startup without overriding existing command rules",
255 () =>
256 Effect.gen(function* () {
257 const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig;
258 yield* writeKeybindingsConfig(keybindingsConfigPath, [
259 { key: "mod+shift+y", command: "terminal.toggle" },
260 { key: "mod+shift+r", command: "script.run-tests.run" },
261 { key: "mod+shift+b", command: "script.custom-panel.run" },
262 {
263 key: "mod+shift+b",
264 command: "script.custom-composer.run",
265 when: "composerFocus",
266 },
267 ]);
268
269 const keybindings = yield* Keybindings.Keybindings;
270 yield* keybindings.syncDefaultKeybindingsOnStartup;
271
272 const persisted = yield* readKeybindingsConfig(keybindingsConfigPath);
273 const byCommand = new Map(persisted.map((entry) => [entry.command, entry]));
274
275 const persistedToggle = byCommand.get("terminal.toggle");
276 assert.isNotNull(persistedToggle);
277 assert.equal(persistedToggle?.key, "mod+shift+y");
278 assert.isFalse(
279 persisted.some((entry) => entry.command === "terminal.toggle" && entry.key === "mod+j"),
280 );
281
282 for (const defaultRule of Keybindings.DEFAULT_KEYBINDINGS) {
283 assert.isTrue(byCommand.has(defaultRule.command), `expected ${defaultRule.command}`);
284 }
285 assert.isTrue(byCommand.has("script.run-tests.run"));
286
287 const configState = yield* keybindings.loadConfigState;
288 assert.deepEqual(
289 configState.keybindings
290 .filter((entry) => entry.shortcut.key === "b" && entry.shortcut.shiftKey)
291 .map((entry) => entry.command),
292 ["threadPanel.toggle", "script.custom-panel.run", "script.custom-composer.run"],
293 );
294 }).pipe(Effect.provide(layerKeybindings())),
295 );
296
297 it.effect("adds a late default to an existing command once", () =>
298 Effect.gen(function* () {
299 const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig;
300 const keybindings = yield* Keybindings.Keybindings;
301 const when = "composerFocus && draftThreadRoute";
302 const existing = { key: "mod+alt+enter", command: "composer.sendBackground", when } as const;
303 const backgroundRules = Effect.map(readKeybindingsConfig(keybindingsConfigPath), (rules) =>
304 rules.filter((entry) => entry.command === "composer.sendBackground"),
305 );
306 yield* writeKeybindingsConfig(keybindingsConfigPath, [existing]);
307
308 yield* keybindings.syncDefaultKeybindingsOnStartup;
309 assert.deepStrictEqual(yield* backgroundRules, [
310 { key: "mod+enter", command: "composer.sendBackground", when },
311 existing,
312 ]);
313
314 // Removing the added rule later must survive the next startup.
315 yield* writeKeybindingsConfig(keybindingsConfigPath, [existing]);
316 yield* keybindings.syncDefaultKeybindingsOnStartup;
317 assert.deepStrictEqual(yield* backgroundRules, [existing]);
318 }).pipe(Effect.provide(layerKeybindings())),
319 );
320
321 it.effect("leaves a customized command without the late default", () =>
322 Effect.gen(function* () {
323 const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig;
324 const keybindings = yield* Keybindings.Keybindings;
325 const custom = {
326 key: "alt+b",
327 command: "composer.sendBackground",
328 when: "composerFocus && draftThreadRoute",
329 } as const;
330 yield* writeKeybindingsConfig(keybindingsConfigPath, [custom]);
331
332 yield* keybindings.syncDefaultKeybindingsOnStartup;
333 const persisted = yield* readKeybindingsConfig(keybindingsConfigPath);
334 assert.deepStrictEqual(
335 persisted.filter((entry) => entry.command === "composer.sendBackground"),
336 [custom],
337 );
338 }).pipe(Effect.provide(layerKeybindings())),
339 );
340
341 it.effect("keeps a late default pending while the config is full", () =>
342 Effect.gen(function* () {
343 const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig;
344 const keybindings = yield* Keybindings.Keybindings;
345 const when = "composerFocus && draftThreadRoute";
346 const existing = { key: "mod+alt+enter", command: "composer.sendBackground", when } as const;
347 const fillers = Array.from({ length: MAX_KEYBINDINGS_COUNT - 1 }, (_, index) => ({
348 key: "mod+alt+f1",
349 command: `script.filler-${index}.run` as const,
350 }));
351 const hasModEnter = Effect.map(readKeybindingsConfig(keybindingsConfigPath), (rules) =>
352 rules.some(
353 (entry) => entry.command === "composer.sendBackground" && entry.key === "mod+enter",
354 ),
355 );
356
357 yield* writeKeybindingsConfig(keybindingsConfigPath, [existing, ...fillers]);
358 yield* keybindings.syncDefaultKeybindingsOnStartup;
359 assert.isFalse(yield* hasModEnter);
360
361 yield* writeKeybindingsConfig(keybindingsConfigPath, [existing]);
362 yield* keybindings.syncDefaultKeybindingsOnStartup;
363 assert.isTrue(yield* hasModEnter);
364 }).pipe(Effect.provide(layerKeybindings())),
365 );
366
367 it.effect("skips conflicting default keybindings on startup and logs a detailed warning", () => {
368 const messages: string[] = [];
369 const logger = Logger.make(({ message }) => {
370 messages.push(String(message));
371 });
372
373 return Effect.gen(function* () {
374 const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig;
375 yield* writeKeybindingsConfig(keybindingsConfigPath, [
376 { key: "mod+j", command: "script.custom-action.run" },
377 ]);
378
379 yield* Effect.gen(function* () {
380 const keybindings = yield* Keybindings.Keybindings;
381 yield* keybindings.syncDefaultKeybindingsOnStartup;
382 });
383
384 const persisted = yield* readKeybindingsConfig(keybindingsConfigPath);
385 assert.isFalse(persisted.some((entry) => entry.command === "terminal.toggle"));
386 assert.isTrue(persisted.some((entry) => entry.command === "script.custom-action.run"));
387
388 assert.isTrue(
389 messages.some((message) =>
390 message.includes("skipping default keybinding due to shortcut conflict"),
391 ),
392 );
393 }).pipe(
394 Effect.provide(
395 Layer.mergeAll(layerKeybindings(), Logger.layer([logger], { mergeWithExisting: false })),
396 ),
397 );
398 });
399
400 it.effect("upserts custom keybindings to configured path", () =>
401 Effect.gen(function* () {
402 const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig;
403 yield* writeKeybindingsConfig(keybindingsConfigPath, [
404 { key: "mod+j", command: "terminal.toggle" },
405 ]);
406
407 const resolved = yield* Effect.gen(function* () {
408 const keybindings = yield* Keybindings.Keybindings;
409 return yield* keybindings.upsertKeybindingRule({
410 key: "mod+shift+r",
411 command: "script.run-tests.run",
412 });
413 });
414
415 const persisted = yield* readKeybindingsConfig(keybindingsConfigPath);
416 const persistedView = persisted.map(({ key, command }) => ({ key, command }));
417
418 assert.deepEqual(persistedView, [
419 { key: "mod+j", command: "terminal.toggle" },
420 { key: "mod+shift+r", command: "script.run-tests.run" },
421 ]);
422 assert.isTrue(resolved.some((entry) => entry.command === "script.run-tests.run"));
423 }).pipe(Effect.provide(layerKeybindings())),
424 );
425
426 it.effect("appends additional custom keybindings for the same command", () =>
427 Effect.gen(function* () {
428 const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig;
429 yield* writeKeybindingsConfig(keybindingsConfigPath, [
430 { key: "mod+r", command: "script.run-tests.run" },
431 ]);
432 yield* Effect.gen(function* () {
433 const keybindings = yield* Keybindings.Keybindings;
434 return yield* keybindings.upsertKeybindingRule({
435 key: "mod+shift+r",
436 command: "script.run-tests.run",
437 });
438 });
439
440 const persisted = yield* readKeybindingsConfig(keybindingsConfigPath);
441 const persistedView = persisted.map(({ key, command }) => ({ key, command }));
442 assert.deepEqual(persistedView, [
443 { key: "mod+r", command: "script.run-tests.run" },
444 { key: "mod+shift+r", command: "script.run-tests.run" },
445 ]);
446 }).pipe(Effect.provide(layerKeybindings())),
447 );
448
449 it.effect("replaces only the targeted custom keybinding", () =>
450 Effect.gen(function* () {
451 const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig;
452 yield* writeKeybindingsConfig(keybindingsConfigPath, [
453 { key: "mod+r", command: "script.run-tests.run" },
454 { key: "mod+shift+r", command: "script.run-tests.run" },
455 ]);
456 yield* Effect.gen(function* () {
457 const keybindings = yield* Keybindings.Keybindings;
458 return yield* keybindings.upsertKeybindingRule({
459 key: "mod+alt+r",
460 command: "script.run-tests.run",
461 replace: { key: "mod+r", command: "script.run-tests.run" },
462 });
463 });
464
465 const persisted = yield* readKeybindingsConfig(keybindingsConfigPath);
466 const persistedView = persisted.map(({ key, command }) => ({ key, command }));
467 assert.deepEqual(persistedView, [
468 { key: "mod+shift+r", command: "script.run-tests.run" },
469 { key: "mod+alt+r", command: "script.run-tests.run" },
470 ]);
471 }).pipe(Effect.provide(layerKeybindings())),
472 );
473
474 it.effect("replacing with a rule that already exists elsewhere does not duplicate it", () =>
475 Effect.gen(function* () {
476 const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig;
477 yield* writeKeybindingsConfig(keybindingsConfigPath, [
478 { key: "mod+r", command: "script.run-tests.run" },
479 { key: "mod+alt+r", command: "script.run-tests.run" },
480 ]);
481 yield* Effect.gen(function* () {
482 const keybindings = yield* Keybindings.Keybindings;
483 return yield* keybindings.upsertKeybindingRule({
484 key: "mod+alt+r",
485 command: "script.run-tests.run",
486 replace: { key: "mod+r", command: "script.run-tests.run" },
487 });
488 });
489
490 const persisted = yield* readKeybindingsConfig(keybindingsConfigPath);
491 const persistedView = persisted.map(({ key, command }) => ({ key, command }));
492 assert.deepEqual(persistedView, [{ key: "mod+alt+r", command: "script.run-tests.run" }]);
493 }).pipe(Effect.provide(layerKeybindings())),
494 );
495
496 it.effect("removes only the targeted custom keybinding", () =>
497 Effect.gen(function* () {
498 const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig;
499 yield* writeKeybindingsConfig(keybindingsConfigPath, [
500 { key: "mod+r", command: "script.run-tests.run" },
501 { key: "mod+shift+r", command: "script.run-tests.run" },
502 ]);
503 yield* Effect.gen(function* () {
504 const keybindings = yield* Keybindings.Keybindings;
505 return yield* keybindings.removeKeybindingRule({
506 key: "mod+r",
507 command: "script.run-tests.run",
508 });
509 });
510
511 const persisted = yield* readKeybindingsConfig(keybindingsConfigPath);
512 const persistedView = persisted.map(({ key, command }) => ({ key, command }));
513 assert.deepEqual(persistedView, [{ key: "mod+shift+r", command: "script.run-tests.run" }]);
514 }).pipe(Effect.provide(layerKeybindings())),
515 );
516
517 it.effect("refuses to overwrite malformed keybindings config", () =>
518 Effect.gen(function* () {
519 const fs = yield* FileSystem.FileSystem;
520 const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig;
521 yield* fs.writeFileString(keybindingsConfigPath, "{ not-json");
522
523 const result = yield* Effect.gen(function* () {
524 const keybindings = yield* Keybindings.Keybindings;
525 return yield* keybindings.upsertKeybindingRule({
526 key: "mod+shift+r",
527 command: "script.run-tests.run",
528 });
529 }).pipe(toDetailResult);
530 assertFailure(result, "expected JSON array");
531
532 const persistedRaw = yield* fs.readFileString(keybindingsConfigPath);
533 assert.equal(persistedRaw, "{ not-json");
534 }).pipe(Effect.provide(layerKeybindings())),
535 );
536
537 it.effect("reports non-array config parse errors without duplicate prefix", () =>
538 Effect.gen(function* () {
539 const fs = yield* FileSystem.FileSystem;
540 const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig;
541 yield* fs.writeFileString(
542 keybindingsConfigPath,
543 '{"key":"mod+j","command":"terminal.toggle"}',
544 );
545
546 const firstResult = yield* Effect.gen(function* () {
547 const keybindings = yield* Keybindings.Keybindings;
548 return yield* keybindings.upsertKeybindingRule({
549 key: "mod+shift+r",
550 command: "script.run-tests.run",
551 });
552 }).pipe(toDetailResult);
553 assertFailure(firstResult, "expected JSON array");
554
555 const secondResult = yield* Effect.gen(function* () {
556 const keybindings = yield* Keybindings.Keybindings;
557 return yield* keybindings.upsertKeybindingRule({
558 key: "mod+shift+r",
559 command: "script.run-tests.run",
560 });
561 }).pipe(toDetailResult);
562 assertFailure(secondResult, "expected JSON array");
563 }).pipe(Effect.provide(layerKeybindings())),
564 );
565
566 // chmod cannot make a directory unwritable on Windows, so the write succeeds.
567 it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")(
568 "fails when config directory is not writable",
569 () =>
570 Effect.gen(function* () {
571 const fs = yield* FileSystem.FileSystem;
572 const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig;
573 const { dirname } = yield* Path.Path;
574 yield* writeKeybindingsConfig(keybindingsConfigPath, [
575 { key: "mod+j", command: "terminal.toggle" },
576 ]);
577 yield* fs.chmod(dirname(keybindingsConfigPath), 0o500);
578
579 const result = yield* Effect.gen(function* () {
580 const keybindings = yield* Keybindings.Keybindings;
581 return yield* keybindings.upsertKeybindingRule({
582 key: "mod+shift+r",
583 command: "script.run-tests.run",
584 });
585 }).pipe(toDetailResult);
586 assertFailure(result, "failed to write keybindings config");
587
588 yield* fs.chmod(dirname(keybindingsConfigPath), 0o700);
589
590 const persisted = yield* readKeybindingsConfig(keybindingsConfigPath);
591 const persistedView = persisted.map(({ key, command }) => ({ key, command }));
592 assert.deepEqual(persistedView, [{ key: "mod+j", command: "terminal.toggle" }]);
593 }).pipe(Effect.provide(layerKeybindings())),
594 );
595
596 it.effect("caches loaded resolved config across repeated reads", () =>
597 Effect.gen(function* () {
598 const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig;
599 yield* writeKeybindingsConfig(keybindingsConfigPath, [
600 { key: "mod+j", command: "terminal.toggle" },
601 ]);
602
603 const [first, second] = yield* Effect.gen(function* () {
604 const keybindings = yield* Keybindings.Keybindings;
605 const firstLoad = (yield* keybindings.loadConfigState).keybindings;
606 const secondLoad = (yield* keybindings.loadConfigState).keybindings;
607 return [firstLoad, secondLoad] as const;
608 });
609
610 assert.deepEqual(first, second);
611 assert.isTrue(second.some((entry) => entry.command === "terminal.toggle"));
612 }).pipe(Effect.provide(layerKeybindings())),
613 );
614
615 it.effect("retries a failed config read instead of keeping the failure", () =>
616 Effect.gen(function* () {
617 const fileSystem = yield* FileSystem.FileSystem;
618 const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig;
619 // A directory where the file should be makes the read itself fail.
620 yield* fileSystem.makeDirectory(keybindingsConfigPath, { recursive: true });
621
622 const keybindings = yield* Keybindings.Keybindings;
623 const failed = yield* toDetailResult(keybindings.loadConfigState);
624 assertFailure(failed, "failed to read keybindings config");
625
626 yield* fileSystem.remove(keybindingsConfigPath, { recursive: true });
627 yield* writeKeybindingsConfig(keybindingsConfigPath, [
628 { key: "mod+j", command: "terminal.toggle" },
629 ]);
630
631 const configState = yield* keybindings.loadConfigState;
632 assert.deepEqual(configState.issues, []);
633 assert.isTrue(configState.keybindings.some((entry) => entry.command === "terminal.toggle"));
634 }).pipe(Effect.provide(layerKeybindings())),
635 );
636
637 it.effect("updates cached resolved config after upsert", () =>
638 Effect.gen(function* () {
639 const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig;
640 yield* writeKeybindingsConfig(keybindingsConfigPath, [
641 { key: "mod+j", command: "terminal.toggle" },
642 ]);
643
644 const loadedAfterUpsert = yield* Effect.gen(function* () {
645 const keybindings = yield* Keybindings.Keybindings;
646 yield* keybindings.loadConfigState;
647 yield* keybindings.upsertKeybindingRule({
648 key: "mod+shift+r",
649 command: "script.run-tests.run",
650 });
651 return (yield* keybindings.loadConfigState).keybindings;
652 });
653
654 assert.isTrue(loadedAfterUpsert.some((entry) => entry.command === "script.run-tests.run"));
655 assert.isTrue(loadedAfterUpsert.some((entry) => entry.command === "terminal.toggle"));
656 }).pipe(Effect.provide(layerKeybindings())),
657 );
658
659 it.effect("serializes concurrent upserts to avoid lost updates", () =>
660 Effect.gen(function* () {
661 const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig;
662 yield* writeKeybindingsConfig(keybindingsConfigPath, []);
663
664 const commands = Array.from(
665 { length: 20 },
666 (_, index): KeybindingCommand => `script.concurrent-${index}.run`,
667 );
668 yield* Effect.gen(function* () {
669 const keybindings = yield* Keybindings.Keybindings;
670 yield* Effect.forEach(
671 commands,
672 (command, index) =>
673 keybindings.upsertKeybindingRule({
674 key: `mod+${String.fromCharCode(97 + index)}`,
675 command,
676 }),
677 { concurrency: "unbounded", discard: true },
678 );
679 });
680
681 const persisted = yield* readKeybindingsConfig(keybindingsConfigPath);
682 const persistedCommands = new Set(persisted.map((entry) => entry.command));
683 for (const command of commands) {
684 assert.isTrue(persistedCommands.has(command), `expected persisted command ${command}`);
685 }
686 }).pipe(Effect.provide(layerKeybindings())),
687 );
688});