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