diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 2096e2e1d908c..8c95bc569b898 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -7,6 +7,7 @@ import { buildKeybindingCommandOptions, buildWhenVariableOptions, commandLabel, + filterKeybindingRows, groupKeybindingRows, keybindingConflictLabels, keybindingFromKeyboardEvent, @@ -19,7 +20,7 @@ import { describe("KeybindingsSettings.logic", () => { it("lists composer, provider, and pull request commands with editable defaults", () => { - const rows = buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS, ""); + const rows = buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS); for (const command of [ "composer.sendAlternate", "composer.sendBackground", @@ -43,24 +44,34 @@ describe("KeybindingsSettings.logic", () => { } }); it("finds the editable shortcut for sending the first queued message", () => { - expect(buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS, "first queued")).toContainEqual( + expect( + filterKeybindingRows(buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS), "first queued"), + ).toContainEqual( expect.objectContaining({ command: "thread.steerQueuedMessage", key: "mod+shift+enter", }), ); }); + it("filtering keeps the row objects so unchanged rows skip rendering", () => { + const rows = buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS); + const filtered = filterKeybindingRows(rows, "terminal"); + expect(filtered.length).toBeGreaterThan(0); + expect(filtered.length).toBeLessThan(rows.length); + for (const row of filtered) expect(rows).toContain(row); + expect(filterKeybindingRows(rows, " ")).toBe(rows); + }); it.each(["pu", "pull request", "copy link", "thread id"])( "finds the copy link shortcut with %s", (query) => { - const rows = buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS, query); + const rows = filterKeybindingRows(buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS), query); expect(rows).toContainEqual( expect.objectContaining({ command: "thread.copyReference", key: "mod+shift+c" }), ); }, ); it("groups rows by command area in page order and drops empty groups", () => { - const groups = groupKeybindingRows(buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS, "")); + const groups = groupKeybindingRows(buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS)); expect(groups.map((group) => group.title)).toEqual([ "Navigation", "Threads", @@ -74,9 +85,11 @@ describe("KeybindingsSettings.logic", () => { expect(composer?.rows.map((row) => row.command)).toEqual( expect.arrayContaining(["composer.host", "modelPicker.toggle"]), ); - expect(groupKeybindingRows(buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS, "split"))).toEqual( - [expect.objectContaining({ id: "terminal" })], - ); + expect( + groupKeybindingRows( + filterKeybindingRows(buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS), "split"), + ), + ).toEqual([expect.objectContaining({ id: "terminal" })]); }); it("orders Usage bindings and command choices like the page", () => { const expected = [ @@ -93,7 +106,9 @@ describe("KeybindingsSettings.logic", () => { DEFAULT_RESOLVED_KEYBINDINGS, DEFAULT_RESOLVED_KEYBINDINGS.toReversed(), ]) { - expect(buildKeybindingRows(bindings, "usage").map((row) => row.command)).toEqual(expected); + expect( + filterKeybindingRows(buildKeybindingRows(bindings), "usage").map((row) => row.command), + ).toEqual(expected); expect( buildKeybindingCommandOptions(bindings).filter((command) => command.startsWith("usage.")), ).toEqual(expected); @@ -101,8 +116,8 @@ describe("KeybindingsSettings.logic", () => { }); it("builds searchable rows with readable key and when values", () => { - const rows = buildKeybindingRows( - [ + const rows = filterKeybindingRows( + buildKeybindingRows([ { command: "terminal.toggle", shortcut: { @@ -118,7 +133,7 @@ describe("KeybindingsSettings.logic", () => { node: { type: "identifier", name: "terminalFocus" }, }, }, - ] satisfies ResolvedKeybindingsConfig, + ] satisfies ResolvedKeybindingsConfig), "terminal", ); @@ -345,81 +360,75 @@ describe("KeybindingsSettings.logic", () => { }); it("marks each default shortcut for multi-binding commands as default", () => { - const rows = buildKeybindingRows( - [ - { - command: "chat.new", - shortcut: { - key: "n", - modKey: true, - metaKey: false, - ctrlKey: false, - altKey: false, - shiftKey: false, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, + const rows = buildKeybindingRows([ + { + command: "chat.new", + shortcut: { + key: "n", + modKey: true, + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, }, - { - command: "chat.new", - shortcut: { - key: "o", - modKey: true, - metaKey: false, - ctrlKey: false, - altKey: false, - shiftKey: true, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, }, - ] satisfies ResolvedKeybindingsConfig, - "", - ); + }, + { + command: "chat.new", + shortcut: { + key: "o", + modKey: true, + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ] satisfies ResolvedKeybindingsConfig); expect(rows.map((row) => row.source)).toEqual(["Default", "Default"]); }); it("reports conflicting shortcuts that share an active when context", () => { - const rows = buildKeybindingRows( - [ - { - command: "chat.new", - shortcut: { - key: "n", - modKey: true, - metaKey: false, - ctrlKey: false, - altKey: false, - shiftKey: false, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, + const rows = buildKeybindingRows([ + { + command: "chat.new", + shortcut: { + key: "n", + modKey: true, + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, }, - { - command: "chat.newLocal", - shortcut: { - key: "n", - modKey: true, - metaKey: false, - ctrlKey: false, - altKey: false, - shiftKey: false, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, }, - ] satisfies ResolvedKeybindingsConfig, - "", - ); + }, + { + command: "chat.newLocal", + shortcut: { + key: "n", + modKey: true, + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ] satisfies ResolvedKeybindingsConfig); expect(rows[0]?.conflicts).toEqual(["Chat: New Local"]); expect( diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index a396941a9861f..18fd0e1acb956 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -243,9 +243,7 @@ export function groupKeybindingRows( export function buildKeybindingRows( keybindings: ResolvedKeybindingsConfig, - query: string, ): ReadonlyArray { - const normalizedQuery = query.trim().toLowerCase(); const rows = keybindings.map((binding, index) => { const defaultBinding = defaultBindingForBinding(binding); const key = shortcutToKeybindingInput(binding.shortcut); @@ -280,11 +278,18 @@ export function buildKeybindingRows( return left.key.localeCompare(right.key); }); - if (normalizedQuery.length === 0) { - return rowsWithConflicts; - } + return rowsWithConflicts; +} - return rowsWithConflicts.filter((row) => { +// Filtering keeps the row objects so rows that stay visible while the query +// changes receive the same props and skip rendering. +export function filterKeybindingRows( + rows: ReadonlyArray, + query: string, +): ReadonlyArray { + const normalizedQuery = query.trim().toLowerCase(); + if (normalizedQuery.length === 0) return rows; + return rows.filter((row) => { return ( row.command.toLowerCase().includes(normalizedQuery) || commandLabel(row.command).toLowerCase().includes(normalizedQuery) || diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index e3f71797ee8e2..995ca8cab3795 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -52,6 +52,7 @@ import { Toggle } from "../ui/toggle"; import { toastManager } from "../ui/toast"; import { buildKeybindingRows, + filterKeybindingRows, buildKeybindingCommandOptions, buildWhenVariableOptions, commandLabel, @@ -1271,6 +1272,18 @@ function keybindingAnchorIds(rows: ReturnType) { return ids; } +// Rows come from a hook so they are cached on the server keybindings alone. Built +// inline, the compiler joins them to the query's cache scope (filtering might +// mutate them), and every keystroke rebuilds and re-renders every row. +function useKeybindingCatalog() { + const serverKeybindings = useSettingsScope().environment?.serverConfig?.keybindings; + const merged = mergeWithDefaultKeybindings(serverKeybindings ?? []); + return { + commandOptions: buildKeybindingCommandOptions(merged), + allRows: buildKeybindingRows(merged), + }; +} + export function KeybindingsSettingsPanel() { const { environment: primaryEnvironment, connectedEnvironments } = useSettingsScope(); const canOpenKeybindingsFile = useEnvironmentScope( @@ -1305,13 +1318,8 @@ export function KeybindingsSettingsPanel() { if (searchTargetId.startsWith("keybinding-")) setQuery(""); } const whenVariables = buildWhenVariableOptions(); - const serverKeybindings = primaryEnvironment?.serverConfig?.keybindings; - // Each derivation merges the defaults itself so the command options key on the - // server keybindings alone and do not rebuild on every search keystroke. - const commandOptions = buildKeybindingCommandOptions( - mergeWithDefaultKeybindings(serverKeybindings ?? []), - ); - const rows = buildKeybindingRows(mergeWithDefaultKeybindings(serverKeybindings ?? []), query); + const { commandOptions, allRows } = useKeybindingCatalog(); + const rows = filterKeybindingRows(allRows, query); const groups = groupKeybindingRows(rows); const anchorIds = keybindingAnchorIds(rows); @@ -1423,7 +1431,7 @@ export function KeybindingsSettingsPanel() { const cancelAdd = () => setIsAddingBinding(false); const rowActions: KeybindingRowActions = { - allRows: rows, + allRows, variables: whenVariables, onSave: saveKeybinding, onReset: resetKeybinding, @@ -1479,7 +1487,7 @@ export function KeybindingsSettingsPanel() {