From 71d12d8c4a05bc50344d6e3fac1b967c6daaff84 Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 16 Sep 2026 23:17:57 -0300 Subject: [PATCH] feat(web): make keybindings searchable from settings search (#12175) --- .../components/CommandPalette.logic.test.ts | 31 +++++++++++++ .../src/components/CommandPalette.logic.ts | 9 +++- apps/web/src/components/CommandPalette.tsx | 1 + .../settings/KeybindingsSettings.tsx | 34 +++++++++++++-- .../settings/settingsSearch.test.ts | 26 +++++++++++ .../src/components/settings/settingsSearch.ts | 43 ++++++++++++++++++- 6 files changed, 139 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 25896baf75559446ff255338c69bb07be0c8751f..a295d199e11489ba3c38b407f0c075ab409860be 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -11,6 +11,7 @@ import { filterPinnedBrowseEntries, filterCommandPaletteGroups, reduceCommandPaletteUiState, + type CommandPaletteActionItem, type CommandPaletteGroup, } from "./CommandPalette.logic"; @@ -738,3 +739,33 @@ it.each([ "Implementation", ]); }); + +describe("filterCommandPaletteGroups", () => { + it("sorts secondary settings results after other matches", () => { + const item = (value: string, title: string, secondary?: boolean) => + ({ + kind: "action", + value, + title, + searchTerms: [title, "General"], + icon: null, + run: async () => undefined, + ...(secondary ? { secondary } : {}), + }) satisfies CommandPaletteActionItem; + const [group] = filterCommandPaletteGroups({ + activeGroups: [], + query: "model", + isInSubmenu: false, + projectSearchItems: [], + settingsSearchItems: [ + item("setting:keybinding-modelPicker.toggle", "Model Picker: Toggle", true), + item("setting:default-model", "Default model"), + ], + threadSearchItems: [], + }); + expect(group?.items.map((entry) => entry.value)).toEqual([ + "setting:default-model", + "setting:keybinding-modelPicker.toggle", + ]); + }); +}); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 9f927c81dbac0eb194c524c5845a7134181fab5b..48833aebc3ebb3320e27eb6b846fd0af94ca8825 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -142,6 +142,8 @@ export interface CommandPaletteItem { /** Optional content rendered inline after the title text (before the timestamp). */ readonly titleTrailingContent?: ReactNode; readonly shortcutCommand?: KeybindingCommand; + /** Sorts after every other match in its group; see `SettingsSearchItem.secondary`. */ + readonly secondary?: boolean; } export interface CommandPaletteActionItem extends CommandPaletteItem { @@ -434,7 +436,12 @@ export function filterCommandPaletteGroups(input: { rank: rankCommandPaletteItemMatch(item, normalizedQuery, queryTokens), }); }) - .toSorted((left, right) => right.rank - left.rank || left.index - right.index) + .toSorted( + (left, right) => + Number(left.item.secondary ?? false) - Number(right.item.secondary ?? false) || + right.rank - left.rank || + left.index - right.index, + ) .map((entry) => entry.item); if (items.length === 0) { diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index ae373e65a3cc782ffdd00d7584a4d04b9ba8a20c..9f993ab86c40a6448f037ed82981af0613688036 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -2058,6 +2058,7 @@ function OpenCommandPaletteDialog(props: { searchTerms: [item.title, SETTINGS_SECTION_LABELS[item.to], ...(item.searchTerms ?? [])], title: item.title, description: `Settings ยท ${SETTINGS_SECTION_LABELS[item.to]}`, + ...(item.secondary ? { secondary: true } : {}), icon: , run: async () => { await navigate({ diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index d02f15939c5e7ac1e31d4c85dfdf4dfb008fffbe..bac46a56d002b29dc9e2af58f6368fbfb13d48da 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -9,6 +9,7 @@ import { TriangleAlertIcon, XIcon, } from "lucide-react"; +import { useLocation } from "@tanstack/react-router"; import { type KeyboardEvent, type ReactNode, @@ -65,7 +66,7 @@ import { whenNodeRemoveLabel, } from "./KeybindingsSettings.logic"; import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; -import { searchableSetting } from "./settingsSearch"; +import { keybindingSearchAnchorId, searchableSetting } from "./settingsSearch"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -823,7 +824,11 @@ interface KeybindingRowActions { onRemove: (row: KeybindingRow) => void; } -type KeybindingRowProps = KeybindingRowActions & { row: KeybindingRow; isSaving: boolean }; +type KeybindingRowProps = KeybindingRowActions & { + row: KeybindingRow; + isSaving: boolean; + anchorId?: string | undefined; +}; /** Shortcut pill that turns into a capture input when clicked, plus Save once the draft changes. */ function KeybindingKeyControl({ @@ -1034,11 +1039,12 @@ function KeybindingHoverRowMenu(props: { /** One binding as a settings row: pills flush right, actions fading in beside them on hover. */ function KeybindingSettingsRow(props: KeybindingRowProps) { - const { row, isSaving, allRows, variables, onSave, onReset, onRemove } = props; + const { row, isSaving, anchorId, allRows, variables, onSave, onReset, onRemove } = props; const editor = useKeybindingRowEditor({ row, allRows, onSave }); return ( } description={} @@ -1296,6 +1302,17 @@ function KeybindingsList(props: KeybindingsListProps) { onSave: rowActions.onSave, onCancel: onCancelAdd, }; + // Settings search jumps to a command, so only its first row anchors. + const anchorIds = useMemo(() => { + const ids = new Map(); + const seen = new Set(); + for (const row of rows) { + if (seen.has(row.command)) continue; + seen.add(row.command); + ids.set(row.id, keybindingSearchAnchorId(row.command)); + } + return ids; + }, [rows]); return (
{isAddingBinding ? : null} @@ -1303,6 +1320,7 @@ function KeybindingsList(props: KeybindingsListProps) { @@ -1357,6 +1375,16 @@ export function KeybindingsSettingsPanel() { const [savingCommand, setSavingCommand] = useState(null); const [isAddingBinding, setIsAddingBinding] = useState(false); const rows = useMemo(() => buildKeybindingRows(keybindings, query), [keybindings, query]); + // The search-target context is provided by this panel's own page container, + // so the jump target is read from the route hash here. + const searchTargetId = useLocation({ select: (location) => location.hash.replace(/^#/, "") }); + const [handledSearchTargetId, setHandledSearchTargetId] = useState(searchTargetId); + + // A settings-search jump must not be hidden by the page's own filter. + if (searchTargetId !== handledSearchTargetId) { + setHandledSearchTargetId(searchTargetId); + if (searchTargetId.startsWith("keybinding-")) setQuery(""); + } const commandOptions = useMemo(() => buildKeybindingCommandOptions(keybindings), [keybindings]); const whenVariables = useMemo(() => buildWhenVariableOptions(), []); diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index a45b20150138eba3afbaa83358b760ca0f4f2900..569d736a62de3e43cc19378d17559e3063d4aefb 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -216,6 +216,32 @@ describe("searchSettings", () => { ]); }); + it("finds keybinding commands by label, command id, and default key", () => { + expect(searchSettings("toggle sidebar")[0]?.id).toBe("keybinding-sidebar.toggle"); + expect(searchSettings("sidebar.toggle")[0]?.id).toBe("keybinding-sidebar.toggle"); + expect(searchSettings("mod+b")[0]?.id).toBe("keybinding-sidebar.toggle"); + expect(searchSettings("copy link")[0]).toMatchObject({ + id: "keybinding-thread.copyReference", + to: "/settings/keybindings", + }); + }); + + it("ranks keybinding commands after other settings", () => { + const ids = searchSettings("model").map((item) => item.id); + expect(ids[0]).toBe("default-model"); + expect(ids.indexOf("keybinding-modelPicker.toggle")).toBeGreaterThan( + ids.indexOf("text-generation-model"), + ); + }); + + it("sends commands without a default binding to the section", () => { + expect(searchSettings("thread.stop")[0]).toMatchObject({ + id: "keybinding-thread.stop", + targetId: "keybindings", + }); + expect(searchSettings("sidebar.toggle")[0]?.targetId).toBeUndefined(); + }); + it("keeps catalog result ids unique", () => { const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id); expect(new Set(ids).size).toBe(ids.length); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 079287439c43817eb76af3489b1d5996af060ce8..cc83ccc92dced286555da5ef71b0178d8f11aba4 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -1,7 +1,10 @@ import { isElectron } from "~/env"; import { isMacPlatform, isWindowsPlatform, normalizeSearchText } from "~/lib/utils"; +import { STATIC_KEYBINDING_COMMANDS, type KeybindingCommand } from "@t3tools/contracts"; import type { EnvironmentId } from "@t3tools/contracts"; import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import { DEFAULT_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { commandLabel } from "./KeybindingsSettings.logic"; import { validateSettingsScopeSearch, type ResolvedSettingsScope, @@ -54,6 +57,11 @@ export interface SettingsSearchItem { readonly localBackendManagementOnly?: boolean; readonly localEnvironmentOnly?: boolean; readonly wslAvailableOnly?: boolean; + /** + * Sorts after every other match. Keybinding commands mirror rows on other + * surfaces, so "model" must still lead with Default model, not Model Picker. + */ + readonly secondary?: boolean; readonly requiresThreadAutoSettlement?: boolean; } @@ -84,6 +92,33 @@ export const SETTINGS_SECTION_LABELS: Readonly> = { "/settings/archived": "Archive", }; +/** Anchor id of the first row bound to `command` on the Keybindings page. */ +export function keybindingSearchAnchorId(command: Command) { + return `keybinding-${command}` as const; +} + +/** + * One result per built-in command, alphabetical by label. The anchor is + * the command's first row; default keys are searchable so "mod+b" lands on + * Sidebar: Toggle. A command with no default binding may have no row, so it + * points at the section instead. + */ +const KEYBINDING_SEARCH_ITEMS = STATIC_KEYBINDING_COMMANDS.toSorted((left, right) => + commandLabel(left).localeCompare(commandLabel(right)), +).map((command) => { + const defaultKeys = DEFAULT_KEYBINDINGS.filter((binding) => binding.command === command).map( + (binding) => binding.key, + ); + return { + id: keybindingSearchAnchorId(command), + title: commandLabel(command), + to: "/settings/keybindings" as const, + searchTerms: [command, ...defaultKeys], + secondary: true, + ...(defaultKeys.length === 0 ? { targetId: "keybindings" } : {}), + }; +}); + /** * Searchable settings and stable destinations, in result order. Rows with a * dedicated anchor render their id and title via `searchableSetting`; items @@ -429,6 +464,7 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/keybindings", searchTerms: ["keyboard shortcuts hotkeys commands bindings json"], }, + ...KEYBINDING_SEARCH_ITEMS, { id: "snap-shot-enabled", title: "SnapShots", @@ -914,6 +950,11 @@ export function searchSettings( : 0; return [{ item, index, rank }]; }) - .toSorted((left, right) => right.rank - left.rank || left.index - right.index) + .toSorted( + (left, right) => + Number(left.item.secondary ?? false) - Number(right.item.secondary ?? false) || + right.rank - left.rank || + left.index - right.index, + ) .map(({ item }) => item); } -- 2.54.0