Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions apps/web/src/components/CommandPalette.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import { makeThreadFixture } from "../test-fixtures";
import {
buildBrowseGroups,
buildCommandPaletteProjectMetadata,
buildCommandPaletteRows,
buildProjectActionItems,
buildThreadActionItems,
buildLinkedThreadActionItems,
enumerateCommandPaletteItems,
filterPinnedBrowseEntries,
filterCommandPaletteGroups,
findHighlightedCommandPaletteItem,
reduceCommandPaletteUiState,
type CommandPaletteActionItem,
type CommandPaletteGroup,
Expand Down Expand Up @@ -849,3 +851,46 @@ describe("filterCommandPaletteGroups", () => {
]);
});
});

describe("virtualized command palette rows", () => {
const action = (value: string, disabled = false): CommandPaletteActionItem => ({
kind: "action",
value,
searchTerms: [],
title: value,
icon: null,
...(disabled ? { disabled } : {}),
run: async () => {},
});
const groups: CommandPaletteGroup[] = [
{ value: "actions", label: "Actions", items: [action("new-thread"), action("offline", true)] },
{ value: "threads", label: "Threads", items: [action("thread-a"), action("thread-b")] },
];

it("keeps group order and headings while indexing only enabled items", () => {
const { rows, itemValues, rowIndexByItemIndex } = buildCommandPaletteRows(groups);

expect(rows.map((row) => (row.kind === "label" ? `# ${row.label}` : row.key))).toEqual([
"# Actions",
"actions:new-thread",
"actions:offline",
"# Threads",
"threads:thread-a",
"threads:thread-b",
]);
expect(itemValues).toEqual(["new-thread", "thread-a", "thread-b"]);
expect(rowIndexByItemIndex).toEqual([1, 4, 5]);
expect(rows.flatMap((row) => (row.kind === "item" ? [row.itemIndex] : []))).toEqual([
0,
null,
1,
2,
]);
});

it("resolves Enter to the highlighted item without needing its row mounted", () => {
expect(findHighlightedCommandPaletteItem(groups, "thread-b")?.value).toBe("thread-b");
expect(findHighlightedCommandPaletteItem(groups, "offline")).toBeNull();
expect(findHighlightedCommandPaletteItem(groups, null)).toBeNull();
});
});
59 changes: 59 additions & 0 deletions apps/web/src/components/CommandPalette.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,65 @@ export interface CommandPaletteView {
readonly initialQuery?: string;
}

export type CommandPaletteRow =
| {
readonly kind: "label";
readonly key: string;
readonly label: string;
readonly first: boolean;
}
| {
readonly kind: "item";
readonly key: string;
readonly item: CommandPaletteActionItem | CommandPaletteSubmenuItem;
/** Position among enabled items, or null for disabled rows the keyboard skips. */
readonly itemIndex: number | null;
};

/**
* Flattens groups into the rows a virtualized list renders. `itemValues` is the
* highlightable item order Base UI navigates; `rowIndexByItemIndex` maps a
* highlight back to its row for scrolling.
*/
export function buildCommandPaletteRows(groups: ReadonlyArray<CommandPaletteGroup>) {
const rows: CommandPaletteRow[] = [];
const itemValues: string[] = [];
const rowIndexByItemIndex: number[] = [];
for (const group of groups) {
if (group.label) {
rows.push({
kind: "label",
key: `group:${group.value}`,
label: group.label,
first: rows.length === 0,
});
}
for (const item of group.items) {
const itemIndex = item.disabled ? null : itemValues.length;
if (itemIndex !== null) {
itemValues.push(item.value);
rowIndexByItemIndex.push(rows.length);
}
rows.push({ kind: "item", key: `${group.value}:${item.value}`, item, itemIndex });
}
}
return { rows, itemValues, rowIndexByItemIndex };
}

/** The enabled item Enter should run for a highlight, whether or not its row is mounted. */
export function findHighlightedCommandPaletteItem(
groups: ReadonlyArray<CommandPaletteGroup>,
highlightedItemValue: string | null,
): CommandPaletteActionItem | CommandPaletteSubmenuItem | null {
if (highlightedItemValue === null) return null;
for (const group of groups) {
for (const item of group.items) {
if (item.value === highlightedItemValue && !item.disabled) return item;
}
}
return null;
}

