| author | |
| committer | |
| log | 71d12d8c4a05bc50344d6e3fac1b967c6daaff84 |
| tree | 9b917154ffe59afd766e5ba8bfc9f48ee84d9380 |
| parent | d359e94cabca4722029359c77d60cb6d13da1e59 |
| signature | Signed by PGP key B5690EEEBB952194 |
6 files changed, 139 insertions(+), 5 deletions(-)
apps/web/src/components/CommandPalette.logic.test.ts+31| ... | ... | @@ -11,6 +11,7 @@ import { |
| 11 | 11 | filterPinnedBrowseEntries, |
| 12 | 12 | filterCommandPaletteGroups, |
| 13 | 13 | reduceCommandPaletteUiState, |
| 14 | type CommandPaletteActionItem, | |
| 14 | 15 | type CommandPaletteGroup, |
| 15 | 16 | } from "./CommandPalette.logic"; |
| 16 | 17 | |
| ... | ... | @@ -738,3 +739,33 @@ it.each([ |
| 738 | 739 | "Implementation", |
| 739 | 740 | ]); |
| 740 | 741 | }); |
| 742 | ||
| 743 | describe("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 { |
| 142 | 142 | /** Optional content rendered inline after the title text (before the timestamp). */ |
| 143 | 143 | readonly titleTrailingContent?: ReactNode; |
| 144 | 144 | readonly shortcutCommand?: KeybindingCommand; |
| 145 | /** Sorts after every other match in its group; see `SettingsSearchItem.secondary`. */ | |
| 146 | readonly secondary?: boolean; | |
| 145 | 147 | } |
| 146 | 148 | |
| 147 | 149 | export interface CommandPaletteActionItem extends CommandPaletteItem { |
| ... | ... | @@ -434,7 +436,12 @@ export function filterCommandPaletteGroups(input: { |
| 434 | 436 | rank: rankCommandPaletteItemMatch(item, normalizedQuery, queryTokens), |
| 435 | 437 | }); |
| 436 | 438 | }) |
| 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 | ) | |
| 438 | 445 | .map((entry) => entry.item); |
| 439 | 446 | |
| 440 | 447 | if (items.length === 0) { |
apps/web/src/components/CommandPalette.tsx+1| ... | ... | @@ -2058,6 +2058,7 @@ function OpenCommandPaletteDialog(props: { |
| 2058 | 2058 | searchTerms: [item.title, SETTINGS_SECTION_LABELS[item.to], ...(item.searchTerms ?? [])], |
| 2059 | 2059 | title: item.title, |
| 2060 | 2060 | description: `Settings · ${SETTINGS_SECTION_LABELS[item.to]}`, |
| 2061 | ...(item.secondary ? { secondary: true } : {}), | |
| 2061 | 2062 | icon: <SettingsIcon className={ITEM_ICON_CLASS} />, |
| 2062 | 2063 | run: async () => { |
| 2063 | 2064 | await navigate({ |
apps/web/src/components/settings/KeybindingsSettings.tsx+31-3| ... | ... | @@ -9,6 +9,7 @@ import { |
| 9 | 9 | TriangleAlertIcon, |
| 10 | 10 | XIcon, |
| 11 | 11 | } from "lucide-react"; |
| 12 | import { useLocation } from "@tanstack/react-router"; | |
| 12 | 13 | import { |
| 13 | 14 | type KeyboardEvent, |
| 14 | 15 | type ReactNode, |
| ... | ... | @@ -65,7 +66,7 @@ import { |
| 65 | 66 | whenNodeRemoveLabel, |
| 66 | 67 | } from "./KeybindingsSettings.logic"; |
| 67 | 68 | import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; |
| 68 | import { searchableSetting } from "./settingsSearch"; | |
| 69 | import { keybindingSearchAnchorId, searchableSetting } from "./settingsSearch"; | |
| 69 | 70 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 70 | 71 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 71 | 72 | |
| ... | ... | @@ -823,7 +824,11 @@ interface KeybindingRowActions { |
| 823 | 824 | onRemove: (row: KeybindingRow) => void; |
| 824 | 825 | } |
| 825 | 826 | |
| 826 | type KeybindingRowProps = KeybindingRowActions & { row: KeybindingRow; isSaving: boolean }; | |
| 827 | type KeybindingRowProps = KeybindingRowActions & { | |
| 828 | row: KeybindingRow; | |
| 829 | isSaving: boolean; | |
| 830 | anchorId?: string | undefined; | |
| 831 | }; | |
| 827 | 832 | |
| 828 | 833 | /** Shortcut pill that turns into a capture input when clicked, plus Save once the draft changes. */ |
| 829 | 834 | function KeybindingKeyControl({ |
| ... | ... | @@ -1034,11 +1039,12 @@ function KeybindingHoverRowMenu(props: { |
| 1034 | 1039 | |
| 1035 | 1040 | /** One binding as a settings row: pills flush right, actions fading in beside them on hover. */ |
| 1036 | 1041 | function KeybindingSettingsRow(props: KeybindingRowProps) { |
| 1037 | const { row, isSaving, allRows, variables, onSave, onReset, onRemove } = props; | |
| 1042 | const { row, isSaving, anchorId, allRows, variables, onSave, onReset, onRemove } = props; | |
| 1038 | 1043 | const editor = useKeybindingRowEditor({ row, allRows, onSave }); |
| 1039 | 1044 | |
| 1040 | 1045 | return ( |
| 1041 | 1046 | <SettingsRow |
| 1047 | id={anchorId} | |
| 1042 | 1048 | className="group/row rounded-none" |
| 1043 | 1049 | title={<KeybindingRowTitle row={row} />} |
| 1044 | 1050 | description={<KeybindingRowWhen row={row} editor={editor} variables={variables} />} |
| ... | ... | @@ -1296,6 +1302,17 @@ function KeybindingsList(props: KeybindingsListProps) { |
| 1296 | 1302 | onSave: rowActions.onSave, |
| 1297 | 1303 | onCancel: onCancelAdd, |
| 1298 | 1304 | }; |
| 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]); | |
| 1299 | 1316 | return ( |
| 1300 | 1317 | <div> |
| 1301 | 1318 | {isAddingBinding ? <NewKeybindingSettingsRow {...newProps} /> : null} |
| ... | ... | @@ -1303,6 +1320,7 @@ function KeybindingsList(props: KeybindingsListProps) { |
| 1303 | 1320 | <KeybindingSettingsRow |
| 1304 | 1321 | key={row.id} |
| 1305 | 1322 | row={row} |
| 1323 | anchorId={anchorIds.get(row.id)} | |
| 1306 | 1324 | isSaving={savingCommand === row.command} |
| 1307 | 1325 | {...rowActions} |
| 1308 | 1326 | /> |
| ... | ... | @@ -1357,6 +1375,16 @@ export function KeybindingsSettingsPanel() { |
| 1357 | 1375 | const [savingCommand, setSavingCommand] = useState<KeybindingCommand | null>(null); |
| 1358 | 1376 | const [isAddingBinding, setIsAddingBinding] = useState(false); |
| 1359 | 1377 | 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 | } | |
| 1360 | 1388 | const commandOptions = useMemo(() => buildKeybindingCommandOptions(keybindings), [keybindings]); |
| 1361 | 1389 | const whenVariables = useMemo(() => buildWhenVariableOptions(), []); |
| 1362 | 1390 |
apps/web/src/components/settings/settingsSearch.test.ts+26| ... | ... | @@ -216,6 +216,32 @@ describe("searchSettings", () => { |
| 216 | 216 | ]); |
| 217 | 217 | }); |
| 218 | 218 | |
| 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 | ||
| 219 | 245 | it("keeps catalog result ids unique", () => { |
| 220 | 246 | const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id); |
| 221 | 247 | expect(new Set(ids).size).toBe(ids.length); |
apps/web/src/components/settings/settingsSearch.ts+42-1| ... | ... | @@ -1,7 +1,10 @@ |
| 1 | 1 | import { isElectron } from "~/env"; |
| 2 | 2 | import { isMacPlatform, isWindowsPlatform, normalizeSearchText } from "~/lib/utils"; |
| 3 | import { STATIC_KEYBINDING_COMMANDS, type KeybindingCommand } from "@t3tools/contracts"; | |
| 3 | 4 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 4 | 5 | import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; |
| 6 | import { DEFAULT_KEYBINDINGS } from "@t3tools/shared/keybindings"; | |
| 7 | import { commandLabel } from "./KeybindingsSettings.logic"; | |
| 5 | 8 | import { |
| 6 | 9 | validateSettingsScopeSearch, |
| 7 | 10 | type ResolvedSettingsScope, |
| ... | ... | @@ -54,6 +57,11 @@ export interface SettingsSearchItem { |
| 54 | 57 | readonly localBackendManagementOnly?: boolean; |
| 55 | 58 | readonly localEnvironmentOnly?: boolean; |
| 56 | 59 | 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; | |
| 57 | 65 | readonly requiresThreadAutoSettlement?: boolean; |
| 58 | 66 | } |
| 59 | 67 | |
| ... | ... | @@ -84,6 +92,33 @@ export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = { |
| 84 | 92 | "/settings/archived": "Archive", |
| 85 | 93 | }; |
| 86 | 94 | |
| 95 | /** Anchor id of the first row bound to `command` on the Keybindings page. */ | |
| 96 | export 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 | */ | |
| 106 | const 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 | ||
| 87 | 122 | /** |
| 88 | 123 | * Searchable settings and stable destinations, in result order. Rows with a |
| 89 | 124 | * dedicated anchor render their id and title via `searchableSetting`; items |
| ... | ... | @@ -429,6 +464,7 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 429 | 464 | to: "/settings/keybindings", |
| 430 | 465 | searchTerms: ["keyboard shortcuts hotkeys commands bindings json"], |
| 431 | 466 | }, |
| 467 | ...KEYBINDING_SEARCH_ITEMS, | |
| 432 | 468 | { |
| 433 | 469 | id: "snap-shot-enabled", |
| 434 | 470 | title: "SnapShots", |
| ... | ... | @@ -914,6 +950,11 @@ export function searchSettings( |
| 914 | 950 | : 0; |
| 915 | 951 | return [{ item, index, rank }]; |
| 916 | 952 | }) |
| 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 | ) | |
| 918 | 959 | .map(({ item }) => item); |
| 919 | 960 | } |