| author | |
| committer | |
| log | 0e388706470fdf8df7b5b5ae37f12bbeea777e06 |
| tree | 75b4aaff741c4eccc2db75e61ee8324306ac0917 |
| parent | 9b604bcae1e2cfe0e0566bc2aff660850e276c6d |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>24 files changed, 2142 insertions(+), 111 deletions(-)
apps/server/src/keybindings.test.ts+50-1| ... | @@ -354,7 +354,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { | ... | @@ -354,7 +354,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { |
| 354 | }).pipe(Effect.provide(makeKeybindingsLayer())), | 354 | }).pipe(Effect.provide(makeKeybindingsLayer())), |
| 355 | ); | 355 | ); |
| 356 | 356 | ||
| 357 | it.effect("replaces existing custom keybinding for the same command", () => | 357 | it.effect("appends additional custom keybindings for the same command", () => |
| 358 | Effect.gen(function* () { | 358 | Effect.gen(function* () { |
| 359 | const { keybindingsConfigPath } = yield* ServerConfig; | 359 | const { keybindingsConfigPath } = yield* ServerConfig; |
| 360 | yield* writeKeybindingsConfig(keybindingsConfigPath, [ | 360 | yield* writeKeybindingsConfig(keybindingsConfigPath, [ |
| ... | @@ -368,6 +368,55 @@ it.layer(NodeServices.layer)("keybindings", (it) => { | ... | @@ -368,6 +368,55 @@ it.layer(NodeServices.layer)("keybindings", (it) => { |
| 368 | }); | 368 | }); |
| 369 | }); | 369 | }); |
| 370 | 370 | ||
| 371 | const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); | ||
| 372 | const persistedView = persisted.map(({ key, command }) => ({ key, command })); | ||
| 373 | assert.deepEqual(persistedView, [ | ||
| 374 | { key: "mod+r", command: "script.run-tests.run" }, | ||
| 375 | { key: "mod+shift+r", command: "script.run-tests.run" }, | ||
| 376 | ]); | ||
| 377 | }).pipe(Effect.provide(makeKeybindingsLayer())), | ||
| 378 | ); | ||
| 379 | |||
| 380 | it.effect("replaces only the targeted custom keybinding", () => | ||
| 381 | Effect.gen(function* () { | ||
| 382 | const { keybindingsConfigPath } = yield* ServerConfig; | ||
| 383 | yield* writeKeybindingsConfig(keybindingsConfigPath, [ | ||
| 384 | { key: "mod+r", command: "script.run-tests.run" }, | ||
| 385 | { key: "mod+shift+r", command: "script.run-tests.run" }, | ||
| 386 | ]); | ||
| 387 | yield* Effect.gen(function* () { | ||
| 388 | const keybindings = yield* Keybindings; | ||
| 389 | return yield* keybindings.upsertKeybindingRule({ | ||
| 390 | key: "mod+alt+r", | ||
| 391 | command: "script.run-tests.run", | ||
| 392 | replace: { key: "mod+r", command: "script.run-tests.run" }, | ||
| 393 | }); | ||
| 394 | }); | ||
| 395 | |||
| 396 | const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); | ||
| 397 | const persistedView = persisted.map(({ key, command }) => ({ key, command })); | ||
| 398 | assert.deepEqual(persistedView, [ | ||
| 399 | { key: "mod+shift+r", command: "script.run-tests.run" }, | ||
| 400 | { key: "mod+alt+r", command: "script.run-tests.run" }, | ||
| 401 | ]); | ||
| 402 | }).pipe(Effect.provide(makeKeybindingsLayer())), | ||
| 403 | ); | ||
| 404 | |||
| 405 | it.effect("removes only the targeted custom keybinding", () => | ||
| 406 | Effect.gen(function* () { | ||
| 407 | const { keybindingsConfigPath } = yield* ServerConfig; | ||
| 408 | yield* writeKeybindingsConfig(keybindingsConfigPath, [ | ||
| 409 | { key: "mod+r", command: "script.run-tests.run" }, | ||
| 410 | { key: "mod+shift+r", command: "script.run-tests.run" }, | ||
| 411 | ]); | ||
| 412 | yield* Effect.gen(function* () { | ||
| 413 | const keybindings = yield* Keybindings; | ||
| 414 | return yield* keybindings.removeKeybindingRule({ | ||
| 415 | key: "mod+r", | ||
| 416 | command: "script.run-tests.run", | ||
| 417 | }); | ||
| 418 | }); | ||
| 419 | |||
| 371 | const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); | 420 | const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); |
| 372 | const persistedView = persisted.map(({ key, command }) => ({ key, command })); | 421 | const persistedView = persisted.map(({ key, command }) => ({ key, command })); |
| 373 | assert.deepEqual(persistedView, [{ key: "mod+shift+r", command: "script.run-tests.run" }]); | 422 | assert.deepEqual(persistedView, [{ key: "mod+shift+r", command: "script.run-tests.run" }]); |
apps/server/src/keybindings.ts+59-3| ... | @@ -15,6 +15,8 @@ import { | ... | @@ -15,6 +15,8 @@ import { |
| 15 | MAX_KEYBINDINGS_COUNT, | 15 | MAX_KEYBINDINGS_COUNT, |
| 16 | ResolvedKeybindingRule, | 16 | ResolvedKeybindingRule, |
| 17 | ResolvedKeybindingsConfig, | 17 | ResolvedKeybindingsConfig, |
| 18 | type ServerRemoveKeybindingInput, | ||
| 19 | type ServerUpsertKeybindingInput, | ||
| 18 | type ServerConfigIssue, | 20 | type ServerConfigIssue, |
| 19 | } from "@t3tools/contracts"; | 21 | } from "@t3tools/contracts"; |
| 20 | import { | 22 | import { |
| ... | @@ -124,6 +126,25 @@ function hasSameShortcutContext(left: KeybindingRule, right: KeybindingRule): bo | ... | @@ -124,6 +126,25 @@ function hasSameShortcutContext(left: KeybindingRule, right: KeybindingRule): bo |
| 124 | return leftContext === rightContext; | 126 | return leftContext === rightContext; |
| 125 | } | 127 | } |
| 126 | 128 | ||
| 129 | function keybindingRuleFromUpsertInput(input: ServerUpsertKeybindingInput): KeybindingRule { | ||
| 130 | return input.when === undefined | ||
| 131 | ? { key: input.key, command: input.command } | ||
| 132 | : { key: input.key, command: input.command, when: input.when }; | ||
| 133 | } | ||
| 134 | |||
| 135 | function replaceTargetFromUpsertInput(input: ServerUpsertKeybindingInput): KeybindingRule | null { | ||
| 136 | if (!input.replace) return null; | ||
| 137 | return input.replace.when === undefined | ||
| 138 | ? { key: input.replace.key, command: input.replace.command } | ||
| 139 | : { key: input.replace.key, command: input.replace.command, when: input.replace.when }; | ||
| 140 | } | ||
| 141 | |||
| 142 | function keybindingRuleFromRemoveInput(input: ServerRemoveKeybindingInput): KeybindingRule { | ||
| 143 | return input.when === undefined | ||
| 144 | ? { key: input.key, command: input.command } | ||
| 145 | : { key: input.key, command: input.command, when: input.when }; | ||
| 146 | } | ||
| 147 | |||
| 127 | function encodeShortcut(shortcut: KeybindingShortcut): string | null { | 148 | function encodeShortcut(shortcut: KeybindingShortcut): string | null { |
| 128 | const modifiers: string[] = []; | 149 | const modifiers: string[] = []; |
| 129 | if (shortcut.modKey) modifiers.push("mod"); | 150 | if (shortcut.modKey) modifiers.push("mod"); |
| ... | @@ -259,7 +280,14 @@ export interface KeybindingsShape { | ... | @@ -259,7 +280,14 @@ export interface KeybindingsShape { |
| 259 | * oldest entries when needed. | 280 | * oldest entries when needed. |
| 260 | */ | 281 | */ |
| 261 | readonly upsertKeybindingRule: ( | 282 | readonly upsertKeybindingRule: ( |
| 262 | rule: KeybindingRule, | 283 | input: ServerUpsertKeybindingInput, |
| 284 | ) => Effect.Effect<ResolvedKeybindingsConfig, KeybindingsConfigError>; | ||
| 285 | |||
| 286 | /** | ||
| 287 | * Remove a single persisted keybinding rule by exact key/command/when match. | ||
| 288 | */ | ||
| 289 | readonly removeKeybindingRule: ( | ||
| 290 | input: ServerRemoveKeybindingInput, | ||
| 263 | ) => Effect.Effect<ResolvedKeybindingsConfig, KeybindingsConfigError>; | 291 | ) => Effect.Effect<ResolvedKeybindingsConfig, KeybindingsConfigError>; |
| 264 | } | 292 | } |
| 265 | 293 | ||
| ... | @@ -616,12 +644,19 @@ const makeKeybindings = Effect.gen(function* () { | ... | @@ -616,12 +644,19 @@ const makeKeybindings = Effect.gen(function* () { |
| 616 | get streamChanges() { | 644 | get streamChanges() { |
| 617 | return Stream.fromPubSub(changesPubSub); | 645 | return Stream.fromPubSub(changesPubSub); |
| 618 | }, | 646 | }, |
| 619 | upsertKeybindingRule: (rule) => | 647 | upsertKeybindingRule: (input) => |
| 620 | upsertSemaphore.withPermits(1)( | 648 | upsertSemaphore.withPermits(1)( |
| 621 | Effect.gen(function* () { | 649 | Effect.gen(function* () { |
| 622 | const customConfig = yield* loadWritableCustomKeybindingsConfig(); | 650 | const customConfig = yield* loadWritableCustomKeybindingsConfig(); |
| 651 | const rule = keybindingRuleFromUpsertInput(input); | ||
| 652 | const replaceTarget = replaceTargetFromUpsertInput(input); | ||
| 623 | const nextConfig = [ | 653 | const nextConfig = [ |
| 624 | ...customConfig.filter((entry) => entry.command !== rule.command), | 654 | ...customConfig.filter((entry) => { |
| 655 | if (replaceTarget) { | ||
| 656 | return !isSameKeybindingRule(entry, replaceTarget); | ||
| 657 | } | ||
| 658 | return !isSameKeybindingRule(entry, rule); | ||
| 659 | }), | ||
| 625 | rule, | 660 | rule, |
| 626 | ]; | 661 | ]; |
| 627 | const cappedConfig = | 662 | const cappedConfig = |
| ... | @@ -649,6 +684,27 @@ const makeKeybindings = Effect.gen(function* () { | ... | @@ -649,6 +684,27 @@ const makeKeybindings = Effect.gen(function* () { |
| 649 | return nextResolved; | 684 | return nextResolved; |
| 650 | }), | 685 | }), |
| 651 | ), | 686 | ), |
| 687 | removeKeybindingRule: (input) => | ||
| 688 | upsertSemaphore.withPermits(1)( | ||
| 689 | Effect.gen(function* () { | ||
| 690 | const customConfig = yield* loadWritableCustomKeybindingsConfig(); | ||
| 691 | const target = keybindingRuleFromRemoveInput(input); | ||
| 692 | const nextConfig = customConfig.filter((entry) => !isSameKeybindingRule(entry, target)); | ||
| 693 | yield* writeConfigAtomically(nextConfig); | ||
| 694 | const nextResolved = mergeWithDefaultKeybindings( | ||
| 695 | compileResolvedKeybindingsConfig(nextConfig), | ||
| 696 | ); | ||
| 697 | yield* Cache.set(resolvedConfigCache, resolvedConfigCacheKey, { | ||
| 698 | keybindings: nextResolved, | ||
| 699 | issues: [], | ||
| 700 | }); | ||
| 701 | yield* emitChange({ | ||
| 702 | keybindings: nextResolved, | ||
| 703 | issues: [], | ||
| 704 | }); | ||
| 705 | return nextResolved; | ||
| 706 | }), | ||
| 707 | ), | ||
| 652 | } satisfies KeybindingsShape; | 708 | } satisfies KeybindingsShape; |
| 653 | }); | 709 | }); |
| 654 | 710 |
apps/server/src/server.test.ts+36| ... | @@ -1918,6 +1918,42 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1918,6 +1918,42 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1918 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 1918 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1919 | ); | 1919 | ); |
| 1920 | 1920 | ||
| 1921 | it.effect("routes websocket rpc server.removeKeybinding", () => | ||
| 1922 | Effect.gen(function* () { | ||
| 1923 | const rule: KeybindingRule = { | ||
| 1924 | command: "terminal.toggle", | ||
| 1925 | key: "ctrl+k", | ||
| 1926 | }; | ||
| 1927 | const resolved: ResolvedKeybindingRule = { | ||
| 1928 | command: "terminal.toggle", | ||
| 1929 | shortcut: { | ||
| 1930 | key: "j", | ||
| 1931 | metaKey: false, | ||
| 1932 | ctrlKey: false, | ||
| 1933 | shiftKey: false, | ||
| 1934 | altKey: false, | ||
| 1935 | modKey: true, | ||
| 1936 | }, | ||
| 1937 | }; | ||
| 1938 | |||
| 1939 | yield* buildAppUnderTest({ | ||
| 1940 | layers: { | ||
| 1941 | keybindings: { | ||
| 1942 | removeKeybindingRule: () => Effect.succeed([resolved]), | ||
| 1943 | }, | ||
| 1944 | }, | ||
| 1945 | }); | ||
| 1946 | |||
| 1947 | const wsUrl = yield* getWsServerUrl("/ws"); | ||
| 1948 | const response = yield* Effect.scoped( | ||
| 1949 | withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverRemoveKeybinding](rule)), | ||
| 1950 | ); | ||
| 1951 | |||
| 1952 | assert.deepEqual(response.issues, []); | ||
| 1953 | assert.deepEqual(response.keybindings, [resolved]); | ||
| 1954 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1955 | ); | ||
| 1956 | |||
| 1921 | it.effect("rejects websocket rpc handshake when session authentication is missing", () => | 1957 | it.effect("rejects websocket rpc handshake when session authentication is missing", () => |
| 1922 | Effect.gen(function* () { | 1958 | Effect.gen(function* () { |
| 1923 | const fs = yield* FileSystem.FileSystem; | 1959 | const fs = yield* FileSystem.FileSystem; |
apps/server/src/ws.ts+9| ... | @@ -804,6 +804,15 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => | ... | @@ -804,6 +804,15 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 804 | }), | 804 | }), |
| 805 | { "rpc.aggregate": "server" }, | 805 | { "rpc.aggregate": "server" }, |
| 806 | ), | 806 | ), |
| 807 | [WS_METHODS.serverRemoveKeybinding]: (rule) => | ||
| 808 | observeRpcEffect( | ||
| 809 | WS_METHODS.serverRemoveKeybinding, | ||
| 810 | Effect.gen(function* () { | ||
| 811 | const keybindingsConfig = yield* keybindings.removeKeybindingRule(rule); | ||
| 812 | return { keybindings: keybindingsConfig, issues: [] }; | ||
| 813 | }), | ||
| 814 | { "rpc.aggregate": "server" }, | ||
| 815 | ), | ||
| 807 | [WS_METHODS.serverGetSettings]: (_input) => | 816 | [WS_METHODS.serverGetSettings]: (_input) => |
| 808 | observeRpcEffect( | 817 | observeRpcEffect( |
| 809 | WS_METHODS.serverGetSettings, | 818 | WS_METHODS.serverGetSettings, |
apps/web/src/components/KeybindingsToast.browser.tsx+7-3| ... | @@ -532,16 +532,20 @@ describe("Keybindings update toast", () => { | ... | @@ -532,16 +532,20 @@ describe("Keybindings update toast", () => { |
| 532 | document.body.innerHTML = ""; | 532 | document.body.innerHTML = ""; |
| 533 | }); | 533 | }); |
| 534 | 534 | ||
| 535 | it("shows a toast for each consecutive keybinding update with no issues", async () => { | 535 | it("coalesces rapid consecutive keybinding update toasts with no issues", async () => { |
| 536 | const mounted = await mountApp(); | 536 | const mounted = await mountApp(); |
| 537 | 537 | ||
| 538 | try { | 538 | try { |
| 539 | sendServerConfigUpdatedPush([]); | 539 | sendServerConfigUpdatedPush([]); |
| 540 | await waitForToast("Keybindings updated", 1); | 540 | await waitForToast("Keybindings updated", 1); |
| 541 | 541 | ||
| 542 | // Each server push represents a distinct file change, so it should produce its own toast. | 542 | // A single edit can produce several reload notifications as the direct update and |
| 543 | // filesystem watcher settle, so avoid stacking identical success toasts. | ||
| 543 | sendServerConfigUpdatedPush([]); | 544 | sendServerConfigUpdatedPush([]); |
| 544 | await waitForToast("Keybindings updated", 2); | 545 | await new Promise((resolve) => setTimeout(resolve, 250)); |
| 546 | |||
| 547 | const titles = queryToastTitles(); | ||
| 548 | expect(titles.filter((title) => title === "Keybindings updated")).toHaveLength(1); | ||
| 545 | } finally { | 549 | } finally { |
| 546 | await mounted.cleanup(); | 550 | await mounted.cleanup(); |
| 547 | } | 551 | } |
apps/web/src/components/ProjectScriptsControl.tsx+2-53| ... | @@ -20,13 +20,13 @@ import { | ... | @@ -20,13 +20,13 @@ import { |
| 20 | keybindingValueForCommand, | 20 | keybindingValueForCommand, |
| 21 | decodeProjectScriptKeybindingRule, | 21 | decodeProjectScriptKeybindingRule, |
| 22 | } from "~/lib/projectScriptKeybindings"; | 22 | } from "~/lib/projectScriptKeybindings"; |
| 23 | import { keybindingFromKeyboardEvent } from "~/components/settings/KeybindingsSettings.logic"; | ||
| 23 | import { | 24 | import { |
| 24 | commandForProjectScript, | 25 | commandForProjectScript, |
| 25 | nextProjectScriptId, | 26 | nextProjectScriptId, |
| 26 | primaryProjectScript, | 27 | primaryProjectScript, |
| 27 | } from "~/projectScripts"; | 28 | } from "~/projectScripts"; |
| 28 | import { shortcutLabelForCommand } from "~/keybindings"; | 29 | import { shortcutLabelForCommand } from "~/keybindings"; |
| 29 | import { isMacPlatform } from "~/lib/utils"; | ||
| 30 | import { | 30 | import { |
| 31 | AlertDialog, | 31 | AlertDialog, |
| 32 | AlertDialogClose, | 32 | AlertDialogClose, |
| ... | @@ -96,57 +96,6 @@ interface ProjectScriptsControlProps { | ... | @@ -96,57 +96,6 @@ interface ProjectScriptsControlProps { |
| 96 | onDeleteScript: (scriptId: string) => Promise<void> | void; | 96 | onDeleteScript: (scriptId: string) => Promise<void> | void; |
| 97 | } | 97 | } |
| 98 | 98 | ||
| 99 | function normalizeShortcutKeyToken(key: string): string | null { | ||
| 100 | const normalized = key.toLowerCase(); | ||
| 101 | if ( | ||
| 102 | normalized === "meta" || | ||
| 103 | normalized === "control" || | ||
| 104 | normalized === "ctrl" || | ||
| 105 | normalized === "shift" || | ||
| 106 | normalized === "alt" || | ||
| 107 | normalized === "option" | ||
| 108 | ) { | ||
| 109 | return null; | ||
| 110 | } | ||
| 111 | if (normalized === " ") return "space"; | ||
| 112 | if (normalized === "escape") return "esc"; | ||
| 113 | if (normalized === "arrowup") return "arrowup"; | ||
| 114 | if (normalized === "arrowdown") return "arrowdown"; | ||
| 115 | if (normalized === "arrowleft") return "arrowleft"; | ||
| 116 | if (normalized === "arrowright") return "arrowright"; | ||
| 117 | if (normalized.length === 1) return normalized; | ||
| 118 | if (normalized.startsWith("f") && normalized.length <= 3) return normalized; | ||
| 119 | if (normalized === "enter" || normalized === "tab" || normalized === "backspace") { | ||
| 120 | return normalized; | ||
| 121 | } | ||
| 122 | if (normalized === "delete" || normalized === "home" || normalized === "end") { | ||
| 123 | return normalized; | ||
| 124 | } | ||
| 125 | if (normalized === "pageup" || normalized === "pagedown") return normalized; | ||
| 126 | return null; | ||
| 127 | } | ||
| 128 | |||
| 129 | function keybindingFromEvent(event: KeyboardEvent<HTMLInputElement>): string | null { | ||
| 130 | const keyToken = normalizeShortcutKeyToken(event.key); | ||
| 131 | if (!keyToken) return null; | ||
| 132 | |||
| 133 | const parts: string[] = []; | ||
| 134 | if (isMacPlatform(navigator.platform)) { | ||
| 135 | if (event.metaKey) parts.push("mod"); | ||
| 136 | if (event.ctrlKey) parts.push("ctrl"); | ||
| 137 | } else { | ||
| 138 | if (event.ctrlKey) parts.push("mod"); | ||
| 139 | if (event.metaKey) parts.push("meta"); | ||
| 140 | } | ||
| 141 | if (event.altKey) parts.push("alt"); | ||
| 142 | if (event.shiftKey) parts.push("shift"); | ||
| 143 | if (parts.length === 0) { | ||
| 144 | return null; | ||
| 145 | } | ||
| 146 | parts.push(keyToken); | ||
| 147 | return parts.join("+"); | ||
| 148 | } | ||
| 149 | |||
| 150 | export default function ProjectScriptsControl({ | 99 | export default function ProjectScriptsControl({ |
| 151 | scripts, | 100 | scripts, |
| 152 | keybindings, | 101 | keybindings, |
| ... | @@ -186,7 +135,7 @@ export default function ProjectScriptsControl({ | ... | @@ -186,7 +135,7 @@ export default function ProjectScriptsControl({ |
| 186 | setKeybinding(""); | 135 | setKeybinding(""); |
| 187 | return; | 136 | return; |
| 188 | } | 137 | } |
| 189 | const next = keybindingFromEvent(event); | 138 | const next = keybindingFromKeyboardEvent(event, navigator.platform); |
| 190 | if (!next) return; | 139 | if (!next) return; |
| 191 | setKeybinding(next); | 140 | setKeybinding(next); |
| 192 | }; | 141 | }; |
apps/web/src/components/settings/KeybindingsSettings.logic.test.ts created+248| ... | @@ -0,0 +1,248 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | buildKeybindingRows, | ||
| 6 | buildKeybindingCommandOptions, | ||
| 7 | buildWhenVariableOptions, | ||
| 8 | commandLabel, | ||
| 9 | keybindingConflictLabels, | ||
| 10 | keybindingFromKeyboardEvent, | ||
| 11 | parseWhenExpressionDraft, | ||
| 12 | shortcutToKeybindingInput, | ||
| 13 | unknownWhenVariables, | ||
| 14 | whenAstToExpression, | ||
| 15 | } from "./KeybindingsSettings.logic"; | ||
| 16 | |||
| 17 | describe("KeybindingsSettings.logic", () => { | ||
| 18 | it("builds searchable rows with readable key and when values", () => { | ||
| 19 | const rows = buildKeybindingRows( | ||
| 20 | [ | ||
| 21 | { | ||
| 22 | command: "terminal.toggle", | ||
| 23 | shortcut: { | ||
| 24 | key: "j", | ||
| 25 | modKey: true, | ||
| 26 | metaKey: false, | ||
| 27 | ctrlKey: false, | ||
| 28 | altKey: false, | ||
| 29 | shiftKey: false, | ||
| 30 | }, | ||
| 31 | whenAst: { | ||
| 32 | type: "not", | ||
| 33 | node: { type: "identifier", name: "terminalFocus" }, | ||
| 34 | }, | ||
| 35 | }, | ||
| 36 | ] satisfies ResolvedKeybindingsConfig, | ||
| 37 | "terminal", | ||
| 38 | ); | ||
| 39 | |||
| 40 | expect(rows).toEqual([ | ||
| 41 | expect.objectContaining({ | ||
| 42 | command: "terminal.toggle", | ||
| 43 | key: "mod+j", | ||
| 44 | when: "!terminalFocus", | ||
| 45 | defaultKey: "mod+j", | ||
| 46 | defaultWhen: "", | ||
| 47 | source: "Custom", | ||
| 48 | }), | ||
| 49 | ]); | ||
| 50 | }); | ||
| 51 | |||
| 52 | it("captures platform-specific mod shortcuts", () => { | ||
| 53 | expect( | ||
| 54 | keybindingFromKeyboardEvent( | ||
| 55 | { key: "K", metaKey: true, ctrlKey: false, altKey: false, shiftKey: true }, | ||
| 56 | "MacIntel", | ||
| 57 | ), | ||
| 58 | ).toBe("mod+shift+k"); | ||
| 59 | expect( | ||
| 60 | keybindingFromKeyboardEvent( | ||
| 61 | { key: "K", metaKey: false, ctrlKey: true, altKey: false, shiftKey: true }, | ||
| 62 | "Win32", | ||
| 63 | ), | ||
| 64 | ).toBe("mod+shift+k"); | ||
| 65 | }); | ||
| 66 | |||
| 67 | it("serializes shortcuts and when expressions for upserts", () => { | ||
| 68 | expect( | ||
| 69 | shortcutToKeybindingInput({ | ||
| 70 | key: " ", | ||
| 71 | modKey: true, | ||
| 72 | metaKey: false, | ||
| 73 | ctrlKey: false, | ||
| 74 | altKey: true, | ||
| 75 | shiftKey: false, | ||
| 76 | }), | ||
| 77 | ).toBe("mod+alt+space"); | ||
| 78 | |||
| 79 | expect( | ||
| 80 | whenAstToExpression({ | ||
| 81 | type: "and", | ||
| 82 | left: { type: "identifier", name: "editorFocus" }, | ||
| 83 | right: { | ||
| 84 | type: "not", | ||
| 85 | node: { type: "identifier", name: "terminalFocus" }, | ||
| 86 | }, | ||
| 87 | }), | ||
| 88 | ).toBe("editorFocus && !terminalFocus"); | ||
| 89 | |||
| 90 | expect(parseWhenExpressionDraft("editorFocus && (!terminalFocus || modelPickerOpen)")).toEqual({ | ||
| 91 | ok: true, | ||
| 92 | value: { | ||
| 93 | type: "and", | ||
| 94 | left: { type: "identifier", name: "editorFocus" }, | ||
| 95 | right: { | ||
| 96 | type: "or", | ||
| 97 | left: { | ||
| 98 | type: "not", | ||
| 99 | node: { type: "identifier", name: "terminalFocus" }, | ||
| 100 | }, | ||
| 101 | right: { type: "identifier", name: "modelPickerOpen" }, | ||
| 102 | }, | ||
| 103 | }, | ||
| 104 | }); | ||
| 105 | expect(parseWhenExpressionDraft("editorFocus &&")).toEqual({ | ||
| 106 | ok: false, | ||
| 107 | message: "Use variables with !, &&, ||, and parentheses.", | ||
| 108 | }); | ||
| 109 | |||
| 110 | expect(parseWhenExpressionDraft("!(terminalFocus || modelPickerOpen)")).toEqual({ | ||
| 111 | ok: true, | ||
| 112 | value: { | ||
| 113 | type: "not", | ||
| 114 | node: { | ||
| 115 | type: "or", | ||
| 116 | left: { type: "identifier", name: "terminalFocus" }, | ||
| 117 | right: { type: "identifier", name: "modelPickerOpen" }, | ||
| 118 | }, | ||
| 119 | }, | ||
| 120 | }); | ||
| 121 | }); | ||
| 122 | |||
| 123 | it("formats static and project script command labels", () => { | ||
| 124 | expect(commandLabel("commandPalette.toggle")).toBe("Command Palette: Toggle"); | ||
| 125 | expect(commandLabel("script.setup-db.run")).toBe("Run Script: Setup Db"); | ||
| 126 | }); | ||
| 127 | |||
| 128 | it("builds known when variable options from defaults without frontend labels", () => { | ||
| 129 | const options = buildWhenVariableOptions(); | ||
| 130 | |||
| 131 | expect(options).toEqual( | ||
| 132 | expect.arrayContaining(["terminalFocus", "terminalOpen", "modelPickerOpen", "true", "false"]), | ||
| 133 | ); | ||
| 134 | expect(options).not.toContain("customModeActive"); | ||
| 135 | }); | ||
| 136 | |||
| 137 | it("builds command options from defaults and resolved project bindings", () => { | ||
| 138 | const options = buildKeybindingCommandOptions([ | ||
| 139 | { | ||
| 140 | command: "script.setup-db.run", | ||
| 141 | shortcut: { | ||
| 142 | key: "r", | ||
| 143 | modKey: true, | ||
| 144 | metaKey: false, | ||
| 145 | ctrlKey: false, | ||
| 146 | altKey: false, | ||
| 147 | shiftKey: false, | ||
| 148 | }, | ||
| 149 | }, | ||
| 150 | ] satisfies ResolvedKeybindingsConfig); | ||
| 151 | |||
| 152 | expect(options).toEqual(expect.arrayContaining(["chat.new", "script.setup-db.run"])); | ||
| 153 | }); | ||
| 154 | |||
| 155 | it("reports unknown when variables without rejecting parseable expressions", () => { | ||
| 156 | const parsed = parseWhenExpressionDraft("!terminalFocus && terminalFoc"); | ||
| 157 | |||
| 158 | expect(parsed.ok).toBe(true); | ||
| 159 | expect(unknownWhenVariables(parsed.ok ? parsed.value : undefined)).toEqual(["terminalFoc"]); | ||
| 160 | }); | ||
| 161 | |||
| 162 | it("marks each default shortcut for multi-binding commands as default", () => { | ||
| 163 | const rows = buildKeybindingRows( | ||
| 164 | [ | ||
| 165 | { | ||
| 166 | command: "chat.new", | ||
| 167 | shortcut: { | ||
| 168 | key: "n", | ||
| 169 | modKey: true, | ||
| 170 | metaKey: false, | ||
| 171 | ctrlKey: false, | ||
| 172 | altKey: false, | ||
| 173 | shiftKey: false, | ||
| 174 | }, | ||
| 175 | whenAst: { | ||
| 176 | type: "not", | ||
| 177 | node: { type: "identifier", name: "terminalFocus" }, | ||
| 178 | }, | ||
| 179 | }, | ||
| 180 | { | ||
| 181 | command: "chat.new", | ||
| 182 | shortcut: { | ||
| 183 | key: "o", | ||
| 184 | modKey: true, | ||
| 185 | metaKey: false, | ||
| 186 | ctrlKey: false, | ||
| 187 | altKey: false, | ||
| 188 | shiftKey: true, | ||
| 189 | }, | ||
| 190 | whenAst: { | ||
| 191 | type: "not", | ||
| 192 | node: { type: "identifier", name: "terminalFocus" }, | ||
| 193 | }, | ||
| 194 | }, | ||
| 195 | ] satisfies ResolvedKeybindingsConfig, | ||
| 196 | "", | ||
| 197 | ); | ||
| 198 | |||
| 199 | expect(rows.map((row) => row.source)).toEqual(["Default", "Default"]); | ||
| 200 | }); | ||
| 201 | |||
| 202 | it("reports conflicting shortcuts that share an active when context", () => { | ||
| 203 | const rows = buildKeybindingRows( | ||
| 204 | [ | ||
| 205 | { | ||
| 206 | command: "chat.new", | ||
| 207 | shortcut: { | ||
| 208 | key: "n", | ||
| 209 | modKey: true, | ||
| 210 | metaKey: false, | ||
| 211 | ctrlKey: false, | ||
| 212 | altKey: false, | ||
| 213 | shiftKey: false, | ||
| 214 | }, | ||
| 215 | whenAst: { | ||
| 216 | type: "not", | ||
| 217 | node: { type: "identifier", name: "terminalFocus" }, | ||
| 218 | }, | ||
| 219 | }, | ||
| 220 | { | ||
| 221 | command: "chat.newLocal", | ||
| 222 | shortcut: { | ||
| 223 | key: "n", | ||
| 224 | modKey: true, | ||
| 225 | metaKey: false, | ||
| 226 | ctrlKey: false, | ||
| 227 | altKey: false, | ||
| 228 | shiftKey: false, | ||
| 229 | }, | ||
| 230 | whenAst: { | ||
| 231 | type: "not", | ||
| 232 | node: { type: "identifier", name: "terminalFocus" }, | ||
| 233 | }, | ||
| 234 | }, | ||
| 235 | ] satisfies ResolvedKeybindingsConfig, | ||
| 236 | "", | ||
| 237 | ); | ||
| 238 | |||
| 239 | expect(rows[0]?.conflicts).toEqual(["Chat: New Local"]); | ||
| 240 | expect( | ||
| 241 | keybindingConflictLabels(rows, { | ||
| 242 | rowId: rows[0]?.id ?? "", | ||
| 243 | key: "mod+n", | ||
| 244 | when: "", | ||
| 245 | }), | ||
| 246 | ).toEqual(["Chat: New Local"]); | ||
| 247 | }); | ||
| 248 | }); | ||
apps/web/src/components/settings/KeybindingsSettings.logic.ts created+337| ... | @@ -0,0 +1,337 @@ | ||
| 1 | import { | ||
| 2 | type KeybindingCommand, | ||
| 3 | type KeybindingShortcut, | ||
| 4 | type KeybindingWhenNode, | ||
| 5 | type ResolvedKeybindingRule, | ||
| 6 | type ResolvedKeybindingsConfig, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import { | ||
| 9 | DEFAULT_RESOLVED_KEYBINDINGS, | ||
| 10 | parseKeybindingWhenExpression, | ||
| 11 | } from "@t3tools/shared/keybindings"; | ||
| 12 | |||
| 13 | import { isMacPlatform } from "../../lib/utils"; | ||
| 14 | |||
| 15 | export type KeybindingSource = "Default" | "Custom" | "Project"; | ||
| 16 | |||
| 17 | export interface KeybindingRow { | ||
| 18 | readonly id: string; | ||
| 19 | readonly command: KeybindingCommand; | ||
| 20 | readonly key: string; | ||
| 21 | readonly when: string; | ||
| 22 | readonly source: KeybindingSource; | ||
| 23 | readonly defaultKey: string | null; | ||
| 24 | readonly defaultWhen: string; | ||
| 25 | readonly binding: ResolvedKeybindingRule; | ||
| 26 | readonly conflicts: ReadonlyArray<string>; | ||
| 27 | } | ||
| 28 | |||
| 29 | export type WhenVariableOption = string; | ||
| 30 | export type KeybindingCommandOption = KeybindingCommand; | ||
| 31 | |||
| 32 | const CORE_WHEN_VARIABLES = ["terminalFocus", "terminalOpen", "true", "false"] as const; | ||
| 33 | |||
| 34 | const DEFAULT_WHEN_VARIABLES = new Set<string>(CORE_WHEN_VARIABLES); | ||
| 35 | for (const binding of DEFAULT_RESOLVED_KEYBINDINGS) { | ||
| 36 | collectWhenIdentifiersFromNode(binding.whenAst, DEFAULT_WHEN_VARIABLES); | ||
| 37 | } | ||
| 38 | |||
| 39 | export const DEFAULT_WHEN_VARIABLE = | ||
| 40 | [...DEFAULT_WHEN_VARIABLES].find( | ||
| 41 | (identifier) => identifier !== "true" && identifier !== "false", | ||
| 42 | ) ?? "terminalFocus"; | ||
| 43 | const KNOWN_WHEN_VARIABLES = new Set(DEFAULT_WHEN_VARIABLES); | ||
| 44 | |||
| 45 | export function shortcutToKeybindingInput(shortcut: KeybindingShortcut): string { | ||
| 46 | const parts: string[] = []; | ||
| 47 | if (shortcut.modKey) parts.push("mod"); | ||
| 48 | if (shortcut.metaKey) parts.push("meta"); | ||
| 49 | if (shortcut.ctrlKey) parts.push("ctrl"); | ||
| 50 | if (shortcut.altKey) parts.push("alt"); | ||
| 51 | if (shortcut.shiftKey) parts.push("shift"); | ||
| 52 | parts.push(shortcut.key === " " ? "space" : shortcut.key === "escape" ? "esc" : shortcut.key); | ||
| 53 | return parts.join("+"); | ||
| 54 | } | ||
| 55 | |||
| 56 | export function whenAstToExpression(node: KeybindingWhenNode | undefined): string { | ||
| 57 | if (!node) return ""; | ||
| 58 | switch (node.type) { | ||
| 59 | case "identifier": | ||
| 60 | return node.name; | ||
| 61 | case "not": | ||
| 62 | return `!${wrapWhenExpression(node.node)}`; | ||
| 63 | case "and": | ||
| 64 | return `${wrapWhenExpression(node.left)} && ${wrapWhenExpression(node.right)}`; | ||
| 65 | case "or": | ||
| 66 | return `${wrapWhenExpression(node.left)} || ${wrapWhenExpression(node.right)}`; | ||
| 67 | } | ||
| 68 | } | ||
| 69 | |||
| 70 | function wrapWhenExpression(node: KeybindingWhenNode): string { | ||
| 71 | if (node.type === "identifier" || node.type === "not") return whenAstToExpression(node); | ||
| 72 | return `(${whenAstToExpression(node)})`; | ||
| 73 | } | ||
| 74 | |||
| 75 | export function parseWhenExpressionDraft( | ||
| 76 | expression: string, | ||
| 77 | ): { ok: true; value: KeybindingWhenNode | undefined } | { ok: false; message: string } { | ||
| 78 | const trimmed = expression.trim(); | ||
| 79 | if (trimmed.length === 0) return { ok: true, value: undefined }; | ||
| 80 | |||
| 81 | const ast = parseKeybindingWhenExpression(trimmed); | ||
| 82 | if (!ast) { | ||
| 83 | return { | ||
| 84 | ok: false, | ||
| 85 | message: "Use variables with !, &&, ||, and parentheses.", | ||
| 86 | }; | ||
| 87 | } | ||
| 88 | |||
| 89 | return { ok: true, value: ast }; | ||
| 90 | } | ||
| 91 | |||
| 92 | function sourceForBinding(binding: ResolvedKeybindingRule): KeybindingSource { | ||
| 93 | if (String(binding.command).startsWith("script.")) { | ||
| 94 | return "Project"; | ||
| 95 | } | ||
| 96 | |||
| 97 | const bindingKey = shortcutToKeybindingInput(binding.shortcut); | ||
| 98 | const bindingWhen = whenAstToExpression(binding.whenAst); | ||
| 99 | const isDefault = DEFAULT_RESOLVED_KEYBINDINGS.some( | ||
| 100 | (entry) => | ||
| 101 | entry.command === binding.command && | ||
| 102 | shortcutToKeybindingInput(entry.shortcut) === bindingKey && | ||
| 103 | whenAstToExpression(entry.whenAst) === bindingWhen, | ||
| 104 | ); | ||
| 105 | |||
| 106 | return isDefault ? "Default" : "Custom"; | ||
| 107 | } | ||
| 108 | |||
| 109 | function defaultBindingForBinding( | ||
| 110 | binding: ResolvedKeybindingRule, | ||
| 111 | ): ResolvedKeybindingRule | undefined { | ||
| 112 | const bindingKey = shortcutToKeybindingInput(binding.shortcut); | ||
| 113 | const bindingWhen = whenAstToExpression(binding.whenAst); | ||
| 114 | |||
| 115 | return ( | ||
| 116 | DEFAULT_RESOLVED_KEYBINDINGS.find( | ||
| 117 | (entry) => | ||
| 118 | entry.command === binding.command && | ||
| 119 | shortcutToKeybindingInput(entry.shortcut) === bindingKey && | ||
| 120 | whenAstToExpression(entry.whenAst) === bindingWhen, | ||
| 121 | ) ?? | ||
| 122 | DEFAULT_RESOLVED_KEYBINDINGS.find( | ||
| 123 | (entry) => | ||
| 124 | entry.command === binding.command && whenAstToExpression(entry.whenAst) === bindingWhen, | ||
| 125 | ) ?? | ||
| 126 | DEFAULT_RESOLVED_KEYBINDINGS.find((entry) => entry.command === binding.command) | ||
| 127 | ); | ||
| 128 | } | ||
| 129 | |||
| 130 | function keybindingRowId(command: KeybindingCommand, key: string, when: string): string { | ||
| 131 | return `${command}\u0000${key}\u0000${when}`; | ||
| 132 | } | ||
| 133 | |||
| 134 | function conflictsWithWhen(leftWhen: string, rightWhen: string): boolean { | ||
| 135 | return leftWhen.length === 0 || rightWhen.length === 0 || leftWhen === rightWhen; | ||
| 136 | } | ||
| 137 | |||
| 138 | export function keybindingConflictLabels( | ||
| 139 | rows: ReadonlyArray<KeybindingRow>, | ||
| 140 | input: { readonly rowId: string; readonly key: string; readonly when: string }, | ||
| 141 | ): ReadonlyArray<string> { | ||
| 142 | if (input.key.trim().length === 0) return []; | ||
| 143 | const conflicts = rows | ||
| 144 | .filter( | ||
| 145 | (candidate) => | ||
| 146 | candidate.id !== input.rowId && | ||
| 147 | candidate.key === input.key && | ||
| 148 | conflictsWithWhen(candidate.when, input.when), | ||
| 149 | ) | ||
| 150 | .map((candidate) => commandLabel(candidate.command)); | ||
| 151 | return [...new Set(conflicts)].toSorted(); | ||
| 152 | } | ||
| 153 | |||
| 154 | export function buildKeybindingRows( | ||
| 155 | keybindings: ResolvedKeybindingsConfig, | ||
| 156 | query: string, | ||
| 157 | ): ReadonlyArray<KeybindingRow> { | ||
| 158 | const normalizedQuery = query.trim().toLowerCase(); | ||
| 159 | const rows = keybindings.map((binding, index) => { | ||
| 160 | const defaultBinding = defaultBindingForBinding(binding); | ||
| 161 | const key = shortcutToKeybindingInput(binding.shortcut); | ||
| 162 | const when = whenAstToExpression(binding.whenAst); | ||
| 163 | return { | ||
| 164 | id: `${keybindingRowId(binding.command, key, when)}\u0000${index}`, | ||
| 165 | command: binding.command, | ||
| 166 | key, | ||
| 167 | when, | ||
| 168 | source: sourceForBinding(binding), | ||
| 169 | defaultKey: defaultBinding ? shortcutToKeybindingInput(defaultBinding.shortcut) : null, | ||
| 170 | defaultWhen: whenAstToExpression(defaultBinding?.whenAst), | ||
| 171 | binding, | ||
| 172 | conflicts: [], | ||
| 173 | } satisfies KeybindingRow; | ||
| 174 | }); | ||
| 175 | |||
| 176 | const rowsWithConflicts = rows.map((row) => { | ||
| 177 | const conflicts = keybindingConflictLabels(rows, { | ||
| 178 | rowId: row.id, | ||
| 179 | key: row.key, | ||
| 180 | when: row.when, | ||
| 181 | }); | ||
| 182 | return conflicts.length > 0 | ||
| 183 | ? Object.assign({}, row, { conflicts: [...new Set(conflicts)].toSorted() }) | ||
| 184 | : row; | ||
| 185 | }); | ||
| 186 | |||
| 187 | rowsWithConflicts.sort((left, right) => { | ||
| 188 | const commandCompare = left.command.localeCompare(right.command); | ||
| 189 | if (commandCompare !== 0) return commandCompare; | ||
| 190 | return left.key.localeCompare(right.key); | ||
| 191 | }); | ||
| 192 | |||
| 193 | if (normalizedQuery.length === 0) { | ||
| 194 | return rowsWithConflicts; | ||
| 195 | } | ||
| 196 | |||
| 197 | return rowsWithConflicts.filter((row) => { | ||
| 198 | return ( | ||
| 199 | row.command.toLowerCase().includes(normalizedQuery) || | ||
| 200 | row.key.toLowerCase().includes(normalizedQuery) || | ||
| 201 | row.when.toLowerCase().includes(normalizedQuery) || | ||
| 202 | row.source.toLowerCase().includes(normalizedQuery) | ||
| 203 | ); | ||
| 204 | }); | ||
| 205 | } | ||
| 206 | |||
| 207 | function collectWhenIdentifiersFromNode( | ||
| 208 | node: KeybindingWhenNode | undefined, | ||
| 209 | identifiers: Set<string>, | ||
| 210 | ): void { | ||
| 211 | if (!node) return; | ||
| 212 | switch (node.type) { | ||
| 213 | case "identifier": | ||
| 214 | identifiers.add(node.name); | ||
| 215 | return; | ||
| 216 | case "not": | ||
| 217 | collectWhenIdentifiersFromNode(node.node, identifiers); | ||
| 218 | return; | ||
| 219 | case "and": | ||
| 220 | case "or": | ||
| 221 | collectWhenIdentifiersFromNode(node.left, identifiers); | ||
| 222 | collectWhenIdentifiersFromNode(node.right, identifiers); | ||
| 223 | return; | ||
| 224 | } | ||
| 225 | } | ||
| 226 | |||
| 227 | export function isKnownWhenVariable(identifier: string): boolean { | ||
| 228 | return KNOWN_WHEN_VARIABLES.has(identifier); | ||
| 229 | } | ||
| 230 | |||
| 231 | export function unknownWhenVariables(node: KeybindingWhenNode | undefined): ReadonlyArray<string> { | ||
| 232 | const identifiers = new Set<string>(); | ||
| 233 | collectWhenIdentifiersFromNode(node, identifiers); | ||
| 234 | return [...identifiers].filter((identifier) => !isKnownWhenVariable(identifier)).toSorted(); | ||
| 235 | } | ||
| 236 | |||
| 237 | export function buildWhenVariableOptions(): ReadonlyArray<WhenVariableOption> { | ||
| 238 | return [...KNOWN_WHEN_VARIABLES].toSorted((left, right) => { | ||
| 239 | const leftCoreIndex = CORE_WHEN_VARIABLES.indexOf(left as (typeof CORE_WHEN_VARIABLES)[number]); | ||
| 240 | const rightCoreIndex = CORE_WHEN_VARIABLES.indexOf( | ||
| 241 | right as (typeof CORE_WHEN_VARIABLES)[number], | ||
| 242 | ); | ||
| 243 | if (leftCoreIndex !== -1 || rightCoreIndex !== -1) { | ||
| 244 | return ( | ||
| 245 | (leftCoreIndex === -1 ? Number.MAX_SAFE_INTEGER : leftCoreIndex) - | ||
| 246 | (rightCoreIndex === -1 ? Number.MAX_SAFE_INTEGER : rightCoreIndex) | ||
| 247 | ); | ||
| 248 | } | ||
| 249 | return left.localeCompare(right); | ||
| 250 | }); | ||
| 251 | } | ||
| 252 | |||
| 253 | export function buildKeybindingCommandOptions( | ||
| 254 | keybindings: ResolvedKeybindingsConfig, | ||
| 255 | ): ReadonlyArray<KeybindingCommandOption> { | ||
| 256 | const commands = new Set<KeybindingCommand>(); | ||
| 257 | for (const binding of DEFAULT_RESOLVED_KEYBINDINGS) { | ||
| 258 | commands.add(binding.command); | ||
| 259 | } | ||
| 260 | for (const binding of keybindings) { | ||
| 261 | commands.add(binding.command); | ||
| 262 | } | ||
| 263 | return [...commands].toSorted((left, right) => | ||
| 264 | commandLabel(left).localeCompare(commandLabel(right)), | ||
| 265 | ); | ||
| 266 | } | ||
| 267 | |||
| 268 | export function commandLabel(command: KeybindingCommand): string { | ||
| 269 | const raw = String(command); | ||
| 270 | if (raw.startsWith("script.") && raw.endsWith(".run")) { | ||
| 271 | return `Run Script: ${titleCaseCommandSegment(raw.slice("script.".length, -".run".length))}`; | ||
| 272 | } | ||
| 273 | return raw.split(".").map(titleCaseCommandSegment).join(": "); | ||
| 274 | } | ||
| 275 | |||
| 276 | function titleCaseCommandSegment(segment: string): string { | ||
| 277 | return segment | ||
| 278 | .replace(/([a-z0-9])([A-Z])/g, "$1 $2") | ||
| 279 | .split(/[-_\s]+/) | ||
| 280 | .filter(Boolean) | ||
| 281 | .map((part) => part.slice(0, 1).toUpperCase() + part.slice(1)) | ||
| 282 | .join(" "); | ||
| 283 | } | ||
| 284 | |||
| 285 | export function normalizeShortcutKeyToken(key: string): string | null { | ||
| 286 | const normalized = key.toLowerCase(); | ||
| 287 | if ( | ||
| 288 | normalized === "meta" || | ||
| 289 | normalized === "control" || | ||
| 290 | normalized === "ctrl" || | ||
| 291 | normalized === "shift" || | ||
| 292 | normalized === "alt" || | ||
| 293 | normalized === "option" | ||
| 294 | ) { | ||
| 295 | return null; | ||
| 296 | } | ||
| 297 | if (normalized === " ") return "space"; | ||
| 298 | if (normalized === "escape") return "esc"; | ||
| 299 | if (normalized === "arrowup") return "arrowup"; | ||
| 300 | if (normalized === "arrowdown") return "arrowdown"; | ||
| 301 | if (normalized === "arrowleft") return "arrowleft"; | ||
| 302 | if (normalized === "arrowright") return "arrowright"; | ||
| 303 | if (normalized.length === 1) return normalized; | ||
| 304 | if (/^f\d{1,2}$/.test(normalized)) return normalized; | ||
| 305 | if (normalized === "enter" || normalized === "tab" || normalized === "backspace") { | ||
| 306 | return normalized; | ||
| 307 | } | ||
| 308 | if (normalized === "delete" || normalized === "home" || normalized === "end") { | ||
| 309 | return normalized; | ||
| 310 | } | ||
| 311 | if (normalized === "pageup" || normalized === "pagedown") return normalized; | ||
| 312 | return null; | ||
| 313 | } | ||
| 314 | |||
| 315 | export function keybindingFromKeyboardEvent( | ||
| 316 | event: Pick<KeyboardEvent, "key" | "metaKey" | "ctrlKey" | "altKey" | "shiftKey">, | ||
| 317 | platform: string, | ||
| 318 | ): string | null { | ||
| 319 | const keyToken = normalizeShortcutKeyToken(event.key); | ||
| 320 | if (!keyToken) return null; | ||
| 321 | |||
| 322 | const parts: string[] = []; | ||
| 323 | if (isMacPlatform(platform)) { | ||
| 324 | if (event.metaKey) parts.push("mod"); | ||
| 325 | if (event.ctrlKey) parts.push("ctrl"); | ||
| 326 | } else { | ||
| 327 | if (event.ctrlKey) parts.push("mod"); | ||
| 328 | if (event.metaKey) parts.push("meta"); | ||
| 329 | } | ||
| 330 | if (event.altKey) parts.push("alt"); | ||
| 331 | if (event.shiftKey) parts.push("shift"); | ||
| 332 | if (parts.length === 0) { | ||
| 333 | return null; | ||
| 334 | } | ||
| 335 | parts.push(keyToken); | ||
| 336 | return parts.join("+"); | ||
| 337 | } | ||
apps/web/src/components/settings/KeybindingsSettings.tsx created+1282| ... | @@ -0,0 +1,1282 @@ | ||
| 1 | import { | ||
| 2 | ChevronDownIcon, | ||
| 3 | CircleXIcon, | ||
| 4 | EllipsisIcon, | ||
| 5 | FileJsonIcon, | ||
| 6 | InfoIcon, | ||
| 7 | MinusIcon, | ||
| 8 | PlusIcon, | ||
| 9 | SearchIcon, | ||
| 10 | TriangleAlertIcon, | ||
| 11 | XIcon, | ||
| 12 | } from "lucide-react"; | ||
| 13 | import { | ||
| 14 | type KeyboardEvent, | ||
| 15 | type ReactNode, | ||
| 16 | type RefObject, | ||
| 17 | useCallback, | ||
| 18 | useEffect, | ||
| 19 | useMemo, | ||
| 20 | useReducer, | ||
| 21 | useRef, | ||
| 22 | useState, | ||
| 23 | } from "react"; | ||
| 24 | import { | ||
| 25 | type KeybindingCommand, | ||
| 26 | type KeybindingWhenNode, | ||
| 27 | type ServerRemoveKeybindingInput, | ||
| 28 | type ServerUpsertKeybindingInput, | ||
| 29 | } from "@t3tools/contracts"; | ||
| 30 | |||
| 31 | import { isElectron } from "../../env"; | ||
| 32 | import { openInPreferredEditor } from "../../editorPreferences"; | ||
| 33 | import { formatShortcutLabel } from "../../keybindings"; | ||
| 34 | import { cn } from "../../lib/utils"; | ||
| 35 | import { ensureLocalApi } from "../../localApi"; | ||
| 36 | import { useServerKeybindings, useServerKeybindingsConfigPath } from "../../rpc/serverState"; | ||
| 37 | import { Button } from "../ui/button"; | ||
| 38 | import { Input } from "../ui/input"; | ||
| 39 | import { Kbd, KbdGroup } from "../ui/kbd"; | ||
| 40 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; | ||
| 41 | import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; | ||
| 42 | import { ScrollArea } from "../ui/scroll-area"; | ||
| 43 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; | ||
| 44 | import { Toggle } from "../ui/toggle"; | ||
| 45 | import { toastManager } from "../ui/toast"; | ||
| 46 | import { | ||
| 47 | buildKeybindingRows, | ||
| 48 | buildKeybindingCommandOptions, | ||
| 49 | buildWhenVariableOptions, | ||
| 50 | commandLabel, | ||
| 51 | DEFAULT_WHEN_VARIABLE, | ||
| 52 | isKnownWhenVariable, | ||
| 53 | keybindingConflictLabels, | ||
| 54 | keybindingFromKeyboardEvent, | ||
| 55 | parseWhenExpressionDraft, | ||
| 56 | type KeybindingCommandOption, | ||
| 57 | type KeybindingRow, | ||
| 58 | type WhenVariableOption, | ||
| 59 | unknownWhenVariables, | ||
| 60 | whenAstToExpression, | ||
| 61 | } from "./KeybindingsSettings.logic"; | ||
| 62 | import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; | ||
| 63 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | ||
| 64 | |||
| 65 | function KeybindingPill({ value }: { value: string }) { | ||
| 66 | const parts = value.split("+"); | ||
| 67 | return ( | ||
| 68 | <KbdGroup className="bg-transparent p-0 shadow-none"> | ||
| 69 | {parts.map((part) => ( | ||
| 70 | <Kbd key={part} className="min-w-6 justify-center px-1.5"> | ||
| 71 | {part === "mod" | ||
| 72 | ? navigator.platform.toLowerCase().includes("mac") | ||
| 73 | ? "⌘" | ||
| 74 | : "Ctrl" | ||
| 75 | : part === "shift" | ||
| 76 | ? "⇧" | ||
| 77 | : part === "alt" | ||
| 78 | ? navigator.platform.toLowerCase().includes("mac") | ||
| 79 | ? "⌥" | ||
| 80 | : "Alt" | ||
| 81 | : part === "ctrl" | ||
| 82 | ? "⌃" | ||
| 83 | : part.length === 1 | ||
| 84 | ? part.toUpperCase() | ||
| 85 | : part} | ||
| 86 | </Kbd> | ||
| 87 | ))} | ||
| 88 | </KbdGroup> | ||
| 89 | ); | ||
| 90 | } | ||
| 91 | |||
| 92 | function ExpandableHeaderSearch({ | ||
| 93 | query, | ||
| 94 | onChange, | ||
| 95 | isOpen, | ||
| 96 | onOpenChange, | ||
| 97 | inputRef, | ||
| 98 | collapsedAccessory, | ||
| 99 | }: { | ||
| 100 | query: string; | ||
| 101 | onChange: (next: string) => void; | ||
| 102 | isOpen: boolean; | ||
| 103 | onOpenChange: (next: boolean) => void; | ||
| 104 | inputRef?: RefObject<HTMLInputElement | null>; | ||
| 105 | collapsedAccessory?: ReactNode; | ||
| 106 | }) { | ||
| 107 | if (!isOpen) { | ||
| 108 | return ( | ||
| 109 | <> | ||
| 110 | {collapsedAccessory} | ||
| 111 | <Tooltip> | ||
| 112 | <TooltipTrigger | ||
| 113 | render={ | ||
| 114 | <Button | ||
| 115 | type="button" | ||
| 116 | size="icon-xs" | ||
| 117 | variant="ghost" | ||
| 118 | className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground" | ||
| 119 | onClick={() => onOpenChange(true)} | ||
| 120 | aria-label="Search keybindings" | ||
| 121 | > | ||
| 122 | <SearchIcon className="size-3" /> | ||
| 123 | </Button> | ||
| 124 | } | ||
| 125 | /> | ||
| 126 | <TooltipPopup side="top">Search keybindings</TooltipPopup> | ||
| 127 | </Tooltip> | ||
| 128 | </> | ||
| 129 | ); | ||
| 130 | } | ||
| 131 | |||
| 132 | return ( | ||
| 133 | <div className="relative"> | ||
| 134 | <SearchIcon className="pointer-events-none absolute top-1/2 left-2 size-3 -translate-y-1/2 text-muted-foreground" /> | ||
| 135 | <input | ||
| 136 | ref={inputRef} | ||
| 137 | autoFocus | ||
| 138 | type="text" | ||
| 139 | value={query} | ||
| 140 | onChange={(event) => onChange(event.currentTarget.value)} | ||
| 141 | onBlur={() => { | ||
| 142 | if (query.length === 0) onOpenChange(false); | ||
| 143 | }} | ||
| 144 | onKeyDown={(event) => { | ||
| 145 | if (event.key === "Escape") { | ||
| 146 | event.preventDefault(); | ||
| 147 | onChange(""); | ||
| 148 | onOpenChange(false); | ||
| 149 | } | ||
| 150 | }} | ||
| 151 | placeholder="Search keybindings" | ||
| 152 | aria-label="Search keybindings" | ||
| 153 | className="h-6 w-44 rounded-md border border-input bg-background pl-7 pr-2 text-[11px] text-foreground outline-none placeholder:text-muted-foreground/72 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/24" | ||
| 154 | /> | ||
| 155 | </div> | ||
| 156 | ); | ||
| 157 | } | ||
| 158 | |||
| 159 | type BooleanOperator = "and" | "or"; | ||
| 160 | |||
| 161 | function flattenWhenChildren( | ||
| 162 | node: KeybindingWhenNode, | ||
| 163 | operator: BooleanOperator, | ||
| 164 | ): KeybindingWhenNode[] { | ||
| 165 | if (node.type !== operator) return [node]; | ||
| 166 | return [ | ||
| 167 | ...flattenWhenChildren(node.left, operator), | ||
| 168 | ...flattenWhenChildren(node.right, operator), | ||
| 169 | ]; | ||
| 170 | } | ||
| 171 | |||
| 172 | function buildWhenExpressionGroup( | ||
| 173 | children: readonly KeybindingWhenNode[], | ||
| 174 | operator: BooleanOperator, | ||
| 175 | ): KeybindingWhenNode | undefined { | ||
| 176 | const first = children[0]; | ||
| 177 | if (!first) return undefined; | ||
| 178 | return children.slice(1).reduce<KeybindingWhenNode>( | ||
| 179 | (left, right) => ({ | ||
| 180 | type: operator, | ||
| 181 | left, | ||
| 182 | right, | ||
| 183 | }), | ||
| 184 | first, | ||
| 185 | ); | ||
| 186 | } | ||
| 187 | |||
| 188 | function conditionParts(node: KeybindingWhenNode): { identifier: string; negated: boolean } | null { | ||
| 189 | if (node.type === "identifier") return { identifier: node.name, negated: false }; | ||
| 190 | if (node.type === "not" && node.node.type === "identifier") { | ||
| 191 | return { identifier: node.node.name, negated: true }; | ||
| 192 | } | ||
| 193 | return null; | ||
| 194 | } | ||
| 195 | |||
| 196 | function setConditionIdentifier(node: KeybindingWhenNode, identifier: string): KeybindingWhenNode { | ||
| 197 | const parts = conditionParts(node); | ||
| 198 | if (!parts) return node; | ||
| 199 | const next: KeybindingWhenNode = { type: "identifier", name: identifier }; | ||
| 200 | return parts.negated ? { type: "not", node: next } : next; | ||
| 201 | } | ||
| 202 | |||
| 203 | function setConditionNegated(node: KeybindingWhenNode, negated: boolean): KeybindingWhenNode { | ||
| 204 | const parts = conditionParts(node); | ||
| 205 | if (!parts) return negated ? { type: "not", node } : node; | ||
| 206 | const identifier: KeybindingWhenNode = { type: "identifier", name: parts.identifier }; | ||
| 207 | return negated ? { type: "not", node: identifier } : identifier; | ||
| 208 | } | ||
| 209 | |||
| 210 | function defaultWhenCondition(): KeybindingWhenNode { | ||
| 211 | return { type: "identifier", name: DEFAULT_WHEN_VARIABLE }; | ||
| 212 | } | ||
| 213 | |||
| 214 | function defaultWhenGroup(operator: BooleanOperator = "and"): KeybindingWhenNode { | ||
| 215 | return { | ||
| 216 | type: operator, | ||
| 217 | left: defaultWhenCondition(), | ||
| 218 | right: { type: "not", node: defaultWhenCondition() }, | ||
| 219 | }; | ||
| 220 | } | ||
| 221 | |||
| 222 | function UnknownWhenVariableWarning({ | ||
| 223 | identifiers, | ||
| 224 | focusable = true, | ||
| 225 | }: { | ||
| 226 | identifiers: ReadonlyArray<string>; | ||
| 227 | focusable?: boolean; | ||
| 228 | }) { | ||
| 229 | if (identifiers.length === 0) return null; | ||
| 230 | const label = | ||
| 231 | identifiers.length === 1 | ||
| 232 | ? `Unknown condition: ${identifiers[0]}` | ||
| 233 | : `Unknown conditions: ${identifiers.join(", ")}`; | ||
| 234 | |||
| 235 | return ( | ||
| 236 | <Tooltip> | ||
| 237 | <TooltipTrigger | ||
| 238 | render={ | ||
| 239 | <span | ||
| 240 | tabIndex={focusable ? 0 : undefined} | ||
| 241 | aria-label={label} | ||
| 242 | className="inline-flex size-4.5 shrink-0 items-center justify-center rounded-sm text-warning outline-none transition-colors hover:bg-warning/10 focus-visible:ring-[3px] focus-visible:ring-warning/25" | ||
| 243 | > | ||
| 244 | <TriangleAlertIcon className="size-3.5" /> | ||
| 245 | </span> | ||
| 246 | } | ||
| 247 | /> | ||
| 248 | <TooltipPopup side="top" className="max-w-72 whitespace-normal leading-relaxed"> | ||
| 249 | T3 Code does not recognize this condition yet. It can still be saved, but it may not match | ||
| 250 | unless the runtime provides it. | ||
| 251 | </TooltipPopup> | ||
| 252 | </Tooltip> | ||
| 253 | ); | ||
| 254 | } | ||
| 255 | |||
| 256 | function KeybindingConflictWarning({ labels }: { labels: ReadonlyArray<string> }) { | ||
| 257 | if (labels.length === 0) return null; | ||
| 258 | const description = | ||
| 259 | labels.length === 1 | ||
| 260 | ? `Conflicts with ${labels[0]}.` | ||
| 261 | : `Conflicts with ${labels.slice(0, 3).join(", ")}${labels.length > 3 ? ", and more" : ""}.`; | ||
| 262 | |||
| 263 | return ( | ||
| 264 | <Tooltip> | ||
| 265 | <TooltipTrigger | ||
| 266 | render={ | ||
| 267 | <span | ||
| 268 | tabIndex={0} | ||
| 269 | aria-label={description} | ||
| 270 | className="inline-flex size-5 shrink-0 items-center justify-center rounded-sm text-warning outline-none transition-colors hover:bg-warning/10 focus-visible:ring-[3px] focus-visible:ring-warning/25" | ||
| 271 | > | ||
| 272 | <TriangleAlertIcon className="size-3.5" /> | ||
| 273 | </span> | ||
| 274 | } | ||
| 275 | /> | ||
| 276 | <TooltipPopup side="top" className="max-w-72 whitespace-normal leading-relaxed"> | ||
| 277 | {description} The most recent matching binding wins when both conditions can apply. | ||
| 278 | </TooltipPopup> | ||
| 279 | </Tooltip> | ||
| 280 | ); | ||
| 281 | } | ||
| 282 | |||
| 283 | function WhenVariableSelect({ | ||
| 284 | value, | ||
| 285 | variables, | ||
| 286 | unknownIdentifiers, | ||
| 287 | onChange, | ||
| 288 | }: { | ||
| 289 | value: string; | ||
| 290 | variables: ReadonlyArray<WhenVariableOption>; | ||
| 291 | unknownIdentifiers?: ReadonlyArray<string>; | ||
| 292 | onChange: (value: string) => void; | ||
| 293 | }) { | ||
| 294 | const selected = variables.find((option) => option === value); | ||
| 295 | const options = | ||
| 296 | selected || variables.some((option) => option === value) ? variables : [value, ...variables]; | ||
| 297 | |||
| 298 | return ( | ||
| 299 | <Select value={value} onValueChange={(nextValue) => nextValue && onChange(nextValue)}> | ||
| 300 | <SelectTrigger | ||
| 301 | size="xs" | ||
| 302 | className="h-7 min-h-7 min-w-0 flex-1 rounded-md font-mono text-xs sm:h-7" | ||
| 303 | > | ||
| 304 | <SelectValue placeholder="Condition" className="leading-7" /> | ||
| 305 | {unknownIdentifiers && unknownIdentifiers.length > 0 ? ( | ||
| 306 | <UnknownWhenVariableWarning identifiers={unknownIdentifiers} focusable={false} /> | ||
| 307 | ) : null} | ||
| 308 | </SelectTrigger> | ||
| 309 | <SelectContent | ||
| 310 | alignItemWithTrigger={false} | ||
| 311 | matchTriggerWidth={false} | ||
| 312 | popupClassName="w-fit" | ||
| 313 | className="max-h-72 w-fit min-w-44" | ||
| 314 | > | ||
| 315 | {options.map((option) => ( | ||
| 316 | <SelectItem | ||
| 317 | key={option} | ||
| 318 | value={option} | ||
| 319 | className="min-h-7 w-full py-1 font-mono text-[12px]" | ||
| 320 | > | ||
| 321 | <span className="truncate">{option}</span> | ||
| 322 | </SelectItem> | ||
| 323 | ))} | ||
| 324 | </SelectContent> | ||
| 325 | </Select> | ||
| 326 | ); | ||
| 327 | } | ||
| 328 | |||
| 329 | function WhenExpressionNodeEditor({ | ||
| 330 | node, | ||
| 331 | variables, | ||
| 332 | depth = 0, | ||
| 333 | onChange, | ||
| 334 | onRemove, | ||
| 335 | }: { | ||
| 336 | node: KeybindingWhenNode; | ||
| 337 | variables: ReadonlyArray<WhenVariableOption>; | ||
| 338 | depth?: number; | ||
| 339 | onChange: (node: KeybindingWhenNode) => void; | ||
| 340 | onRemove?: () => void; | ||
| 341 | }) { | ||
| 342 | const condition = conditionParts(node); | ||
| 343 | |||
| 344 | if (condition) { | ||
| 345 | const unknownIdentifiers = isKnownWhenVariable(condition.identifier) | ||
| 346 | ? [] | ||
| 347 | : [condition.identifier]; | ||
| 348 | |||
| 349 | return ( | ||
| 350 | <div className="flex items-center gap-2 rounded-md border border-border/70 bg-background/60 px-2 py-2"> | ||
| 351 | <Toggle | ||
| 352 | pressed={condition.negated} | ||
| 353 | onPressedChange={(pressed) => onChange(setConditionNegated(node, pressed))} | ||
| 354 | aria-label={`Negate ${condition.identifier}`} | ||
| 355 | variant="outline" | ||
| 356 | size="xs" | ||
| 357 | className="h-7 min-w-10 px-2 text-[11px] sm:h-7" | ||
| 358 | > | ||
| 359 | Not | ||
| 360 | </Toggle> | ||
| 361 | <WhenVariableSelect | ||
| 362 | value={condition.identifier} | ||
| 363 | variables={variables} | ||
| 364 | unknownIdentifiers={unknownIdentifiers} | ||
| 365 | onChange={(value) => onChange(setConditionIdentifier(node, value))} | ||
| 366 | /> | ||
| 367 | {onRemove ? ( | ||
| 368 | <Button | ||
| 369 | type="button" | ||
| 370 | variant="ghost" | ||
| 371 | size="icon-sm" | ||
| 372 | className="size-7 sm:size-7" | ||
| 373 | aria-label="Remove condition" | ||
| 374 | onClick={onRemove} | ||
| 375 | > | ||
| 376 | <MinusIcon className="size-3.5" /> | ||
| 377 | </Button> | ||
| 378 | ) : null} | ||
| 379 | </div> | ||
| 380 | ); | ||
| 381 | } | ||
| 382 | |||
| 383 | if (node.type === "not") { | ||
| 384 | return ( | ||
| 385 | <div | ||
| 386 | className={cn( | ||
| 387 | "space-y-2 rounded-lg border border-border/70 bg-muted/20 p-2", | ||
| 388 | depth > 0 && "border-border/50 bg-background/50", | ||
| 389 | )} | ||
| 390 | > | ||
| 391 | <div className="flex items-center gap-2"> | ||
| 392 | <Toggle | ||
| 393 | pressed | ||
| 394 | onPressedChange={(pressed) => onChange(pressed ? node : node.node)} | ||
| 395 | aria-label="Negate group" | ||
| 396 | variant="outline" | ||
| 397 | size="xs" | ||
| 398 | className="h-7 min-w-10 px-2 text-[11px] sm:h-7" | ||
| 399 | > | ||
| 400 | Not | ||
| 401 | </Toggle> | ||
| 402 | {onRemove ? ( | ||
| 403 | <Button | ||
| 404 | type="button" | ||
| 405 | variant="ghost" | ||
| 406 | size="icon-sm" | ||
| 407 | className="ml-auto size-7 sm:size-7" | ||
| 408 | aria-label="Remove negated group" | ||
| 409 | onClick={onRemove} | ||
| 410 | > | ||
| 411 | <MinusIcon className="size-3.5" /> | ||
| 412 | </Button> | ||
| 413 | ) : null} | ||
| 414 | </div> | ||
| 415 | <div className="relative pl-4"> | ||
| 416 | <span className="absolute top-0 bottom-0 left-1.5 w-px bg-border/70" aria-hidden /> | ||
| 417 | <span className="absolute top-4 left-1.5 h-px w-2.5 bg-border/70" aria-hidden /> | ||
| 418 | <WhenExpressionNodeEditor | ||
| 419 | node={node.node} | ||
| 420 | variables={variables} | ||
| 421 | depth={depth + 1} | ||
| 422 | onChange={(next) => onChange({ type: "not", node: next })} | ||
| 423 | /> | ||
| 424 | </div> | ||
| 425 | </div> | ||
| 426 | ); | ||
| 427 | } | ||
| 428 | |||
| 429 | const operator: BooleanOperator = node.type === "or" ? "or" : "and"; | ||
| 430 | const children = flattenWhenChildren(node, operator); | ||
| 431 | const childKeyCounts = new Map<string, number>(); | ||
| 432 | const childEntries = children.map((child) => { | ||
| 433 | const baseKey = `${child.type}-${whenAstToExpression(child)}`; | ||
| 434 | const count = childKeyCounts.get(baseKey) ?? 0; | ||
| 435 | childKeyCounts.set(baseKey, count + 1); | ||
| 436 | return { child, key: count === 0 ? baseKey : `${baseKey}-${count}` }; | ||
| 437 | }); | ||
| 438 | |||
| 439 | const updateChild = (target: KeybindingWhenNode, next: KeybindingWhenNode) => { | ||
| 440 | let didUpdate = false; | ||
| 441 | const nextChildren = children.map((child) => { | ||
| 442 | if (!didUpdate && child === target) { | ||
| 443 | didUpdate = true; | ||
| 444 | return next; | ||
| 445 | } | ||
| 446 | return child; | ||
| 447 | }); | ||
| 448 | const nextNode = buildWhenExpressionGroup(nextChildren, operator); | ||
| 449 | if (nextNode) onChange(nextNode); | ||
| 450 | }; | ||
| 451 | |||
| 452 | const removeChild = (target: KeybindingWhenNode) => { | ||
| 453 | let didRemove = false; | ||
| 454 | const nextChildren = children.filter((child) => { | ||
| 455 | if (!didRemove && child === target) { | ||
| 456 | didRemove = true; | ||
| 457 | return false; | ||
| 458 | } | ||
| 459 | return true; | ||
| 460 | }); | ||
| 461 | const nextNode = buildWhenExpressionGroup(nextChildren, operator); | ||
| 462 | if (nextNode) { | ||
| 463 | onChange(nextNode); | ||
| 464 | } else { | ||
| 465 | onChange(defaultWhenCondition()); | ||
| 466 | } | ||
| 467 | }; | ||
| 468 | |||
| 469 | const setOperator = (nextOperator: BooleanOperator) => { | ||
| 470 | if (nextOperator === operator) return; | ||
| 471 | const nextNode = buildWhenExpressionGroup(children, nextOperator); | ||
| 472 | if (nextNode) onChange(nextNode); | ||
| 473 | }; | ||
| 474 | |||
| 475 | const addCondition = () => { | ||
| 476 | const nextNode = buildWhenExpressionGroup([...children, defaultWhenCondition()], operator); | ||
| 477 | if (nextNode) onChange(nextNode); | ||
| 478 | }; | ||
| 479 | |||
| 480 | const addGroup = () => { | ||
| 481 | const nestedOperator: BooleanOperator = operator === "and" ? "or" : "and"; | ||
| 482 | const group: KeybindingWhenNode = { | ||
| 483 | type: nestedOperator, | ||
| 484 | left: defaultWhenCondition(), | ||
| 485 | right: { type: "not", node: defaultWhenCondition() }, | ||
| 486 | }; | ||
| 487 | const nextNode = buildWhenExpressionGroup([...children, group], operator); | ||
| 488 | if (nextNode) onChange(nextNode); | ||
| 489 | }; | ||
| 490 | |||
| 491 | return ( | ||
| 492 | <div | ||
| 493 | className={cn( | ||
| 494 | "space-y-2 rounded-lg border border-border/60 bg-muted/10 p-2", | ||
| 495 | depth > 0 && "border-border/70 bg-background/55", | ||
| 496 | )} | ||
| 497 | > | ||
| 498 | <div className="flex flex-wrap items-center gap-2"> | ||
| 499 | <Select value={operator} onValueChange={(value) => setOperator(value as BooleanOperator)}> | ||
| 500 | <SelectTrigger size="xs" className="h-7 min-h-7 w-24 rounded-md text-xs sm:h-7"> | ||
| 501 | <SelectValue /> | ||
| 502 | </SelectTrigger> | ||
| 503 | <SelectContent | ||
| 504 | alignItemWithTrigger={false} | ||
| 505 | matchTriggerWidth={false} | ||
| 506 | popupClassName="w-fit" | ||
| 507 | className="w-fit min-w-24" | ||
| 508 | > | ||
| 509 | <SelectItem value="and" className="min-h-7 py-1 font-mono text-[12px]"> | ||
| 510 | and | ||
| 511 | </SelectItem> | ||
| 512 | <SelectItem value="or" className="min-h-7 py-1 font-mono text-[12px]"> | ||
| 513 | or | ||
| 514 | </SelectItem> | ||
| 515 | </SelectContent> | ||
| 516 | </Select> | ||
| 517 | <Button | ||
| 518 | type="button" | ||
| 519 | variant="outline" | ||
| 520 | size="xs" | ||
| 521 | className="h-7 sm:h-7" | ||
| 522 | onClick={addCondition} | ||
| 523 | > | ||
| 524 | <PlusIcon className="size-3.5" /> | ||
| 525 | Condition | ||
| 526 | </Button> | ||
| 527 | <Button type="button" variant="outline" size="xs" className="h-7 sm:h-7" onClick={addGroup}> | ||
| 528 | <PlusIcon className="size-3.5" /> | ||
| 529 | Group | ||
| 530 | </Button> | ||
| 531 | {onRemove ? ( | ||
| 532 | <Button | ||
| 533 | type="button" | ||
| 534 | variant="ghost" | ||
| 535 | size="icon-sm" | ||
| 536 | className="ml-auto size-7 sm:size-7" | ||
| 537 | aria-label="Remove group" | ||
| 538 | onClick={onRemove} | ||
| 539 | > | ||
| 540 | <MinusIcon className="size-3.5" /> | ||
| 541 | </Button> | ||
| 542 | ) : null} | ||
| 543 | </div> | ||
| 544 | <div className="space-y-2"> | ||
| 545 | {childEntries.map(({ child, key }) => ( | ||
| 546 | <div key={key} className="relative pl-4"> | ||
| 547 | <span | ||
| 548 | className={cn( | ||
| 549 | "absolute top-0 bottom-0 left-1.5 w-px", | ||
| 550 | depth === 0 ? "bg-border" : "bg-border/70", | ||
| 551 | )} | ||
| 552 | aria-hidden | ||
| 553 | /> | ||
| 554 | <span | ||
| 555 | className={cn( | ||
| 556 | "absolute top-4 left-1.5 h-px w-2.5", | ||
| 557 | depth === 0 ? "bg-border" : "bg-border/70", | ||
| 558 | )} | ||
| 559 | aria-hidden | ||
| 560 | /> | ||
| 561 | <WhenExpressionNodeEditor | ||
| 562 | node={child} | ||
| 563 | variables={variables} | ||
| 564 | depth={depth + 1} | ||
| 565 | onChange={(next) => updateChild(child, next)} | ||
| 566 | onRemove={() => removeChild(child)} | ||
| 567 | /> | ||
| 568 | </div> | ||
| 569 | ))} | ||
| 570 | </div> | ||
| 571 | </div> | ||
| 572 | ); | ||
| 573 | } | ||
| 574 | |||
| 575 | function WhenExpressionBuilder({ | ||
| 576 | value, | ||
| 577 | variables, | ||
| 578 | onChange, | ||
| 579 | onValidityChange, | ||
| 580 | }: { | ||
| 581 | value: KeybindingWhenNode | undefined; | ||
| 582 | variables: ReadonlyArray<WhenVariableOption>; | ||
| 583 | onChange: (value: KeybindingWhenNode | undefined) => void; | ||
| 584 | onValidityChange?: (valid: boolean) => void; | ||
| 585 | }) { | ||
| 586 | const expression = whenAstToExpression(value); | ||
| 587 | const [expressionDraft, setExpressionDraft] = useState(expression); | ||
| 588 | const parseResult = useMemo(() => parseWhenExpressionDraft(expressionDraft), [expressionDraft]); | ||
| 589 | const parseError = parseResult.ok ? null : parseResult.message; | ||
| 590 | const unknownIdentifiers = parseResult.ok ? unknownWhenVariables(parseResult.value) : []; | ||
| 591 | |||
| 592 | const updateExpressionDraft = (nextExpression: string) => { | ||
| 593 | setExpressionDraft(nextExpression); | ||
| 594 | const nextResult = parseWhenExpressionDraft(nextExpression); | ||
| 595 | onValidityChange?.(nextResult.ok); | ||
| 596 | if (nextResult.ok) { | ||
| 597 | onChange(nextResult.value); | ||
| 598 | } | ||
| 599 | }; | ||
| 600 | |||
| 601 | const updateExpressionValue = (nextValue: KeybindingWhenNode | undefined) => { | ||
| 602 | setExpressionDraft(whenAstToExpression(nextValue)); | ||
| 603 | onValidityChange?.(true); | ||
| 604 | onChange(nextValue); | ||
| 605 | }; | ||
| 606 | |||
| 607 | const addRootCondition = () => { | ||
| 608 | if (!value) { | ||
| 609 | updateExpressionValue(defaultWhenCondition()); | ||
| 610 | return; | ||
| 611 | } | ||
| 612 | updateExpressionValue({ type: "and", left: value, right: defaultWhenCondition() }); | ||
| 613 | }; | ||
| 614 | |||
| 615 | const addRootGroup = () => { | ||
| 616 | const group = defaultWhenGroup("or"); | ||
| 617 | if (!value) { | ||
| 618 | updateExpressionValue(group); | ||
| 619 | return; | ||
| 620 | } | ||
| 621 | updateExpressionValue({ type: "and", left: value, right: group }); | ||
| 622 | }; | ||
| 623 | |||
| 624 | return ( | ||
| 625 | <div className="w-[min(34rem,calc(100vw-2rem))] space-y-3"> | ||
| 626 | <div className="flex items-start justify-between gap-3"> | ||
| 627 | <div className="min-w-0"> | ||
| 628 | <div className="text-sm font-medium text-foreground">When</div> | ||
| 629 | </div> | ||
| 630 | <div className="flex shrink-0 items-center gap-2"> | ||
| 631 | <Button | ||
| 632 | type="button" | ||
| 633 | variant="outline" | ||
| 634 | size="xs" | ||
| 635 | className="h-7 sm:h-7" | ||
| 636 | onClick={addRootCondition} | ||
| 637 | > | ||
| 638 | <PlusIcon className="size-3.5" /> | ||
| 639 | Condition | ||
| 640 | </Button> | ||
| 641 | <Button | ||
| 642 | type="button" | ||
| 643 | variant="outline" | ||
| 644 | size="xs" | ||
| 645 | className="h-7 sm:h-7" | ||
| 646 | onClick={addRootGroup} | ||
| 647 | > | ||
| 648 | <PlusIcon className="size-3.5" /> | ||
| 649 | Group | ||
| 650 | </Button> | ||
| 651 | </div> | ||
| 652 | </div> | ||
| 653 | |||
| 654 | <div className="space-y-1.5"> | ||
| 655 | <div className="relative"> | ||
| 656 | <Input | ||
| 657 | value={expressionDraft} | ||
| 658 | onChange={(event) => updateExpressionDraft(event.currentTarget.value)} | ||
| 659 | placeholder="Always" | ||
| 660 | aria-invalid={Boolean(parseError)} | ||
| 661 | aria-label="When expression" | ||
| 662 | className={cn( | ||
| 663 | "h-7 rounded-md font-mono text-[12px] leading-7 sm:h-7 sm:leading-7", | ||
| 664 | unknownIdentifiers.length > 0 && "pr-9", | ||
| 665 | parseError && "border-destructive/70 focus-visible:border-destructive", | ||
| 666 | )} | ||
| 667 | /> | ||
| 668 | {unknownIdentifiers.length > 0 ? ( | ||
| 669 | <span className="absolute inset-y-0 right-2 flex items-center"> | ||
| 670 | <UnknownWhenVariableWarning identifiers={unknownIdentifiers} /> | ||
| 671 | </span> | ||
| 672 | ) : null} | ||
| 673 | </div> | ||
| 674 | {parseError ? ( | ||
| 675 | <div className="flex items-center gap-1.5 text-[11px] text-destructive"> | ||
| 676 | <CircleXIcon className="size-3.5" /> | ||
| 677 | {parseError} | ||
| 678 | </div> | ||
| 679 | ) : null} | ||
| 680 | </div> | ||
| 681 | |||
| 682 | <div className="relative"> | ||
| 683 | {value ? ( | ||
| 684 | <WhenExpressionNodeEditor | ||
| 685 | node={value} | ||
| 686 | variables={variables} | ||
| 687 | onChange={updateExpressionValue} | ||
| 688 | onRemove={() => updateExpressionValue(undefined)} | ||
| 689 | /> | ||
| 690 | ) : ( | ||
| 691 | <div className="rounded-md border border-dashed border-border/80 bg-muted/15 p-3"> | ||
| 692 | <div className="flex flex-wrap gap-2"> | ||
| 693 | <Button type="button" size="xs" className="h-7 sm:h-7" onClick={addRootCondition}> | ||
| 694 | <PlusIcon className="size-3.5" /> | ||
| 695 | Condition | ||
| 696 | </Button> | ||
| 697 | <Button | ||
| 698 | type="button" | ||
| 699 | variant="outline" | ||
| 700 | size="xs" | ||
| 701 | className="h-7 sm:h-7" | ||
| 702 | onClick={addRootGroup} | ||
| 703 | > | ||
| 704 | <PlusIcon className="size-3.5" /> | ||
| 705 | Group | ||
| 706 | </Button> | ||
| 707 | </div> | ||
| 708 | </div> | ||
| 709 | )} | ||
| 710 | {parseError ? ( | ||
| 711 | <div className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-lg border border-destructive/30 bg-background/75 p-4 text-center text-xs text-destructive backdrop-blur-[1px]"> | ||
| 712 | Fix the expression above to continue editing visually. | ||
| 713 | </div> | ||
| 714 | ) : null} | ||
| 715 | </div> | ||
| 716 | </div> | ||
| 717 | ); | ||
| 718 | } | ||
| 719 | |||
| 720 | type KeybindingRowDraftState = { | ||
| 721 | keyDraft: string; | ||
| 722 | whenDraft: KeybindingWhenNode | undefined; | ||
| 723 | isRecording: boolean; | ||
| 724 | isWhenDraftValid: boolean; | ||
| 725 | }; | ||
| 726 | |||
| 727 | function createKeybindingRowDraft(row: KeybindingRow): KeybindingRowDraftState { | ||
| 728 | return { | ||
| 729 | keyDraft: row.key, | ||
| 730 | whenDraft: row.binding.whenAst, | ||
| 731 | isRecording: false, | ||
| 732 | isWhenDraftValid: true, | ||
| 733 | }; | ||
| 734 | } | ||
| 735 | |||
| 736 | function keybindingRowDraftReducer( | ||
| 737 | state: KeybindingRowDraftState, | ||
| 738 | patch: Partial<KeybindingRowDraftState>, | ||
| 739 | ): KeybindingRowDraftState { | ||
| 740 | return { ...state, ...patch }; | ||
| 741 | } | ||
| 742 | |||
| 743 | function rowKeybindingTarget(row: KeybindingRow): ServerRemoveKeybindingInput { | ||
| 744 | return { | ||
| 745 | command: row.command, | ||
| 746 | key: row.key, | ||
| 747 | ...(row.when.trim().length > 0 ? { when: row.when } : {}), | ||
| 748 | }; | ||
| 749 | } | ||
| 750 | |||
| 751 | function KeybindingTableRow({ | ||
| 752 | row, | ||
| 753 | allRows, | ||
| 754 | variables, | ||
| 755 | isSaving, | ||
| 756 | onSave, | ||
| 757 | onReset, | ||
| 758 | onRemove, | ||
| 759 | }: { | ||
| 760 | row: KeybindingRow; | ||
| 761 | allRows: ReadonlyArray<KeybindingRow>; | ||
| 762 | variables: ReadonlyArray<WhenVariableOption>; | ||
| 763 | isSaving: boolean; | ||
| 764 | onSave: (input: ServerUpsertKeybindingInput) => void; | ||
| 765 | onReset: (row: KeybindingRow) => void; | ||
| 766 | onRemove: (row: KeybindingRow) => void; | ||
| 767 | }) { | ||
| 768 | const [draft, setDraft] = useReducer(keybindingRowDraftReducer, row, createKeybindingRowDraft); | ||
| 769 | const { keyDraft, whenDraft, isRecording, isWhenDraftValid } = draft; | ||
| 770 | const whenDraftExpression = whenAstToExpression(whenDraft); | ||
| 771 | const isDirty = keyDraft !== row.key || whenDraftExpression !== row.when; | ||
| 772 | const displayShortcut = formatShortcutLabel(row.binding.shortcut); | ||
| 773 | const canReset = row.source === "Custom" && row.defaultKey !== null; | ||
| 774 | const canRemove = row.source !== "Default"; | ||
| 775 | const hasRowActions = canReset || canRemove; | ||
| 776 | const showPill = !isRecording && keyDraft === row.key && row.key.length > 0 && !isDirty; | ||
| 777 | const conflictLabels = keybindingConflictLabels(allRows, { | ||
| 778 | rowId: row.id, | ||
| 779 | key: keyDraft, | ||
| 780 | when: whenDraftExpression, | ||
| 781 | }); | ||
| 782 | |||
| 783 | const save = () => { | ||
| 784 | onSave({ | ||
| 785 | command: row.command, | ||
| 786 | key: keyDraft, | ||
| 787 | when: whenDraftExpression.trim().length > 0 ? whenDraftExpression : undefined, | ||
| 788 | replace: rowKeybindingTarget(row), | ||
| 789 | }); | ||
| 790 | }; | ||
| 791 | |||
| 792 | const captureKeybinding = (event: KeyboardEvent<HTMLInputElement>) => { | ||
| 793 | if (event.key === "Tab") return; | ||
| 794 | event.preventDefault(); | ||
| 795 | if (event.key === "Escape") { | ||
| 796 | setDraft({ keyDraft: row.key, isRecording: false }); | ||
| 797 | return; | ||
| 798 | } | ||
| 799 | const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform); | ||
| 800 | if (!next) return; | ||
| 801 | setDraft({ keyDraft: next, isRecording: false }); | ||
| 802 | }; | ||
| 803 | |||
| 804 | return ( | ||
| 805 | <div className="grid grid-cols-[minmax(190px,1.1fr)_minmax(220px,0.85fr)_minmax(210px,1fr)_60px] items-center px-4 py-1.5 text-sm even:bg-muted/15 hover:bg-accent/40"> | ||
| 806 | <div className="min-w-0 pr-4"> | ||
| 807 | <div className="flex min-w-0 items-center gap-1.5"> | ||
| 808 | <div className="truncate text-[13px] font-medium text-foreground" title={row.command}> | ||
| 809 | {commandLabel(row.command)} | ||
| 810 | </div> | ||
| 811 | </div> | ||
| 812 | </div> | ||
| 813 | <div className="flex min-w-0 items-center gap-2 pr-4"> | ||
| 814 | {showPill ? ( | ||
| 815 | <button | ||
| 816 | type="button" | ||
| 817 | onClick={() => setDraft({ isRecording: true })} | ||
| 818 | aria-label={`Edit shortcut for ${commandLabel(row.command)}`} | ||
| 819 | className="group inline-flex h-7 items-center gap-1.5 rounded-md border border-transparent px-1.5 outline-none transition-colors hover:border-border/70 hover:bg-background focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/24" | ||
| 820 | > | ||
| 821 | <KeybindingPill value={row.key} /> | ||
| 822 | <span className="text-[10px] uppercase tracking-[0.08em] text-muted-foreground/0 transition-opacity group-hover:text-muted-foreground/70 group-focus-visible:text-muted-foreground/70"> | ||
| 823 | Edit | ||
| 824 | </span> | ||
| 825 | </button> | ||
| 826 | ) : ( | ||
| 827 | <Input | ||
| 828 | autoFocus={isRecording} | ||
| 829 | aria-label={`Keybinding for ${commandLabel(row.command)}`} | ||
| 830 | value={isRecording ? "" : keyDraft} | ||
| 831 | placeholder={isRecording ? "Press shortcut" : "Unassigned"} | ||
| 832 | className={cn( | ||
| 833 | "h-7 w-44 rounded-md font-mono text-[12px] sm:h-7", | ||
| 834 | isRecording && "border-primary/70 bg-primary/5", | ||
| 835 | )} | ||
| 836 | onFocus={() => setDraft({ isRecording: true })} | ||
| 837 | onBlur={() => setDraft({ isRecording: false })} | ||
| 838 | onChange={(event) => setDraft({ keyDraft: event.currentTarget.value })} | ||
| 839 | onKeyDown={captureKeybinding} | ||
| 840 | /> | ||
| 841 | )} | ||
| 842 | {isDirty ? ( | ||
| 843 | <Button | ||
| 844 | size="xs" | ||
| 845 | className="h-7 sm:h-7" | ||
| 846 | disabled={isSaving || keyDraft.trim().length === 0 || !isWhenDraftValid} | ||
| 847 | onClick={save} | ||
| 848 | > | ||
| 849 | {isSaving ? "Saving" : "Save"} | ||
| 850 | </Button> | ||
| 851 | ) : null} | ||
| 852 | </div> | ||
| 853 | <div className="pr-4"> | ||
| 854 | <Popover> | ||
| 855 | <PopoverTrigger | ||
| 856 | className={cn( | ||
| 857 | "inline-flex h-7 w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-2.5 text-left font-mono text-[12px] text-foreground shadow-xs/5 outline-none transition-colors hover:bg-accent focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/24", | ||
| 858 | !whenDraftExpression && "text-muted-foreground", | ||
| 859 | )} | ||
| 860 | aria-label={`Edit when clause for ${commandLabel(row.command)}`} | ||
| 861 | > | ||
| 862 | <span className="truncate">{whenDraftExpression || "Always"}</span> | ||
| 863 | <ChevronDownIcon className="size-3.5 shrink-0 opacity-60" /> | ||
| 864 | </PopoverTrigger> | ||
| 865 | <PopoverContent align="start" sideOffset={6}> | ||
| 866 | <WhenExpressionBuilder | ||
| 867 | value={whenDraft} | ||
| 868 | variables={variables} | ||
| 869 | onChange={(nextWhenDraft) => setDraft({ whenDraft: nextWhenDraft })} | ||
| 870 | onValidityChange={(nextIsValid) => setDraft({ isWhenDraftValid: nextIsValid })} | ||
| 871 | /> | ||
| 872 | </PopoverContent> | ||
| 873 | </Popover> | ||
| 874 | </div> | ||
| 875 | <div className="flex items-center justify-end gap-1"> | ||
| 876 | <KeybindingConflictWarning labels={conflictLabels} /> | ||
| 877 | {hasRowActions ? ( | ||
| 878 | <Menu> | ||
| 879 | <MenuTrigger | ||
| 880 | render={ | ||
| 881 | <Button | ||
| 882 | type="button" | ||
| 883 | variant="ghost" | ||
| 884 | size="icon-sm" | ||
| 885 | className="size-7 text-muted-foreground hover:text-foreground sm:size-7" | ||
| 886 | disabled={isSaving} | ||
| 887 | aria-label={`Actions for ${commandLabel(row.command)}`} | ||
| 888 | /> | ||
| 889 | } | ||
| 890 | > | ||
| 891 | <EllipsisIcon className="size-3.5" /> | ||
| 892 | </MenuTrigger> | ||
| 893 | <MenuPopup align="end" className="min-w-36"> | ||
| 894 | {canReset ? ( | ||
| 895 | <MenuItem disabled={isSaving} onClick={() => onReset(row)}> | ||
| 896 | Reset to default | ||
| 897 | </MenuItem> | ||
| 898 | ) : null} | ||
| 899 | {canRemove ? ( | ||
| 900 | <MenuItem variant="destructive" disabled={isSaving} onClick={() => onRemove(row)}> | ||
| 901 | Remove | ||
| 902 | </MenuItem> | ||
| 903 | ) : null} | ||
| 904 | </MenuPopup> | ||
| 905 | </Menu> | ||
| 906 | ) : null} | ||
| 907 | <span className="sr-only">{displayShortcut}</span> | ||
| 908 | </div> | ||
| 909 | </div> | ||
| 910 | ); | ||
| 911 | } | ||
| 912 | |||
| 913 | function NewKeybindingTableRow({ | ||
| 914 | commandOptions, | ||
| 915 | allRows, | ||
| 916 | variables, | ||
| 917 | isSaving, | ||
| 918 | onSave, | ||
| 919 | onCancel, | ||
| 920 | }: { | ||
| 921 | commandOptions: ReadonlyArray<KeybindingCommandOption>; | ||
| 922 | allRows: ReadonlyArray<KeybindingRow>; | ||
| 923 | variables: ReadonlyArray<WhenVariableOption>; | ||
| 924 | isSaving: boolean; | ||
| 925 | onSave: (input: ServerUpsertKeybindingInput) => void; | ||
| 926 | onCancel: () => void; | ||
| 927 | }) { | ||
| 928 | const [commandDraft, setCommandDraft] = useState<KeybindingCommand | "">(""); | ||
| 929 | const [draft, setDraft] = useReducer(keybindingRowDraftReducer, { | ||
| 930 | keyDraft: "", | ||
| 931 | whenDraft: undefined, | ||
| 932 | isRecording: false, | ||
| 933 | isWhenDraftValid: true, | ||
| 934 | }); | ||
| 935 | const { keyDraft, whenDraft, isRecording, isWhenDraftValid } = draft; | ||
| 936 | const whenDraftExpression = whenAstToExpression(whenDraft); | ||
| 937 | const conflictLabels = keybindingConflictLabels(allRows, { | ||
| 938 | rowId: "new", | ||
| 939 | key: keyDraft, | ||
| 940 | when: whenDraftExpression, | ||
| 941 | }); | ||
| 942 | const commandLabelText = commandDraft ? commandLabel(commandDraft) : "new keybinding"; | ||
| 943 | |||
| 944 | const save = () => { | ||
| 945 | if (!commandDraft) return; | ||
| 946 | onSave({ | ||
| 947 | command: commandDraft, | ||
| 948 | key: keyDraft, | ||
| 949 | ...(whenDraftExpression.trim().length > 0 ? { when: whenDraftExpression } : {}), | ||
| 950 | }); | ||
| 951 | }; | ||
| 952 | |||
| 953 | const captureKeybinding = (event: KeyboardEvent<HTMLInputElement>) => { | ||
| 954 | if (event.key === "Tab") return; | ||
| 955 | event.preventDefault(); | ||
| 956 | if (event.key === "Escape") { | ||
| 957 | setDraft({ keyDraft: "", isRecording: false }); | ||
| 958 | return; | ||
| 959 | } | ||
| 960 | const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform); | ||
| 961 | if (!next) return; | ||
| 962 | setDraft({ keyDraft: next, isRecording: false }); | ||
| 963 | }; | ||
| 964 | |||
| 965 | return ( | ||
| 966 | <div className="grid grid-cols-[minmax(190px,1.1fr)_minmax(220px,0.85fr)_minmax(210px,1fr)_60px] items-center px-4 py-1.5 text-sm even:bg-muted/15 hover:bg-accent/40"> | ||
| 967 | <div className="min-w-0 pr-4"> | ||
| 968 | <Select | ||
| 969 | value={commandDraft} | ||
| 970 | onValueChange={(value) => setCommandDraft(value as KeybindingCommand)} | ||
| 971 | > | ||
| 972 | <SelectTrigger | ||
| 973 | size="xs" | ||
| 974 | className="h-7 min-h-7 w-full max-w-60 rounded-md text-xs sm:h-7" | ||
| 975 | > | ||
| 976 | <SelectValue placeholder="Command" /> | ||
| 977 | </SelectTrigger> | ||
| 978 | <SelectContent | ||
| 979 | alignItemWithTrigger={false} | ||
| 980 | matchTriggerWidth={false} | ||
| 981 | className="max-h-72 w-fit min-w-56" | ||
| 982 | > | ||
| 983 | {commandOptions.map((command) => ( | ||
| 984 | <SelectItem key={command} value={command} className="min-h-7 w-full py-1 text-[12px]"> | ||
| 985 | <span className="truncate">{commandLabel(command)}</span> | ||
| 986 | </SelectItem> | ||
| 987 | ))} | ||
| 988 | </SelectContent> | ||
| 989 | </Select> | ||
| 990 | </div> | ||
| 991 | <div className="flex min-w-0 items-center gap-2 pr-4"> | ||
| 992 | <Input | ||
| 993 | aria-label={`Keybinding for ${commandLabelText}`} | ||
| 994 | value={isRecording ? "" : keyDraft} | ||
| 995 | placeholder={isRecording ? "Press shortcut" : "Unassigned"} | ||
| 996 | className={cn( | ||
| 997 | "h-7 w-44 rounded-md font-mono text-[12px] sm:h-7", | ||
| 998 | isRecording && "border-primary/70 bg-primary/5", | ||
| 999 | )} | ||
| 1000 | onFocus={() => setDraft({ isRecording: true })} | ||
| 1001 | onBlur={() => setDraft({ isRecording: false })} | ||
| 1002 | onChange={(event) => setDraft({ keyDraft: event.currentTarget.value })} | ||
| 1003 | onKeyDown={captureKeybinding} | ||
| 1004 | /> | ||
| 1005 | <Button | ||
| 1006 | size="xs" | ||
| 1007 | className="h-7 sm:h-7" | ||
| 1008 | disabled={isSaving || !commandDraft || keyDraft.trim().length === 0 || !isWhenDraftValid} | ||
| 1009 | onClick={save} | ||
| 1010 | > | ||
| 1011 | {isSaving ? "Saving" : "Save"} | ||
| 1012 | </Button> | ||
| 1013 | </div> | ||
| 1014 | <div className="pr-4"> | ||
| 1015 | <Popover> | ||
| 1016 | <PopoverTrigger | ||
| 1017 | className={cn( | ||
| 1018 | "inline-flex h-7 w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-2.5 text-left font-mono text-[12px] text-foreground shadow-xs/5 outline-none transition-colors hover:bg-accent focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/24", | ||
| 1019 | !whenDraftExpression && "text-muted-foreground", | ||
| 1020 | )} | ||
| 1021 | aria-label={`Edit when clause for ${commandLabelText}`} | ||
| 1022 | > | ||
| 1023 | <span className="truncate">{whenDraftExpression || "Always"}</span> | ||
| 1024 | <ChevronDownIcon className="size-3.5 shrink-0 opacity-60" /> | ||
| 1025 | </PopoverTrigger> | ||
| 1026 | <PopoverContent align="start" sideOffset={6}> | ||
| 1027 | <WhenExpressionBuilder | ||
| 1028 | value={whenDraft} | ||
| 1029 | variables={variables} | ||
| 1030 | onChange={(nextWhenDraft) => setDraft({ whenDraft: nextWhenDraft })} | ||
| 1031 | onValidityChange={(nextIsValid) => setDraft({ isWhenDraftValid: nextIsValid })} | ||
| 1032 | /> | ||
| 1033 | </PopoverContent> | ||
| 1034 | </Popover> | ||
| 1035 | </div> | ||
| 1036 | <div className="flex items-center justify-end gap-1"> | ||
| 1037 | <KeybindingConflictWarning labels={conflictLabels} /> | ||
| 1038 | <Tooltip> | ||
| 1039 | <TooltipTrigger | ||
| 1040 | render={ | ||
| 1041 | <Button | ||
| 1042 | type="button" | ||
| 1043 | variant="ghost" | ||
| 1044 | size="icon-sm" | ||
| 1045 | className="size-7 text-muted-foreground hover:text-foreground sm:size-7" | ||
| 1046 | disabled={isSaving} | ||
| 1047 | aria-label="Cancel new keybinding" | ||
| 1048 | onClick={onCancel} | ||
| 1049 | /> | ||
| 1050 | } | ||
| 1051 | > | ||
| 1052 | <XIcon className="size-3.5" /> | ||
| 1053 | </TooltipTrigger> | ||
| 1054 | <TooltipPopup side="top">Cancel</TooltipPopup> | ||
| 1055 | </Tooltip> | ||
| 1056 | </div> | ||
| 1057 | </div> | ||
| 1058 | ); | ||
| 1059 | } | ||
| 1060 | |||
| 1061 | export function KeybindingsSettingsPanel() { | ||
| 1062 | const keybindings = useServerKeybindings(); | ||
| 1063 | const keybindingsConfigPath = useServerKeybindingsConfigPath(); | ||
| 1064 | const [query, setQuery] = useState(""); | ||
| 1065 | const [isSearchOpen, setIsSearchOpen] = useState(false); | ||
| 1066 | const searchInputRef = useRef<HTMLInputElement>(null); | ||
| 1067 | const [savingCommand, setSavingCommand] = useState<KeybindingCommand | null>(null); | ||
| 1068 | const [isAddingBinding, setIsAddingBinding] = useState(false); | ||
| 1069 | const rows = useMemo(() => buildKeybindingRows(keybindings, query), [keybindings, query]); | ||
| 1070 | const commandOptions = useMemo(() => buildKeybindingCommandOptions(keybindings), [keybindings]); | ||
| 1071 | const whenVariables = useMemo(() => buildWhenVariableOptions(), []); | ||
| 1072 | |||
| 1073 | useEffect(() => { | ||
| 1074 | const handleKeyDown = (event: globalThis.KeyboardEvent) => { | ||
| 1075 | const isMod = event.metaKey || event.ctrlKey; | ||
| 1076 | if (!isMod || event.altKey || event.key.toLowerCase() !== "f") return; | ||
| 1077 | |||
| 1078 | const target = event.target; | ||
| 1079 | if ( | ||
| 1080 | target !== searchInputRef.current && | ||
| 1081 | target instanceof HTMLElement && | ||
| 1082 | (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) | ||
| 1083 | ) { | ||
| 1084 | return; | ||
| 1085 | } | ||
| 1086 | |||
| 1087 | event.preventDefault(); | ||
| 1088 | setIsSearchOpen(true); | ||
| 1089 | requestAnimationFrame(() => { | ||
| 1090 | searchInputRef.current?.focus(); | ||
| 1091 | searchInputRef.current?.select(); | ||
| 1092 | }); | ||
| 1093 | }; | ||
| 1094 | window.addEventListener("keydown", handleKeyDown); | ||
| 1095 | return () => window.removeEventListener("keydown", handleKeyDown); | ||
| 1096 | }, []); | ||
| 1097 | |||
| 1098 | const openKeybindingsFile = useCallback(() => { | ||
| 1099 | if (!keybindingsConfigPath) return; | ||
| 1100 | void openInPreferredEditor(ensureLocalApi(), keybindingsConfigPath).catch((error: unknown) => { | ||
| 1101 | toastManager.add({ | ||
| 1102 | title: "Unable to open keybindings file", | ||
| 1103 | description: | ||
| 1104 | error instanceof Error ? error.message : "The keybindings file was not opened.", | ||
| 1105 | type: "error", | ||
| 1106 | }); | ||
| 1107 | }); | ||
| 1108 | }, [keybindingsConfigPath]); | ||
| 1109 | |||
| 1110 | const saveKeybinding = useCallback((input: ServerUpsertKeybindingInput) => { | ||
| 1111 | setSavingCommand(input.command); | ||
| 1112 | const payload: ServerUpsertKeybindingInput = { | ||
| 1113 | command: input.command, | ||
| 1114 | key: input.key.trim(), | ||
| 1115 | ...(input.when?.trim() ? { when: input.when.trim() } : {}), | ||
| 1116 | ...(input.replace ? { replace: input.replace } : {}), | ||
| 1117 | }; | ||
| 1118 | void ensureLocalApi() | ||
| 1119 | .server.upsertKeybinding(payload) | ||
| 1120 | .then(() => { | ||
| 1121 | setIsAddingBinding(false); | ||
| 1122 | }) | ||
| 1123 | .catch((error: unknown) => { | ||
| 1124 | toastManager.add({ | ||
| 1125 | title: "Unable to save keybinding", | ||
| 1126 | description: error instanceof Error ? error.message : "The keybinding was not saved.", | ||
| 1127 | type: "error", | ||
| 1128 | }); | ||
| 1129 | }) | ||
| 1130 | .finally(() => { | ||
| 1131 | setSavingCommand(null); | ||
| 1132 | }); | ||
| 1133 | }, []); | ||
| 1134 | |||
| 1135 | const removeKeybinding = useCallback((row: KeybindingRow) => { | ||
| 1136 | setSavingCommand(row.command); | ||
| 1137 | void ensureLocalApi() | ||
| 1138 | .server.removeKeybinding(rowKeybindingTarget(row)) | ||
| 1139 | .catch((error: unknown) => { | ||
| 1140 | toastManager.add({ | ||
| 1141 | title: "Unable to remove keybinding", | ||
| 1142 | description: error instanceof Error ? error.message : "The keybinding was not removed.", | ||
| 1143 | type: "error", | ||
| 1144 | }); | ||
| 1145 | }) | ||
| 1146 | .finally(() => { | ||
| 1147 | setSavingCommand(null); | ||
| 1148 | }); | ||
| 1149 | }, []); | ||
| 1150 | |||
| 1151 | const resetKeybinding = useCallback( | ||
| 1152 | (row: KeybindingRow) => { | ||
| 1153 | if (!row.defaultKey) return; | ||
| 1154 | saveKeybinding({ | ||
| 1155 | command: row.command, | ||
| 1156 | key: row.defaultKey, | ||
| 1157 | when: row.defaultWhen.trim().length > 0 ? row.defaultWhen : undefined, | ||
| 1158 | replace: { | ||
| 1159 | command: row.command, | ||
| 1160 | key: row.key, | ||
| 1161 | ...(row.when.trim().length > 0 ? { when: row.when } : {}), | ||
| 1162 | }, | ||
| 1163 | }); | ||
| 1164 | }, | ||
| 1165 | [saveKeybinding], | ||
| 1166 | ); | ||
| 1167 | |||
| 1168 | const bindingsCount = ( | ||
| 1169 | <span className="text-[11px] text-muted-foreground"> | ||
| 1170 | {rows.length + (isAddingBinding ? 1 : 0)}{" "} | ||
| 1171 | {rows.length + (isAddingBinding ? 1 : 0) === 1 ? "binding" : "bindings"} | ||
| 1172 | </span> | ||
| 1173 | ); | ||
| 1174 | |||
| 1175 | return ( | ||
| 1176 | <SettingsPageContainer className="max-w-5xl"> | ||
| 1177 | <SettingsSection | ||
| 1178 | title="Keybindings" | ||
| 1179 | headerAction={ | ||
| 1180 | <div className="flex items-center gap-1.5"> | ||
| 1181 | <ExpandableHeaderSearch | ||
| 1182 | query={query} | ||
| 1183 | onChange={setQuery} | ||
| 1184 | isOpen={isSearchOpen} | ||
| 1185 | onOpenChange={setIsSearchOpen} | ||
| 1186 | inputRef={searchInputRef} | ||
| 1187 | collapsedAccessory={bindingsCount} | ||
| 1188 | /> | ||
| 1189 | <Tooltip> | ||
| 1190 | <TooltipTrigger | ||
| 1191 | render={ | ||
| 1192 | <Button | ||
| 1193 | type="button" | ||
| 1194 | size="icon-xs" | ||
| 1195 | variant="ghost" | ||
| 1196 | className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground" | ||
| 1197 | onClick={() => setIsAddingBinding(true)} | ||
| 1198 | aria-label="Add keybinding" | ||
| 1199 | > | ||
| 1200 | <PlusIcon className="size-3" /> | ||
| 1201 | </Button> | ||
| 1202 | } | ||
| 1203 | /> | ||
| 1204 | <TooltipPopup side="top">Add keybinding</TooltipPopup> | ||
| 1205 | </Tooltip> | ||
| 1206 | <Tooltip> | ||
| 1207 | <TooltipTrigger | ||
| 1208 | render={ | ||
| 1209 | <Button | ||
| 1210 | type="button" | ||
| 1211 | size="icon-xs" | ||
| 1212 | variant="ghost" | ||
| 1213 | className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground" | ||
| 1214 | disabled={!keybindingsConfigPath} | ||
| 1215 | onClick={openKeybindingsFile} | ||
| 1216 | aria-label="Open keybindings.json" | ||
| 1217 | > | ||
| 1218 | <FileJsonIcon className="size-3" /> | ||
| 1219 | </Button> | ||
| 1220 | } | ||
| 1221 | /> | ||
| 1222 | <TooltipPopup side="top">Open keybindings.json</TooltipPopup> | ||
| 1223 | </Tooltip> | ||
| 1224 | </div> | ||
| 1225 | } | ||
| 1226 | > | ||
| 1227 | {!isElectron ? ( | ||
| 1228 | <div className="flex items-start gap-2 border-b border-warning/20 bg-warning/5 px-3 py-2.5 text-[12px] leading-relaxed text-muted-foreground sm:px-4"> | ||
| 1229 | <InfoIcon className="mt-0.5 size-3.5 shrink-0 text-warning" /> | ||
| 1230 | <p> | ||
| 1231 | Some shortcuts may be claimed by the browser before T3 Code sees them. Use the desktop | ||
| 1232 | app for better keybinding support. | ||
| 1233 | </p> | ||
| 1234 | </div> | ||
| 1235 | ) : null} | ||
| 1236 | |||
| 1237 | <ScrollArea | ||
| 1238 | chainVerticalScroll | ||
| 1239 | scrollFade | ||
| 1240 | hideScrollbars | ||
| 1241 | className="w-full max-w-full rounded-none" | ||
| 1242 | > | ||
| 1243 | <div className="grid min-w-[680px] grid-cols-[minmax(190px,1.1fr)_minmax(220px,0.85fr)_minmax(210px,1fr)_60px] border-b border-border/70 bg-muted/25 px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.07em] text-muted-foreground"> | ||
| 1244 | <div>Command</div> | ||
| 1245 | <div>Keybinding</div> | ||
| 1246 | <div>When</div> | ||
| 1247 | <div>Status</div> | ||
| 1248 | </div> | ||
| 1249 | <div className="min-w-[680px] divide-y divide-border/60"> | ||
| 1250 | {isAddingBinding ? ( | ||
| 1251 | <NewKeybindingTableRow | ||
| 1252 | commandOptions={commandOptions} | ||
| 1253 | allRows={rows} | ||
| 1254 | variables={whenVariables} | ||
| 1255 | isSaving={savingCommand !== null} | ||
| 1256 | onSave={saveKeybinding} | ||
| 1257 | onCancel={() => setIsAddingBinding(false)} | ||
| 1258 | /> | ||
| 1259 | ) : null} | ||
| 1260 | {rows.map((row) => ( | ||
| 1261 | <KeybindingTableRow | ||
| 1262 | key={row.id} | ||
| 1263 | row={row} | ||
| 1264 | allRows={rows} | ||
| 1265 | variables={whenVariables} | ||
| 1266 | isSaving={savingCommand === row.command} | ||
| 1267 | onSave={saveKeybinding} | ||
| 1268 | onReset={resetKeybinding} | ||
| 1269 | onRemove={removeKeybinding} | ||
| 1270 | /> | ||
| 1271 | ))} | ||
| 1272 | {rows.length === 0 && !isAddingBinding ? ( | ||
| 1273 | <div className="px-4 py-12 text-center text-sm text-muted-foreground"> | ||
| 1274 | No keybindings match your search. | ||
| 1275 | </div> | ||
| 1276 | ) : null} | ||
| 1277 | </div> | ||
| 1278 | </ScrollArea> | ||
| 1279 | </SettingsSection> | ||
| 1280 | </SettingsPageContainer> | ||
| 1281 | ); | ||
| 1282 | } | ||
apps/web/src/components/settings/SettingsPanels.tsx+2-40| ... | @@ -77,7 +77,6 @@ import { | ... | @@ -77,7 +77,6 @@ import { |
| 77 | import { ProjectFavicon } from "../ProjectFavicon"; | 77 | import { ProjectFavicon } from "../ProjectFavicon"; |
| 78 | import { | 78 | import { |
| 79 | useServerAvailableEditors, | 79 | useServerAvailableEditors, |
| 80 | useServerKeybindingsConfigPath, | ||
| 81 | useServerObservability, | 80 | useServerObservability, |
| 82 | useServerProviders, | 81 | useServerProviders, |
| 83 | } from "../../rpc/serverState"; | 82 | } from "../../rpc/serverState"; |
| ... | @@ -444,13 +443,11 @@ export function GeneralSettingsPanel() { | ... | @@ -444,13 +443,11 @@ export function GeneralSettingsPanel() { |
| 444 | const settings = useSettings(); | 443 | const settings = useSettings(); |
| 445 | const { updateSettings } = useUpdateSettings(); | 444 | const { updateSettings } = useUpdateSettings(); |
| 446 | const [openingPathByTarget, setOpeningPathByTarget] = useState({ | 445 | const [openingPathByTarget, setOpeningPathByTarget] = useState({ |
| 447 | keybindings: false, | ||
| 448 | logsDirectory: false, | 446 | logsDirectory: false, |
| 449 | }); | 447 | }); |
| 450 | const [openPathErrorByTarget, setOpenPathErrorByTarget] = useState< | 448 | const [openPathErrorByTarget, setOpenPathErrorByTarget] = useState< |
| 451 | Partial<Record<"keybindings" | "logsDirectory", string | null>> | 449 | Partial<Record<"logsDirectory", string | null>> |
| 452 | >({}); | 450 | >({}); |
| 453 | const keybindingsConfigPath = useServerKeybindingsConfigPath(); | ||
| 454 | const availableEditors = useServerAvailableEditors(); | 451 | const availableEditors = useServerAvailableEditors(); |
| 455 | const observability = useServerObservability(); | 452 | const observability = useServerObservability(); |
| 456 | const serverProviders = useServerProviders(); | 453 | const serverProviders = useServerProviders(); |
| ... | @@ -491,7 +488,7 @@ export function GeneralSettingsPanel() { | ... | @@ -491,7 +488,7 @@ export function GeneralSettingsPanel() { |
| 491 | ); | 488 | ); |
| 492 | 489 | ||
| 493 | const openInPreferredEditor = useCallback( | 490 | const openInPreferredEditor = useCallback( |
| 494 | (target: "keybindings" | "logsDirectory", path: string | null, failureMessage: string) => { | 491 | (target: "logsDirectory", path: string | null, failureMessage: string) => { |
| 495 | if (!path) return; | 492 | if (!path) return; |
| 496 | setOpenPathErrorByTarget((existing) => ({ ...existing, [target]: null })); | 493 | setOpenPathErrorByTarget((existing) => ({ ...existing, [target]: null })); |
| 497 | setOpeningPathByTarget((existing) => ({ ...existing, [target]: true })); | 494 | setOpeningPathByTarget((existing) => ({ ...existing, [target]: true })); |
| ... | @@ -521,17 +518,11 @@ export function GeneralSettingsPanel() { | ... | @@ -521,17 +518,11 @@ export function GeneralSettingsPanel() { |
| 521 | [availableEditors], | 518 | [availableEditors], |
| 522 | ); | 519 | ); |
| 523 | 520 | ||
| 524 | const openKeybindingsFile = useCallback(() => { | ||
| 525 | openInPreferredEditor("keybindings", keybindingsConfigPath, "Unable to open keybindings file."); | ||
| 526 | }, [keybindingsConfigPath, openInPreferredEditor]); | ||
| 527 | |||
| 528 | const openLogsDirectory = useCallback(() => { | 521 | const openLogsDirectory = useCallback(() => { |
| 529 | openInPreferredEditor("logsDirectory", logsDirectoryPath, "Unable to open logs folder."); | 522 | openInPreferredEditor("logsDirectory", logsDirectoryPath, "Unable to open logs folder."); |
| 530 | }, [logsDirectoryPath, openInPreferredEditor]); | 523 | }, [logsDirectoryPath, openInPreferredEditor]); |
| 531 | 524 | ||
| 532 | const openKeybindingsError = openPathErrorByTarget.keybindings ?? null; | ||
| 533 | const openDiagnosticsError = openPathErrorByTarget.logsDirectory ?? null; | 525 | const openDiagnosticsError = openPathErrorByTarget.logsDirectory ?? null; |
| 534 | const isOpeningKeybindings = openingPathByTarget.keybindings; | ||
| 535 | const isOpeningLogsDirectory = openingPathByTarget.logsDirectory; | 526 | const isOpeningLogsDirectory = openingPathByTarget.logsDirectory; |
| 536 | 527 | ||
| 537 | return ( | 528 | return ( |
| ... | @@ -911,35 +902,6 @@ export function GeneralSettingsPanel() { | ... | @@ -911,35 +902,6 @@ export function GeneralSettingsPanel() { |
| 911 | /> | 902 | /> |
| 912 | </SettingsSection> | 903 | </SettingsSection> |
| 913 | 904 | ||
| 914 | <SettingsSection title="Advanced"> | ||
| 915 | <SettingsRow | ||
| 916 | title="Keybindings" | ||
| 917 | description="Open the persisted `keybindings.json` file to edit advanced bindings directly." | ||
| 918 | status={ | ||
| 919 | <> | ||
| 920 | <span className="block break-all font-mono text-[11px] text-foreground"> | ||
| 921 | {keybindingsConfigPath ?? "Resolving keybindings path..."} | ||
| 922 | </span> | ||
| 923 | {openKeybindingsError ? ( | ||
| 924 | <span className="mt-1 block text-destructive">{openKeybindingsError}</span> | ||
| 925 | ) : ( | ||
| 926 | <span className="mt-1 block">Opens in your preferred editor.</span> | ||
| 927 | )} | ||
| 928 | </> | ||
| 929 | } | ||
| 930 | control={ | ||
| 931 | <Button | ||
| 932 | size="xs" | ||
| 933 | variant="outline" | ||
| 934 | disabled={!keybindingsConfigPath || isOpeningKeybindings} | ||
| 935 | onClick={openKeybindingsFile} | ||
| 936 | > | ||
| 937 | {isOpeningKeybindings ? "Opening..." : "Open file"} | ||
| 938 | </Button> | ||
| 939 | } | ||
| 940 | /> | ||
| 941 | </SettingsSection> | ||
| 942 | |||
| 943 | <SettingsSection title="About"> | 905 | <SettingsSection title="About"> |
| 944 | {isElectron ? ( | 906 | {isElectron ? ( |
| 945 | <AboutVersionSection /> | 907 | <AboutVersionSection /> |
apps/web/src/components/settings/SettingsSidebarNav.tsx+3| ... | @@ -4,6 +4,7 @@ import { | ... | @@ -4,6 +4,7 @@ import { |
| 4 | ArrowLeftIcon, | 4 | ArrowLeftIcon, |
| 5 | BotIcon, | 5 | BotIcon, |
| 6 | GitBranchIcon, | 6 | GitBranchIcon, |
| 7 | KeyboardIcon, | ||
| 7 | Link2Icon, | 8 | Link2Icon, |
| 8 | Settings2Icon, | 9 | Settings2Icon, |
| 9 | } from "lucide-react"; | 10 | } from "lucide-react"; |
| ... | @@ -22,6 +23,7 @@ import { | ... | @@ -22,6 +23,7 @@ import { |
| 22 | 23 | ||
| 23 | export type SettingsSectionPath = | 24 | export type SettingsSectionPath = |
| 24 | | "/settings/general" | 25 | | "/settings/general" |
| 26 | | "/settings/keybindings" | ||
| 25 | | "/settings/providers" | 27 | | "/settings/providers" |
| 26 | | "/settings/source-control" | 28 | | "/settings/source-control" |
| 27 | | "/settings/connections" | 29 | | "/settings/connections" |
| ... | @@ -33,6 +35,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ | ... | @@ -33,6 +35,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ |
| 33 | icon: ComponentType<{ className?: string }>; | 35 | icon: ComponentType<{ className?: string }>; |
| 34 | }> = [ | 36 | }> = [ |
| 35 | { label: "General", to: "/settings/general", icon: Settings2Icon }, | 37 | { label: "General", to: "/settings/general", icon: Settings2Icon }, |
| 38 | { label: "Keybindings", to: "/settings/keybindings", icon: KeyboardIcon }, | ||
| 36 | { label: "Providers", to: "/settings/providers", icon: BotIcon }, | 39 | { label: "Providers", to: "/settings/providers", icon: BotIcon }, |
| 37 | { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon }, | 40 | { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon }, |
| 38 | { label: "Connections", to: "/settings/connections", icon: Link2Icon }, | 41 | { label: "Connections", to: "/settings/connections", icon: Link2Icon }, |
apps/web/src/components/settings/settingsLayout.tsx+10-2| ... | @@ -115,10 +115,18 @@ export function SettingResetButton({ label, onClick }: { label: string; onClick: | ... | @@ -115,10 +115,18 @@ export function SettingResetButton({ label, onClick }: { label: string; onClick: |
| 115 | ); | 115 | ); |
| 116 | } | 116 | } |
| 117 | 117 | ||
| 118 | export function SettingsPageContainer({ children }: { children: ReactNode }) { | 118 | export function SettingsPageContainer({ |
| 119 | children, | ||
| 120 | className, | ||
| 121 | }: { | ||
| 122 | children: ReactNode; | ||
| 123 | className?: string; | ||
| 124 | }) { | ||
| 119 | return ( | 125 | return ( |
| 120 | <div className="flex-1 overflow-y-auto p-6 sm:p-8"> | 126 | <div className="flex-1 overflow-y-auto p-6 sm:p-8"> |
| 121 | <div className="mx-auto flex w-full max-w-3xl flex-col gap-8">{children}</div> | 127 | <div className={cn("mx-auto flex w-full max-w-3xl flex-col gap-8", className)}> |
| 128 | {children} | ||
| 129 | </div> | ||
| 122 | </div> | 130 | </div> |
| 123 | ); | 131 | ); |
| 124 | } | 132 | } |
apps/web/src/components/ui/scroll-area.tsx+4-1| ... | @@ -10,11 +10,13 @@ function ScrollArea({ | ... | @@ -10,11 +10,13 @@ function ScrollArea({ |
| 10 | scrollFade = false, | 10 | scrollFade = false, |
| 11 | scrollbarGutter = false, | 11 | scrollbarGutter = false, |
| 12 | hideScrollbars = false, | 12 | hideScrollbars = false, |
| 13 | chainVerticalScroll = false, | ||
| 13 | ...props | 14 | ...props |
| 14 | }: ScrollAreaPrimitive.Root.Props & { | 15 | }: ScrollAreaPrimitive.Root.Props & { |
| 15 | scrollFade?: boolean; | 16 | scrollFade?: boolean; |
| 16 | scrollbarGutter?: boolean; | 17 | scrollbarGutter?: boolean; |
| 17 | hideScrollbars?: boolean; | 18 | hideScrollbars?: boolean; |
| 19 | chainVerticalScroll?: boolean; | ||
| 18 | }) { | 20 | }) { |
| 19 | return ( | 21 | return ( |
| 20 | <ScrollAreaPrimitive.Root | 22 | <ScrollAreaPrimitive.Root |
| ... | @@ -23,7 +25,8 @@ function ScrollArea({ | ... | @@ -23,7 +25,8 @@ function ScrollArea({ |
| 23 | > | 25 | > |
| 24 | <ScrollAreaPrimitive.Viewport | 26 | <ScrollAreaPrimitive.Viewport |
| 25 | className={cn( | 27 | className={cn( |
| 26 | "h-full max-h-[inherit] overflow-y-auto overscroll-contain rounded-[inherit] outline-none transition-shadows focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background data-has-overflow-x:overscroll-x-contain", | 28 | "h-full max-h-[inherit] overflow-auto overscroll-contain rounded-[inherit] outline-none transition-shadows focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background data-has-overflow-x:overscroll-x-contain", |
| 29 | chainVerticalScroll && "overscroll-y-auto", | ||
| 27 | scrollFade && | 30 | scrollFade && |
| 28 | "mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))] mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))] mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] [--fade-size:1.5rem]", | 31 | "mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))] mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))] mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] [--fade-size:1.5rem]", |
| 29 | scrollbarGutter && "[scrollbar-gutter:stable]", | 32 | scrollbarGutter && "[scrollbar-gutter:stable]", |
apps/web/src/components/ui/select.tsx+11-1| ... | @@ -105,20 +105,24 @@ function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) { | ... | @@ -105,20 +105,24 @@ function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) { |
| 105 | 105 | ||
| 106 | function SelectPopup({ | 106 | function SelectPopup({ |
| 107 | className, | 107 | className, |
| 108 | popupClassName, | ||
| 108 | children, | 109 | children, |
| 109 | side = "bottom", | 110 | side = "bottom", |
| 110 | sideOffset = 4, | 111 | sideOffset = 4, |
| 111 | align = "start", | 112 | align = "start", |
| 112 | alignOffset = 0, | 113 | alignOffset = 0, |
| 113 | alignItemWithTrigger = true, | 114 | alignItemWithTrigger = true, |
| 115 | matchTriggerWidth = true, | ||
| 114 | anchor, | 116 | anchor, |
| 115 | ...props | 117 | ...props |
| 116 | }: SelectPrimitive.Popup.Props & { | 118 | }: SelectPrimitive.Popup.Props & { |
| 119 | popupClassName?: string; | ||
| 117 | side?: SelectPrimitive.Positioner.Props["side"]; | 120 | side?: SelectPrimitive.Positioner.Props["side"]; |
| 118 | sideOffset?: SelectPrimitive.Positioner.Props["sideOffset"]; | 121 | sideOffset?: SelectPrimitive.Positioner.Props["sideOffset"]; |
| 119 | align?: SelectPrimitive.Positioner.Props["align"]; | 122 | align?: SelectPrimitive.Positioner.Props["align"]; |
| 120 | alignOffset?: SelectPrimitive.Positioner.Props["alignOffset"]; | 123 | alignOffset?: SelectPrimitive.Positioner.Props["alignOffset"]; |
| 121 | alignItemWithTrigger?: SelectPrimitive.Positioner.Props["alignItemWithTrigger"]; | 124 | alignItemWithTrigger?: SelectPrimitive.Positioner.Props["alignItemWithTrigger"]; |
| 125 | matchTriggerWidth?: boolean; | ||
| 122 | anchor?: SelectPrimitive.Positioner.Props["anchor"]; | 126 | anchor?: SelectPrimitive.Positioner.Props["anchor"]; |
| 123 | }) { | 127 | }) { |
| 124 | return ( | 128 | return ( |
| ... | @@ -144,7 +148,13 @@ function SelectPopup({ | ... | @@ -144,7 +148,13 @@ function SelectPopup({ |
| 144 | > | 148 | > |
| 145 | <ChevronUpIcon className="relative size-4.5 sm:size-4" /> | 149 | <ChevronUpIcon className="relative size-4.5 sm:size-4" /> |
| 146 | </SelectPrimitive.ScrollUpArrow> | 150 | </SelectPrimitive.ScrollUpArrow> |
| 147 | <div className="relative h-full min-w-(--anchor-width) rounded-lg border bg-popover not-dark:bg-clip-padding shadow-lg/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]"> | 151 | <div |
| 152 | className={cn( | ||
| 153 | "relative h-full rounded-lg border bg-popover not-dark:bg-clip-padding shadow-lg/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]", | ||
| 154 | matchTriggerWidth && "min-w-(--anchor-width)", | ||
| 155 | popupClassName, | ||
| 156 | )} | ||
| 157 | > | ||
| 148 | <SelectPrimitive.List | 158 | <SelectPrimitive.List |
| 149 | className={cn("max-h-(--available-height) overflow-y-auto p-1", className)} | 159 | className={cn("max-h-(--available-height) overflow-y-auto p-1", className)} |
| 150 | data-slot="select-list" | 160 | data-slot="select-list" |
apps/web/src/localApi.ts+4| ... | @@ -133,6 +133,10 @@ function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { | ... | @@ -133,6 +133,10 @@ function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { |
| 133 | rpcClient | 133 | rpcClient |
| 134 | ? rpcClient.server.upsertKeybinding(input) | 134 | ? rpcClient.server.upsertKeybinding(input) |
| 135 | : Promise.reject(unavailableLocalBackendError()), | 135 | : Promise.reject(unavailableLocalBackendError()), |
| 136 | removeKeybinding: (input) => | ||
| 137 | rpcClient | ||
| 138 | ? rpcClient.server.removeKeybinding(input) | ||
| 139 | : Promise.reject(unavailableLocalBackendError()), | ||
| 136 | getSettings: () => | 140 | getSettings: () => |
| 137 | rpcClient ? rpcClient.server.getSettings() : Promise.reject(unavailableLocalBackendError()), | 141 | rpcClient ? rpcClient.server.getSettings() : Promise.reject(unavailableLocalBackendError()), |
| 138 | updateSettings: (patch) => | 142 | updateSettings: (patch) => |
apps/web/src/routeTree.gen.ts+21| ... | @@ -15,6 +15,7 @@ import { Route as ChatRouteImport } from './routes/_chat' | ... | @@ -15,6 +15,7 @@ import { Route as ChatRouteImport } from './routes/_chat' |
| 15 | import { Route as ChatIndexRouteImport } from './routes/_chat.index' | 15 | import { Route as ChatIndexRouteImport } from './routes/_chat.index' |
| 16 | import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' | 16 | import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' |
| 17 | import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' | 17 | import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' |
| 18 | import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings' | ||
| 18 | import { Route as SettingsGeneralRouteImport } from './routes/settings.general' | 19 | import { Route as SettingsGeneralRouteImport } from './routes/settings.general' |
| 19 | import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' | 20 | import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' |
| 20 | import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' | 21 | import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' |
| ... | @@ -50,6 +51,11 @@ const SettingsProvidersRoute = SettingsProvidersRouteImport.update({ | ... | @@ -50,6 +51,11 @@ const SettingsProvidersRoute = SettingsProvidersRouteImport.update({ |
| 50 | path: '/providers', | 51 | path: '/providers', |
| 51 | getParentRoute: () => SettingsRoute, | 52 | getParentRoute: () => SettingsRoute, |
| 52 | } as any) | 53 | } as any) |
| 54 | const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({ | ||
| 55 | id: '/keybindings', | ||
| 56 | path: '/keybindings', | ||
| 57 | getParentRoute: () => SettingsRoute, | ||
| 58 | } as any) | ||
| 53 | const SettingsGeneralRoute = SettingsGeneralRouteImport.update({ | 59 | const SettingsGeneralRoute = SettingsGeneralRouteImport.update({ |
| 54 | id: '/general', | 60 | id: '/general', |
| 55 | path: '/general', | 61 | path: '/general', |
| ... | @@ -84,6 +90,7 @@ export interface FileRoutesByFullPath { | ... | @@ -84,6 +90,7 @@ export interface FileRoutesByFullPath { |
| 84 | '/settings/archived': typeof SettingsArchivedRoute | 90 | '/settings/archived': typeof SettingsArchivedRoute |
| 85 | '/settings/connections': typeof SettingsConnectionsRoute | 91 | '/settings/connections': typeof SettingsConnectionsRoute |
| 86 | '/settings/general': typeof SettingsGeneralRoute | 92 | '/settings/general': typeof SettingsGeneralRoute |
| 93 | '/settings/keybindings': typeof SettingsKeybindingsRoute | ||
| 87 | '/settings/providers': typeof SettingsProvidersRoute | 94 | '/settings/providers': typeof SettingsProvidersRoute |
| 88 | '/settings/source-control': typeof SettingsSourceControlRoute | 95 | '/settings/source-control': typeof SettingsSourceControlRoute |
| 89 | '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute | 96 | '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute |
| ... | @@ -95,6 +102,7 @@ export interface FileRoutesByTo { | ... | @@ -95,6 +102,7 @@ export interface FileRoutesByTo { |
| 95 | '/settings/archived': typeof SettingsArchivedRoute | 102 | '/settings/archived': typeof SettingsArchivedRoute |
| 96 | '/settings/connections': typeof SettingsConnectionsRoute | 103 | '/settings/connections': typeof SettingsConnectionsRoute |
| 97 | '/settings/general': typeof SettingsGeneralRoute | 104 | '/settings/general': typeof SettingsGeneralRoute |
| 105 | '/settings/keybindings': typeof SettingsKeybindingsRoute | ||
| 98 | '/settings/providers': typeof SettingsProvidersRoute | 106 | '/settings/providers': typeof SettingsProvidersRoute |
| 99 | '/settings/source-control': typeof SettingsSourceControlRoute | 107 | '/settings/source-control': typeof SettingsSourceControlRoute |
| 100 | '/': typeof ChatIndexRoute | 108 | '/': typeof ChatIndexRoute |
| ... | @@ -109,6 +117,7 @@ export interface FileRoutesById { | ... | @@ -109,6 +117,7 @@ export interface FileRoutesById { |
| 109 | '/settings/archived': typeof SettingsArchivedRoute | 117 | '/settings/archived': typeof SettingsArchivedRoute |
| 110 | '/settings/connections': typeof SettingsConnectionsRoute | 118 | '/settings/connections': typeof SettingsConnectionsRoute |
| 111 | '/settings/general': typeof SettingsGeneralRoute | 119 | '/settings/general': typeof SettingsGeneralRoute |
| 120 | '/settings/keybindings': typeof SettingsKeybindingsRoute | ||
| 112 | '/settings/providers': typeof SettingsProvidersRoute | 121 | '/settings/providers': typeof SettingsProvidersRoute |
| 113 | '/settings/source-control': typeof SettingsSourceControlRoute | 122 | '/settings/source-control': typeof SettingsSourceControlRoute |
| 114 | '/_chat/': typeof ChatIndexRoute | 123 | '/_chat/': typeof ChatIndexRoute |
| ... | @@ -124,6 +133,7 @@ export interface FileRouteTypes { | ... | @@ -124,6 +133,7 @@ export interface FileRouteTypes { |
| 124 | | '/settings/archived' | 133 | | '/settings/archived' |
| 125 | | '/settings/connections' | 134 | | '/settings/connections' |
| 126 | | '/settings/general' | 135 | | '/settings/general' |
| 136 | | '/settings/keybindings' | ||
| 127 | | '/settings/providers' | 137 | | '/settings/providers' |
| 128 | | '/settings/source-control' | 138 | | '/settings/source-control' |
| 129 | | '/$environmentId/$threadId' | 139 | | '/$environmentId/$threadId' |
| ... | @@ -135,6 +145,7 @@ export interface FileRouteTypes { | ... | @@ -135,6 +145,7 @@ export interface FileRouteTypes { |
| 135 | | '/settings/archived' | 145 | | '/settings/archived' |
| 136 | | '/settings/connections' | 146 | | '/settings/connections' |
| 137 | | '/settings/general' | 147 | | '/settings/general' |
| 148 | | '/settings/keybindings' | ||
| 138 | | '/settings/providers' | 149 | | '/settings/providers' |
| 139 | | '/settings/source-control' | 150 | | '/settings/source-control' |
| 140 | | '/' | 151 | | '/' |
| ... | @@ -148,6 +159,7 @@ export interface FileRouteTypes { | ... | @@ -148,6 +159,7 @@ export interface FileRouteTypes { |
| 148 | | '/settings/archived' | 159 | | '/settings/archived' |
| 149 | | '/settings/connections' | 160 | | '/settings/connections' |
| 150 | | '/settings/general' | 161 | | '/settings/general' |
| 162 | | '/settings/keybindings' | ||
| 151 | | '/settings/providers' | 163 | | '/settings/providers' |
| 152 | | '/settings/source-control' | 164 | | '/settings/source-control' |
| 153 | | '/_chat/' | 165 | | '/_chat/' |
| ... | @@ -205,6 +217,13 @@ declare module '@tanstack/react-router' { | ... | @@ -205,6 +217,13 @@ declare module '@tanstack/react-router' { |
| 205 | preLoaderRoute: typeof SettingsProvidersRouteImport | 217 | preLoaderRoute: typeof SettingsProvidersRouteImport |
| 206 | parentRoute: typeof SettingsRoute | 218 | parentRoute: typeof SettingsRoute |
| 207 | } | 219 | } |
| 220 | '/settings/keybindings': { | ||
| 221 | id: '/settings/keybindings' | ||
| 222 | path: '/keybindings' | ||
| 223 | fullPath: '/settings/keybindings' | ||
| 224 | preLoaderRoute: typeof SettingsKeybindingsRouteImport | ||
| 225 | parentRoute: typeof SettingsRoute | ||
| 226 | } | ||
| 208 | '/settings/general': { | 227 | '/settings/general': { |
| 209 | id: '/settings/general' | 228 | id: '/settings/general' |
| 210 | path: '/general' | 229 | path: '/general' |
| ... | @@ -261,6 +280,7 @@ interface SettingsRouteChildren { | ... | @@ -261,6 +280,7 @@ interface SettingsRouteChildren { |
| 261 | SettingsArchivedRoute: typeof SettingsArchivedRoute | 280 | SettingsArchivedRoute: typeof SettingsArchivedRoute |
| 262 | SettingsConnectionsRoute: typeof SettingsConnectionsRoute | 281 | SettingsConnectionsRoute: typeof SettingsConnectionsRoute |
| 263 | SettingsGeneralRoute: typeof SettingsGeneralRoute | 282 | SettingsGeneralRoute: typeof SettingsGeneralRoute |
| 283 | SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute | ||
| 264 | SettingsProvidersRoute: typeof SettingsProvidersRoute | 284 | SettingsProvidersRoute: typeof SettingsProvidersRoute |
| 265 | SettingsSourceControlRoute: typeof SettingsSourceControlRoute | 285 | SettingsSourceControlRoute: typeof SettingsSourceControlRoute |
| 266 | } | 286 | } |
| ... | @@ -269,6 +289,7 @@ const SettingsRouteChildren: SettingsRouteChildren = { | ... | @@ -269,6 +289,7 @@ const SettingsRouteChildren: SettingsRouteChildren = { |
| 269 | SettingsArchivedRoute: SettingsArchivedRoute, | 289 | SettingsArchivedRoute: SettingsArchivedRoute, |
| 270 | SettingsConnectionsRoute: SettingsConnectionsRoute, | 290 | SettingsConnectionsRoute: SettingsConnectionsRoute, |
| 271 | SettingsGeneralRoute: SettingsGeneralRoute, | 291 | SettingsGeneralRoute: SettingsGeneralRoute, |
| 292 | SettingsKeybindingsRoute: SettingsKeybindingsRoute, | ||
| 272 | SettingsProvidersRoute: SettingsProvidersRoute, | 293 | SettingsProvidersRoute: SettingsProvidersRoute, |
| 273 | SettingsSourceControlRoute: SettingsSourceControlRoute, | 294 | SettingsSourceControlRoute: SettingsSourceControlRoute, |
| 274 | } | 295 | } |
apps/web/src/routes/__root.tsx+6| ... | @@ -288,6 +288,7 @@ function EventRouter() { | ... | @@ -288,6 +288,7 @@ function EventRouter() { |
| 288 | const readPathname = useEffectEvent(() => pathname); | 288 | const readPathname = useEffectEvent(() => pathname); |
| 289 | const handledBootstrapThreadIdRef = useRef<string | null>(null); | 289 | const handledBootstrapThreadIdRef = useRef<string | null>(null); |
| 290 | const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0); | 290 | const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0); |
| 291 | const lastKeybindingsSuccessToastAtRef = useRef(0); | ||
| 291 | const disposedRef = useRef(false); | 292 | const disposedRef = useRef(false); |
| 292 | const serverConfig = useServerConfig(); | 293 | const serverConfig = useServerConfig(); |
| 293 | 294 | ||
| ... | @@ -354,6 +355,11 @@ function EventRouter() { | ... | @@ -354,6 +355,11 @@ function EventRouter() { |
| 354 | 355 | ||
| 355 | const issue = payload.issues.find((entry) => entry.kind.startsWith("keybindings.")); | 356 | const issue = payload.issues.find((entry) => entry.kind.startsWith("keybindings.")); |
| 356 | if (!issue) { | 357 | if (!issue) { |
| 358 | const now = Date.now(); | ||
| 359 | if (now - lastKeybindingsSuccessToastAtRef.current < 2_000) { | ||
| 360 | return; | ||
| 361 | } | ||
| 362 | lastKeybindingsSuccessToastAtRef.current = now; | ||
| 357 | toastManager.add({ | 363 | toastManager.add({ |
| 358 | type: "success", | 364 | type: "success", |
| 359 | title: "Keybindings updated", | 365 | title: "Keybindings updated", |
apps/web/src/routes/settings.keybindings.tsx created+7| ... | @@ -0,0 +1,7 @@ | ||
| 1 | import { createFileRoute } from "@tanstack/react-router"; | ||
| 2 | |||
| 3 | import { KeybindingsSettingsPanel } from "../components/settings/KeybindingsSettings"; | ||
| 4 | |||
| 5 | export const Route = createFileRoute("/settings/keybindings")({ | ||
| 6 | component: KeybindingsSettingsPanel, | ||
| 7 | }); | ||
apps/web/src/rpc/wsRpcClient.ts+3| ... | @@ -121,6 +121,7 @@ export interface WsRpcClient { | ... | @@ -121,6 +121,7 @@ export interface WsRpcClient { |
| 121 | ) => ReturnType<RpcUnaryMethod<typeof WS_METHODS.serverRefreshProviders>>; | 121 | ) => ReturnType<RpcUnaryMethod<typeof WS_METHODS.serverRefreshProviders>>; |
| 122 | readonly updateProvider: RpcUnaryMethod<typeof WS_METHODS.serverUpdateProvider>; | 122 | readonly updateProvider: RpcUnaryMethod<typeof WS_METHODS.serverUpdateProvider>; |
| 123 | readonly upsertKeybinding: RpcUnaryMethod<typeof WS_METHODS.serverUpsertKeybinding>; | 123 | readonly upsertKeybinding: RpcUnaryMethod<typeof WS_METHODS.serverUpsertKeybinding>; |
| 124 | readonly removeKeybinding: RpcUnaryMethod<typeof WS_METHODS.serverRemoveKeybinding>; | ||
| 124 | readonly getSettings: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetSettings>; | 125 | readonly getSettings: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetSettings>; |
| 125 | readonly updateSettings: ( | 126 | readonly updateSettings: ( |
| 126 | patch: ServerSettingsPatch, | 127 | patch: ServerSettingsPatch, |
| ... | @@ -239,6 +240,8 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { | ... | @@ -239,6 +240,8 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { |
| 239 | transport.request((client) => client[WS_METHODS.serverUpdateProvider](input)), | 240 | transport.request((client) => client[WS_METHODS.serverUpdateProvider](input)), |
| 240 | upsertKeybinding: (input) => | 241 | upsertKeybinding: (input) => |
| 241 | transport.request((client) => client[WS_METHODS.serverUpsertKeybinding](input)), | 242 | transport.request((client) => client[WS_METHODS.serverUpsertKeybinding](input)), |
| 243 | removeKeybinding: (input) => | ||
| 244 | transport.request((client) => client[WS_METHODS.serverRemoveKeybinding](input)), | ||
| 242 | getSettings: () => transport.request((client) => client[WS_METHODS.serverGetSettings]({})), | 245 | getSettings: () => transport.request((client) => client[WS_METHODS.serverGetSettings]({})), |
| 243 | updateSettings: (patch) => | 246 | updateSettings: (patch) => |
| 244 | transport.request((client) => client[WS_METHODS.serverUpdateSettings]({ patch })), | 247 | transport.request((client) => client[WS_METHODS.serverUpdateSettings]({ patch })), |
packages/contracts/src/ipc.ts+3-1| ... | @@ -30,6 +30,7 @@ import type { | ... | @@ -30,6 +30,7 @@ import type { |
| 30 | ServerConfig, | 30 | ServerConfig, |
| 31 | ServerProviderUpdateInput, | 31 | ServerProviderUpdateInput, |
| 32 | ServerProviderUpdatedPayload, | 32 | ServerProviderUpdatedPayload, |
| 33 | ServerRemoveKeybindingResult, | ||
| 33 | ServerUpsertKeybindingResult, | 34 | ServerUpsertKeybindingResult, |
| 34 | } from "./server.ts"; | 35 | } from "./server.ts"; |
| 35 | import type { | 36 | import type { |
| ... | @@ -42,7 +43,7 @@ import type { | ... | @@ -42,7 +43,7 @@ import type { |
| 42 | TerminalSessionSnapshot, | 43 | TerminalSessionSnapshot, |
| 43 | TerminalWriteInput, | 44 | TerminalWriteInput, |
| 44 | } from "./terminal.ts"; | 45 | } from "./terminal.ts"; |
| 45 | import type { ServerUpsertKeybindingInput } from "./server.ts"; | 46 | import type { ServerRemoveKeybindingInput, ServerUpsertKeybindingInput } from "./server.ts"; |
| 46 | import type { | 47 | import type { |
| 47 | ClientOrchestrationCommand, | 48 | ClientOrchestrationCommand, |
| 48 | OrchestrationGetFullThreadDiffInput, | 49 | OrchestrationGetFullThreadDiffInput, |
| ... | @@ -299,6 +300,7 @@ export interface LocalApi { | ... | @@ -299,6 +300,7 @@ export interface LocalApi { |
| 299 | }) => Promise<ServerProviderUpdatedPayload>; | 300 | }) => Promise<ServerProviderUpdatedPayload>; |
| 300 | updateProvider: (input: ServerProviderUpdateInput) => Promise<ServerProviderUpdatedPayload>; | 301 | updateProvider: (input: ServerProviderUpdateInput) => Promise<ServerProviderUpdatedPayload>; |
| 301 | upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>; | 302 | upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>; |
| 303 | removeKeybinding: (input: ServerRemoveKeybindingInput) => Promise<ServerRemoveKeybindingResult>; | ||
| 302 | getSettings: () => Promise<ServerSettings>; | 304 | getSettings: () => Promise<ServerSettings>; |
| 303 | updateSettings: (patch: ServerSettingsPatch) => Promise<ServerSettings>; | 305 | updateSettings: (patch: ServerSettingsPatch) => Promise<ServerSettings>; |
| 304 | discoverSourceControl: () => Promise<SourceControlDiscoveryResult>; | 306 | discoverSourceControl: () => Promise<SourceControlDiscoveryResult>; |
packages/contracts/src/keybindings.ts+3-3| ... | @@ -2,7 +2,7 @@ import { Schema } from "effect"; | ... | @@ -2,7 +2,7 @@ import { Schema } from "effect"; |
| 2 | import { TrimmedString } from "./baseSchemas.ts"; | 2 | import { TrimmedString } from "./baseSchemas.ts"; |
| 3 | 3 | ||
| 4 | export const MAX_KEYBINDING_VALUE_LENGTH = 64; | 4 | export const MAX_KEYBINDING_VALUE_LENGTH = 64; |
| 5 | const MAX_KEYBINDING_WHEN_LENGTH = 256; | 5 | export const MAX_KEYBINDING_WHEN_LENGTH = 256; |
| 6 | export const MAX_WHEN_EXPRESSION_DEPTH = 64; | 6 | export const MAX_WHEN_EXPRESSION_DEPTH = 64; |
| 7 | export const MAX_SCRIPT_ID_LENGTH = 24; | 7 | export const MAX_SCRIPT_ID_LENGTH = 24; |
| 8 | export const MAX_KEYBINDINGS_COUNT = 256; | 8 | export const MAX_KEYBINDINGS_COUNT = 256; |
| ... | @@ -76,12 +76,12 @@ export const KeybindingCommand = Schema.Union([ | ... | @@ -76,12 +76,12 @@ export const KeybindingCommand = Schema.Union([ |
| 76 | ]); | 76 | ]); |
| 77 | export type KeybindingCommand = typeof KeybindingCommand.Type; | 77 | export type KeybindingCommand = typeof KeybindingCommand.Type; |
| 78 | 78 | ||
| 79 | const KeybindingValue = TrimmedString.check( | 79 | export const KeybindingValue = TrimmedString.check( |
| 80 | Schema.isMinLength(1), | 80 | Schema.isMinLength(1), |
| 81 | Schema.isMaxLength(MAX_KEYBINDING_VALUE_LENGTH), | 81 | Schema.isMaxLength(MAX_KEYBINDING_VALUE_LENGTH), |
| 82 | ); | 82 | ); |
| 83 | 83 | ||
| 84 | const KeybindingWhen = TrimmedString.check( | 84 | export const KeybindingWhen = TrimmedString.check( |
| 85 | Schema.isMinLength(1), | 85 | Schema.isMinLength(1), |
| 86 | Schema.isMaxLength(MAX_KEYBINDING_WHEN_LENGTH), | 86 | Schema.isMaxLength(MAX_KEYBINDING_WHEN_LENGTH), |
| 87 | ); | 87 | ); |
packages/contracts/src/rpc.ts+10| ... | @@ -74,6 +74,8 @@ import { | ... | @@ -74,6 +74,8 @@ import { |
| 74 | ServerProviderUpdateError, | 74 | ServerProviderUpdateError, |
| 75 | ServerProviderUpdateInput, | 75 | ServerProviderUpdateInput, |
| 76 | ServerLifecycleStreamEvent, | 76 | ServerLifecycleStreamEvent, |
| 77 | ServerRemoveKeybindingInput, | ||
| 78 | ServerRemoveKeybindingResult, | ||
| 77 | ServerProviderUpdatedPayload, | 79 | ServerProviderUpdatedPayload, |
| 78 | ServerUpsertKeybindingInput, | 80 | ServerUpsertKeybindingInput, |
| 79 | ServerUpsertKeybindingResult, | 81 | ServerUpsertKeybindingResult, |
| ... | @@ -133,6 +135,7 @@ export const WS_METHODS = { | ... | @@ -133,6 +135,7 @@ export const WS_METHODS = { |
| 133 | serverRefreshProviders: "server.refreshProviders", | 135 | serverRefreshProviders: "server.refreshProviders", |
| 134 | serverUpdateProvider: "server.updateProvider", | 136 | serverUpdateProvider: "server.updateProvider", |
| 135 | serverUpsertKeybinding: "server.upsertKeybinding", | 137 | serverUpsertKeybinding: "server.upsertKeybinding", |
| 138 | serverRemoveKeybinding: "server.removeKeybinding", | ||
| 136 | serverGetSettings: "server.getSettings", | 139 | serverGetSettings: "server.getSettings", |
| 137 | serverUpdateSettings: "server.updateSettings", | 140 | serverUpdateSettings: "server.updateSettings", |
| 138 | serverDiscoverSourceControl: "server.discoverSourceControl", | 141 | serverDiscoverSourceControl: "server.discoverSourceControl", |
| ... | @@ -156,6 +159,12 @@ export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybi | ... | @@ -156,6 +159,12 @@ export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybi |
| 156 | error: KeybindingsConfigError, | 159 | error: KeybindingsConfigError, |
| 157 | }); | 160 | }); |
| 158 | 161 | ||
| 162 | export const WsServerRemoveKeybindingRpc = Rpc.make(WS_METHODS.serverRemoveKeybinding, { | ||
| 163 | payload: ServerRemoveKeybindingInput, | ||
| 164 | success: ServerRemoveKeybindingResult, | ||
| 165 | error: KeybindingsConfigError, | ||
| 166 | }); | ||
| 167 | |||
| 159 | export const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, { | 168 | export const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, { |
| 160 | payload: Schema.Struct({}), | 169 | payload: Schema.Struct({}), |
| 161 | success: ServerConfig, | 170 | success: ServerConfig, |
| ... | @@ -426,6 +435,7 @@ export const WsRpcGroup = RpcGroup.make( | ... | @@ -426,6 +435,7 @@ export const WsRpcGroup = RpcGroup.make( |
| 426 | WsServerRefreshProvidersRpc, | 435 | WsServerRefreshProvidersRpc, |
| 427 | WsServerUpdateProviderRpc, | 436 | WsServerUpdateProviderRpc, |
| 428 | WsServerUpsertKeybindingRpc, | 437 | WsServerUpsertKeybindingRpc, |
| 438 | WsServerRemoveKeybindingRpc, | ||
| 429 | WsServerGetSettingsRpc, | 439 | WsServerGetSettingsRpc, |
| 430 | WsServerUpdateSettingsRpc, | 440 | WsServerUpdateSettingsRpc, |
| 431 | WsServerDiscoverSourceControlRpc, | 441 | WsServerDiscoverSourceControlRpc, |
packages/contracts/src/server.ts+24-2| ... | @@ -8,7 +8,12 @@ import { | ... | @@ -8,7 +8,12 @@ import { |
| 8 | ThreadId, | 8 | ThreadId, |
| 9 | TrimmedNonEmptyString, | 9 | TrimmedNonEmptyString, |
| 10 | } from "./baseSchemas.ts"; | 10 | } from "./baseSchemas.ts"; |
| 11 | import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings.ts"; | 11 | import { |
| 12 | KeybindingCommand, | ||
| 13 | KeybindingValue, | ||
| 14 | KeybindingWhen, | ||
| 15 | ResolvedKeybindingsConfig, | ||
| 16 | } from "./keybindings.ts"; | ||
| 12 | import { EditorId } from "./editor.ts"; | 17 | import { EditorId } from "./editor.ts"; |
| 13 | import { ModelCapabilities } from "./model.ts"; | 18 | import { ModelCapabilities } from "./model.ts"; |
| 14 | import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; | 19 | import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; |
| ... | @@ -220,15 +225,32 @@ export const ServerConfig = Schema.Struct({ | ... | @@ -220,15 +225,32 @@ export const ServerConfig = Schema.Struct({ |
| 220 | }); | 225 | }); |
| 221 | export type ServerConfig = typeof ServerConfig.Type; | 226 | export type ServerConfig = typeof ServerConfig.Type; |
| 222 | 227 | ||
| 223 | export const ServerUpsertKeybindingInput = KeybindingRule; | 228 | const ServerUpsertKeybindingReplaceTarget = Schema.Struct({ |
| 229 | key: KeybindingValue, | ||
| 230 | command: KeybindingCommand, | ||
| 231 | when: Schema.optional(KeybindingWhen), | ||
| 232 | }); | ||
| 233 | |||
| 234 | export const ServerUpsertKeybindingInput = Schema.Struct({ | ||
| 235 | key: KeybindingValue, | ||
| 236 | command: KeybindingCommand, | ||
| 237 | when: Schema.optional(KeybindingWhen), | ||
| 238 | replace: Schema.optional(ServerUpsertKeybindingReplaceTarget), | ||
| 239 | }); | ||
| 224 | export type ServerUpsertKeybindingInput = typeof ServerUpsertKeybindingInput.Type; | 240 | export type ServerUpsertKeybindingInput = typeof ServerUpsertKeybindingInput.Type; |
| 225 | 241 | ||
| 242 | export const ServerRemoveKeybindingInput = ServerUpsertKeybindingReplaceTarget; | ||
| 243 | export type ServerRemoveKeybindingInput = typeof ServerRemoveKeybindingInput.Type; | ||
| 244 | |||
| 226 | export const ServerUpsertKeybindingResult = Schema.Struct({ | 245 | export const ServerUpsertKeybindingResult = Schema.Struct({ |
| 227 | keybindings: ResolvedKeybindingsConfig, | 246 | keybindings: ResolvedKeybindingsConfig, |
| 228 | issues: ServerConfigIssues, | 247 | issues: ServerConfigIssues, |
| 229 | }); | 248 | }); |
| 230 | export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.Type; | 249 | export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.Type; |
| 231 | 250 | ||
| 251 | export const ServerRemoveKeybindingResult = ServerUpsertKeybindingResult; | ||
| 252 | export type ServerRemoveKeybindingResult = typeof ServerRemoveKeybindingResult.Type; | ||
| 253 | |||
| 232 | export const ServerConfigUpdatedPayload = Schema.Struct({ | 254 | export const ServerConfigUpdatedPayload = Schema.Struct({ |
| 233 | issues: ServerConfigIssues, | 255 | issues: ServerConfigIssues, |
| 234 | providers: ServerProviders, | 256 | providers: ServerProviders, |
packages/shared/src/keybindings.ts+1-1| ... | @@ -162,7 +162,7 @@ function tokenizeWhenExpression(expression: string): WhenToken[] | null { | ... | @@ -162,7 +162,7 @@ function tokenizeWhenExpression(expression: string): WhenToken[] | null { |
| 162 | return tokens; | 162 | return tokens; |
| 163 | } | 163 | } |
| 164 | 164 | ||
| 165 | function parseKeybindingWhenExpression(expression: string): KeybindingWhenNode | null { | 165 | export function parseKeybindingWhenExpression(expression: string): KeybindingWhenNode | null { |
| 166 | const tokens = tokenizeWhenExpression(expression); | 166 | const tokens = tokenizeWhenExpression(expression); |
| 167 | if (!tokens || tokens.length === 0) return null; | 167 | if (!tokens || tokens.length === 0) return null; |
| 168 | let index = 0; | 168 | let index = 0; |