| author | |
| committer | |
| log | fffe6e6c82c12769e0cbc4d89a23ec845c5abed1 |
| tree | 7470f9cac23c4e224933e81d1868e89d1ff5f44d |
| parent | 71dbaf1f94b580491d033f2e347a9b1c507fd793 |
| signature | Signed by PGP key B5690EEEBB952194 |
8 files changed, 400 insertions(+), 19 deletions(-)
apps/web/src/components/CommandPalette.logic.test.ts+45| ... | @@ -5,12 +5,14 @@ import { makeThreadFixture } from "../test-fixtures"; | ... | @@ -5,12 +5,14 @@ import { makeThreadFixture } from "../test-fixtures"; |
| 5 | import { | 5 | import { |
| 6 | buildBrowseGroups, | 6 | buildBrowseGroups, |
| 7 | buildCommandPaletteProjectMetadata, | 7 | buildCommandPaletteProjectMetadata, |
| 8 | buildCommandPaletteRows, | ||
| 8 | buildProjectActionItems, | 9 | buildProjectActionItems, |
| 9 | buildThreadActionItems, | 10 | buildThreadActionItems, |
| 10 | buildLinkedThreadActionItems, | 11 | buildLinkedThreadActionItems, |
| 11 | enumerateCommandPaletteItems, | 12 | enumerateCommandPaletteItems, |
| 12 | filterPinnedBrowseEntries, | 13 | filterPinnedBrowseEntries, |
| 13 | filterCommandPaletteGroups, | 14 | filterCommandPaletteGroups, |
| 15 | findHighlightedCommandPaletteItem, | ||
| 14 | reduceCommandPaletteUiState, | 16 | reduceCommandPaletteUiState, |
| 15 | type CommandPaletteActionItem, | 17 | type CommandPaletteActionItem, |
| 16 | type CommandPaletteGroup, | 18 | type CommandPaletteGroup, |
| ... | @@ -849,3 +851,46 @@ describe("filterCommandPaletteGroups", () => { | ... | @@ -849,3 +851,46 @@ describe("filterCommandPaletteGroups", () => { |
| 849 | ]); | 851 | ]); |
| 850 | }); | 852 | }); |
| 851 | }); | 853 | }); |
| 854 | |||
| 855 | describe("virtualized command palette rows", () => { | ||
| 856 | const action = (value: string, disabled = false): CommandPaletteActionItem => ({ | ||
| 857 | kind: "action", | ||
| 858 | value, | ||
| 859 | searchTerms: [], | ||
| 860 | title: value, | ||
| 861 | icon: null, | ||
| 862 | ...(disabled ? { disabled } : {}), | ||
| 863 | run: async () => {}, | ||
| 864 | }); | ||
| 865 | const groups: CommandPaletteGroup[] = [ | ||
| 866 | { value: "actions", label: "Actions", items: [action("new-thread"), action("offline", true)] }, | ||
| 867 | { value: "threads", label: "Threads", items: [action("thread-a"), action("thread-b")] }, | ||
| 868 | ]; | ||
| 869 | |||
| 870 | it("keeps group order and headings while indexing only enabled items", () => { | ||
| 871 | const { rows, itemValues, rowIndexByItemIndex } = buildCommandPaletteRows(groups); | ||
| 872 | |||
| 873 | expect(rows.map((row) => (row.kind === "label" ? `# ${row.label}` : row.key))).toEqual([ | ||
| 874 | "# Actions", | ||
| 875 | "actions:new-thread", | ||
| 876 | "actions:offline", | ||
| 877 | "# Threads", | ||
| 878 | "threads:thread-a", | ||
| 879 | "threads:thread-b", | ||
| 880 | ]); | ||
| 881 | expect(itemValues).toEqual(["new-thread", "thread-a", "thread-b"]); | ||
| 882 | expect(rowIndexByItemIndex).toEqual([1, 4, 5]); | ||
| 883 | expect(rows.flatMap((row) => (row.kind === "item" ? [row.itemIndex] : []))).toEqual([ | ||
| 884 | 0, | ||
| 885 | null, | ||
| 886 | 1, | ||
| 887 | 2, | ||
| 888 | ]); | ||
| 889 | }); | ||
| 890 | |||
| 891 | it("resolves Enter to the highlighted item without needing its row mounted", () => { | ||
| 892 | expect(findHighlightedCommandPaletteItem(groups, "thread-b")?.value).toBe("thread-b"); | ||
| 893 | expect(findHighlightedCommandPaletteItem(groups, "offline")).toBeNull(); | ||
| 894 | expect(findHighlightedCommandPaletteItem(groups, null)).toBeNull(); | ||
| 895 | }); | ||
| 896 | }); |
apps/web/src/components/CommandPalette.logic.ts+59| ... | @@ -172,6 +172,65 @@ export interface CommandPaletteView { | ... | @@ -172,6 +172,65 @@ export interface CommandPaletteView { |
| 172 | readonly initialQuery?: string; | 172 | readonly initialQuery?: string; |
| 173 | } | 173 | } |
| 174 | 174 | ||
| 175 | export type CommandPaletteRow = | ||
| 176 | | { | ||
| 177 | readonly kind: "label"; | ||
| 178 | readonly key: string; | ||
| 179 | readonly label: string; | ||
| 180 | readonly first: boolean; | ||
| 181 | } | ||
| 182 | | { | ||
| 183 | readonly kind: "item"; | ||
| 184 | readonly key: string; | ||
| 185 | readonly item: CommandPaletteActionItem | CommandPaletteSubmenuItem; | ||
| 186 | /** Position among enabled items, or null for disabled rows the keyboard skips. */ | ||
| 187 | readonly itemIndex: number | null; | ||
| 188 | }; | ||
| 189 | |||
| 190 | /** | ||
| 191 | * Flattens groups into the rows a virtualized list renders. `itemValues` is the | ||
| 192 | * highlightable item order Base UI navigates; `rowIndexByItemIndex` maps a | ||
| 193 | * highlight back to its row for scrolling. | ||
| 194 | */ | ||
| 195 | export function buildCommandPaletteRows(groups: ReadonlyArray<CommandPaletteGroup>) { | ||
| 196 | const rows: CommandPaletteRow[] = []; | ||
| 197 | const itemValues: string[] = []; | ||
| 198 | const rowIndexByItemIndex: number[] = []; | ||
| 199 | for (const group of groups) { | ||
| 200 | if (group.label) { | ||
| 201 | rows.push({ | ||
| 202 | kind: "label", | ||
| 203 | key: `group:${group.value}`, | ||
| 204 | label: group.label, | ||
| 205 | first: rows.length === 0, | ||
| 206 | }); | ||
| 207 | } | ||
| 208 | for (const item of group.items) { | ||
| 209 | const itemIndex = item.disabled ? null : itemValues.length; | ||
| 210 | if (itemIndex !== null) { | ||
| 211 | itemValues.push(item.value); | ||
| 212 | rowIndexByItemIndex.push(rows.length); | ||
| 213 | } | ||
| 214 | rows.push({ kind: "item", key: `${group.value}:${item.value}`, item, itemIndex }); | ||
| 215 | } | ||
| 216 | } | ||
| 217 | return { rows, itemValues, rowIndexByItemIndex }; | ||
| 218 | } | ||
| 219 | |||
| 220 | /** The enabled item Enter should run for a highlight, whether or not its row is mounted. */ | ||
| 221 | export function findHighlightedCommandPaletteItem( | ||
| 222 | groups: ReadonlyArray<CommandPaletteGroup>, | ||
| 223 | highlightedItemValue: string | null, | ||
| 224 | ): CommandPaletteActionItem | CommandPaletteSubmenuItem | null { | ||
| 225 | if (highlightedItemValue === null) return null; | ||
| 226 | for (const group of groups) { | ||
| 227 | for (const item of group.items) { | ||
| 228 | if (item.value === highlightedItemValue && !item.disabled) return item; | ||
| 229 | } | ||
| 230 | } | ||
| 231 | return null; | ||
| 232 | } | ||
| 233 | |||
| 175 | export function enumerateCommandPaletteItems( | 234 | export function enumerateCommandPaletteItems( |
| 176 | items: ReadonlyArray<CommandPaletteActionItem>, | 235 | items: ReadonlyArray<CommandPaletteActionItem>, |
| 177 | ): CommandPaletteActionItem[] { | 236 | ): CommandPaletteActionItem[] { |
apps/web/src/components/CommandPalette.tsx+73-10| ... | @@ -77,6 +77,7 @@ import { | ... | @@ -77,6 +77,7 @@ import { |
| 77 | type KeyboardEvent, | 77 | type KeyboardEvent, |
| 78 | type ReactNode, | 78 | type ReactNode, |
| 79 | } from "react"; | 79 | } from "react"; |
| 80 | import type { LegendListRef } from "@legendapp/list/react"; | ||
| 80 | import { useAtomValue } from "@effect/atom-react"; | 81 | import { useAtomValue } from "@effect/atom-react"; |
| 81 | 82 | ||
| 82 | import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; | 83 | import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; |
| ... | @@ -157,7 +158,9 @@ import { | ... | @@ -157,7 +158,9 @@ import { |
| 157 | buildRootGroups, | 158 | buildRootGroups, |
| 158 | buildThreadActionItems, | 159 | buildThreadActionItems, |
| 159 | buildLinkedThreadActionItems, | 160 | buildLinkedThreadActionItems, |
| 161 | buildCommandPaletteRows, | ||
| 160 | enumerateCommandPaletteItems, | 162 | enumerateCommandPaletteItems, |
| 163 | findHighlightedCommandPaletteItem, | ||
| 161 | type CommandPaletteActionItem, | 164 | type CommandPaletteActionItem, |
| 162 | type CommandPaletteOpenIntent, | 165 | type CommandPaletteOpenIntent, |
| 163 | type CommandPaletteSubmenuItem, | 166 | type CommandPaletteSubmenuItem, |
| ... | @@ -174,7 +177,10 @@ import { | ... | @@ -174,7 +177,10 @@ import { |
| 174 | import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; | 177 | import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; |
| 175 | import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; | 178 | import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; |
| 176 | import { CommandPaletteContent } from "./CommandPaletteContent"; | 179 | import { CommandPaletteContent } from "./CommandPaletteContent"; |
| 177 | import { CommandPaletteResults } from "./CommandPaletteResults"; | 180 | import { |
| 181 | CommandPaletteVirtualizedResults, | ||
| 182 | scrollCommandPaletteRowIntoView, | ||
| 183 | } from "./CommandPaletteResults"; | ||
| 178 | import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon, ForgejoIcon } from "./Icons"; | 184 | import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon, ForgejoIcon } from "./Icons"; |
| 179 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; | 185 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; |
| 180 | import { Checkbox } from "./ui/checkbox"; | 186 | import { Checkbox } from "./ui/checkbox"; |
| ... | @@ -694,6 +700,15 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -694,6 +700,15 @@ function OpenCommandPaletteDialog(props: { |
| 694 | const deferredQuery = useDeferredValue(query); | 700 | const deferredQuery = useDeferredValue(query); |
| 695 | const isActionsOnly = deferredQuery.startsWith(">"); | 701 | const isActionsOnly = deferredQuery.startsWith(">"); |
| 696 | const [highlightedItemValue, setHighlightedItemValue] = useState<string | null>(null); | 702 | const [highlightedItemValue, setHighlightedItemValue] = useState<string | null>(null); |
| 703 | const resultListRef = useRef<LegendListRef | null>(null); | ||
| 704 | // Typing or entering a submenu clears the highlight. Base UI keeps its own on the | ||
| 705 | // first row, but the palette shows none until the user navigates, and the first | ||
| 706 | // ArrowDown lands on it. | ||
| 707 | const highlightClearedRef = useRef(false); | ||
| 708 | function clearTypedHighlight(): void { | ||
| 709 | highlightClearedRef.current = true; | ||
| 710 | setHighlightedItemValue(null); | ||
| 711 | } | ||
| 697 | const clientSettings = useClientSettings(); | 712 | const clientSettings = useClientSettings(); |
| 698 | const createProject = useAtomCommand(projectEnvironment.create, { | 713 | const createProject = useAtomCommand(projectEnvironment.create, { |
| 699 | reportFailure: false, | 714 | reportFailure: false, |
| ... | @@ -1425,7 +1440,7 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1425,7 +1440,7 @@ function OpenCommandPaletteDialog(props: { |
| 1425 | ? { | 1440 | ? { |
| 1426 | source: match.source, | 1441 | source: match.source, |
| 1427 | snippet: match.snippet, | 1442 | snippet: match.snippet, |
| 1428 | query: threadSearchQuery, | 1443 | query: threadSearch.query, |
| 1429 | } | 1444 | } |
| 1430 | : undefined; | 1445 | : undefined; |
| 1431 | }, | 1446 | }, |
| ... | @@ -1446,7 +1461,7 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1446,7 +1461,7 @@ function OpenCommandPaletteDialog(props: { |
| 1446 | projectTitleById, | 1461 | projectTitleById, |
| 1447 | providerEntryByEnvironmentAndInstanceId, | 1462 | providerEntryByEnvironmentAndInstanceId, |
| 1448 | threadContentMatchByKey, | 1463 | threadContentMatchByKey, |
| 1449 | threadSearchQuery, | 1464 | threadSearch.query, |
| 1450 | threads, | 1465 | threads, |
| 1451 | ], | 1466 | ], |
| 1452 | ); | 1467 | ); |
| ... | @@ -1463,6 +1478,7 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1463,6 +1478,7 @@ function OpenCommandPaletteDialog(props: { |
| 1463 | ...(view.initialQuery ? { initialQuery: view.initialQuery } : {}), | 1478 | ...(view.initialQuery ? { initialQuery: view.initialQuery } : {}), |
| 1464 | }, | 1479 | }, |
| 1465 | ]); | 1480 | ]); |
| 1481 | highlightClearedRef.current = true; | ||
| 1466 | setHighlightedItemValue(null); | 1482 | setHighlightedItemValue(null); |
| 1467 | setQuery(view.initialQuery ?? ""); | 1483 | setQuery(view.initialQuery ?? ""); |
| 1468 | }, | 1484 | }, |
| ... | @@ -1494,7 +1510,7 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1494,7 +1510,7 @@ function OpenCommandPaletteDialog(props: { |
| 1494 | 1510 | ||
| 1495 | function handleQueryChange(nextQuery: string): void { | 1511 | function handleQueryChange(nextQuery: string): void { |
| 1496 | browseNavigation.invalidate(); | 1512 | browseNavigation.invalidate(); |
| 1497 | setHighlightedItemValue(null); | 1513 | clearTypedHighlight(); |
| 1498 | setQuery(nextQuery); | 1514 | setQuery(nextQuery); |
| 1499 | if (nextQuery === "" && currentView?.initialQuery) { | 1515 | if (nextQuery === "" && currentView?.initialQuery) { |
| 1500 | popView(); | 1516 | popView(); |
| ... | @@ -2938,6 +2954,9 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -2938,6 +2954,9 @@ function OpenCommandPaletteDialog(props: { |
| 2938 | } else if (isBrowsing) { | 2954 | } else if (isBrowsing) { |
| 2939 | displayedGroups = relativePathNeedsActiveProject ? [] : browseGroups; | 2955 | displayedGroups = relativePathNeedsActiveProject ? [] : browseGroups; |
| 2940 | } | 2956 | } |
| 2957 | const resultRows = buildCommandPaletteRows(displayedGroups); | ||
| 2958 | const autoHighlightsFirstRow = | ||
| 2959 | !isBrowsing && !isRemoteProjectCloneFlow && newProjectFlow === null; | ||
| 2941 | 2960 | ||
| 2942 | const inputPlaceholder = | 2961 | const inputPlaceholder = |
| 2943 | newProjectFlow !== null | 2962 | newProjectFlow !== null |
| ... | @@ -3083,6 +3102,43 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -3083,6 +3102,43 @@ function OpenCommandPaletteDialog(props: { |
| 3083 | if (event.key === "Backspace" && query === "" && isSubmenu) { | 3102 | if (event.key === "Backspace" && query === "" && isSubmenu) { |
| 3084 | event.preventDefault(); | 3103 | event.preventDefault(); |
| 3085 | popView(); | 3104 | popView(); |
| 3105 | return; | ||
| 3106 | } | ||
| 3107 | |||
| 3108 | // Base UI ignores navigation keys with modifiers, so these fallbacks do too. | ||
| 3109 | if (event.ctrlKey || event.shiftKey || event.altKey || event.metaKey) return; | ||
| 3110 | // Base UI only keeps a hidden highlight on the first row when it auto-highlights. | ||
| 3111 | const firstItemValue = autoHighlightsFirstRow ? resultRows.itemValues[0] : undefined; | ||
| 3112 | if ( | ||
| 3113 | event.key === "ArrowDown" && | ||
| 3114 | highlightClearedRef.current && | ||
| 3115 | firstItemValue && | ||
| 3116 | !event.nativeEvent.isComposing | ||
| 3117 | ) { | ||
| 3118 | (event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.(); | ||
| 3119 | event.preventDefault(); | ||
| 3120 | highlightClearedRef.current = false; | ||
| 3121 | setHighlightedItemValue(firstItemValue); | ||
| 3122 | scrollCommandPaletteRowIntoView( | ||
| 3123 | resultListRef.current, | ||
| 3124 | resultRows.rowIndexByItemIndex[0] ?? 0, | ||
| 3125 | ); | ||
| 3126 | return; | ||
| 3127 | } | ||
| 3128 | |||
| 3129 | // Base UI clicks the highlighted row on Enter, which does nothing once the | ||
| 3130 | // virtualized list has unmounted it, so run the tracked highlight directly. | ||
| 3131 | if (event.key === "Enter" && !event.nativeEvent.isComposing && event.keyCode !== 229) { | ||
| 3132 | const highlightedItem = findHighlightedCommandPaletteItem( | ||
| 3133 | displayedGroups, | ||
| 3134 | highlightedItemValue ?? firstItemValue ?? null, | ||
| 3135 | ); | ||
| 3136 | if (highlightedItem) { | ||
| 3137 | (event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.(); | ||
| 3138 | event.preventDefault(); | ||
| 3139 | event.stopPropagation(); | ||
| 3140 | executeItem(highlightedItem); | ||
| 3141 | } | ||
| 3086 | } | 3142 | } |
| 3087 | } | 3143 | } |
| 3088 | 3144 | ||
| ... | @@ -3349,9 +3405,7 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -3349,9 +3405,7 @@ function OpenCommandPaletteDialog(props: { |
| 3349 | <CommandPaletteContent | 3405 | <CommandPaletteContent |
| 3350 | key={`${viewStack.length}-${browseGeneration}-${isBrowsing}-${newProjectFlow ? "new-project" : (addProjectCloneFlow?.step ?? "none")}`} | 3406 | key={`${viewStack.length}-${browseGeneration}-${isBrowsing}-${newProjectFlow ? "new-project" : (addProjectCloneFlow?.step ?? "none")}`} |
| 3351 | aria-label="Command palette" | 3407 | aria-label="Command palette" |
| 3352 | autoHighlight={ | 3408 | autoHighlight={autoHighlightsFirstRow ? "always" : false} |
| 3353 | isBrowsing || isRemoteProjectCloneFlow || newProjectFlow !== null ? false : "always" | ||
| 3354 | } | ||
| 3355 | footerActionLabel={footerActionLabel} | 3409 | footerActionLabel={footerActionLabel} |
| 3356 | footerTrailing={footerTrailing} | 3410 | footerTrailing={footerTrailing} |
| 3357 | inputAccessory={inputAccessory} | 3411 | inputAccessory={inputAccessory} |
| ... | @@ -3387,8 +3441,16 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -3387,8 +3441,16 @@ function OpenCommandPaletteDialog(props: { |
| 3387 | onKeyDown: handleKeyDown, | 3441 | onKeyDown: handleKeyDown, |
| 3388 | }} | 3442 | }} |
| 3389 | mode="none" | 3443 | mode="none" |
| 3390 | onItemHighlighted={(value) => { | 3444 | items={resultRows.itemValues} |
| 3445 | virtualized | ||
| 3446 | onItemHighlighted={(value, eventDetails) => { | ||
| 3447 | if (eventDetails.reason === "none" && highlightClearedRef.current) return; | ||
| 3448 | highlightClearedRef.current = false; | ||
| 3391 | setHighlightedItemValue(typeof value === "string" ? value : null); | 3449 | setHighlightedItemValue(typeof value === "string" ? value : null); |
| 3450 | const rowIndex = resultRows.rowIndexByItemIndex[eventDetails.index]; | ||
| 3451 | if (eventDetails.reason === "keyboard" && rowIndex !== undefined) { | ||
| 3452 | scrollCommandPaletteRowIntoView(resultListRef.current, rowIndex); | ||
| 3453 | } | ||
| 3392 | }} | 3454 | }} |
| 3393 | onValueChange={handleQueryChange} | 3455 | onValueChange={handleQueryChange} |
| 3394 | showBackHint={isSubmenu} | 3456 | showBackHint={isSubmenu} |
| ... | @@ -3426,8 +3488,9 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -3426,8 +3488,9 @@ function OpenCommandPaletteDialog(props: { |
| 3426 | </div> | 3488 | </div> |
| 3427 | </div> | 3489 | </div> |
| 3428 | ) : null} | 3490 | ) : null} |
| 3429 | <CommandPaletteResults | 3491 | <CommandPaletteVirtualizedResults |
| 3430 | groups={displayedGroups} | 3492 | rows={resultRows.rows} |
| 3493 | listRef={resultListRef} | ||
| 3431 | highlightedItemValue={highlightedItemValue} | 3494 | highlightedItemValue={highlightedItemValue} |
| 3432 | isActionsOnly={isActionsOnly} | 3495 | isActionsOnly={isActionsOnly} |
| 3433 | keybindings={keybindings} | 3496 | keybindings={keybindings} |
apps/web/src/components/CommandPaletteResults.tsx+102-8| ... | @@ -1,9 +1,13 @@ | ... | @@ -1,9 +1,13 @@ |
| 1 | import { type ResolvedKeybindingsConfig } from "@t3tools/contracts"; | 1 | import { type ResolvedKeybindingsConfig } from "@t3tools/contracts"; |
| 2 | import { LegendList, type LegendListRef } from "@legendapp/list/react"; | ||
| 2 | import { ChevronRightIcon } from "lucide-react"; | 3 | import { ChevronRightIcon } from "lucide-react"; |
| 4 | import { type RefObject, useCallback, useState } from "react"; | ||
| 5 | import { cn } from "~/lib/utils"; | ||
| 3 | import { shortcutLabelForCommand } from "../keybindings"; | 6 | import { shortcutLabelForCommand } from "../keybindings"; |
| 4 | import { | 7 | import { |
| 5 | type CommandPaletteActionItem, | 8 | type CommandPaletteActionItem, |
| 6 | type CommandPaletteGroup, | 9 | type CommandPaletteGroup, |
| 10 | type CommandPaletteRow, | ||
| 7 | type CommandPaletteSubmenuItem, | 11 | type CommandPaletteSubmenuItem, |
| 8 | } from "./CommandPalette.logic"; | 12 | } from "./CommandPalette.logic"; |
| 9 | import { | 13 | import { |
| ... | @@ -12,8 +16,11 @@ import { | ... | @@ -12,8 +16,11 @@ import { |
| 12 | CommandGroupLabel, | 16 | CommandGroupLabel, |
| 13 | CommandItem, | 17 | CommandItem, |
| 14 | CommandList, | 18 | CommandList, |
| 19 | CommandListHeading, | ||
| 20 | CommandListVirtualized, | ||
| 15 | CommandShortcut, | 21 | CommandShortcut, |
| 16 | } from "./ui/command"; | 22 | } from "./ui/command"; |
| 23 | import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area"; | ||
| 17 | import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch"; | 24 | import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch"; |
| 18 | 25 | ||
| 19 | interface CommandPaletteResultsProps { | 26 | interface CommandPaletteResultsProps { |
| ... | @@ -25,16 +32,20 @@ interface CommandPaletteResultsProps { | ... | @@ -25,16 +32,20 @@ interface CommandPaletteResultsProps { |
| 25 | onExecuteItem: (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => void; | 32 | onExecuteItem: (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => void; |
| 26 | } | 33 | } |
| 27 | 34 | ||
| 35 | function CommandPaletteEmptyState(props: { emptyStateMessage?: string; isActionsOnly: boolean }) { | ||
| 36 | return ( | ||
| 37 | <div className="py-10 text-center text-sm text-muted-foreground"> | ||
| 38 | {props.emptyStateMessage ?? | ||
| 39 | (props.isActionsOnly | ||
| 40 | ? "No matching actions." | ||
| 41 | : "No matching commands, projects, or threads.")} | ||
| 42 | </div> | ||
| 43 | ); | ||
| 44 | } | ||
| 45 | |||
| 28 | export function CommandPaletteResults(props: CommandPaletteResultsProps) { | 46 | export function CommandPaletteResults(props: CommandPaletteResultsProps) { |
| 29 | if (props.groups.length === 0) { | 47 | if (props.groups.length === 0) { |
| 30 | return ( | 48 | return <CommandPaletteEmptyState {...props} />; |
| 31 | <div className="py-10 text-center text-sm text-muted-foreground"> | ||
| 32 | {props.emptyStateMessage ?? | ||
| 33 | (props.isActionsOnly | ||
| 34 | ? "No matching actions." | ||
| 35 | : "No matching commands, projects, or threads.")} | ||
| 36 | </div> | ||
| 37 | ); | ||
| 38 | } | 49 | } |
| 39 | 50 | ||
| 40 | return ( | 51 | return ( |
| ... | @@ -63,6 +74,87 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { | ... | @@ -63,6 +74,87 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { |
| 63 | ); | 74 | ); |
| 64 | } | 75 | } |
| 65 | 76 | ||
| 77 | /** | ||
| 78 | * Scrolls a keyboard highlight into view the way the unvirtualized list did: | ||
| 79 | * nearest edge, clear of the scroll fade. Rows outside the rendered window | ||
| 80 | * fall back to the list's own scrolling. | ||
| 81 | */ | ||
| 82 | export function scrollCommandPaletteRowIntoView(list: LegendListRef | null, rowIndex: number) { | ||
| 83 | const element = list?.getState?.().elementAtIndex(rowIndex); | ||
| 84 | if (element instanceof HTMLElement) { | ||
| 85 | element.scrollIntoView({ block: "nearest" }); | ||
| 86 | return; | ||
| 87 | } | ||
| 88 | void list?.scrollIndexIntoView?.({ index: rowIndex, animated: false }); | ||
| 89 | } | ||
| 90 | |||
| 91 | interface CommandPaletteVirtualizedResultsProps extends Omit<CommandPaletteResultsProps, "groups"> { | ||
| 92 | rows: ReadonlyArray<CommandPaletteRow>; | ||
| 93 | listRef: RefObject<LegendListRef | null>; | ||
| 94 | } | ||
| 95 | |||
| 96 | /** | ||
| 97 | * Renders only the visible rows. The parent passes the flat item order to the | ||
| 98 | * Command root as `items` with `virtualized`, scrolls keyboard highlights into | ||
| 99 | * view through `listRef`, and runs Enter itself since the row may be unmounted. | ||
| 100 | */ | ||
| 101 | export function CommandPaletteVirtualizedResults(props: CommandPaletteVirtualizedResultsProps) { | ||
| 102 | const { listRef } = props; | ||
| 103 | const [scrollFade, setScrollFade] = useState({ top: false, bottom: false }); | ||
| 104 | const updateScrollFade = useCallback(() => { | ||
| 105 | const scrollElement = listRef.current?.getScrollableNode?.(); | ||
| 106 | if (!(scrollElement instanceof HTMLElement)) return; | ||
| 107 | const top = scrollElement.scrollTop > 1; | ||
| 108 | const bottom = | ||
| 109 | scrollElement.scrollHeight - scrollElement.clientHeight - scrollElement.scrollTop > 1; | ||
| 110 | setScrollFade((current) => | ||
| 111 | current.top === top && current.bottom === bottom ? current : { top, bottom }, | ||
| 112 | ); | ||
| 113 | }, [listRef]); | ||
| 114 | |||
| 115 | if (props.rows.length === 0) { | ||
| 116 | return <CommandPaletteEmptyState {...props} />; | ||
| 117 | } | ||
| 118 | |||
| 119 | return ( | ||
| 120 | <CommandListVirtualized> | ||
| 121 | <LegendList<CommandPaletteRow> | ||
| 122 | ref={listRef} | ||
| 123 | data={props.rows} | ||
| 124 | keyExtractor={(row) => row.key} | ||
| 125 | getItemType={(row) => row.kind} | ||
| 126 | extraData={props.highlightedItemValue} | ||
| 127 | renderItem={({ item: row }) => | ||
| 128 | row.kind === "label" ? ( | ||
| 129 | <div className={row.first ? undefined : "pt-1.5"}> | ||
| 130 | <CommandListHeading>{row.label}</CommandListHeading> | ||
| 131 | </div> | ||
| 132 | ) : row.itemIndex === null ? ( | ||
| 133 | <DisabledCommandPaletteResultRow item={row.item} /> | ||
| 134 | ) : ( | ||
| 135 | <CommandPaletteResultRow | ||
| 136 | index={row.itemIndex} | ||
| 137 | item={row.item} | ||
| 138 | keybindings={props.keybindings} | ||
| 139 | isActive={props.highlightedItemValue === row.item.value} | ||
| 140 | onExecuteItem={props.onExecuteItem} | ||
| 141 | /> | ||
| 142 | ) | ||
| 143 | } | ||
| 144 | estimatedItemSize={40} | ||
| 145 | drawDistance={400} | ||
| 146 | onLayout={updateScrollFade} | ||
| 147 | onScroll={updateScrollFade} | ||
| 148 | contentContainerClassName="px-2" | ||
| 149 | className={cn( | ||
| 150 | "min-h-0 scroll-py-6 overflow-x-hidden overscroll-y-contain py-2", | ||
| 151 | getVirtualizedScrollFadeClassName(scrollFade), | ||
| 152 | )} | ||
| 153 | /> | ||
| 154 | </CommandListVirtualized> | ||
| 155 | ); | ||
| 156 | } | ||
| 157 | |||
| 66 | function DisabledCommandPaletteResultRow(props: { | 158 | function DisabledCommandPaletteResultRow(props: { |
| 67 | item: CommandPaletteActionItem | CommandPaletteSubmenuItem; | 159 | item: CommandPaletteActionItem | CommandPaletteSubmenuItem; |
| 68 | }) { | 160 | }) { |
| ... | @@ -96,6 +188,7 @@ function DisabledCommandPaletteResultRow(props: { | ... | @@ -96,6 +188,7 @@ function DisabledCommandPaletteResultRow(props: { |
| 96 | } | 188 | } |
| 97 | 189 | ||
| 98 | function CommandPaletteResultRow(props: { | 190 | function CommandPaletteResultRow(props: { |
| 191 | index?: number; | ||
| 99 | item: CommandPaletteActionItem | CommandPaletteSubmenuItem; | 192 | item: CommandPaletteActionItem | CommandPaletteSubmenuItem; |
| 100 | isActive: boolean; | 193 | isActive: boolean; |
| 101 | keybindings: ResolvedKeybindingsConfig; | 194 | keybindings: ResolvedKeybindingsConfig; |
| ... | @@ -107,6 +200,7 @@ function CommandPaletteResultRow(props: { | ... | @@ -107,6 +200,7 @@ function CommandPaletteResultRow(props: { |
| 107 | 200 | ||
| 108 | return ( | 201 | return ( |
| 109 | <CommandItem | 202 | <CommandItem |
| 203 | {...(props.index === undefined ? {} : { index: props.index })} | ||
| 110 | value={props.item.value} | 204 | value={props.item.value} |
| 111 | active={props.isActive} | 205 | active={props.isActive} |
| 112 | onMouseDown={(event) => { | 206 | onMouseDown={(event) => { |
apps/web/src/components/ui/autocomplete.tsx+27-1| ... | @@ -146,11 +146,35 @@ function AutocompleteGroup({ className, ...props }: AutocompletePrimitive.Group. | ... | @@ -146,11 +146,35 @@ function AutocompleteGroup({ className, ...props }: AutocompletePrimitive.Group. |
| 146 | ); | 146 | ); |
| 147 | } | 147 | } |
| 148 | 148 | ||
| 149 | const GROUP_LABEL_CLASS_NAME = "px-2 py-1.5 font-medium text-muted-foreground text-xs"; | ||
| 150 | |||
| 149 | function AutocompleteGroupLabel({ className, ...props }: AutocompletePrimitive.GroupLabel.Props) { | 151 | function AutocompleteGroupLabel({ className, ...props }: AutocompletePrimitive.GroupLabel.Props) { |
| 150 | return ( | 152 | return ( |
| 151 | <AutocompletePrimitive.GroupLabel | 153 | <AutocompletePrimitive.GroupLabel |
| 152 | className={cn("px-2 py-1.5 font-medium text-muted-foreground text-xs", className)} | 154 | className={cn(GROUP_LABEL_CLASS_NAME, className)} |
| 155 | data-slot="autocomplete-group-label" | ||
| 156 | {...props} | ||
| 157 | /> | ||
| 158 | ); | ||
| 159 | } | ||
| 160 | |||
| 161 | // A group label for virtualized lists, where rows cannot be wrapped in a group. | ||
| 162 | function AutocompleteListHeading({ className, ...props }: React.ComponentProps<"div">) { | ||
| 163 | return ( | ||
| 164 | <div | ||
| 165 | className={cn(GROUP_LABEL_CLASS_NAME, className)} | ||
| 153 | data-slot="autocomplete-group-label" | 166 | data-slot="autocomplete-group-label" |
| 167 | role="presentation" | ||
| 168 | {...props} | ||
| 169 | /> | ||
| 170 | ); | ||
| 171 | } | ||
| 172 | |||
| 173 | function AutocompleteListVirtualized({ className, ...props }: AutocompletePrimitive.List.Props) { | ||
| 174 | return ( | ||
| 175 | <AutocompletePrimitive.List | ||
| 176 | className={cn("size-full min-w-0", className)} | ||
| 177 | data-slot="autocomplete-list" | ||
| 154 | {...props} | 178 | {...props} |
| 155 | /> | 179 | /> |
| 156 | ); | 180 | ); |
| ... | @@ -226,6 +250,8 @@ export { | ... | @@ -226,6 +250,8 @@ export { |
| 226 | AutocompleteGroupLabel, | 250 | AutocompleteGroupLabel, |
| 227 | AutocompleteEmpty, | 251 | AutocompleteEmpty, |
| 228 | AutocompleteList, | 252 | AutocompleteList, |
| 253 | AutocompleteListHeading, | ||
| 254 | AutocompleteListVirtualized, | ||
| 229 | AutocompleteClear, | 255 | AutocompleteClear, |
| 230 | AutocompleteCollection, | 256 | AutocompleteCollection, |
| 231 | }; | 257 | }; |
apps/web/src/components/ui/command.tsx+27| ... | @@ -12,6 +12,8 @@ import { | ... | @@ -12,6 +12,8 @@ import { |
| 12 | AutocompleteInput, | 12 | AutocompleteInput, |
| 13 | AutocompleteItem, | 13 | AutocompleteItem, |
| 14 | AutocompleteList, | 14 | AutocompleteList, |
| 15 | AutocompleteListHeading, | ||
| 16 | AutocompleteListVirtualized, | ||
| 15 | } from "~/components/ui/autocomplete"; | 17 | } from "~/components/ui/autocomplete"; |
| 16 | import { DIALOG_BACKDROP_CLASS, DIALOG_POPUP_CLASS } from "~/components/ui/dialog-styles"; | 18 | import { DIALOG_BACKDROP_CLASS, DIALOG_POPUP_CLASS } from "~/components/ui/dialog-styles"; |
| 17 | import { Button } from "~/components/ui/button"; | 19 | import { Button } from "~/components/ui/button"; |
| ... | @@ -123,6 +125,29 @@ function CommandList({ className, ...props }: React.ComponentProps<typeof Autoco | ... | @@ -123,6 +125,29 @@ function CommandList({ className, ...props }: React.ComponentProps<typeof Autoco |
| 123 | ); | 125 | ); |
| 124 | } | 126 | } |
| 125 | 127 | ||
| 128 | // Pair with a LegendList child; the list owns scrolling and spacing. | ||
| 129 | function CommandListVirtualized({ | ||
| 130 | className, | ||
| 131 | ...props | ||
| 132 | }: React.ComponentProps<typeof AutocompleteListVirtualized>) { | ||
| 133 | return ( | ||
| 134 | <AutocompleteListVirtualized | ||
| 135 | className={cn("flex max-h-[inherit] flex-col", className)} | ||
| 136 | data-slot="command-list" | ||
| 137 | {...props} | ||
| 138 | /> | ||
| 139 | ); | ||
| 140 | } | ||
| 141 | |||
| 142 | function CommandListHeading({ | ||
| 143 | className, | ||
| 144 | ...props | ||
| 145 | }: React.ComponentProps<typeof AutocompleteListHeading>) { | ||
| 146 | return ( | ||
| 147 | <AutocompleteListHeading className={className} data-slot="command-group-label" {...props} /> | ||
| 148 | ); | ||
| 149 | } | ||
| 150 | |||
| 126 | function CommandPanel({ className, ...props }: React.ComponentProps<"div">) { | 151 | function CommandPanel({ className, ...props }: React.ComponentProps<"div">) { |
| 127 | return ( | 152 | return ( |
| 128 | <div | 153 | <div |
| ... | @@ -229,6 +254,8 @@ export { | ... | @@ -229,6 +254,8 @@ export { |
| 229 | CommandInput, | 254 | CommandInput, |
| 230 | CommandItem, | 255 | CommandItem, |
| 231 | CommandList, | 256 | CommandList, |
| 257 | CommandListHeading, | ||
| 258 | CommandListVirtualized, | ||
| 232 | CommandPanel, | 259 | CommandPanel, |
| 233 | CommandShortcut, | 260 | CommandShortcut, |
| 234 | }; | 261 | }; |
apps/web/src/state/queries.threadSearch.test.tsx created+64| ... | @@ -0,0 +1,64 @@ | ||
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { act } from "react"; | ||
| 3 | import { create, type ReactTestRenderer } from "react-test-renderer"; | ||
| 4 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 5 | |||
| 6 | import { useThreadSearch } from "./queries"; | ||
| 7 | |||
| 8 | const searchResults = vi.hoisted(() => ({ | ||
| 9 | matches: [{ threadId: "thread-1", source: "user", snippet: "a link" }], | ||
| 10 | isLoading: false, | ||
| 11 | })); | ||
| 12 | vi.mock("@effect/atom-react", async (importOriginal) => ({ | ||
| 13 | ...(await importOriginal<typeof import("@effect/atom-react")>()), | ||
| 14 | useAtomValue: (atom: { label?: ReadonlyArray<unknown> }) => | ||
| 15 | String(atom.label?.[0]).includes("empty") ? { matches: [], isLoading: false } : searchResults, | ||
| 16 | })); | ||
| 17 | |||
| 18 | const environmentIds = [EnvironmentId.make("local")]; | ||
| 19 | type ThreadSearch = ReturnType<typeof useThreadSearch>; | ||
| 20 | |||
| 21 | function Probe(props: { query: string; onResult: (result: ThreadSearch) => void }) { | ||
| 22 | props.onResult(useThreadSearch(environmentIds, props.query)); | ||
| 23 | return null; | ||
| 24 | } | ||
| 25 | |||
| 26 | describe("useThreadSearch", () => { | ||
| 27 | let renderer: ReactTestRenderer | null = null; | ||
| 28 | beforeEach(() => { | ||
| 29 | vi.useFakeTimers(); | ||
| 30 | vi.stubGlobal("window", globalThis); | ||
| 31 | }); | ||
| 32 | afterEach(() => { | ||
| 33 | act(() => renderer?.unmount()); | ||
| 34 | renderer = null; | ||
| 35 | vi.unstubAllGlobals(); | ||
| 36 | vi.useRealTimers(); | ||
| 37 | }); | ||
| 38 | |||
| 39 | it("keeps the settled query and matches stable while typing, then reports the settled query", () => { | ||
| 40 | const results: ThreadSearch[] = []; | ||
| 41 | const render = (query: string) => { | ||
| 42 | const element = <Probe query={query} onResult={(result) => results.push(result)} />; | ||
| 43 | act(() => { | ||
| 44 | if (renderer) renderer.update(element); | ||
| 45 | else renderer = create(element); | ||
| 46 | }); | ||
| 47 | return results.at(-1)!; | ||
| 48 | }; | ||
| 49 | |||
| 50 | render(""); | ||
| 51 | const typing = ["li", "lin", "link "].map(render); | ||
| 52 | expect(typing.map((result) => result.query)).toEqual(["", "", ""]); | ||
| 53 | expect(new Set(typing.map((result) => result.matches)).size).toBe(1); | ||
| 54 | expect(typing.every((result) => result.isPending)).toBe(true); | ||
| 55 | |||
| 56 | act(() => { | ||
| 57 | vi.advanceTimersByTime(200); | ||
| 58 | }); | ||
| 59 | const settled = results.at(-1)!; | ||
| 60 | expect(settled.query).toBe("link"); | ||
| 61 | expect(settled.matches).toBe(searchResults.matches); | ||
| 62 | expect(settled.isPending).toBe(false); | ||
| 63 | }); | ||
| 64 | }); | ||
apps/web/src/state/queries.ts+3| ... | @@ -72,6 +72,8 @@ export function useThreadSearch( | ... | @@ -72,6 +72,8 @@ export function useThreadSearch( |
| 72 | query: string, | 72 | query: string, |
| 73 | ): { | 73 | ): { |
| 74 | readonly matches: ReadonlyArray<EnvironmentThreadSearchMatch>; | 74 | readonly matches: ReadonlyArray<EnvironmentThreadSearchMatch>; |
| 75 | /** The settled query `matches` came from; it only changes when results do. */ | ||
| 76 | readonly query: string; | ||
| 75 | readonly isPending: boolean; | 77 | readonly isPending: boolean; |
| 76 | } { | 78 | } { |
| 77 | const normalizedQuery = query.trim(); | 79 | const normalizedQuery = query.trim(); |
| ... | @@ -88,6 +90,7 @@ export function useThreadSearch( | ... | @@ -88,6 +90,7 @@ export function useThreadSearch( |
| 88 | const isDebouncing = canSearch && normalizedQuery !== debouncedQuery; | 90 | const isDebouncing = canSearch && normalizedQuery !== debouncedQuery; |
| 89 | return { | 91 | return { |
| 90 | matches: isDebouncing ? EMPTY_THREAD_SEARCH_MATCHES : result.matches, | 92 | matches: isDebouncing ? EMPTY_THREAD_SEARCH_MATCHES : result.matches, |
| 93 | query: settledQuery ?? "", | ||
| 91 | isPending: canSearch && (isDebouncing || result.isLoading), | 94 | isPending: canSearch && (isDebouncing || result.isLoading), |
| 92 | }; | 95 | }; |
| 93 | } | 96 | } |