diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 486d80a988666..81a5ac4922881 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -32,6 +32,7 @@ import { createBrowseNavigationCoordinator, filterFilesystemBrowseEntries, getFilesystemBrowsePath, + getFilesystemBrowseTabCompletion, } from "@t3tools/client-runtime/state/filesystem"; import { isAtomCommandInterrupted, @@ -2492,6 +2493,30 @@ function OpenCommandPaletteDialog(props: { } if (event.ctrlKey || event.shiftKey || event.altKey || event.metaKey) return; + + if (event.key === "Tab" && isBrowsing && !relativePathNeedsActiveProject) { + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + (event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.(); + event.preventDefault(); + if (highlightedItemValue === "browse:up") { + void browseUp(); + return; + } + const highlightedEntry = visibleBrowseEntries.find( + (entry) => `browse:${entry.fullPath}` === highlightedItemValue, + ); + const completion = highlightedEntry + ? { name: highlightedEntry.name, isComplete: true } + : getFilesystemBrowseTabCompletion(visibleBrowseEntries, browsePath.filterQuery); + if (completion?.isComplete) { + void browseTo(completion.name); + } else if (completion && !pinnedCloneDirectoryName) { + setHighlightedItemValue(null); + setQuery(`${getBrowseDirectoryPath(query)}${completion.name}`); + } + return; + } + const firstItemValue = autoHighlightsFirstRow ? resultRows.itemValues[0] : undefined; if ( event.key === "ArrowDown" && diff --git a/packages/client-runtime/src/state/filesystem.test.ts b/packages/client-runtime/src/state/filesystem.test.ts index 6d1c22bb856d3..6c21c5d926919 100644 --- a/packages/client-runtime/src/state/filesystem.test.ts +++ b/packages/client-runtime/src/state/filesystem.test.ts @@ -4,6 +4,7 @@ import { AuthFilesystemReadScope, AuthOrchestrationReadScope } from "@t3tools/co import { createBrowseNavigationCoordinator, getFilesystemBrowsePath, + getFilesystemBrowseTabCompletion, resolveFilesystemReadAccess, } from "./filesystem.ts"; @@ -79,6 +80,33 @@ describe("filesystem browse model", () => { expect(getFilesystemBrowsePath("C:\\Users\\test", "MacIntel").isBrowsing).toBe(false); expect(getFilesystemBrowsePath("~/projects/", "", false).isBrowsing).toBe(false); }); + + it("completes Tab like a shell", () => { + const entry = (name: string) => ({ name, fullPath: `/Users/me/${name}` }); + expect(getFilesystemBrowseTabCompletion([entry("Creations")], "Creation")).toEqual({ + name: "Creations", + isComplete: true, + }); + expect(getFilesystemBrowseTabCompletion([entry("OpenSourced"), entry("OpenAI")], "op")).toEqual( + { name: "Open", isComplete: false }, + ); + expect(getFilesystemBrowseTabCompletion([entry("Open"), entry("Opal")], "Op")).toBeNull(); + expect(getFilesystemBrowseTabCompletion([entry("openAI"), entry("OpenSource")], "o")).toEqual({ + name: "open", + isComplete: false, + }); + expect(getFilesystemBrowseTabCompletion([entry("😀one"), entry("😁two")], "")).toBeNull(); + expect(getFilesystemBrowseTabCompletion([entry("ΟΣα"), entry("οσβ")], "ο")).toBeNull(); + expect(getFilesystemBrowseTabCompletion([entry("İfoo"), entry("İfar")], "i\u0307")).toEqual({ + name: "İf", + isComplete: false, + }); + expect(getFilesystemBrowseTabCompletion([entry("😀one"), entry("😀other")], "😀")).toEqual({ + name: "😀o", + isComplete: false, + }); + expect(getFilesystemBrowseTabCompletion([], "x")).toBeNull(); + }); }); describe("browse navigation", () => { diff --git a/packages/client-runtime/src/state/filesystem.ts b/packages/client-runtime/src/state/filesystem.ts index 90276fe21661e..03d9b27af57cd 100644 --- a/packages/client-runtime/src/state/filesystem.ts +++ b/packages/client-runtime/src/state/filesystem.ts @@ -85,6 +85,33 @@ export function filterFilesystemBrowseEntries( return { visibleEntries, exactEntry }; } +/** Shell-style Tab: a single match enters the directory, several extend to their common prefix. */ +export function getFilesystemBrowseTabCompletion( + visibleEntries: ReadonlyArray, + filterQuery: string, +): { readonly name: string; readonly isComplete: boolean } | null { + const [first, ...rest] = visibleEntries; + if (first === undefined) return null; + if (rest.length === 0) return { name: first.name, isComplete: true }; + let prefix = Array.from(first.name); + for (const entry of rest) { + const characters = Array.from(entry.name); + let length = 0; + while ( + length < prefix.length && + prefix[length]?.toLowerCase() === characters[length]?.toLowerCase() + ) + length += 1; + prefix = prefix.slice(0, length); + } + const name = prefix.join(""); + const lowerName = name.toLowerCase(); + return lowerName.length > filterQuery.toLowerCase().length && + visibleEntries.every((entry) => entry.name.toLowerCase().startsWith(lowerName)) + ? { name, isComplete: false } + : null; +} + export function createBrowseNavigationCoordinator() { let generation = 0;