| author | |
| committer | |
| log | 667c8c6166592f614ab8316756fc2f817e8b761e |
| tree | 8f6ce22e2d1554a8386560d54ae3e17ff3e607b0 |
| parent | 8efbb31725f8500e1aff76655b04a3fab7b064fe |
- Sync and persist missing default keybindings on server startup without overriding existing command mappings
- Return `keybindingsConfigPath` from `server.getConfig` and add Settings UI action to open `keybindings.json`
- Update server/keybindings tests to cover bootstrapping, conflict handling, and config responses6 files changed, 355 insertions(+), 53 deletions(-)
apps/server/src/keybindings.test.ts+86-1| ... | @@ -2,10 +2,11 @@ import { KeybindingCommand, KeybindingRule, KeybindingsConfig } from "@t3tools/c | ... | @@ -2,10 +2,11 @@ import { KeybindingCommand, KeybindingRule, KeybindingsConfig } from "@t3tools/c |
| 2 | import { NodeServices } from "@effect/platform-node"; | 2 | import { NodeServices } from "@effect/platform-node"; |
| 3 | import { assert, it } from "@effect/vitest"; | 3 | import { assert, it } from "@effect/vitest"; |
| 4 | import { assertFailure } from "@effect/vitest/utils"; | 4 | import { assertFailure } from "@effect/vitest/utils"; |
| 5 | import { Effect, FileSystem, Layer, Path, Schema } from "effect"; | 5 | import { Effect, FileSystem, Layer, Logger, Path, Schema } from "effect"; |
| 6 | import { ServerConfig, type ServerConfigShape } from "./config"; | 6 | import { ServerConfig, type ServerConfigShape } from "./config"; |
| 7 | 7 | ||
| 8 | import { | 8 | import { |
| 9 | DEFAULT_KEYBINDINGS, | ||
| 9 | Keybindings, | 10 | Keybindings, |
| 10 | KeybindingsConfigError, | 11 | KeybindingsConfigError, |
| 11 | KeybindingsLive, | 12 | KeybindingsLive, |
| ... | @@ -149,6 +150,90 @@ it.layer(NodeServices.layer)("keybindings", (it) => { | ... | @@ -149,6 +150,90 @@ it.layer(NodeServices.layer)("keybindings", (it) => { |
| 149 | }), | 150 | }), |
| 150 | ); | 151 | ); |
| 151 | 152 | ||
| 153 | it.effect("bootstraps default keybindings when config file is missing", () => | ||
| 154 | Effect.gen(function* () { | ||
| 155 | const fs = yield* FileSystem.FileSystem; | ||
| 156 | const { keybindingsConfigPath } = yield* ServerConfig; | ||
| 157 | assert.isFalse(yield* fs.exists(keybindingsConfigPath)); | ||
| 158 | |||
| 159 | yield* Effect.gen(function* () { | ||
| 160 | const keybindings = yield* Keybindings; | ||
| 161 | yield* keybindings.syncDefaultKeybindingsOnStartup; | ||
| 162 | }); | ||
| 163 | |||
| 164 | const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); | ||
| 165 | assert.deepEqual(persisted, DEFAULT_KEYBINDINGS); | ||
| 166 | }).pipe(Effect.provide(makeKeybindingsLayer())), | ||
| 167 | ); | ||
| 168 | |||
| 169 | it.effect( | ||
| 170 | "upserts missing default keybindings on startup without overriding existing command rules", | ||
| 171 | () => | ||
| 172 | Effect.gen(function* () { | ||
| 173 | const { keybindingsConfigPath } = yield* ServerConfig; | ||
| 174 | yield* writeKeybindingsConfig(keybindingsConfigPath, [ | ||
| 175 | { key: "mod+shift+t", command: "terminal.toggle" }, | ||
| 176 | { key: "mod+shift+r", command: "script.run-tests.run" }, | ||
| 177 | ]); | ||
| 178 | |||
| 179 | yield* Effect.gen(function* () { | ||
| 180 | const keybindings = yield* Keybindings; | ||
| 181 | yield* keybindings.syncDefaultKeybindingsOnStartup; | ||
| 182 | }); | ||
| 183 | |||
| 184 | const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); | ||
| 185 | const byCommand = new Map(persisted.map((entry) => [entry.command, entry])); | ||
| 186 | |||
| 187 | const persistedToggle = byCommand.get("terminal.toggle"); | ||
| 188 | assert.isNotNull(persistedToggle); | ||
| 189 | assert.equal(persistedToggle?.key, "mod+shift+t"); | ||
| 190 | assert.isFalse( | ||
| 191 | persisted.some((entry) => entry.command === "terminal.toggle" && entry.key === "mod+j"), | ||
| 192 | ); | ||
| 193 | |||
| 194 | for (const defaultRule of DEFAULT_KEYBINDINGS) { | ||
| 195 | assert.isTrue(byCommand.has(defaultRule.command), `expected ${defaultRule.command}`); | ||
| 196 | } | ||
| 197 | assert.isTrue(byCommand.has("script.run-tests.run")); | ||
| 198 | }).pipe(Effect.provide(makeKeybindingsLayer())), | ||
| 199 | ); | ||
| 200 | |||
| 201 | it.effect("skips conflicting default keybindings on startup and logs a detailed warning", () => { | ||
| 202 | const messages: string[] = []; | ||
| 203 | const logger = Logger.make(({ message }) => { | ||
| 204 | messages.push(String(message)); | ||
| 205 | }); | ||
| 206 | |||
| 207 | return Effect.gen(function* () { | ||
| 208 | const { keybindingsConfigPath } = yield* ServerConfig; | ||
| 209 | yield* writeKeybindingsConfig(keybindingsConfigPath, [ | ||
| 210 | { key: "mod+j", command: "script.custom-action.run" }, | ||
| 211 | ]); | ||
| 212 | |||
| 213 | yield* Effect.gen(function* () { | ||
| 214 | const keybindings = yield* Keybindings; | ||
| 215 | yield* keybindings.syncDefaultKeybindingsOnStartup; | ||
| 216 | }); | ||
| 217 | |||
| 218 | const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); | ||
| 219 | assert.isFalse(persisted.some((entry) => entry.command === "terminal.toggle")); | ||
| 220 | assert.isTrue(persisted.some((entry) => entry.command === "script.custom-action.run")); | ||
| 221 | |||
| 222 | assert.isTrue( | ||
| 223 | messages.some((message) => | ||
| 224 | message.includes("skipping default keybinding due to shortcut conflict"), | ||
| 225 | ), | ||
| 226 | ); | ||
| 227 | }).pipe( | ||
| 228 | Effect.provide( | ||
| 229 | Layer.mergeAll( | ||
| 230 | makeKeybindingsLayer(), | ||
| 231 | Logger.layer([logger], { mergeWithExisting: false }), | ||
| 232 | ), | ||
| 233 | ), | ||
| 234 | ); | ||
| 235 | }); | ||
| 236 | |||
| 152 | it.effect("upserts custom keybindings to configured path", () => | 237 | it.effect("upserts custom keybindings to configured path", () => |
| 153 | Effect.gen(function* () { | 238 | Effect.gen(function* () { |
| 154 | const { keybindingsConfigPath } = yield* ServerConfig; | 239 | const { keybindingsConfigPath } = yield* ServerConfig; |
apps/server/src/keybindings.ts+152-15| ... | @@ -19,6 +19,7 @@ import { | ... | @@ -19,6 +19,7 @@ import { |
| 19 | import { Mutable } from "effect/Types"; | 19 | import { Mutable } from "effect/Types"; |
| 20 | import { | 20 | import { |
| 21 | Array, | 21 | Array, |
| 22 | Cache, | ||
| 22 | Cause, | 23 | Cause, |
| 23 | Effect, | 24 | Effect, |
| 24 | FileSystem, | 25 | FileSystem, |
| ... | @@ -26,8 +27,8 @@ import { | ... | @@ -26,8 +27,8 @@ import { |
| 26 | Layer, | 27 | Layer, |
| 27 | Option, | 28 | Option, |
| 28 | Predicate, | 29 | Predicate, |
| 29 | Ref, | ||
| 30 | Schema, | 30 | Schema, |
| 31 | SchemaGetter, | ||
| 31 | SchemaIssue, | 32 | SchemaIssue, |
| 32 | SchemaTransformation, | 33 | SchemaTransformation, |
| 33 | ServiceMap, | 34 | ServiceMap, |
| ... | @@ -348,6 +349,29 @@ export const ResolvedKeybindingsFromConfig = Schema.Array(ResolvedKeybindingFrom | ... | @@ -348,6 +349,29 @@ export const ResolvedKeybindingsFromConfig = Schema.Array(ResolvedKeybindingFrom |
| 348 | Schema.isMaxLength(MAX_KEYBINDINGS_COUNT), | 349 | Schema.isMaxLength(MAX_KEYBINDINGS_COUNT), |
| 349 | ); | 350 | ); |
| 350 | 351 | ||
| 352 | function isSameKeybindingRule(left: KeybindingRule, right: KeybindingRule): boolean { | ||
| 353 | return ( | ||
| 354 | left.command === right.command && | ||
| 355 | left.key === right.key && | ||
| 356 | (left.when ?? undefined) === (right.when ?? undefined) | ||
| 357 | ); | ||
| 358 | } | ||
| 359 | |||
| 360 | function keybindingShortcutContext(rule: KeybindingRule): string | null { | ||
| 361 | const parsed = parseKeybindingShortcut(rule.key); | ||
| 362 | if (!parsed) return null; | ||
| 363 | const encoded = encodeShortcut(parsed); | ||
| 364 | if (!encoded) return null; | ||
| 365 | return `${encoded}\u0000${rule.when ?? ""}`; | ||
| 366 | } | ||
| 367 | |||
| 368 | function hasSameShortcutContext(left: KeybindingRule, right: KeybindingRule): boolean { | ||
| 369 | const leftContext = keybindingShortcutContext(left); | ||
| 370 | const rightContext = keybindingShortcutContext(right); | ||
| 371 | if (!leftContext || !rightContext) return false; | ||
| 372 | return leftContext === rightContext; | ||
| 373 | } | ||
| 374 | |||
| 351 | function encodeShortcut(shortcut: KeybindingShortcut): string | null { | 375 | function encodeShortcut(shortcut: KeybindingShortcut): string | null { |
| 352 | const modifiers: string[] = []; | 376 | const modifiers: string[] = []; |
| 353 | if (shortcut.modKey) modifiers.push("mod"); | 377 | if (shortcut.modKey) modifiers.push("mod"); |
| ... | @@ -378,6 +402,15 @@ const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KE | ... | @@ -378,6 +402,15 @@ const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KE |
| 378 | 402 | ||
| 379 | const RawKeybindingsEntries = Schema.fromJsonString(Schema.Array(Schema.Unknown)); | 403 | const RawKeybindingsEntries = Schema.fromJsonString(Schema.Array(Schema.Unknown)); |
| 380 | const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig); | 404 | const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig); |
| 405 | const PrettyJsonString = SchemaGetter.parseJson<string>().compose( | ||
| 406 | SchemaGetter.stringifyJson({ space: 2 }), | ||
| 407 | ); | ||
| 408 | const KeybindingsConfigPrettyJson = KeybindingsConfigJson.pipe( | ||
| 409 | Schema.encode({ | ||
| 410 | decode: PrettyJsonString, | ||
| 411 | encode: PrettyJsonString, | ||
| 412 | }), | ||
| 413 | ); | ||
| 381 | 414 | ||
| 382 | function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig { | 415 | function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig { |
| 383 | if (custom.length === 0) { | 416 | if (custom.length === 0) { |
| ... | @@ -402,6 +435,12 @@ function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): Resolve | ... | @@ -402,6 +435,12 @@ function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): Resolve |
| 402 | * KeybindingsShape - Service API for keybinding configuration operations. | 435 | * KeybindingsShape - Service API for keybinding configuration operations. |
| 403 | */ | 436 | */ |
| 404 | export interface KeybindingsShape { | 437 | export interface KeybindingsShape { |
| 438 | /** | ||
| 439 | * Ensure the on-disk keybindings file exists and includes all default | ||
| 440 | * commands so newly-added defaults are backfilled on startup. | ||
| 441 | */ | ||
| 442 | readonly syncDefaultKeybindingsOnStartup: Effect.Effect<void, KeybindingsConfigError>; | ||
| 443 | |||
| 405 | /** | 444 | /** |
| 406 | * Load and resolve keybindings from disk merged with defaults. | 445 | * Load and resolve keybindings from disk merged with defaults. |
| 407 | * | 446 | * |
| ... | @@ -434,13 +473,28 @@ const makeKeybindings = Effect.gen(function* () { | ... | @@ -434,13 +473,28 @@ const makeKeybindings = Effect.gen(function* () { |
| 434 | const { keybindingsConfigPath } = yield* ServerConfig; | 473 | const { keybindingsConfigPath } = yield* ServerConfig; |
| 435 | const fs = yield* FileSystem.FileSystem; | 474 | const fs = yield* FileSystem.FileSystem; |
| 436 | const path = yield* Path.Path; | 475 | const path = yield* Path.Path; |
| 437 | const cachedResolvedConfig = yield* Ref.make<ResolvedKeybindingsConfig | null>(null); | ||
| 438 | const upsertSemaphore = yield* Semaphore.make(1); | 476 | const upsertSemaphore = yield* Semaphore.make(1); |
| 477 | const resolvedConfigCacheKey = "resolved" as const; | ||
| 478 | |||
| 479 | const readConfigExists = fs.exists(keybindingsConfigPath).pipe( | ||
| 480 | Effect.mapError( | ||
| 481 | (cause) => | ||
| 482 | new KeybindingsConfigError({ | ||
| 483 | configPath: keybindingsConfigPath, | ||
| 484 | detail: "failed to access keybindings config", | ||
| 485 | cause, | ||
| 486 | }), | ||
| 487 | ), | ||
| 488 | ); | ||
| 439 | 489 | ||
| 440 | const loadCustomKeybindingsConfig = Effect.fn(function* (): Effect.fn.Return< | 490 | const loadCustomKeybindingsConfig = Effect.fn(function* (): Effect.fn.Return< |
| 441 | readonly KeybindingRule[], | 491 | readonly KeybindingRule[], |
| 442 | KeybindingsConfigError | 492 | KeybindingsConfigError |
| 443 | > { | 493 | > { |
| 494 | if (!(yield* readConfigExists)) { | ||
| 495 | return []; | ||
| 496 | } | ||
| 497 | |||
| 444 | const rawConfig = yield* fs.readFileString(keybindingsConfigPath).pipe( | 498 | const rawConfig = yield* fs.readFileString(keybindingsConfigPath).pipe( |
| 445 | Effect.flatMap(Schema.decodeEffect(RawKeybindingsEntries)), | 499 | Effect.flatMap(Schema.decodeEffect(RawKeybindingsEntries)), |
| 446 | Effect.mapError( | 500 | Effect.mapError( |
| ... | @@ -481,9 +535,8 @@ const makeKeybindings = Effect.gen(function* () { | ... | @@ -481,9 +535,8 @@ const makeKeybindings = Effect.gen(function* () { |
| 481 | const writeConfigAtomically = (rules: readonly KeybindingRule[]) => { | 535 | const writeConfigAtomically = (rules: readonly KeybindingRule[]) => { |
| 482 | const tempPath = `${keybindingsConfigPath}.${process.pid}.${Date.now()}.tmp`; | 536 | const tempPath = `${keybindingsConfigPath}.${process.pid}.${Date.now()}.tmp`; |
| 483 | 537 | ||
| 484 | return Schema.encodeEffect(KeybindingsConfigJson)( | 538 | return Schema.encodeEffect(KeybindingsConfigPrettyJson)(rules).pipe( |
| 485 | rules, | 539 | Effect.map((encoded) => `${encoded}\n`), |
| 486 | ).pipe( | ||
| 487 | Effect.tap(() => fs.makeDirectory(path.dirname(keybindingsConfigPath), { recursive: true })), | 540 | Effect.tap(() => fs.makeDirectory(path.dirname(keybindingsConfigPath), { recursive: true })), |
| 488 | Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)), | 541 | Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)), |
| 489 | Effect.flatMap(() => fs.rename(tempPath, keybindingsConfigPath)), | 542 | Effect.flatMap(() => fs.rename(tempPath, keybindingsConfigPath)), |
| ... | @@ -498,18 +551,102 @@ const makeKeybindings = Effect.gen(function* () { | ... | @@ -498,18 +551,102 @@ const makeKeybindings = Effect.gen(function* () { |
| 498 | ); | 551 | ); |
| 499 | }; | 552 | }; |
| 500 | 553 | ||
| 501 | const loadResolvedFromCacheOrDisk = Effect.gen(function* () { | 554 | const loadResolvedFromDisk = loadCustomKeybindingsConfig().pipe( |
| 502 | const cached = yield* Ref.get(cachedResolvedConfig); | 555 | Effect.map(compileResolvedKeybindingsConfig), |
| 503 | if (cached) return cached; | 556 | Effect.map(mergeWithDefaultKeybindings), |
| 504 | const resolved = yield* loadCustomKeybindingsConfig().pipe( | 557 | ); |
| 505 | Effect.map(compileResolvedKeybindingsConfig), | 558 | |
| 506 | Effect.map(mergeWithDefaultKeybindings), | 559 | const resolvedConfigCache = yield* Cache.make< |
| 507 | ); | 560 | typeof resolvedConfigCacheKey, |
| 508 | yield* Ref.set(cachedResolvedConfig, resolved); | 561 | ResolvedKeybindingsConfig, |
| 509 | return resolved; | 562 | KeybindingsConfigError |
| 563 | >({ | ||
| 564 | capacity: 1, | ||
| 565 | lookup: () => loadResolvedFromDisk, | ||
| 510 | }); | 566 | }); |
| 511 | 567 | ||
| 568 | const syncDefaultKeybindingsOnStartup = upsertSemaphore.withPermits(1)( | ||
| 569 | Effect.gen(function* () { | ||
| 570 | const configExists = yield* readConfigExists; | ||
| 571 | if (!configExists) { | ||
| 572 | yield* writeConfigAtomically(DEFAULT_KEYBINDINGS); | ||
| 573 | yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); | ||
| 574 | return; | ||
| 575 | } | ||
| 576 | |||
| 577 | const customConfig = yield* loadCustomKeybindingsConfig(); | ||
| 578 | const existingCommands = new Set(customConfig.map((entry) => entry.command)); | ||
| 579 | const missingDefaults: KeybindingRule[] = []; | ||
| 580 | const shortcutConflictWarnings: Array<{ | ||
| 581 | defaultCommand: KeybindingRule["command"]; | ||
| 582 | conflictingCommand: KeybindingRule["command"]; | ||
| 583 | key: string; | ||
| 584 | when: string | null; | ||
| 585 | }> = []; | ||
| 586 | for (const defaultRule of DEFAULT_KEYBINDINGS) { | ||
| 587 | if (existingCommands.has(defaultRule.command)) { | ||
| 588 | continue; | ||
| 589 | } | ||
| 590 | const conflictingEntry = customConfig.find((entry) => | ||
| 591 | hasSameShortcutContext(entry, defaultRule), | ||
| 592 | ); | ||
| 593 | if (conflictingEntry) { | ||
| 594 | shortcutConflictWarnings.push({ | ||
| 595 | defaultCommand: defaultRule.command, | ||
| 596 | conflictingCommand: conflictingEntry.command, | ||
| 597 | key: defaultRule.key, | ||
| 598 | when: defaultRule.when ?? null, | ||
| 599 | }); | ||
| 600 | continue; | ||
| 601 | } | ||
| 602 | missingDefaults.push(defaultRule); | ||
| 603 | } | ||
| 604 | for (const conflict of shortcutConflictWarnings) { | ||
| 605 | yield* Effect.logWarning("skipping default keybinding due to shortcut conflict", { | ||
| 606 | path: keybindingsConfigPath, | ||
| 607 | defaultCommand: conflict.defaultCommand, | ||
| 608 | conflictingCommand: conflict.conflictingCommand, | ||
| 609 | key: conflict.key, | ||
| 610 | when: conflict.when, | ||
| 611 | reason: "shortcut context already used by existing rule", | ||
| 612 | }); | ||
| 613 | } | ||
| 614 | if (missingDefaults.length === 0) { | ||
| 615 | yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); | ||
| 616 | return; | ||
| 617 | } | ||
| 618 | |||
| 619 | const matchingDefaults = DEFAULT_KEYBINDINGS.filter((defaultRule) => | ||
| 620 | customConfig.some((entry) => isSameKeybindingRule(entry, defaultRule)), | ||
| 621 | ).map((rule) => rule.command); | ||
| 622 | if (matchingDefaults.length > 0) { | ||
| 623 | yield* Effect.logWarning("default keybinding rule already defined in user config", { | ||
| 624 | path: keybindingsConfigPath, | ||
| 625 | commands: matchingDefaults, | ||
| 626 | }); | ||
| 627 | } | ||
| 628 | |||
| 629 | const nextConfig = [...customConfig, ...missingDefaults]; | ||
| 630 | const cappedConfig = | ||
| 631 | nextConfig.length > MAX_KEYBINDINGS_COUNT | ||
| 632 | ? nextConfig.slice(-MAX_KEYBINDINGS_COUNT) | ||
| 633 | : nextConfig; | ||
| 634 | if (nextConfig.length > MAX_KEYBINDINGS_COUNT) { | ||
| 635 | yield* Effect.logWarning("truncating keybindings config to max entries", { | ||
| 636 | path: keybindingsConfigPath, | ||
| 637 | maxEntries: MAX_KEYBINDINGS_COUNT, | ||
| 638 | }); | ||
| 639 | } | ||
| 640 | |||
| 641 | yield* writeConfigAtomically(cappedConfig); | ||
| 642 | yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); | ||
| 643 | }), | ||
| 644 | ); | ||
| 645 | |||
| 646 | const loadResolvedFromCacheOrDisk = Cache.get(resolvedConfigCache, resolvedConfigCacheKey); | ||
| 647 | |||
| 512 | return { | 648 | return { |
| 649 | syncDefaultKeybindingsOnStartup, | ||
| 513 | loadResolvedKeybindingsConfig: loadResolvedFromCacheOrDisk, | 650 | loadResolvedKeybindingsConfig: loadResolvedFromCacheOrDisk, |
| 514 | upsertKeybindingRule: (rule) => | 651 | upsertKeybindingRule: (rule) => |
| 515 | upsertSemaphore.withPermits(1)( | 652 | upsertSemaphore.withPermits(1)( |
| ... | @@ -533,7 +670,7 @@ const makeKeybindings = Effect.gen(function* () { | ... | @@ -533,7 +670,7 @@ const makeKeybindings = Effect.gen(function* () { |
| 533 | const nextResolved = mergeWithDefaultKeybindings( | 670 | const nextResolved = mergeWithDefaultKeybindings( |
| 534 | compileResolvedKeybindingsConfig(cappedConfig), | 671 | compileResolvedKeybindingsConfig(cappedConfig), |
| 535 | ); | 672 | ); |
| 536 | yield* Ref.set(cachedResolvedConfig, nextResolved); | 673 | yield* Cache.set(resolvedConfigCache, resolvedConfigCacheKey, nextResolved); |
| 537 | return nextResolved; | 674 | return nextResolved; |
| 538 | }), | 675 | }), |
| 539 | ), | 676 | ), |
apps/server/src/wsServer.test.ts+42-36| ... | @@ -309,18 +309,6 @@ function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsCon | ... | @@ -309,18 +309,6 @@ function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsCon |
| 309 | 309 | ||
| 310 | const DEFAULT_RESOLVED_KEYBINDINGS = compileKeybindings([...DEFAULT_KEYBINDINGS]); | 310 | const DEFAULT_RESOLVED_KEYBINDINGS = compileKeybindings([...DEFAULT_KEYBINDINGS]); |
| 311 | 311 | ||
| 312 | function mergeWithDefaultsForTest(custom: KeybindingsConfig): ResolvedKeybindingsConfig { | ||
| 313 | if (custom.length === 0) { | ||
| 314 | return DEFAULT_RESOLVED_KEYBINDINGS; | ||
| 315 | } | ||
| 316 | |||
| 317 | const overriddenCommands = new Set(custom.map((binding) => binding.command)); | ||
| 318 | const retainedDefaults = DEFAULT_KEYBINDINGS.filter( | ||
| 319 | (binding) => !overriddenCommands.has(binding.command), | ||
| 320 | ); | ||
| 321 | return compileKeybindings([...retainedDefaults, ...custom].slice(-256)); | ||
| 322 | } | ||
| 323 | |||
| 324 | describe("WebSocket Server", () => { | 312 | describe("WebSocket Server", () => { |
| 325 | let server: Http.Server | null = null; | 313 | let server: Http.Server | null = null; |
| 326 | let serverScope: Scope.Closeable | null = null; | 314 | let serverScope: Scope.Closeable | null = null; |
| ... | @@ -510,7 +498,8 @@ describe("WebSocket Server", () => { | ... | @@ -510,7 +498,8 @@ describe("WebSocket Server", () => { |
| 510 | 498 | ||
| 511 | it("responds to server.getConfig", async () => { | 499 | it("responds to server.getConfig", async () => { |
| 512 | const stateDir = makeTempDir("t3code-state-get-config-"); | 500 | const stateDir = makeTempDir("t3code-state-get-config-"); |
| 513 | fs.writeFileSync(path.join(stateDir, "keybindings.json"), "[]", "utf8"); | 501 | const keybindingsPath = path.join(stateDir, "keybindings.json"); |
| 502 | fs.writeFileSync(keybindingsPath, "[]", "utf8"); | ||
| 514 | 503 | ||
| 515 | server = await createTestServer({ cwd: "/my/workspace", stateDir }); | 504 | server = await createTestServer({ cwd: "/my/workspace", stateDir }); |
| 516 | const addr = server.address(); | 505 | const addr = server.address(); |
| ... | @@ -526,8 +515,34 @@ describe("WebSocket Server", () => { | ... | @@ -526,8 +515,34 @@ describe("WebSocket Server", () => { |
| 526 | expect(response.error).toBeUndefined(); | 515 | expect(response.error).toBeUndefined(); |
| 527 | expect(response.result).toEqual({ | 516 | expect(response.result).toEqual({ |
| 528 | cwd: "/my/workspace", | 517 | cwd: "/my/workspace", |
| 518 | keybindingsConfigPath: keybindingsPath, | ||
| 519 | keybindings: DEFAULT_RESOLVED_KEYBINDINGS, | ||
| 520 | }); | ||
| 521 | }); | ||
| 522 | |||
| 523 | it("bootstraps default keybindings file when missing", async () => { | ||
| 524 | const stateDir = makeTempDir("t3code-state-bootstrap-keybindings-"); | ||
| 525 | const keybindingsPath = path.join(stateDir, "keybindings.json"); | ||
| 526 | expect(fs.existsSync(keybindingsPath)).toBe(false); | ||
| 527 | |||
| 528 | server = await createTestServer({ cwd: "/my/workspace", stateDir }); | ||
| 529 | const addr = server.address(); | ||
| 530 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | ||
| 531 | |||
| 532 | const ws = await connectWs(port); | ||
| 533 | connections.push(ws); | ||
| 534 | await waitForMessage(ws); | ||
| 535 | |||
| 536 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); | ||
| 537 | expect(response.error).toBeUndefined(); | ||
| 538 | expect(response.result).toEqual({ | ||
| 539 | cwd: "/my/workspace", | ||
| 540 | keybindingsConfigPath: keybindingsPath, | ||
| 529 | keybindings: DEFAULT_RESOLVED_KEYBINDINGS, | 541 | keybindings: DEFAULT_RESOLVED_KEYBINDINGS, |
| 530 | }); | 542 | }); |
| 543 | |||
| 544 | const persistedConfig = JSON.parse(fs.readFileSync(keybindingsPath, "utf8")) as KeybindingsConfig; | ||
| 545 | expect(persistedConfig).toEqual(DEFAULT_KEYBINDINGS); | ||
| 531 | }); | 546 | }); |
| 532 | 547 | ||
| 533 | it("routes shell.openInEditor through the injected open service", async () => { | 548 | it("routes shell.openInEditor through the injected open service", async () => { |
| ... | @@ -558,8 +573,9 @@ describe("WebSocket Server", () => { | ... | @@ -558,8 +573,9 @@ describe("WebSocket Server", () => { |
| 558 | 573 | ||
| 559 | it("reads keybindings from the configured state directory", async () => { | 574 | it("reads keybindings from the configured state directory", async () => { |
| 560 | const stateDir = makeTempDir("t3code-state-keybindings-"); | 575 | const stateDir = makeTempDir("t3code-state-keybindings-"); |
| 576 | const keybindingsPath = path.join(stateDir, "keybindings.json"); | ||
| 561 | fs.writeFileSync( | 577 | fs.writeFileSync( |
| 562 | path.join(stateDir, "keybindings.json"), | 578 | keybindingsPath, |
| 563 | JSON.stringify([ | 579 | JSON.stringify([ |
| 564 | { key: "cmd+j", command: "terminal.toggle" }, | 580 | { key: "cmd+j", command: "terminal.toggle" }, |
| 565 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, | 581 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, |
| ... | @@ -578,13 +594,11 @@ describe("WebSocket Server", () => { | ... | @@ -578,13 +594,11 @@ describe("WebSocket Server", () => { |
| 578 | 594 | ||
| 579 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); | 595 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); |
| 580 | expect(response.error).toBeUndefined(); | 596 | expect(response.error).toBeUndefined(); |
| 597 | const persistedConfig = JSON.parse(fs.readFileSync(keybindingsPath, "utf8")) as KeybindingsConfig; | ||
| 581 | expect(response.result).toEqual({ | 598 | expect(response.result).toEqual({ |
| 582 | cwd: "/my/workspace", | 599 | cwd: "/my/workspace", |
| 583 | keybindings: mergeWithDefaultsForTest([ | 600 | keybindingsConfigPath: keybindingsPath, |
| 584 | { key: "cmd+j", command: "terminal.toggle" }, | 601 | keybindings: compileKeybindings(persistedConfig), |
| 585 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, | ||
| 586 | { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, | ||
| 587 | ]), | ||
| 588 | }); | 602 | }); |
| 589 | }); | 603 | }); |
| 590 | 604 | ||
| ... | @@ -610,30 +624,22 @@ describe("WebSocket Server", () => { | ... | @@ -610,30 +624,22 @@ describe("WebSocket Server", () => { |
| 610 | command: "script.run-tests.run", | 624 | command: "script.run-tests.run", |
| 611 | }); | 625 | }); |
| 612 | expect(upsertResponse.error).toBeUndefined(); | 626 | expect(upsertResponse.error).toBeUndefined(); |
| 627 | const persistedConfig = JSON.parse(fs.readFileSync(keybindingsPath, "utf8")) as KeybindingsConfig; | ||
| 628 | const persistedCommands = new Set(persistedConfig.map((entry) => entry.command)); | ||
| 629 | for (const defaultRule of DEFAULT_KEYBINDINGS) { | ||
| 630 | expect(persistedCommands.has(defaultRule.command)).toBe(true); | ||
| 631 | } | ||
| 632 | expect(persistedCommands.has("script.run-tests.run")).toBe(true); | ||
| 613 | expect(upsertResponse.result).toEqual({ | 633 | expect(upsertResponse.result).toEqual({ |
| 614 | keybindings: mergeWithDefaultsForTest([ | 634 | keybindings: compileKeybindings(persistedConfig), |
| 615 | { key: "mod+j", command: "terminal.toggle" }, | ||
| 616 | { key: "mod+shift+r", command: "script.run-tests.run" }, | ||
| 617 | ]), | ||
| 618 | }); | 635 | }); |
| 619 | 636 | ||
| 620 | const persistedConfig = JSON.parse(fs.readFileSync(keybindingsPath, "utf8")) as Array<{ | ||
| 621 | key: string; | ||
| 622 | command: string; | ||
| 623 | }>; | ||
| 624 | expect(persistedConfig).toEqual([ | ||
| 625 | { key: "mod+j", command: "terminal.toggle" }, | ||
| 626 | { key: "mod+shift+r", command: "script.run-tests.run" }, | ||
| 627 | ]); | ||
| 628 | |||
| 629 | const configResponse = await sendRequest(ws, WS_METHODS.serverGetConfig); | 637 | const configResponse = await sendRequest(ws, WS_METHODS.serverGetConfig); |
| 630 | expect(configResponse.error).toBeUndefined(); | 638 | expect(configResponse.error).toBeUndefined(); |
| 631 | expect(configResponse.result).toEqual({ | 639 | expect(configResponse.result).toEqual({ |
| 632 | cwd: "/my/workspace", | 640 | cwd: "/my/workspace", |
| 633 | keybindings: mergeWithDefaultsForTest([ | 641 | keybindingsConfigPath: keybindingsPath, |
| 634 | { key: "mod+j", command: "terminal.toggle" }, | 642 | keybindings: compileKeybindings(persistedConfig), |
| 635 | { key: "mod+shift+r", command: "script.run-tests.run" }, | ||
| 636 | ]), | ||
| 637 | }); | 643 | }); |
| 638 | }); | 644 | }); |
| 639 | 645 |
apps/server/src/wsServer.ts+12-1| ... | @@ -172,6 +172,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -172,6 +172,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 172 | const { | 172 | const { |
| 173 | port, | 173 | port, |
| 174 | cwd, | 174 | cwd, |
| 175 | keybindingsConfigPath, | ||
| 175 | staticDir, | 176 | staticDir, |
| 176 | devUrl, | 177 | devUrl, |
| 177 | authToken, | 178 | authToken, |
| ... | @@ -185,6 +186,16 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -185,6 +186,16 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 185 | const keybindingsManager = yield* Keybindings; | 186 | const keybindingsManager = yield* Keybindings; |
| 186 | const git = yield* GitCore; | 187 | const git = yield* GitCore; |
| 187 | 188 | ||
| 189 | yield* keybindingsManager.syncDefaultKeybindingsOnStartup.pipe( | ||
| 190 | Effect.catch((error) => | ||
| 191 | Effect.logWarning("failed to sync keybindings defaults on startup", { | ||
| 192 | path: error.configPath, | ||
| 193 | detail: error.detail, | ||
| 194 | cause: error.cause, | ||
| 195 | }), | ||
| 196 | ), | ||
| 197 | ); | ||
| 198 | |||
| 188 | const clients = yield* Ref.make(new Set<WebSocket>()); | 199 | const clients = yield* Ref.make(new Set<WebSocket>()); |
| 189 | const logger = createLogger("ws"); | 200 | const logger = createLogger("ws"); |
| 190 | 201 | ||
| ... | @@ -462,7 +473,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -462,7 +473,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 462 | 473 | ||
| 463 | case WS_METHODS.serverGetConfig: | 474 | case WS_METHODS.serverGetConfig: |
| 464 | const keybindingsConfig = yield* keybindingsManager.loadResolvedKeybindingsConfig; | 475 | const keybindingsConfig = yield* keybindingsManager.loadResolvedKeybindingsConfig; |
| 465 | return { cwd, keybindings: keybindingsConfig }; | 476 | return { cwd, keybindingsConfigPath, keybindings: keybindingsConfig }; |
| 466 | 477 | ||
| 467 | case WS_METHODS.serverUpsertKeybinding: { | 478 | case WS_METHODS.serverUpsertKeybinding: { |
| 468 | const body = stripRequestTag(request.body); | 479 | const body = stripRequestTag(request.body); |
apps/web/src/routes/_chat.settings.tsx+62| ... | @@ -1,9 +1,14 @@ | ... | @@ -1,9 +1,14 @@ |
| 1 | import { createFileRoute } from "@tanstack/react-router"; | 1 | import { createFileRoute } from "@tanstack/react-router"; |
| 2 | import { useQuery } from "@tanstack/react-query"; | ||
| 3 | import { useCallback, useState } from "react"; | ||
| 2 | 4 | ||
| 3 | import { useAppSettings } from "../appSettings"; | 5 | import { useAppSettings } from "../appSettings"; |
| 4 | import { isElectron } from "../env"; | 6 | import { isElectron } from "../env"; |
| 5 | import { useTheme } from "../hooks/useTheme"; | 7 | import { useTheme } from "../hooks/useTheme"; |
| 8 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; | ||
| 9 | import { ensureNativeApi } from "../nativeApi"; | ||
| 6 | import { useStore } from "../store"; | 10 | import { useStore } from "../store"; |
| 11 | import { preferredTerminalEditor } from "../terminal-links"; | ||
| 7 | import { Button } from "../components/ui/button"; | 12 | import { Button } from "../components/ui/button"; |
| 8 | import { Input } from "../components/ui/input"; | 13 | import { Input } from "../components/ui/input"; |
| 9 | import { Switch } from "../components/ui/switch"; | 14 | import { Switch } from "../components/ui/switch"; |
| ... | @@ -44,9 +49,30 @@ function SettingsRouteView() { | ... | @@ -44,9 +49,30 @@ function SettingsRouteView() { |
| 44 | const { theme, setTheme, resolvedTheme } = useTheme(); | 49 | const { theme, setTheme, resolvedTheme } = useTheme(); |
| 45 | const { state, dispatch } = useStore(); | 50 | const { state, dispatch } = useStore(); |
| 46 | const { settings, defaults, updateSettings } = useAppSettings(); | 51 | const { settings, defaults, updateSettings } = useAppSettings(); |
| 52 | const serverConfigQuery = useQuery(serverConfigQueryOptions()); | ||
| 53 | const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); | ||
| 54 | const [openKeybindingsError, setOpenKeybindingsError] = useState<string | null>(null); | ||
| 47 | 55 | ||
| 48 | const codexBinaryPath = settings.codexBinaryPath; | 56 | const codexBinaryPath = settings.codexBinaryPath; |
| 49 | const codexHomePath = settings.codexHomePath; | 57 | const codexHomePath = settings.codexHomePath; |
| 58 | const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; | ||
| 59 | |||
| 60 | const openKeybindingsFile = useCallback(() => { | ||
| 61 | if (!keybindingsConfigPath) return; | ||
| 62 | setOpenKeybindingsError(null); | ||
| 63 | setIsOpeningKeybindings(true); | ||
| 64 | const api = ensureNativeApi(); | ||
| 65 | void api.shell | ||
| 66 | .openInEditor(keybindingsConfigPath, preferredTerminalEditor()) | ||
| 67 | .catch((error) => { | ||
| 68 | setOpenKeybindingsError( | ||
| 69 | error instanceof Error ? error.message : "Unable to open keybindings file.", | ||
| 70 | ); | ||
| 71 | }) | ||
| 72 | .finally(() => { | ||
| 73 | setIsOpeningKeybindings(false); | ||
| 74 | }); | ||
| 75 | }, [keybindingsConfigPath]); | ||
| 50 | 76 | ||
| 51 | return ( | 77 | return ( |
| 52 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate"> | 78 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate"> |
| ... | @@ -211,6 +237,42 @@ function SettingsRouteView() { | ... | @@ -211,6 +237,42 @@ function SettingsRouteView() { |
| 211 | </div> | 237 | </div> |
| 212 | </section> | 238 | </section> |
| 213 | 239 | ||
| 240 | <section className="rounded-2xl border border-border bg-card p-5"> | ||
| 241 | <div className="mb-4"> | ||
| 242 | <h2 className="text-sm font-medium text-foreground">Keybindings</h2> | ||
| 243 | <p className="mt-1 text-xs text-muted-foreground"> | ||
| 244 | Open the persisted <code>keybindings.json</code> file to edit advanced | ||
| 245 | bindings directly. | ||
| 246 | </p> | ||
| 247 | </div> | ||
| 248 | |||
| 249 | <div className="space-y-3"> | ||
| 250 | <div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-background px-3 py-2"> | ||
| 251 | <div className="min-w-0 flex-1"> | ||
| 252 | <p className="text-xs font-medium text-foreground">Config file path</p> | ||
| 253 | <p className="mt-1 break-all font-mono text-[11px] text-muted-foreground"> | ||
| 254 | {keybindingsConfigPath ?? "Resolving keybindings path..."} | ||
| 255 | </p> | ||
| 256 | </div> | ||
| 257 | <Button | ||
| 258 | size="xs" | ||
| 259 | variant="outline" | ||
| 260 | disabled={!keybindingsConfigPath || isOpeningKeybindings} | ||
| 261 | onClick={openKeybindingsFile} | ||
| 262 | > | ||
| 263 | {isOpeningKeybindings ? "Opening..." : "Open keybindings.json"} | ||
| 264 | </Button> | ||
| 265 | </div> | ||
| 266 | |||
| 267 | <p className="text-xs text-muted-foreground"> | ||
| 268 | Opens in your preferred editor selection. | ||
| 269 | </p> | ||
| 270 | {openKeybindingsError ? ( | ||
| 271 | <p className="text-xs text-destructive">{openKeybindingsError}</p> | ||
| 272 | ) : null} | ||
| 273 | </div> | ||
| 274 | </section> | ||
| 275 | |||
| 214 | <section className="rounded-2xl border border-border bg-card p-5"> | 276 | <section className="rounded-2xl border border-border bg-card p-5"> |
| 215 | <div className="mb-4"> | 277 | <div className="mb-4"> |
| 216 | <h2 className="text-sm font-medium text-foreground">Safety</h2> | 278 | <h2 className="text-sm font-medium text-foreground">Safety</h2> |
packages/contracts/src/server.ts+1| ... | @@ -4,6 +4,7 @@ import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; | ... | @@ -4,6 +4,7 @@ import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; |
| 4 | 4 | ||
| 5 | export const ServerConfig = Schema.Struct({ | 5 | export const ServerConfig = Schema.Struct({ |
| 6 | cwd: TrimmedNonEmptyString, | 6 | cwd: TrimmedNonEmptyString, |
| 7 | keybindingsConfigPath: TrimmedNonEmptyString, | ||
| 7 | keybindings: ResolvedKeybindingsConfig, | 8 | keybindings: ResolvedKeybindingsConfig, |
| 8 | }); | 9 | }); |
| 9 | export type ServerConfig = typeof ServerConfig.Type; | 10 | export type ServerConfig = typeof ServerConfig.Type; |