authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-16 23:17:57-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-16 23:17:57-03:00
log71d12d8c4a05bc50344d6e3fac1b967c6daaff84
tree9b917154ffe59afd766e5ba8bfc9f48ee84d9380
parentd359e94cabca4722029359c77d60cb6d13da1e59
signature Signed by PGP key B5690EEEBB952194

feat(web): make keybindings searchable from settings search (#12175)


6 files changed, 139 insertions(+), 5 deletions(-)

apps/web/src/components/CommandPalette.logic.test.ts+31
......@@ -11,6 +11,7 @@ import {
1111 filterPinnedBrowseEntries,
1212 filterCommandPaletteGroups,
1313 reduceCommandPaletteUiState,
14 type CommandPaletteActionItem,
1415 type CommandPaletteGroup,
1516} from "./CommandPalette.logic";
1617
......@@ -738,3 +739,33 @@ it.each([
738739 "Implementation",
739740 ]);
740741});
742
743describe("filterCommandPaletteGroups", () => {
744 it("sorts secondary settings results after other matches", () => {
745 const item = (value: string, title: string, secondary?: boolean) =>
746 ({
747 kind: "action",
748 value,
749 title,
750 searchTerms: [title, "General"],
751 icon: null,
752 run: async () => undefined,
753 ...(secondary ? { secondary } : {}),
754 }) satisfies CommandPaletteActionItem;
755 const [group] = filterCommandPaletteGroups({
756 activeGroups: [],
757 query: "model",
758 isInSubmenu: false,
759 projectSearchItems: [],
760 settingsSearchItems: [
761 item("setting:keybinding-modelPicker.toggle", "Model Picker: Toggle", true),
762 item("setting:default-model", "Default model"),
763 ],
764 threadSearchItems: [],
765 });
766 expect(group?.items.map((entry) => entry.value)).toEqual([
767 "setting:default-model",
768 "setting:keybinding-modelPicker.toggle",
769 ]);
770 });
771});
apps/web/src/components/CommandPalette.logic.ts+8-1
......@@ -142,6 +142,8 @@ export interface CommandPaletteItem {
142142 /** Optional content rendered inline after the title text (before the timestamp). */
143143 readonly titleTrailingContent?: ReactNode;
144144 readonly shortcutCommand?: KeybindingCommand;
145 /** Sorts after every other match in its group; see `SettingsSearchItem.secondary`. */
146 readonly secondary?: boolean;
145147}
146148
147149export interface CommandPaletteActionItem extends CommandPaletteItem {
......@@ -434,7 +436,12 @@ export function filterCommandPaletteGroups(input: {
434436 rank: rankCommandPaletteItemMatch(item, normalizedQuery, queryTokens),
435437 });
436438 })
437 .toSorted((left, right) => right.rank - left.rank || left.index - right.index)
439 .toSorted(
440 (left, right) =>
441 Number(left.item.secondary ?? false) - Number(right.item.secondary ?? false) ||
442 right.rank - left.rank ||
443 left.index - right.index,
444 )
438445 .map((entry) => entry.item);
439446
440447 if (items.length === 0) {
apps/web/src/components/CommandPalette.tsx+1
......@@ -2058,6 +2058,7 @@ function OpenCommandPaletteDialog(props: {
20582058 searchTerms: [item.title, SETTINGS_SECTION_LABELS[item.to], ...(item.searchTerms ?? [])],
20592059 title: item.title,
20602060 description: `Settings · ${SETTINGS_SECTION_LABELS[item.to]}`,
2061 ...(item.secondary ? { secondary: true } : {}),
20612062 icon: <SettingsIcon className={ITEM_ICON_CLASS} />,
20622063 run: async () => {
20632064 await navigate({
apps/web/src/components/settings/KeybindingsSettings.tsx+31-3
......@@ -9,6 +9,7 @@ import {
99 TriangleAlertIcon,
1010 XIcon,
1111} from "lucide-react";
12import { useLocation } from "@tanstack/react-router";
1213import {
1314 type KeyboardEvent,
1415 type ReactNode,
......@@ -65,7 +66,7 @@ import {
6566 whenNodeRemoveLabel,
6667} from "./KeybindingsSettings.logic";
6768import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout";
68import { searchableSetting } from "./settingsSearch";
69import { keybindingSearchAnchorId, searchableSetting } from "./settingsSearch";
6970import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
7071import { useAtomCommand } from "../../state/use-atom-command";
7172
......@@ -823,7 +824,11 @@ interface KeybindingRowActions {
823824 onRemove: (row: KeybindingRow) => void;
824825}
825826
826type KeybindingRowProps = KeybindingRowActions & { row: KeybindingRow; isSaving: boolean };
827type KeybindingRowProps = KeybindingRowActions & {
828 row: KeybindingRow;
829 isSaving: boolean;
830 anchorId?: string | undefined;
831};
827832
828833/** Shortcut pill that turns into a capture input when clicked, plus Save once the draft changes. */
829834function KeybindingKeyControl({
......@@ -1034,11 +1039,12 @@ function KeybindingHoverRowMenu(props: {
10341039
10351040/** One binding as a settings row: pills flush right, actions fading in beside them on hover. */
10361041function KeybindingSettingsRow(props: KeybindingRowProps) {
1037 const { row, isSaving, allRows, variables, onSave, onReset, onRemove } = props;
1042 const { row, isSaving, anchorId, allRows, variables, onSave, onReset, onRemove } = props;
10381043 const editor = useKeybindingRowEditor({ row, allRows, onSave });
10391044
10401045 return (
10411046 <SettingsRow
1047 id={anchorId}
10421048 className="group/row rounded-none"
10431049 title={<KeybindingRowTitle row={row} />}
10441050 description={<KeybindingRowWhen row={row} editor={editor} variables={variables} />}
......@@ -1296,6 +1302,17 @@ function KeybindingsList(props: KeybindingsListProps) {
12961302 onSave: rowActions.onSave,
12971303 onCancel: onCancelAdd,
12981304 };
1305 // Settings search jumps to a command, so only its first row anchors.
1306 const anchorIds = useMemo(() => {
1307 const ids = new Map<string, string>();
1308 const seen = new Set<KeybindingCommand>();
1309 for (const row of rows) {
1310 if (seen.has(row.command)) continue;
1311 seen.add(row.command);
1312 ids.set(row.id, keybindingSearchAnchorId(row.command));
1313 }
1314 return ids;
1315 }, [rows]);
12991316 return (
13001317 <div>
13011318 {isAddingBinding ? <NewKeybindingSettingsRow {...newProps} /> : null}
......@@ -1303,6 +1320,7 @@ function KeybindingsList(props: KeybindingsListProps) {
13031320 <KeybindingSettingsRow
13041321 key={row.id}
13051322 row={row}
1323 anchorId={anchorIds.get(row.id)}
13061324 isSaving={savingCommand === row.command}
13071325 {...rowActions}
13081326 />
......@@ -1357,6 +1375,16 @@ export function KeybindingsSettingsPanel() {
13571375 const [savingCommand, setSavingCommand] = useState<KeybindingCommand | null>(null);
13581376 const [isAddingBinding, setIsAddingBinding] = useState(false);
13591377 const rows = useMemo(() => buildKeybindingRows(keybindings, query), [keybindings, query]);
1378 // The search-target context is provided by this panel's own page container,
1379 // so the jump target is read from the route hash here.
1380 const searchTargetId = useLocation({ select: (location) => location.hash.replace(/^#/, "") });
1381 const [handledSearchTargetId, setHandledSearchTargetId] = useState(searchTargetId);
1382
1383 // A settings-search jump must not be hidden by the page's own filter.
1384 if (searchTargetId !== handledSearchTargetId) {
1385 setHandledSearchTargetId(searchTargetId);
1386 if (searchTargetId.startsWith("keybinding-")) setQuery("");
1387 }
13601388 const commandOptions = useMemo(() => buildKeybindingCommandOptions(keybindings), [keybindings]);
13611389 const whenVariables = useMemo(() => buildWhenVariableOptions(), []);
13621390
apps/web/src/components/settings/settingsSearch.test.ts+26
......@@ -216,6 +216,32 @@ describe("searchSettings", () => {
216216 ]);
217217 });
218218
219 it("finds keybinding commands by label, command id, and default key", () => {
220 expect(searchSettings("toggle sidebar")[0]?.id).toBe("keybinding-sidebar.toggle");
221 expect(searchSettings("sidebar.toggle")[0]?.id).toBe("keybinding-sidebar.toggle");
222 expect(searchSettings("mod+b")[0]?.id).toBe("keybinding-sidebar.toggle");
223 expect(searchSettings("copy link")[0]).toMatchObject({
224 id: "keybinding-thread.copyReference",
225 to: "/settings/keybindings",
226 });
227 });
228
229 it("ranks keybinding commands after other settings", () => {
230 const ids = searchSettings("model").map((item) => item.id);
231 expect(ids[0]).toBe("default-model");
232 expect(ids.indexOf("keybinding-modelPicker.toggle")).toBeGreaterThan(
233 ids.indexOf("text-generation-model"),
234 );
235 });
236
237 it("sends commands without a default binding to the section", () => {
238 expect(searchSettings("thread.stop")[0]).toMatchObject({
239 id: "keybinding-thread.stop",
240 targetId: "keybindings",
241 });
242 expect(searchSettings("sidebar.toggle")[0]?.targetId).toBeUndefined();
243 });
244
219245 it("keeps catalog result ids unique", () => {
220246 const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id);
221247 expect(new Set(ids).size).toBe(ids.length);
apps/web/src/components/settings/settingsSearch.ts+42-1
......@@ -1,7 +1,10 @@
11import { isElectron } from "~/env";
22import { isMacPlatform, isWindowsPlatform, normalizeSearchText } from "~/lib/utils";
3import { STATIC_KEYBINDING_COMMANDS, type KeybindingCommand } from "@t3tools/contracts";
34import type { EnvironmentId } from "@t3tools/contracts";
45import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
6import { DEFAULT_KEYBINDINGS } from "@t3tools/shared/keybindings";
7import { commandLabel } from "./KeybindingsSettings.logic";
58import {
69 validateSettingsScopeSearch,
710 type ResolvedSettingsScope,
......@@ -54,6 +57,11 @@ export interface SettingsSearchItem {
5457 readonly localBackendManagementOnly?: boolean;
5558 readonly localEnvironmentOnly?: boolean;
5659 readonly wslAvailableOnly?: boolean;
60 /**
61 * Sorts after every other match. Keybinding commands mirror rows on other
62 * surfaces, so "model" must still lead with Default model, not Model Picker.
63 */
64 readonly secondary?: boolean;
5765 readonly requiresThreadAutoSettlement?: boolean;
5866}
5967
......@@ -84,6 +92,33 @@ export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = {
8492 "/settings/archived": "Archive",
8593};
8694
95/** Anchor id of the first row bound to `command` on the Keybindings page. */
96export function keybindingSearchAnchorId<Command extends KeybindingCommand>(command: Command) {
97 return `keybinding-${command}` as const;
98}
99
100/**
101 * One result per built-in command, alphabetical by label. The anchor is
102 * the command's first row; default keys are searchable so "mod+b" lands on
103 * Sidebar: Toggle. A command with no default binding may have no row, so it
104 * points at the section instead.
105 */
106const KEYBINDING_SEARCH_ITEMS = STATIC_KEYBINDING_COMMANDS.toSorted((left, right) =>
107 commandLabel(left).localeCompare(commandLabel(right)),
108).map((command) => {
109 const defaultKeys = DEFAULT_KEYBINDINGS.filter((binding) => binding.command === command).map(
110 (binding) => binding.key,
111 );
112 return {
113 id: keybindingSearchAnchorId(command),
114 title: commandLabel(command),
115 to: "/settings/keybindings" as const,
116 searchTerms: [command, ...defaultKeys],
117 secondary: true,
118 ...(defaultKeys.length === 0 ? { targetId: "keybindings" } : {}),
119 };
120});
121
87122/**
88123 * Searchable settings and stable destinations, in result order. Rows with a
89124 * dedicated anchor render their id and title via `searchableSetting`; items
......@@ -429,6 +464,7 @@ export const SETTINGS_SEARCH_ITEMS = [
429464 to: "/settings/keybindings",
430465 searchTerms: ["keyboard shortcuts hotkeys commands bindings json"],
431466 },
467 ...KEYBINDING_SEARCH_ITEMS,
432468 {
433469 id: "snap-shot-enabled",
434470 title: "SnapShots",
......@@ -914,6 +950,11 @@ export function searchSettings(
914950 : 0;
915951 return [{ item, index, rank }];
916952 })
917 .toSorted((left, right) => right.rank - left.rank || left.index - right.index)
953 .toSorted(
954 (left, right) =>
955 Number(left.item.secondary ?? false) - Number(right.item.secondary ?? false) ||
956 right.rank - left.rank ||
957 left.index - right.index,
958 )
918959 .map(({ item }) => item);
919960}