| author | |
| committer | |
| log | c6d1f92ba9f2a820cdbee71ff6b9499d1886bba6 |
| tree | 468b12b9362d4b8fe70f51dd3382e8f7377d7664 |
| parent | 07df7fb73bf98367f9eedc3796c0b4a0afb3c919 |
3 files changed, 85 insertions(+), 32 deletions(-)
.plans/16c-pr89-remediation-checklist.md+4-4| ... | ... | @@ -313,8 +313,8 @@ Counts: active `51` (`valid=33`, `partially-valid=18`), closed-invalid `6` |
| 313 | 313 | - Threads: PRRT_kwDORLtfbc5w1GPo |
| 314 | 314 | - Audit note: Chunk-by-chunk UTF-8 decode can still corrupt split multibyte characters. |
| 315 | 315 | |
| 316 | - [ ] `C039` The `+` key can be parsed (via trailing `+` handling) but cannot be encoded because `shortcut.key.includes("+")` returns true for the literal `+` key. Consider checking `shortcut.key === "+"` separately and encoding it as `"space"` style (e.g., a special token), or adjusting the condition to allow the single `+` character. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> | |
| 317 | - Status: `TODO` | |
| 316 | - [x] `C039` The `+` key can be parsed (via trailing `+` handling) but cannot be encoded because `shortcut.key.includes("+")` returns true for the literal `+` key. Consider checking `shortcut.key === "+"` separately and encoding it as `"space"` style (e.g., a special token), or adjusting the condition to allow the single `+` character. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> | |
| 317 | - Status: `DONE` | |
| 318 | 318 | - Verdict: `partially-valid` |
| 319 | 319 | - Severity: `Low` |
| 320 | 320 | - Area: `Edge-case parsing/platform behavior` |
| ... | ... | @@ -322,8 +322,8 @@ Counts: active `51` (`valid=33`, `partially-valid=18`), closed-invalid `6` |
| 322 | 322 | - Threads: PRRT_kwDORLtfbc5wxvIB |
| 323 | 323 | - Audit note: Parser/encoder mismatch remains, but encoder path currently low-use. |
| 324 | 324 | |
| 325 | - [ ] `C040` `upsertKeybindingRule` has a race condition: concurrent calls read the same file state, then the last write overwrites earlier changes. Consider wrapping the read-modify-write sequence with `Effect.Semaphore` to serialize access. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> | |
| 326 | - Status: `TODO` | |
| 325 | - [x] `C040` `upsertKeybindingRule` has a race condition: concurrent calls read the same file state, then the last write overwrites earlier changes. Consider wrapping the read-modify-write sequence with `Effect.Semaphore` to serialize access. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary> | |
| 326 | - Status: `DONE` | |
| 327 | 327 | - Verdict: `valid` |
| 328 | 328 | - Severity: `Low` |
| 329 | 329 | - Area: `Edge-case parsing/platform behavior` |
apps/server/src/keybindings.test.ts+52-4| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { KeybindingRule, KeybindingsConfig } from "@t3tools/contracts"; | |
| 1 | import { KeybindingCommand, KeybindingRule, KeybindingsConfig } from "@t3tools/contracts"; | |
| 2 | 2 | import { NodeServices } from "@effect/platform-node"; |
| 3 | 3 | import { assert, it } from "@effect/vitest"; |
| 4 | 4 | import { assertFailure } from "@effect/vitest/utils"; |
| ... | ... | @@ -9,6 +9,7 @@ import { |
| 9 | 9 | Keybindings, |
| 10 | 10 | KeybindingsConfigError, |
| 11 | 11 | KeybindingsLive, |
| 12 | ResolvedKeybindingFromConfig, | |
| 12 | 13 | compileResolvedKeybindingRule, |
| 13 | 14 | parseKeybindingShortcut, |
| 14 | 15 | } from "./keybindings"; |
| ... | ... | @@ -39,9 +40,7 @@ const toDetailResult = <A, R>(effect: Effect.Effect<A, KeybindingsConfigError, R |
| 39 | 40 | const writeKeybindingsConfig = (configPath: string, rules: readonly KeybindingRule[]) => |
| 40 | 41 | Effect.gen(function* () { |
| 41 | 42 | const fileSystem = yield* FileSystem.FileSystem; |
| 42 | const encoded = yield* Schema.encodeEffect(KeybindingsConfigJson)( | |
| 43 | rules, | |
| 44 | ); | |
| 43 | const encoded = yield* Schema.encodeEffect(KeybindingsConfigJson)(rules); | |
| 45 | 44 | yield* fileSystem.writeFileString(configPath, encoded); |
| 46 | 45 | }); |
| 47 | 46 | |
| ... | ... | @@ -104,6 +103,25 @@ it.layer(NodeServices.layer)("keybindings", (it) => { |
| 104 | 103 | }), |
| 105 | 104 | ); |
| 106 | 105 | |
| 106 | it.effect("encodes resolved plus-key shortcuts", () => | |
| 107 | Effect.gen(function* () { | |
| 108 | const encoded = yield* Schema.encodeEffect(ResolvedKeybindingFromConfig)({ | |
| 109 | command: "terminal.toggle", | |
| 110 | shortcut: { | |
| 111 | key: "+", | |
| 112 | metaKey: false, | |
| 113 | ctrlKey: false, | |
| 114 | shiftKey: false, | |
| 115 | altKey: false, | |
| 116 | modKey: true, | |
| 117 | }, | |
| 118 | }); | |
| 119 | ||
| 120 | assert.equal(encoded.key, "mod++"); | |
| 121 | assert.equal(encoded.command, "terminal.toggle"); | |
| 122 | }), | |
| 123 | ); | |
| 124 | ||
| 107 | 125 | it.effect("rejects invalid rules", () => |
| 108 | 126 | Effect.sync(() => { |
| 109 | 127 | assert.isNull( |
| ... | ... | @@ -299,4 +317,34 @@ it.layer(NodeServices.layer)("keybindings", (it) => { |
| 299 | 317 | assert.isTrue(loadedAfterUpsert.some((entry) => entry.command === "terminal.toggle")); |
| 300 | 318 | }).pipe(Effect.provide(makeKeybindingsLayer())), |
| 301 | 319 | ); |
| 320 | ||
| 321 | it.effect("serializes concurrent upserts to avoid lost updates", () => | |
| 322 | Effect.gen(function* () { | |
| 323 | const { keybindingsConfigPath } = yield* ServerConfig; | |
| 324 | yield* writeKeybindingsConfig(keybindingsConfigPath, []); | |
| 325 | ||
| 326 | const commands = Array.from( | |
| 327 | { length: 20 }, | |
| 328 | (_, index): KeybindingCommand => `script.concurrent-${index}.run`, | |
| 329 | ); | |
| 330 | yield* Effect.gen(function* () { | |
| 331 | const keybindings = yield* Keybindings; | |
| 332 | yield* Effect.all( | |
| 333 | commands.map((command, index) => | |
| 334 | keybindings.upsertKeybindingRule({ | |
| 335 | key: `mod+${String.fromCharCode(97 + index)}`, | |
| 336 | command, | |
| 337 | }), | |
| 338 | ), | |
| 339 | { concurrency: "unbounded", discard: true }, | |
| 340 | ); | |
| 341 | }); | |
| 342 | ||
| 343 | const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); | |
| 344 | const persistedCommands = new Set(persisted.map((entry) => entry.command)); | |
| 345 | for (const command of commands) { | |
| 346 | assert.isTrue(persistedCommands.has(command), `expected persisted command ${command}`); | |
| 347 | } | |
| 348 | }).pipe(Effect.provide(makeKeybindingsLayer())), | |
| 349 | ); | |
| 302 | 350 | }); |
apps/server/src/keybindings.ts+29-24| ... | ... | @@ -32,6 +32,7 @@ import { |
| 32 | 32 | SchemaTransformation, |
| 33 | 33 | ServiceMap, |
| 34 | 34 | } from "effect"; |
| 35 | import * as Semaphore from "effect/Semaphore"; | |
| 35 | 36 | import { ServerConfig } from "./config"; |
| 36 | 37 | |
| 37 | 38 | export class KeybindingsConfigError extends Schema.TaggedErrorClass<KeybindingsConfigError>()( |
| ... | ... | @@ -354,7 +355,8 @@ function encodeShortcut(shortcut: KeybindingShortcut): string | null { |
| 354 | 355 | if (shortcut.ctrlKey) modifiers.push("ctrl"); |
| 355 | 356 | if (shortcut.altKey) modifiers.push("alt"); |
| 356 | 357 | if (shortcut.shiftKey) modifiers.push("shift"); |
| 357 | if (!shortcut.key || shortcut.key.includes("+")) return null; | |
| 358 | if (!shortcut.key) return null; | |
| 359 | if (shortcut.key !== "+" && shortcut.key.includes("+")) return null; | |
| 358 | 360 | const key = shortcut.key === " " ? "space" : shortcut.key; |
| 359 | 361 | return [...modifiers, key].join("+"); |
| 360 | 362 | } |
| ... | ... | @@ -433,6 +435,7 @@ const makeKeybindings = Effect.gen(function* () { |
| 433 | 435 | const fs = yield* FileSystem.FileSystem; |
| 434 | 436 | const path = yield* Path.Path; |
| 435 | 437 | const cachedResolvedConfig = yield* Ref.make<ResolvedKeybindingsConfig | null>(null); |
| 438 | const upsertSemaphore = yield* Semaphore.make(1); | |
| 436 | 439 | |
| 437 | 440 | const loadCustomKeybindingsConfig = Effect.fn(function* (): Effect.fn.Return< |
| 438 | 441 | readonly KeybindingRule[], |
| ... | ... | @@ -509,29 +512,31 @@ const makeKeybindings = Effect.gen(function* () { |
| 509 | 512 | return { |
| 510 | 513 | loadResolvedKeybindingsConfig: loadResolvedFromCacheOrDisk, |
| 511 | 514 | upsertKeybindingRule: (rule) => |
| 512 | Effect.gen(function* () { | |
| 513 | const customConfig = yield* loadCustomKeybindingsConfig(); | |
| 514 | const nextConfig = [ | |
| 515 | ...customConfig.filter((entry) => entry.command !== rule.command), | |
| 516 | rule, | |
| 517 | ]; | |
| 518 | const cappedConfig = | |
| 519 | nextConfig.length > MAX_KEYBINDINGS_COUNT | |
| 520 | ? nextConfig.slice(-MAX_KEYBINDINGS_COUNT) | |
| 521 | : nextConfig; | |
| 522 | if (nextConfig.length > MAX_KEYBINDINGS_COUNT) { | |
| 523 | yield* Effect.logWarning("truncating keybindings config to max entries", { | |
| 524 | path: keybindingsConfigPath, | |
| 525 | maxEntries: MAX_KEYBINDINGS_COUNT, | |
| 526 | }); | |
| 527 | } | |
| 528 | yield* writeConfigAtomically(cappedConfig); | |
| 529 | const nextResolved = mergeWithDefaultKeybindings( | |
| 530 | compileResolvedKeybindingsConfig(cappedConfig), | |
| 531 | ); | |
| 532 | yield* Ref.set(cachedResolvedConfig, nextResolved); | |
| 533 | return nextResolved; | |
| 534 | }), | |
| 515 | upsertSemaphore.withPermits(1)( | |
| 516 | Effect.gen(function* () { | |
| 517 | const customConfig = yield* loadCustomKeybindingsConfig(); | |
| 518 | const nextConfig = [ | |
| 519 | ...customConfig.filter((entry) => entry.command !== rule.command), | |
| 520 | rule, | |
| 521 | ]; | |
| 522 | const cappedConfig = | |
| 523 | nextConfig.length > MAX_KEYBINDINGS_COUNT | |
| 524 | ? nextConfig.slice(-MAX_KEYBINDINGS_COUNT) | |
| 525 | : nextConfig; | |
| 526 | if (nextConfig.length > MAX_KEYBINDINGS_COUNT) { | |
| 527 | yield* Effect.logWarning("truncating keybindings config to max entries", { | |
| 528 | path: keybindingsConfigPath, | |
| 529 | maxEntries: MAX_KEYBINDINGS_COUNT, | |
| 530 | }); | |
| 531 | } | |
| 532 | yield* writeConfigAtomically(cappedConfig); | |
| 533 | const nextResolved = mergeWithDefaultKeybindings( | |
| 534 | compileResolvedKeybindingsConfig(cappedConfig), | |
| 535 | ); | |
| 536 | yield* Ref.set(cachedResolvedConfig, nextResolved); | |
| 537 | return nextResolved; | |
| 538 | }), | |
| 539 | ), | |
| 535 | 540 | } satisfies KeybindingsShape; |
| 536 | 541 | }); |
| 537 | 542 |