From 037b932cb287687ac40a574e4b9c807b4e22d55e Mon Sep 17 00:00:00 2001 From: Marc Neuhaus Date: Tue, 15 Sep 2026 13:04:35 +0200 Subject: [PATCH] feat(projects): find folders with fuzzy path queries --- .../features/projects/AddProjectScreen.tsx | 51 +++- .../src/workspace/WorkspaceEntries.test.ts | 263 ++++++++++++++++++ apps/server/src/workspace/WorkspaceEntries.ts | 257 ++++++++++++++--- .../components/CommandPalette.logic.test.ts | 2 +- .../src/components/CommandPalette.logic.ts | 9 +- apps/web/src/components/CommandPalette.tsx | 64 +++-- .../src/components/CommandPaletteContent.tsx | 8 + .../src/state/filesystem.test.ts | 64 +++++ .../client-runtime/src/state/filesystem.ts | 39 ++- packages/contracts/src/filesystem.ts | 6 + packages/shared/src/searchRanking.ts | 36 +++ 11 files changed, 721 insertions(+), 78 deletions(-) diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 5724abb138c8..14efa33fe93f 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -28,6 +28,7 @@ import { } from "@t3tools/client-runtime/state/filesystem"; import { appendBrowsePathSegment, + getBrowseDirectoryPath, inferProjectTitleFromPath, isWindowsPlatform, } from "@t3tools/client-runtime/state/projects"; @@ -304,7 +305,7 @@ function useBrowsePathInput(environment: EnvironmentOption | null, pinnedDirecto if (environment && canPreloadBrowsePath(environmentRuntime?.connectionState)) { await loadBrowsePath({ environmentId: environment.environmentId, - input: { partialPath: selectedDirectoryPath }, + input: { partialPath: selectedDirectoryPath, fuzzy: true }, }); } }, @@ -766,12 +767,32 @@ function FolderBrowser(props: { readonly pinnedDirectoryName?: string; }) { const browsePath = useMemo( - () => getFilesystemBrowsePath(props.pathInput, props.environment.platform), - [props.environment.platform, props.pathInput], + () => + getFilesystemBrowsePath( + props.pathInput, + props.environment.platform, + true, + props.pinnedDirectoryName ? "" : getAddProjectInitialQuery(props.environment.baseDirectory), + ), + [ + props.environment.platform, + props.environment.baseDirectory, + props.pathInput, + props.pinnedDirectoryName, + ], ); + // A pinned repository folder does not exist yet; search the selected parent. + const pinnedDirectoryName = props.pinnedDirectoryName ?? ""; + const pinnedDirectoryMatches = isWindowsPlatform(props.environment.platform) + ? browsePath.filterQuery.toLowerCase() === pinnedDirectoryName.toLowerCase() + : browsePath.filterQuery === pinnedDirectoryName; + const browseFilterQuery = pinnedDirectoryMatches ? "" : browsePath.filterQuery; const browseInput = useMemo( - () => (browsePath.directoryPath.length > 0 ? { partialPath: browsePath.directoryPath } : null), - [browsePath.directoryPath], + () => + browsePath.directoryPath.length > 0 + ? { partialPath: `${browsePath.directoryPath}${browseFilterQuery}`, fuzzy: true } + : null, + [browsePath.directoryPath, browseFilterQuery], ); const browseState = useEnvironmentQuery( browseInput === null @@ -781,13 +802,6 @@ function FolderBrowser(props: { input: browseInput, }), ); - // A pinned repository folder does not exist yet, so filtering the listing by - // it would empty the folder picker. Anything the user typed still filters. - const pinnedDirectoryName = props.pinnedDirectoryName ?? ""; - const pinnedDirectoryMatches = isWindowsPlatform(props.environment.platform) - ? browsePath.filterQuery.toLowerCase() === pinnedDirectoryName.toLowerCase() - : browsePath.filterQuery === pinnedDirectoryName; - const browseFilterQuery = pinnedDirectoryMatches ? "" : browsePath.filterQuery; const { visibleEntries: visibleBrowseEntries } = useMemo( () => filterFilesystemBrowseEntries(browseState.data?.entries ?? [], browseFilterQuery), [browseFilterQuery, browseState.data?.entries], @@ -829,6 +843,7 @@ function FolderBrowser(props: { { void props.navigateToBrowsePath({ - browseDirectoryPath: browsePath.directoryPath, + browseDirectoryPath: getBrowseDirectoryPath(entry.fullPath), selectedDirectoryName: entry.name, }); }} @@ -863,8 +878,16 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st const submitPath = useCallback(async () => { if (!environment || isBrowseNavigating || isSubmitting) return; setError(null); + const browsePath = getFilesystemBrowsePath( + pathInput, + environment.platform, + true, + getAddProjectInitialQuery(environment.baseDirectory), + ); const resolved = resolveAddProjectPath({ - rawPath: pathInput, + rawPath: browsePath.isBrowsing + ? `${browsePath.directoryPath}${browsePath.filterQuery}` + : pathInput, currentProjectCwd: null, platform: environment.platform, }); diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 6013f978ed0c..8a87c6e71b8d 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -734,6 +734,269 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { }); describe("browse", () => { + it.effect("bounds directory reads for ambiguous compact queries", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const cwd = yield* makeTempDir(); + for (let index = 0; index < 140; index += 1) { + yield* writeTextFile(cwd, `workspace-${index}/makespace/index.ts`); + } + vi.mocked(NodeFSP.readdir).mockClear(); + const result = yield* workspaceEntries.browse({ + cwd, + partialPath: "./wormak", + fuzzy: true, + }); + expect(result.entries.length).toBeGreaterThan(0); + expect(vi.mocked(NodeFSP.readdir).mock.calls.length).toBeLessThanOrEqual(128); + }), + ); + + it.effect("matches compact queries across folder names and returns each path once", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const path = yield* Path.Path; + const cwd = yield* makeTempDir(); + yield* writeTextFile(cwd, "wor/unrelated/index.ts"); + yield* writeTextFile(cwd, "Workspace/makespace/index.ts"); + const result = yield* workspaceEntries.browse({ + cwd, + partialPath: "./wormak", + fuzzy: true, + }); + expect(result.entries).toEqual([ + { + name: "makespace", + fullPath: path.join(cwd, "Workspace/makespace"), + searchMatch: { query: "wormak", score: expect.any(Number) }, + }, + ]); + }), + ); + + it.effect("matches compact queries over three levels and tolerates fragment typos", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const path = yield* Path.Path; + const cwd = yield* makeTempDir(); + yield* writeTextFile(cwd, "Workspace/projects/makespace/index.ts"); + for (const query of ["worprjmak", "worprjmkaes"]) { + const result = yield* workspaceEntries.browse({ + cwd, + partialPath: `./${query}`, + fuzzy: true, + }); + expect(result.entries).toEqual([ + { + name: "makespace", + fullPath: path.join(cwd, "Workspace/projects/makespace"), + searchMatch: { query, score: expect.any(Number) }, + }, + ]); + } + }), + ); + + it.effect("retains literal folder names without searching their descendants", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const path = yield* Path.Path; + const cwd = yield* makeTempDir(); + yield* writeTextFile(cwd, "wormak/index.ts"); + yield* writeTextFile(cwd, "Workspace/makespace/index.ts"); + vi.mocked(NodeFSP.readdir).mockClear(); + expect( + (yield* workspaceEntries.browse({ cwd, partialPath: "./wormak", fuzzy: true })).entries, + ).toEqual([{ name: "wormak", fullPath: path.join(cwd, "wormak") }]); + expect(NodeFSP.readdir).toHaveBeenCalledTimes(1); + }), + ); + + it.effect("keeps compact searches out of hidden and nonmatching subtrees", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const cwd = yield* makeTempDir(); + yield* writeTextFile(cwd, ".Workspace/makespace/index.ts"); + yield* writeTextFile(cwd, "Archive/Workspace/makespace/index.ts"); + vi.mocked(NodeFSP.readdir).mockClear(); + expect( + (yield* workspaceEntries.browse({ cwd, partialPath: "./wormak", fuzzy: true })).entries, + ).toEqual([]); + expect(vi.mocked(NodeFSP.readdir).mock.calls.map(([directory]) => directory)).toEqual([ + cwd, + ]); + }), + ); + + it.effect("does not widen a relative search beyond its requested root", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const path = yield* Path.Path; + const cwd = yield* makeTempDir(); + yield* writeTextFile(cwd, "wor/unrelated/index.ts"); + vi.mocked(NodeFSP.readdir).mockClear(); + expect( + (yield* workspaceEntries.browse({ cwd, partialPath: "./wor/zzzzz", fuzzy: true })) + .entries, + ).toEqual([]); + expect( + vi + .mocked(NodeFSP.readdir) + .mock.calls.some(([directory]) => directory === path.dirname(cwd)), + ).toBe(false); + expect(NodeFSP.readdir).toHaveBeenCalledTimes(2); + }), + ); + + it.effect("continues through fuzzy parents when an exact abbreviation is a dead end", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const path = yield* Path.Path; + const cwd = yield* makeTempDir(); + yield* writeTextFile(cwd, "wor/unrelated/index.ts"); + yield* writeTextFile(cwd, "Workspace/makespace/index.ts"); + const result = yield* workspaceEntries.browse({ + cwd, + partialPath: "./wor/mak", + fuzzy: true, + }); + expect(result.entries).toEqual([ + { name: "makespace", fullPath: path.join(cwd, "Workspace/makespace") }, + ]); + }), + ); + + it.effect("backs up past an existing parent when a later abbreviated level has no match", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const path = yield* Path.Path; + const cwd = yield* makeTempDir(); + yield* writeTextFile(cwd, "wor/projects/unrelated/index.ts"); + yield* writeTextFile(cwd, "Workspace/projects/makespace/index.ts"); + const result = yield* workspaceEntries.browse({ + cwd, + partialPath: "./wor/prj/mak", + fuzzy: true, + }); + expect(result.entries).toEqual([ + { name: "makespace", fullPath: path.join(cwd, "Workspace/projects/makespace") }, + ]); + }), + ); + + it.effect("keeps empty exact directories and exact paths with matching children", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* makeTempDir(); + yield* fileSystem.makeDirectory(path.join(cwd, "wor")); + yield* writeTextFile(cwd, "Workspace/makespace/index.ts"); + expect(yield* workspaceEntries.browse({ cwd, partialPath: "./wor/", fuzzy: true })).toEqual( + { parentPath: path.join(cwd, "wor"), entries: [] }, + ); + yield* writeTextFile(cwd, "wor/makers/index.ts"); + vi.mocked(NodeFSP.readdir).mockClear(); + expect( + (yield* workspaceEntries.browse({ cwd, partialPath: "./wor/mak", fuzzy: true })).entries, + ).toEqual([{ name: "makers", fullPath: path.join(cwd, "wor/makers") }]); + expect(NodeFSP.readdir).toHaveBeenCalledTimes(1); + }), + ); + + it.effect("resolves abbreviations and typos across multiple parent directories", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const path = yield* Path.Path; + const cwd = yield* makeTempDir(); + yield* writeTextFile(cwd, "Workspace/projects/t3code/index.ts"); + yield* writeTextFile(cwd, "Downloads/unrelated/index.ts"); + + for (const partialPath of ["./wrk/prj/t3cd", "./workspcae/projcts/t3cdoe"]) { + const result = yield* workspaceEntries.browse({ cwd, partialPath, fuzzy: true }); + expect(result).toEqual({ + parentPath: path.join(cwd, "Workspace/projects"), + entries: [{ name: "t3code", fullPath: path.join(cwd, "Workspace/projects/t3code") }], + }); + } + expect( + vi + .mocked(NodeFSP.readdir) + .mock.calls.some(([directory]) => directory === path.join(cwd, "Downloads")), + ).toBe(false); + }), + ); + + it.effect("preserves equally named results from ambiguous parent directories", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const path = yield* Path.Path; + const cwd = yield* makeTempDir(); + yield* writeTextFile(cwd, "work/t3code/index.ts"); + yield* writeTextFile(cwd, "Workspace/t3code/index.ts"); + const result = yield* workspaceEntries.browse({ + cwd, + partialPath: "./wrk/t3", + fuzzy: true, + }); + expect(result.entries).toEqual([ + { name: "t3code", fullPath: path.join(cwd, "work/t3code") }, + { name: "t3code", fullPath: path.join(cwd, "Workspace/t3code") }, + ]); + }), + ); + + it.effect("keeps exact directories on the single-listing path", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const path = yield* Path.Path; + const cwd = yield* makeTempDir(); + yield* writeTextFile(cwd, "work/t3code/index.ts"); + yield* writeTextFile(cwd, "Workspace/another/index.ts"); + vi.mocked(NodeFSP.readdir).mockClear(); + const result = yield* workspaceEntries.browse({ cwd, partialPath: "./work/", fuzzy: true }); + expect(result.entries).toEqual([ + { name: "t3code", fullPath: path.join(cwd, "work/t3code") }, + ]); + expect(NodeFSP.readdir).toHaveBeenCalledTimes(1); + }), + ); + + it.effect( + "requires an explicit dot to traverse hidden folders and preserves exact-path errors", + () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const path = yield* Path.Path; + const cwd = yield* makeTempDir(); + yield* writeTextFile(cwd, ".config/projects/index.ts"); + expect( + (yield* workspaceEntries.browse({ cwd, partialPath: "./cfg/", fuzzy: true })).entries, + ).toEqual([]); + expect( + (yield* workspaceEntries.browse({ cwd, partialPath: "./.cfg/", fuzzy: true })).entries, + ).toEqual([{ name: "projects", fullPath: path.join(cwd, ".config/projects") }]); + const error = yield* workspaceEntries + .browse({ cwd, partialPath: "./cfg/" }) + .pipe(Effect.flip); + expect(error._tag).toBe("WorkspaceEntriesReadDirectoryError"); + }), + ); + + it.effect("bounds ambiguous searches instead of scanning every matching subtree", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const cwd = yield* makeTempDir(); + for (let index = 0; index < 40; index += 1) { + yield* writeTextFile(cwd, `workspace-${index}/project/index.ts`); + } + vi.mocked(NodeFSP.readdir).mockClear(); + const result = yield* workspaceEntries.browse({ cwd, partialPath: "./wrk/", fuzzy: true }); + expect(result.entries).toHaveLength(20); + expect(NodeFSP.readdir).toHaveBeenCalledTimes(22); + }), + ); + it.effect("returns matching directories and excludes files", () => Effect.gen(function* () { const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index d575f944d885..35c51eede766 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -1,5 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeFSP from "node:fs/promises"; +import type * as NodeFS from "node:fs"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -21,7 +22,7 @@ import type { } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; -import { normalizeSearchQuery } from "@t3tools/shared/searchRanking"; +import { normalizeSearchQuery, scoreDirectoryMatch } from "@t3tools/shared/searchRanking"; import { expandHomePathWith } from "../pathExpansion.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; @@ -183,52 +184,238 @@ export const make = Effect.gen(function* () { }, ); + const readBrowseDirectory = Effect.fn("WorkspaceEntries.readBrowseDirectory")(function* ( + parentPath: string, + input: FilesystemBrowseInput, + ) { + return yield* Effect.tryPromise({ + try: () => NodeFSP.readdir(parentPath, { withFileTypes: true }), + catch: (cause) => + new WorkspaceEntriesReadDirectoryError({ + cwd: input.cwd, + partialPath: input.partialPath, + parentPath, + cause, + }), + }).pipe( + Effect.catchIf( + (error) => { + const code = (error.cause as NodeJS.ErrnoException | undefined)?.code; + return code === "EACCES" || code === "EPERM"; + }, + () => Effect.succeed([]), + ), + ); + }); + const browse: WorkspaceEntries["Service"]["browse"] = Effect.fn("WorkspaceEntries.browse")( function* (input) { const resolvedInputPath = yield* resolveBrowseTarget(input, path); const endsWithSeparator = /[\\/]$/.test(input.partialPath) || input.partialPath === "~"; const parentPath = endsWithSeparator ? resolvedInputPath : path.dirname(resolvedInputPath); const prefix = endsWithSeparator ? "" : path.basename(resolvedInputPath); + const searchRoot = + input.partialPath.startsWith("~/") || input.partialPath === "~" + ? path.resolve(expandHomePathWith("~", path)) + : isExplicitRelativePath(input.partialPath) && input.cwd + ? path.resolve( + expandHomePathWith(input.cwd, path), + input.partialPath.match(/^(?:\.\.?[\\/])+/)?.[0] ?? ".", + ) + : path.parse(parentPath).root; + let anchorPath = parentPath; + const missingSegments: string[] = []; + let remainingReads = 128; + const listings = new Map | undefined>(); + const loadDirectory = Effect.fn(function* (directoryPath: string) { + if (listings.has(directoryPath)) return listings.get(directoryPath); + if (remainingReads <= 0) return undefined; + remainingReads -= 1; + const dirents = yield* readBrowseDirectory(directoryPath, input).pipe( + Effect.catchIf( + (error) => { + const code = (error.cause as NodeJS.ErrnoException | undefined)?.code; + return input.fuzzy === true && (code === "ENOENT" || code === "ENOTDIR"); + }, + () => Effect.succeed(undefined), + ), + ); + listings.set(directoryPath, dirents); + return dirents; + }); - const dirents = yield* Effect.tryPromise({ - try: () => NodeFSP.readdir(parentPath, { withFileTypes: true }), - catch: (cause) => - new WorkspaceEntriesReadDirectoryError({ - cwd: input.cwd, - partialPath: input.partialPath, - parentPath, - cause, - }), - }).pipe( - Effect.catchIf( - (error) => { - const code = (error.cause as NodeJS.ErrnoException | undefined)?.code; - return code === "EACCES" || code === "EPERM"; - }, - () => Effect.succeed([]), - ), - ); + // An existing abbreviation can be a dead end ("wor/mak" when "wor" is + // empty but "Workspace/makespace" exists). Widen the anchor until the + // whole query matches, retaining the one-listing path for exact hits. + // Listings are reused when widening; work stays bounded along typed paths. + while (true) { + const dirents = yield* loadDirectory(anchorPath); + let directories = + dirents === undefined ? [] : [{ fullPath: anchorPath, dirents, score: 0 }]; + for (const segment of missingSegments) { + const candidates = directories + .flatMap((directory) => + directory.dirents.flatMap((entry) => { + if ( + !entry.isDirectory() || + (entry.name.startsWith(".") && !segment.startsWith(".")) + ) + return []; + const score = scoreDirectoryMatch(entry.name, segment); + return score === null + ? [] + : [ + { + fullPath: path.join(directory.fullPath, entry.name), + score: directory.score + score, + }, + ]; + }), + ) + .sort( + (left, right) => + left.score - right.score || left.fullPath.localeCompare(right.fullPath), + ) + .slice(0, 20); + const results = yield* Effect.forEach( + candidates, + Effect.fn(function* (candidate) { + const children = yield* loadDirectory(candidate.fullPath).pipe( + Effect.orElseSucceed(() => undefined), + ); + return children === undefined ? [] : [{ ...candidate, dirents: children }]; + }), + { concurrency: 4 }, + ); + directories = results.flat(); + if (directories.length === 0) break; + } + + const showHidden = endsWithSeparator || prefix.startsWith("."); + const lowerPrefix = prefix.toLowerCase(); + const entries: Array<{ + readonly name: string; + readonly fullPath: string; + readonly score: number; + readonly searchMatch?: { readonly query: string; readonly score: number }; + }> = []; + for (const directory of directories) { + for (const dirent of directory.dirents) { + if (!dirent.isDirectory() || (!showHidden && dirent.name.startsWith("."))) continue; + const score = input.fuzzy + ? scoreDirectoryMatch(dirent.name, prefix) + : dirent.name.toLowerCase().startsWith(lowerPrefix) + ? 0 + : null; + if (score !== null) + entries.push({ + name: dirent.name, + fullPath: path.join(directory.fullPath, dirent.name), + score: directory.score + score, + }); + } + } - const showHidden = endsWithSeparator || prefix.startsWith("."); - const lowerPrefix = prefix.toLowerCase(); - const entries: Array<{ readonly name: string; readonly fullPath: string }> = []; - for (const dirent of dirents) { + // Split a compact query across directory names ("wormak" -> + // "Workspace/makespace"). Each visited level consumes query characters; + // unrelated branches and symlinks are never recursively crawled. if ( - dirent.isDirectory() && - dirent.name.toLowerCase().startsWith(lowerPrefix) && - (showHidden || !dirent.name.startsWith(".")) + input.fuzzy && + prefix.length >= 2 && + !entries.some((entry) => entry.name.toLowerCase() === lowerPrefix) && + (entries.length === 0 || prefix.length >= 4) ) { - entries.push({ - name: dirent.name, - fullPath: path.join(parentPath, dirent.name), - }); + let nodes = directories.map((directory) => ({ ...directory, rest: prefix })); + for (let depth = 0; depth < 6 && nodes.length > 0; depth += 1) { + const candidates: Array<{ fullPath: string; rest: string; score: number }> = []; + for (const node of nodes) { + for (const child of node.dirents) { + if ( + !child.isDirectory() || + (child.name.startsWith(".") && !node.rest.startsWith(".")) + ) + continue; + const fullPath = path.join(node.fullPath, child.name); + if (depth > 0) { + const leafScore = scoreDirectoryMatch(child.name, node.rest); + if (leafScore !== null) { + const score = 5_000 + node.score + leafScore; + entries.push({ + name: child.name, + fullPath, + score, + searchMatch: { query: prefix, score }, + }); + } + } + if (depth === 5 || remainingReads <= 0) continue; + for ( + let split = 1; + split < Math.min(node.rest.length, child.name.length + 2); + split += 1 + ) { + const score = scoreDirectoryMatch(child.name, node.rest.slice(0, split)); + if (score !== null) + candidates.push({ + fullPath, + rest: node.rest.slice(split), + score: node.score + score, + }); + } + } + } + candidates.sort( + (left, right) => + left.score - right.score || left.fullPath.localeCompare(right.fullPath), + ); + const uniqueCandidates = new Map(); + for (const candidate of candidates) { + const key = `${candidate.fullPath}\0${candidate.rest}`; + if (!uniqueCandidates.has(key)) uniqueCandidates.set(key, candidate); + if (uniqueCandidates.size === 20) break; + } + const bestCandidates = [...uniqueCandidates.values()]; + yield* Effect.forEach( + [...new Set(bestCandidates.map((candidate) => candidate.fullPath))], + (directoryPath) => + loadDirectory(directoryPath).pipe(Effect.orElseSucceed(() => undefined)), + { concurrency: 4 }, + ); + nodes = bestCandidates.flatMap((candidate) => { + const children = listings.get(candidate.fullPath); + return children === undefined ? [] : [{ ...candidate, dirents: children }]; + }); + } } - } - return { - parentPath, - entries: entries.toSorted((left, right) => left.name.localeCompare(right.name)), - }; + if (!input.fuzzy || entries.length > 0 || (prefix.length === 0 && directories.length > 0)) { + const rankedEntries = entries.sort( + (left, right) => left.score - right.score || left.name.localeCompare(right.name), + ); + const uniqueEntries = new Map(); + for (const entry of rankedEntries) + if (!uniqueEntries.has(entry.fullPath)) uniqueEntries.set(entry.fullPath, entry); + return { + parentPath: directories.length === 1 ? directories[0]!.fullPath : parentPath, + entries: [...uniqueEntries.values()].map(({ name, fullPath, searchMatch }) => ({ + name, + fullPath, + ...(searchMatch ? { searchMatch } : {}), + })), + }; + } + const nextAnchor = path.dirname(anchorPath); + if ( + anchorPath === searchRoot || + nextAnchor === anchorPath || + missingSegments.length >= 32 || + remainingReads <= 0 + ) { + return { parentPath, entries: [] }; + } + missingSegments.unshift(path.basename(anchorPath)); + anchorPath = nextAnchor; + } }, ); diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 25896baf7555..282a3dc989c6 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -652,7 +652,7 @@ describe("buildBrowseGroups", () => { }); await Promise.resolve(); - expect(browseTo).toHaveBeenCalledWith("Downloads"); + expect(browseTo).toHaveBeenCalledWith("Downloads", "/Users/test/Downloads"); expect(actionSettled).toBe(false); finishNavigation?.(); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 57c1711de158..5ece542ceade 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -449,7 +449,7 @@ export function buildBrowseGroups(input: { upIcon: ReactNode; directoryIcon: ReactNode; browseUp: () => void | Promise; - browseTo: (name: string) => void | Promise; + browseTo: (name: string, fullPath: string) => void | Promise; }): CommandPaletteGroup[] { const items: CommandPaletteActionItem[] = []; @@ -473,10 +473,11 @@ export function buildBrowseGroups(input: { value: `browse:${entry.fullPath}`, searchTerms: [input.browseQuery, entry.fullPath, entry.name], title: entry.name, + description: entry.fullPath, icon: input.directoryIcon, keepOpen: true, run: async () => { - await input.browseTo(entry.name); + await input.browseTo(entry.name, entry.fullPath); }, }); } @@ -498,7 +499,9 @@ export function filterPinnedBrowseEntries(input: { const { visibleEntries } = filterFilesystemBrowseEntries(input.browseEntries, visibleFilterQuery); const exactEntry = input.filterQuery.length > 0 - ? (input.browseEntries.find((entry) => namesMatch(entry.name, input.filterQuery)) ?? null) + ? (input.browseEntries.find( + (entry) => !entry.searchMatch && namesMatch(entry.name, input.filterQuery), + ) ?? null) : null; return { visibleEntries, exactEntry }; } diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index a8d1e57e5172..aef4c8f2f1fc 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -88,7 +88,6 @@ import { useProjects, useServerConfigs, useThreadShells, waitForProject } from " import { useThreadSearch } from "../state/queries"; import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; import { - appendBrowsePathSegment, ensureBrowseDirectoryPath, findProjectByPath, getBrowseDirectoryPath, @@ -961,10 +960,21 @@ function OpenCommandPaletteDialog(props: { query, browseEnvironmentPlatform, browseEnvironmentId !== null && !isRemoteProjectRepositoryStep, + isRemoteProjectCloneFlow ? "" : (currentView?.initialQuery ?? ""), ), - [browseEnvironmentId, browseEnvironmentPlatform, isRemoteProjectRepositoryStep, query], + [ + browseEnvironmentId, + browseEnvironmentPlatform, + isRemoteProjectRepositoryStep, + isRemoteProjectCloneFlow, + currentView?.initialQuery, + query, + ], ); const isBrowsing = browsePath.isBrowsing; + const browseInputPath = isBrowsing + ? `${browsePath.directoryPath}${browsePath.filterQuery}` + : query.trim(); const browseDirectoryPath = browsePath.directoryPath; const paletteMode = getCommandPaletteMode({ currentView, isBrowsing }); const getAddProjectInitialQueryForEnvironment = useCallback( @@ -1014,6 +1024,11 @@ function OpenCommandPaletteDialog(props: { ); const relativePathNeedsActiveProject = isExplicitRelativeProjectPath(query.trim()) && currentProjectCwdForBrowse === null; + const isPinnedBrowseQuery = + pinnedCloneDirectoryName.length > 0 && + (isWindowsPlatform(browseEnvironmentPlatform) + ? browsePath.filterQuery.toLowerCase() === pinnedCloneDirectoryName.toLowerCase() + : browsePath.filterQuery === pinnedCloneDirectoryName); const browseQuery = useEnvironmentQuery( isBrowsing && browsePath.directoryPath.length > 0 && @@ -1022,7 +1037,8 @@ function OpenCommandPaletteDialog(props: { ? filesystemEnvironment.browse({ environmentId: browseEnvironmentId, input: { - partialPath: browsePath.directoryPath, + partialPath: isPinnedBrowseQuery ? browsePath.directoryPath : browseInputPath, + fuzzy: true, ...(currentProjectCwdForBrowse ? { cwd: currentProjectCwdForBrowse } : {}), }, }) @@ -1064,6 +1080,7 @@ function OpenCommandPaletteDialog(props: { environmentId, input: { partialPath, + fuzzy: true, ...(cwd ? { cwd } : {}), }, }); @@ -2278,15 +2295,15 @@ function OpenCommandPaletteDialog(props: { } const browseTo = useCallback( - async (name: string): Promise => { + async (name: string, fullPath: string): Promise => { const nextQuery = pinnedCloneDirectoryName ? getCloneDestinationBrowsePath({ - browseDirectoryPath: browsePath.directoryPath, + browseDirectoryPath: getBrowseDirectoryPath(fullPath), selectedDirectoryName: name, cloneDirectoryName: pinnedCloneDirectoryName, caseSensitive: !isWindowsPlatform(browseEnvironmentPlatform), }) - : appendBrowsePathSegment(query, name); + : ensureBrowseDirectoryPath(fullPath); await browseNavigation.run( () => prefetchBrowsePath(getBrowseDirectoryPath(nextQuery)), () => { @@ -2296,14 +2313,7 @@ function OpenCommandPaletteDialog(props: { }, ); }, - [ - browseNavigation, - browseEnvironmentPlatform, - browsePath.directoryPath, - pinnedCloneDirectoryName, - prefetchBrowsePath, - query, - ], + [browseNavigation, browseEnvironmentPlatform, pinnedCloneDirectoryName, prefetchBrowsePath], ); const browseUp = useCallback(async (): Promise => { @@ -2326,10 +2336,10 @@ function OpenCommandPaletteDialog(props: { // Resolve the add-project path from browse data when available. When the // query has a trailing separator (e.g. "~/projects/foo/"), parentPath is the // directory itself. Otherwise the user typed a partial leaf name, so we need - // the exact browse entry's fullPath or fall back to the raw query. + // the exact browse entry's fullPath or the input resolved against the picker base. const resolvedAddProjectPath = hasTrailingPathSeparator(query) - ? (browseResult?.parentPath ?? query.trim()) - : (exactBrowseEntry?.fullPath ?? query.trim()); + ? (browseResult?.parentPath ?? browseInputPath) + : (exactBrowseEntry?.fullPath ?? browseInputPath); const canBrowseUp = !relativePathNeedsActiveProject && browsePath.canBrowseUp; @@ -2480,6 +2490,25 @@ function OpenCommandPaletteDialog(props: { return; } + if ( + isBrowsing && + event.key === "Tab" && + !event.shiftKey && + !event.metaKey && + !event.ctrlKey && + !event.altKey + ) { + const entry = + visibleBrowseEntries.find( + (candidate) => `browse:${candidate.fullPath}` === highlightedItemValue, + ) ?? visibleBrowseEntries[0]; + if (entry && !isBrowsePending) { + event.preventDefault(); + void browseTo(entry.name, entry.fullPath); + return; + } + } + const shouldSubmitBrowsePath = canSubmitBrowsePath && event.key === "Enter" && @@ -2736,6 +2765,7 @@ function OpenCommandPaletteDialog(props: { aria-label="Command palette" autoHighlight={isBrowsing || isRemoteProjectCloneFlow ? false : "always"} footerActionLabel={footerActionLabel} + showCompletionHint={isBrowsing} footerTrailing={footerTrailing} inputAccessory={inputAccessory} inputProps={{ diff --git a/apps/web/src/components/CommandPaletteContent.tsx b/apps/web/src/components/CommandPaletteContent.tsx index 8c1a5b0e3c83..6c89620cdb7f 100644 --- a/apps/web/src/components/CommandPaletteContent.tsx +++ b/apps/web/src/components/CommandPaletteContent.tsx @@ -13,6 +13,7 @@ type CommandPaletteContentProps = Omit, "children readonly inputProps: ComponentProps; readonly panelClassName?: string; readonly showBackHint?: boolean; + readonly showCompletionHint?: boolean; readonly testId?: string; }; @@ -30,6 +31,7 @@ export function CommandPaletteContent({ inputProps, panelClassName, showBackHint, + showCompletionHint, testId, ...commandProps }: CommandPaletteContentProps) { @@ -73,6 +75,12 @@ export function CommandPaletteContent({ Back ) : null} + {showCompletionHint ? ( + + Tab + Complete path + + ) : null} Esc {escapeLabel} diff --git a/packages/client-runtime/src/state/filesystem.test.ts b/packages/client-runtime/src/state/filesystem.test.ts index 44e3df6ab267..0fc39b22cd7e 100644 --- a/packages/client-runtime/src/state/filesystem.test.ts +++ b/packages/client-runtime/src/state/filesystem.test.ts @@ -8,6 +8,45 @@ import { } from "./filesystem.ts"; describe("filesystem browse model", () => { + it("retains server matches spanning folders without confusing them with exact leaf names", () => { + const match = { + name: "makespace", + fullPath: "/Workspace/makespace", + searchMatch: { query: "wormak", score: 5200 }, + }; + expect(filterFilesystemBrowseEntries([match], "wormak")).toEqual({ + visibleEntries: [match], + exactEntry: null, + }); + expect(filterFilesystemBrowseEntries([match], "different").visibleEntries).toEqual([]); + const literal = { name: "wormak", fullPath: "/wormak" }; + expect(filterFilesystemBrowseEntries([match, literal], "wormak")).toEqual({ + visibleEntries: [literal, match], + exactEntry: literal, + }); + }); + + it("accepts unrooted search fragments only inside a folder picker with a base", () => { + expect(getFilesystemBrowsePath("wor/mak").isBrowsing).toBe(false); + expect(getFilesystemBrowsePath("wor/mak", "", true, "~/")).toEqual({ + isBrowsing: true, + directoryPath: "~/wor/", + filterQuery: "mak", + parentPath: "~/", + canBrowseUp: true, + }); + expect(getFilesystemBrowsePath("my-project", "", true, "/projects").directoryPath).toBe( + "/projects/", + ); + expect(getFilesystemBrowsePath("/absolute/path", "", true, "~/").directoryPath).toBe( + "/absolute/", + ); + expect(getFilesystemBrowsePath("3D Scan", "", true, "~/").filterQuery).toBe("3D Scan"); + expect(getFilesystemBrowsePath("C:\\Users\\test", "MacIntel", true, "~/").isBrowsing).toBe( + false, + ); + }); + it("derives the browse target and navigation state", () => { expect(getFilesystemBrowsePath("~/projects/t3")).toEqual({ isBrowsing: true, @@ -35,6 +74,31 @@ describe("filesystem browse model", () => { expect(filterFilesystemBrowseEntries(entries, ".").visibleEntries).toEqual(entries.slice(0, 1)); expect(filterFilesystemBrowseEntries(entries, "Code").exactEntry).toEqual(entries[1]); }); + + it("ranks exact names before prefixes, path words, substrings, and abbreviations", () => { + const entries = ["t3-cool-dev", "myt3code", "my-t3code", "t3code-next", "t3code"].map( + (name) => ({ name, fullPath: `/projects/${name}` }), + ); + expect( + filterFilesystemBrowseEntries(entries, "t3code").visibleEntries.map((entry) => entry.name), + ).toEqual(["t3code", "t3code-next", "my-t3code", "myt3code", "t3-cool-dev"]); + expect(filterFilesystemBrowseEntries(entries, "T3CD").visibleEntries[0]?.name).toBe("t3code"); + }); + + it("finds mistyped folders without treating a suggestion as an exact path", () => { + const entries = ["Downloads", "Workspace", ".workspace"].map((name) => ({ + name, + fullPath: `/Users/test/${name}`, + })); + for (const query of ["wrkspc", "workspcae", "workspaxe", "worksspace"]) { + expect(filterFilesystemBrowseEntries(entries, query)).toEqual({ + visibleEntries: [entries[1]], + exactEntry: null, + }); + } + expect(filterFilesystemBrowseEntries(entries, "zzz").visibleEntries).toEqual([]); + expect(filterFilesystemBrowseEntries(entries, ".wrk").visibleEntries).toEqual([entries[2]]); + }); }); describe("browse navigation", () => { diff --git a/packages/client-runtime/src/state/filesystem.ts b/packages/client-runtime/src/state/filesystem.ts index 794dc404147d..b19cb694031b 100644 --- a/packages/client-runtime/src/state/filesystem.ts +++ b/packages/client-runtime/src/state/filesystem.ts @@ -1,19 +1,35 @@ import { type FilesystemBrowseEntry, WS_METHODS } from "@t3tools/contracts"; +import { scoreDirectoryMatch } from "@t3tools/shared/searchRanking"; import { Atom } from "effect/unstable/reactivity"; import type { EnvironmentConnectionPhase } from "../connection/presentation.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; import { canNavigateUp, + ensureBrowseDirectoryPath, getBrowseDirectoryPath, getBrowseLeafPathSegment, getBrowseParentPath, hasTrailingPathSeparator, isFilesystemBrowseQuery, + isUnsupportedWindowsProjectPath, } from "./projects.ts"; import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; -export function getFilesystemBrowsePath(query: string, platform = "", enabled = true) { +export function getFilesystemBrowsePath( + query: string, + platform = "", + enabled = true, + baseDirectory = "", +) { + if ( + baseDirectory && + query && + !isFilesystemBrowseQuery(query, platform) && + !isUnsupportedWindowsProjectPath(query, platform) + ) { + query = `${ensureBrowseDirectoryPath(baseDirectory)}${query}`; + } const isBrowsing = enabled && isFilesystemBrowseQuery(query, platform); const directoryPath = isBrowsing ? getBrowseDirectoryPath(query) : ""; const filterQuery = @@ -33,15 +49,22 @@ export function filterFilesystemBrowseEntries( entries: ReadonlyArray, query: string, ) { - const lowerQuery = query.toLowerCase(); const showHidden = query.startsWith("."); - const visibleEntries = entries.filter( - (entry) => - entry.name.toLowerCase().startsWith(lowerQuery) && - (showHidden || !entry.name.startsWith(".")), - ); + const visibleEntries = entries + .flatMap((entry) => { + if (!showHidden && entry.name.startsWith(".")) return []; + const score = + entry.searchMatch?.query === query + ? entry.searchMatch.score + : scoreDirectoryMatch(entry.name, query); + return score === null ? [] : [{ entry, score }]; + }) + .sort((left, right) => left.score - right.score) + .map(({ entry }) => entry); const exactEntry = - query.length > 0 ? (visibleEntries.find((entry) => entry.name === query) ?? null) : null; + query.length > 0 + ? (visibleEntries.find((entry) => !entry.searchMatch && entry.name === query) ?? null) + : null; return { visibleEntries, exactEntry }; } diff --git a/packages/contracts/src/filesystem.ts b/packages/contracts/src/filesystem.ts index 73815fd2c466..a02dc306ebc5 100644 --- a/packages/contracts/src/filesystem.ts +++ b/packages/contracts/src/filesystem.ts @@ -6,12 +6,18 @@ const FILESYSTEM_PATH_MAX_LENGTH = 512; export const FilesystemBrowseInput = Schema.Struct({ partialPath: TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH)), cwd: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH))), + /** Resolve abbreviated or mistyped directory names when the exact path has no matches. */ + fuzzy: Schema.optional(Schema.Boolean), }); export type FilesystemBrowseInput = typeof FilesystemBrowseInput.Type; export const FilesystemBrowseEntry = Schema.Struct({ name: TrimmedNonEmptyString, fullPath: TrimmedNonEmptyString, + /** A server-ranked match spanning multiple directory names. */ + searchMatch: Schema.optional( + Schema.Struct({ query: TrimmedNonEmptyString, score: Schema.Finite }), + ), }); export type FilesystemBrowseEntry = typeof FilesystemBrowseEntry.Type; diff --git a/packages/shared/src/searchRanking.ts b/packages/shared/src/searchRanking.ts index c8ec69e39703..30d7518f1e2b 100644 --- a/packages/shared/src/searchRanking.ts +++ b/packages/shared/src/searchRanking.ts @@ -51,6 +51,42 @@ export function scoreSubsequenceMatch(value: string, query: string): number | nu return null; } +/** Matches directory abbreviations, with one typo allowed in queries of at least four characters. */ +export function scoreDirectoryMatch(name: string, query: string): number | null { + const value = name.toLowerCase(); + const normalizedQuery = query.toLowerCase(); + if (!normalizedQuery) return 0; + + const score = scoreQueryMatch({ + value, + query: normalizedQuery, + exactBase: 0, + prefixBase: 100, + boundaryBase: 200, + includesBase: 300, + fuzzyBase: 1_000, + boundaryMarkers: [" ", "-", "_", "."], + }); + if (score !== null) return score; + if (normalizedQuery.length < 4) return null; + + // After the first mismatch, a single insertion, deletion, substitution or + // adjacent transposition must make the rest of the query match a prefix. + let index = 0; + while (index < normalizedQuery.length && value[index] === normalizedQuery[index]) index += 1; + const queryRest = normalizedQuery.slice(index + 1); + const valueRest = value.slice(index + 1); + const hasTypoMatch = + value.slice(index).startsWith(queryRest) || + valueRest.startsWith(normalizedQuery.slice(index)) || + valueRest.startsWith(queryRest) || + (value[index] === normalizedQuery[index + 1] && + value[index + 1] === normalizedQuery[index] && + value.slice(index + 2).startsWith(normalizedQuery.slice(index + 2))); + + return hasTypoMatch ? 10_000 + Math.abs(value.length - normalizedQuery.length) : null; +} + function lengthPenalty(value: string, query: string): number { return Math.min(64, Math.max(0, value.length - query.length)); }