export function enumerateCommandPaletteItems(
items: ReadonlyArray<CommandPaletteActionItem>,
): CommandPaletteActionItem[] {
Expand Down
83 changes: 73 additions & 10 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ import {
type KeyboardEvent,
type ReactNode,
} from "react";
import type { LegendListRef } from "@legendapp/list/react";
import { useAtomValue } from "@effect/atom-react";

import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal";
Expand Down Expand Up @@ -157,7 +158,9 @@ import {
buildRootGroups,
buildThreadActionItems,
buildLinkedThreadActionItems,
buildCommandPaletteRows,
enumerateCommandPaletteItems,
findHighlightedCommandPaletteItem,
type CommandPaletteActionItem,
type CommandPaletteOpenIntent,
type CommandPaletteSubmenuItem,
Expand All @@ -174,7 +177,10 @@ import {
import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic";
import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";
import { CommandPaletteContent } from "./CommandPaletteContent";
import { CommandPaletteResults } from "./CommandPaletteResults";
import {
CommandPaletteVirtualizedResults,
scrollCommandPaletteRowIntoView,
} from "./CommandPaletteResults";
import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon, ForgejoIcon } from "./Icons";
import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
import { Checkbox } from "./ui/checkbox";
Expand Down Expand Up @@ -694,6 +700,15 @@ function OpenCommandPaletteDialog(props: {
const deferredQuery = useDeferredValue(query);
const isActionsOnly = deferredQuery.startsWith(">");
const [highlightedItemValue, setHighlightedItemValue] = useState<string | null>(null);
const resultListRef = useRef<LegendListRef | null>(null);
// Typing or entering a submenu clears the highlight. Base UI keeps its own on the
// first row, but the palette shows none until the user navigates, and the first
// ArrowDown lands on it.
const highlightClearedRef = useRef(false);
function clearTypedHighlight(): void {
highlightClearedRef.current = true;
setHighlightedItemValue(null);
}
const clientSettings = useClientSettings();
const createProject = useAtomCommand(projectEnvironment.create, {
reportFailure: false,
Expand Down Expand Up @@ -1425,7 +1440,7 @@ function OpenCommandPaletteDialog(props: {
? {
source: match.source,
snippet: match.snippet,
query: threadSearchQuery,
query: threadSearch.query,
}
: undefined;
},
Expand All @@ -1446,7 +1461,7 @@ function OpenCommandPaletteDialog(props: {
projectTitleById,
providerEntryByEnvironmentAndInstanceId,
threadContentMatchByKey,
threadSearchQuery,
threadSearch.query,
threads,
],
);
Expand All @@ -1463,6 +1478,7 @@ function OpenCommandPaletteDialog(props: {
...(view.initialQuery ? { initialQuery: view.initialQuery } : {}),
},
]);
highlightClearedRef.current = true;
setHighlightedItemValue(null);
setQuery(view.initialQuery ?? "");
},
Expand Down Expand Up @@ -1494,7 +1510,7 @@ function OpenCommandPaletteDialog(props: {

function handleQueryChange(nextQuery: string): void {
browseNavigation.invalidate();
setHighlightedItemValue(null);
clearTypedHighlight();
setQuery(nextQuery);
if (nextQuery === "" && currentView?.initialQuery) {
popView();
Expand Down Expand Up @@ -2938,6 +2954,9 @@ function OpenCommandPaletteDialog(props: {
} else if (isBrowsing) {
displayedGroups = relativePathNeedsActiveProject ? [] : browseGroups;
}
const resultRows = buildCommandPaletteRows(displayedGroups);
const autoHighlightsFirstRow =
!isBrowsing && !isRemoteProjectCloneFlow && newProjectFlow === null;

const inputPlaceholder =
newProjectFlow !== null
Expand Down Expand Up @@ -3083,6 +3102,43 @@ function OpenCommandPaletteDialog(props: {
if (event.key === "Backspace" && query === "" && isSubmenu) {
event.preventDefault();
popView();
return;
}

// Base UI ignores navigation keys with modifiers, so these fallbacks do too.
if (event.ctrlKey || event.shiftKey || event.altKey || event.metaKey) return;
// Base UI only keeps a hidden highlight on the first row when it auto-highlights.
const firstItemValue = autoHighlightsFirstRow ? resultRows.itemValues[0] : undefined;
if (
event.key === "ArrowDown" &&
highlightClearedRef.current &&
firstItemValue &&
!event.nativeEvent.isComposing
) {
(event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.();
event.preventDefault();
highlightClearedRef.current = false;
setHighlightedItemValue(firstItemValue);
scrollCommandPaletteRowIntoView(
resultListRef.current,
resultRows.rowIndexByItemIndex[0] ?? 0,
);
return;
}

// Base UI clicks the highlighted row on Enter, which does nothing once the
// virtualized list has unmounted it, so run the tracked highlight directly.
if (event.key === "Enter" && !event.nativeEvent.isComposing && event.keyCode !== 229) {
const highlightedItem = findHighlightedCommandPaletteItem(
displayedGroups,
highlightedItemValue ?? firstItemValue ?? null,
);
if (highlightedItem) {
(event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.();
event.preventDefault();
event.stopPropagation();
executeItem(highlightedItem);
}
}
}

Expand Down Expand Up @@ -3349,9 +3405,7 @@ function OpenCommandPaletteDialog(props: {
<CommandPaletteContent
key={`${viewStack.length}-${browseGeneration}-${isBrowsing}-${newProjectFlow ? "new-project" : (addProjectCloneFlow?.step ?? "none")}`}
aria-label="Command palette"
autoHighlight={
isBrowsing || isRemoteProjectCloneFlow || newProjectFlow !== null ? false : "always"
}
autoHighlight={autoHighlightsFirstRow ? "always" : false}
footerActionLabel={footerActionLabel}
footerTrailing={footerTrailing}
inputAccessory={inputAccessory}
Expand Down Expand Up @@ -3387,8 +3441,16 @@ function OpenCommandPaletteDialog(props: {
onKeyDown: handleKeyDown,
}}
mode="none"
onItemHighlighted={(value) => {
items={resultRows.itemValues}
virtualized
onItemHighlighted={(value, eventDetails) => {
if (eventDetails.reason === "none" && highlightClearedRef.current) return;
highlightClearedRef.current = false;
setHighlightedItemValue(typeof value === "string" ? value : null);
const rowIndex = resultRows.rowIndexByItemIndex[eventDetails.index];
if (eventDetails.reason === "keyboard" && rowIndex !== undefined) {
scrollCommandPaletteRowIntoView(resultListRef.current, rowIndex);
}
}}
onValueChange={handleQueryChange}
showBackHint={isSubmenu}
Expand Down Expand Up @@ -3426,8 +3488,9 @@ function OpenCommandPaletteDialog(props: {
</div>
</div>
) : null}
<CommandPaletteResults
groups={displayedGroups}
<CommandPaletteVirtualizedResults
rows={resultRows.rows}
listRef={resultListRef}
highlightedItemValue={highlightedItemValue}
isActionsOnly={isActionsOnly}
keybindings={keybindings}
Expand Down
Loading
Loading