diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 902b7e87773..f3079a0e349 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -5,6 +5,7 @@ import { buildThreadActionItems, enumerateCommandPaletteItems, filterCommandPaletteGroups, + resolveBrowseTabCompletion, type CommandPaletteGroup, } from "./CommandPalette.logic"; @@ -38,6 +39,109 @@ describe("enumerateCommandPaletteItems", () => { const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); const PROJECT_ID = ProjectId.make("project-1"); +describe("resolveBrowseTabCompletion", () => { + const entries = [ + { name: "alpha", fullPath: "/workspace/alpha" }, + { name: "alpine", fullPath: "/workspace/alpine" }, + ]; + + it("uses the highlighted directory", () => { + expect( + resolveBrowseTabCompletion({ + exactEntry: null, + filteredEntries: entries, + highlightedItemValue: "browse:/workspace/alpine", + }), + ).toEqual({ kind: "entry", entry: entries[1] }); + }); + + it("uses the first directory when none is highlighted", () => { + expect( + resolveBrowseTabCompletion({ + exactEntry: null, + filteredEntries: entries, + highlightedItemValue: null, + }), + ).toEqual({ kind: "entry", entry: entries[0] }); + }); + + it("preserves the highlighted parent-directory action", () => { + expect( + resolveBrowseTabCompletion({ + exactEntry: null, + filteredEntries: entries, + highlightedItemValue: "browse:up", + }), + ).toEqual({ kind: "up" }); + }); + + it("returns null when there are no matching directories", () => { + expect( + resolveBrowseTabCompletion({ + exactEntry: null, + filteredEntries: [], + highlightedItemValue: null, + }), + ).toBeNull(); + }); + + it("does not enter the first child when the completed path has no leaf filter", () => { + expect( + resolveBrowseTabCompletion({ + allowFirstEntryFallback: false, + exactEntry: null, + filteredEntries: entries, + highlightedItemValue: null, + }), + ).toBeNull(); + }); + + it("uses the case-sensitive exact entry before the first prefix match", () => { + const caseVariants = [ + { name: "Docs", fullPath: "/workspace/Docs" }, + { name: "docs", fullPath: "/workspace/docs" }, + ]; + + expect( + resolveBrowseTabCompletion({ + exactEntry: caseVariants[1] ?? null, + filteredEntries: caseVariants, + highlightedItemValue: null, + }), + ).toEqual({ kind: "entry", entry: caseVariants[1] }); + }); + + it("keeps a highlighted row ahead of a different exact entry", () => { + const caseVariants = [ + { name: "Docs", fullPath: "/workspace/Docs" }, + { name: "docs", fullPath: "/workspace/docs" }, + ]; + + expect( + resolveBrowseTabCompletion({ + exactEntry: caseVariants[1] ?? null, + filteredEntries: caseVariants, + highlightedItemValue: "browse:/workspace/Docs", + }), + ).toEqual({ kind: "entry", entry: caseVariants[0] }); + }); + + it("uses an exact entry when a stored highlight no longer resolves", () => { + const caseVariants = [ + { name: "Docs", fullPath: "/workspace/Docs" }, + { name: "docs", fullPath: "/workspace/docs" }, + ]; + + expect( + resolveBrowseTabCompletion({ + exactEntry: caseVariants[1] ?? null, + filteredEntries: caseVariants, + highlightedItemValue: "browse:/workspace/removed", + }), + ).toEqual({ kind: "entry", entry: caseVariants[1] }); + }); +}); + function makeThread(overrides: Partial = {}): Thread { return { id: ThreadId.make("thread-1"), diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index f69c38e1a0f..8927b42e5f5 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -102,6 +102,38 @@ export function filterBrowseEntries(input: { return { filteredEntries, highlightedEntry, exactEntry }; } +export type BrowseTabCompletion = + | { readonly kind: "up" } + | { readonly kind: "entry"; readonly entry: FilesystemBrowseEntry }; + +export function resolveBrowseTabCompletion(input: { + allowFirstEntryFallback?: boolean; + exactEntry: FilesystemBrowseEntry | null; + filteredEntries: ReadonlyArray; + highlightedItemValue: string | null; +}): BrowseTabCompletion | null { + if (input.highlightedItemValue === "browse:up") { + return { kind: "up" }; + } + + if (input.highlightedItemValue?.startsWith("browse:")) { + const highlightedPath = input.highlightedItemValue.slice("browse:".length); + const highlightedEntry = input.filteredEntries.find( + (entry) => entry.fullPath === highlightedPath, + ); + if (highlightedEntry) { + return { kind: "entry", entry: highlightedEntry }; + } + } + + if (input.exactEntry) { + return { kind: "entry", entry: input.exactEntry }; + } + + const firstEntry = input.allowFirstEntryFallback === false ? undefined : input.filteredEntries[0]; + return firstEntry ? { kind: "entry", entry: firstEntry } : null; +} + export function normalizeSearchText(value: string): string { return value.trim().toLowerCase().replace(/\s+/g, " "); } diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index aa7547c8ba6..6686b4e20be 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -102,6 +102,7 @@ import { getCommandPaletteMode, ITEM_ICON_CLASS, RECENT_THREAD_LIMIT, + resolveBrowseTabCompletion, } from "./CommandPalette.logic"; import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; @@ -1699,6 +1700,30 @@ function OpenCommandPaletteDialog(props: { } } + const isPlainTab = + event.key === "Tab" && !event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey; + + if (isBrowsing && isPlainTab) { + event.preventDefault(); + + if (relativePathNeedsActiveProject || (isBrowsePending && browseResult === null)) { + return; + } + + const completion = resolveBrowseTabCompletion({ + allowFirstEntryFallback: browseFilterQuery.length > 0, + exactEntry: exactBrowseEntry, + filteredEntries: filteredBrowseEntries, + highlightedItemValue, + }); + if (completion?.kind === "up") { + browseUp(); + } else if (completion?.kind === "entry") { + browseTo(completion.entry.name); + } + return; + } + if (addProjectCloneFlow?.step === "repository" && event.key === "Enter") { event.preventDefault(); void submitAddProjectCloneFlow();