| 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 | 2 | import { NodeServices } from "@effect/platform-node"; |
| 3 | 3 | import { assert, it } from "@effect/vitest"; |
| 4 | 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 | 6 | import { ServerConfig, type ServerConfigShape } from "./config"; |
| 7 | 7 | |
| 8 | 8 | import { |
| 9 | DEFAULT_KEYBINDINGS, | |
| 9 | 10 | Keybindings, |
| 10 | 11 | KeybindingsConfigError, |
| 11 | 12 | KeybindingsLive, |
| ... | ... | @@ -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 | 237 | it.effect("upserts custom keybindings to configured path", () => |
| 153 | 238 | Effect.gen(function* () { |
| 154 | 239 | const { keybindingsConfigPath } = yield* ServerConfig; |
apps/server/src/keybindings.ts+152-15| ... | ... | @@ -19,6 +19,7 @@ import { |
| 19 | 19 | import { Mutable } from "effect/Types"; |
| 20 | 20 | import { |
| 21 | 21 | Array, |
| 22 | Cache, | |
| 22 | 23 | Cause, |
| 23 | 24 | Effect, |
| 24 | 25 | FileSystem, |
| ... | ... | @@ -26,8 +27,8 @@ import { |
| 26 | 27 | Layer, |
| 27 | 28 | Option, |
| 28 | 29 | Predicate, |
| 29 | Ref, | |
| 30 | 30 | Schema, |
| 31 | SchemaGetter, | |
| 31 | 32 | SchemaIssue, |
| 32 | 33 | SchemaTransformation, |
| 33 | 34 | ServiceMap, |
| ... | ... | @@ -348,6 +349,29 @@ export const ResolvedKeybindingsFromConfig = Schema.Array(ResolvedKeybindingFrom |
| 348 | 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 | 375 | function encodeShortcut(shortcut: KeybindingShortcut): string | null { |
| 352 | 376 | const modifiers: string[] = []; |
| 353 | 377 | if (shortcut.modKey) modifiers.push("mod"); |
| ... | ... | @@ -378,6 +402,15 @@ const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KE |
| 378 | 402 | |
| 379 | 403 | const RawKeybindingsEntries = Schema.fromJsonString(Schema.Array(Schema.Unknown)); |
| 380 | 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 | 415 | function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig { |
| 383 | 416 | if (custom.length === 0) { |
| ... | ... | @@ -402,6 +435,12 @@ function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): Resolve |
| 402 | 435 | * KeybindingsShape - Service API for keybinding configuration operations. |
| 403 | 436 | */ |
| 404 | 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 | 445 | * Load and resolve keybindings from disk merged with defaults. |
| 407 | 446 | * |
| ... | ... | @@ -434,13 +473,28 @@ const makeKeybindings = Effect.gen(function* () { |
| 434 | 473 | const { keybindingsConfigPath } = yield* ServerConfig; |
| 435 | 474 | const fs = yield* FileSystem.FileSystem; |
| 436 | 475 | const path = yield* Path.Path; |
| 437 | const cachedResolvedConfig = yield* Ref.make<ResolvedKeybindingsConfig | null>(null); | |
| 438 | 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 | 490 | const loadCustomKeybindingsConfig = Effect.fn(function* (): Effect.fn.Return< |
| 441 | 491 | readonly KeybindingRule[], |
| 442 | 492 | KeybindingsConfigError |
| 443 | 493 | > { |
| 494 | if (!(yield* readConfigExists)) { | |
| 495 | return []; | |
| 496 | } | |
| 497 | ||
| 444 | 498 | const rawConfig = yield* fs.readFileString(keybindingsConfigPath).pipe( |
| 445 | 499 | Effect.flatMap(Schema.decodeEffect(RawKeybindingsEntries)), |
| 446 | 500 | Effect.mapError( |
| ... | ... | @@ -481,9 +535,8 @@ const makeKeybindings = Effect.gen(function* () { |
| 481 | 535 | const writeConfigAtomically = (rules: readonly KeybindingRule[]) => { |
| 482 | 536 | const tempPath = `${keybindingsConfigPath}.${process.pid}.${Date.now()}.tmp`; |
| 483 | 537 | |
| 484 | return Schema.encodeEffect(KeybindingsConfigJson)( | |
| 485 | rules, | |
| 486 | ).pipe( | |
| 538 | return Schema.encodeEffect(KeybindingsConfigPrettyJson)(rules).pipe( | |
| 539 | Effect.map((encoded) => `${encoded}\n`), | |
| 487 | 540 | Effect.tap(() => fs.makeDirectory(path.dirname(keybindingsConfigPath), { recursive: true })), |
| 488 | 541 | Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)), |
| 489 | 542 | Effect.flatMap(() => fs.rename(tempPath, keybindingsConfigPath)), |
| ... | ... | @@ -498,18 +551,102 @@ const makeKeybindings = Effect.gen(function* () { |
| 498 | 551 | ); |
| 499 | 552 | }; |
| 500 | 553 | |
| 501 | const loadResolvedFromCacheOrDisk = Effect.gen(function* () { | |
| 502 | const cached = yield* Ref.get(cachedResolvedConfig); | |
| 503 | if (cached) return cached; | |
| 504 | const resolved = yield* loadCustomKeybindingsConfig().pipe( | |
| 505 | Effect.map(compileResolvedKeybindingsConfig), | |
| 506 | Effect.map(mergeWithDefaultKeybindings), | |
| 507 | ); | |
| 508 | yield* Ref.set(cachedResolvedConfig, resolved); | |
| 509 | return resolved; | |
| 554 | const loadResolvedFromDisk = loadCustomKeybindingsConfig().pipe( | |
| 555 | Effect.map(compileResolvedKeybindingsConfig), | |
| 556 | Effect.map(mergeWithDefaultKeybindings), | |
| 557 | ); | |
| 558 | ||
| 559 | const resolvedConfigCache = yield* Cache.make< | |
| 560 | typeof resolvedConfigCacheKey, | |
| 561 | ResolvedKeybindingsConfig, | |
| 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 | 648 | return { |
| 649 | syncDefaultKeybindingsOnStartup, | |
| 513 | 650 | loadResolvedKeybindingsConfig: loadResolvedFromCacheOrDisk, |
| 514 | 651 | upsertKeybindingRule: (rule) => |
| 515 | 652 | upsertSemaphore.withPermits(1)( |
| ... | ... | @@ -533,7 +670,7 @@ const makeKeybindings = Effect.gen(function* () { |
| 533 | 670 | const nextResolved = mergeWithDefaultKeybindings( |
| 534 | 671 | compileResolvedKeybindingsConfig(cappedConfig), |
| 535 | 672 | ); |
| 536 | yield* Ref.set(cachedResolvedConfig, nextResolved); | |
| 673 | yield* Cache.set(resolvedConfigCache, resolvedConfigCacheKey, nextResolved); | |
| 537 | 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 | 309 | |
| 310 | 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 | 312 | describe("WebSocket Server", () => { |
| 325 | 313 | let server: Http.Server | null = null; |
| 326 | 314 | let serverScope: Scope.Closeable | null = null; |
| ... | ... | @@ -510,7 +498,8 @@ describe("WebSocket Server", () => { |
| 510 | 498 | |
| 511 | 499 | it("responds to server.getConfig", async () => { |
| 512 | 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 | 504 | server = await createTestServer({ cwd: "/my/workspace", stateDir }); |
| 516 | 505 | const addr = server.address(); |
| ... | ... | @@ -526,8 +515,34 @@ describe("WebSocket Server", () => { |
| 526 | 515 | expect(response.error).toBeUndefined(); |
| 527 | 516 | expect(response.result).toEqual({ |
| 528 | 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 | 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 | 548 | it("routes shell.openInEditor through the injected open service", async () => { |
| ... | ... | @@ -558,8 +573,9 @@ describe("WebSocket Server", () => { |
| 558 | 573 | |
| 559 | 574 | it("reads keybindings from the configured state directory", async () => { |
| 560 | 575 | const stateDir = makeTempDir("t3code-state-keybindings-"); |
| 576 | const keybindingsPath = path.join(stateDir, "keybindings.json"); | |
| 561 | 577 | fs.writeFileSync( |
| 562 | path.join(stateDir, "keybindings.json"), | |
| 578 | keybindingsPath, | |
| 563 | 579 | JSON.stringify([ |
| 564 | 580 | { key: "cmd+j", command: "terminal.toggle" }, |
| 565 | 581 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, |
| ... | ... | @@ -578,13 +594,11 @@ describe("WebSocket Server", () => { |
| 578 | 594 | |
| 579 | 595 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); |
| 580 | 596 | expect(response.error).toBeUndefined(); |
| 597 | const persistedConfig = JSON.parse(fs.readFileSync(keybindingsPath, "utf8")) as KeybindingsConfig; | |
| 581 | 598 | expect(response.result).toEqual({ |
| 582 | 599 | cwd: "/my/workspace", |
| 583 | keybindings: mergeWithDefaultsForTest([ | |
| 584 | { key: "cmd+j", command: "terminal.toggle" }, | |
| 585 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, | |
| 586 | { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, | |
| 587 | ]), | |
| 600 | keybindingsConfigPath: keybindingsPath, | |
| 601 | keybindings: compileKeybindings(persistedConfig), | |
| 588 | 602 | }); |
| 589 | 603 | }); |
| 590 | 604 | |
| ... | ... | @@ -610,30 +624,22 @@ describe("WebSocket Server", () => { |
| 610 | 624 | command: "script.run-tests.run", |
| 611 | 625 | }); |
| 612 | 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 | 633 | expect(upsertResponse.result).toEqual({ |
| 614 | keybindings: mergeWithDefaultsForTest([ | |
| 615 | { key: "mod+j", command: "terminal.toggle" }, | |
| 616 | { key: "mod+shift+r", command: "script.run-tests.run" }, | |
| 617 | ]), | |
| 634 | keybindings: compileKeybindings(persistedConfig), | |
| 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 | 637 | const configResponse = await sendRequest(ws, WS_METHODS.serverGetConfig); |
| 630 | 638 | expect(configResponse.error).toBeUndefined(); |
| 631 | 639 | expect(configResponse.result).toEqual({ |
| 632 | 640 | cwd: "/my/workspace", |
| 633 | keybindings: mergeWithDefaultsForTest([ | |
| 634 | { key: "mod+j", command: "terminal.toggle" }, | |
| 635 | { key: "mod+shift+r", command: "script.run-tests.run" }, | |
| 636 | ]), | |
| 641 | keybindingsConfigPath: keybindingsPath, | |
| 642 | keybindings: compileKeybindings(persistedConfig), | |
| 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 | 172 | const { |
| 173 | 173 | port, |
| 174 | 174 | cwd, |
| 175 | keybindingsConfigPath, | |
| 175 | 176 | staticDir, |
| 176 | 177 | devUrl, |
| 177 | 178 | authToken, |
| ... | ... | @@ -185,6 +186,16 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 185 | 186 | const keybindingsManager = yield* Keybindings; |
| 186 | 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 | 199 | const clients = yield* Ref.make(new Set<WebSocket>()); |
| 189 | 200 | const logger = createLogger("ws"); |
| 190 | 201 | |
| ... | ... | @@ -462,7 +473,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 462 | 473 | |
| 463 | 474 | case WS_METHODS.serverGetConfig: |
| 464 | 475 | const keybindingsConfig = yield* keybindingsManager.loadResolvedKeybindingsConfig; |
| 465 | return { cwd, keybindings: keybindingsConfig }; | |
| 476 | return { cwd, keybindingsConfigPath, keybindings: keybindingsConfig }; | |
| 466 | 477 | |
| 467 | 478 | case WS_METHODS.serverUpsertKeybinding: { |
| 468 | 479 | const body = stripRequestTag(request.body); |
apps/web/src/routes/_chat.settings.tsx+62| ... | ... | @@ -1,9 +1,14 @@ |
| 1 | 1 | import { createFileRoute } from "@tanstack/react-router"; |
| 2 | import { useQuery } from "@tanstack/react-query"; | |
| 3 | import { useCallback, useState } from "react"; | |
| 2 | 4 | |
| 3 | 5 | import { useAppSettings } from "../appSettings"; |
| 4 | 6 | import { isElectron } from "../env"; |
| 5 | 7 | import { useTheme } from "../hooks/useTheme"; |
| 8 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; | |
| 9 | import { ensureNativeApi } from "../nativeApi"; | |
| 6 | 10 | import { useStore } from "../store"; |
| 11 | import { preferredTerminalEditor } from "../terminal-links"; | |
| 7 | 12 | import { Button } from "../components/ui/button"; |
| 8 | 13 | import { Input } from "../components/ui/input"; |
| 9 | 14 | import { Switch } from "../components/ui/switch"; |
| ... | ... | @@ -44,9 +49,30 @@ function SettingsRouteView() { |
| 44 | 49 | const { theme, setTheme, resolvedTheme } = useTheme(); |
| 45 | 50 | const { state, dispatch } = useStore(); |
| 46 | 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 | 56 | const codexBinaryPath = settings.codexBinaryPath; |
| 49 | 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 | 77 | return ( |
| 52 | 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 | 237 | </div> |
| 212 | 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 | 276 | <section className="rounded-2xl border border-border bg-card p-5"> |
| 215 | 277 | <div className="mb-4"> |
| 216 | 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 | 4 | |
| 5 | 5 | export const ServerConfig = Schema.Struct({ |
| 6 | 6 | cwd: TrimmedNonEmptyString, |
| 7 | keybindingsConfigPath: TrimmedNonEmptyString, | |
| 7 | 8 | keybindings: ResolvedKeybindingsConfig, |
| 8 | 9 | }); |
| 9 | 10 | export type ServerConfig = typeof ServerConfig.Type; |