From d216a7dd9c8824325e20c2f12b83c7fda97547fd Mon Sep 17 00:00:00 2001 From: Fabio Plunser Date: Sat, 7 Nov 2026 11:35:17 +0100 Subject: [PATCH 1/8] feat: fuzzy-search project folders across configured base folders Add Project's local folder browser only listed one directory level, so finding a project buried in ~/projects// meant typing the full path. Typing a bare name now fuzzy-matches nested folders across the new "Project search folders" setting (or the browsed folder when unset). The server scans each root breadth-first (skipping hidden folders, node_modules, vendor and symlinks, capped at 20k folders), caches the scan for 30s, and returns the top 100 subsequence matches. Web and mobile show a spinner with what is being searched while results load. Co-Authored-By: Claude Opus 5.5 --- .../features/projects/AddProjectScreen.tsx | 43 +++++++-- ...SettingsServerControlsRouteScreen.test.tsx | 2 +- .../SettingsServerControlsRouteScreen.tsx | 63 ++++++++++++- .../src/workspace/WorkspaceEntries.test.ts | 20 +++++ apps/server/src/workspace/WorkspaceEntries.ts | 59 +++++++++++- apps/web/src/components/CommandPalette.tsx | 90 ++++++++++++++++--- .../src/components/CommandPaletteResults.tsx | 7 +- .../components/settings/SettingsPanels.tsx | 34 +++++++ .../src/components/settings/settingsSearch.ts | 7 ++ docs/user/project-settings.md | 14 +++ .../src/state/filesystem.test.ts | 7 ++ .../client-runtime/src/state/filesystem.ts | 28 ++++-- packages/contracts/src/filesystem.ts | 7 ++ packages/contracts/src/settings.ts | 6 ++ 14 files changed, 355 insertions(+), 32 deletions(-) diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 93680aa38159..aa55502ca3f0 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -45,6 +45,7 @@ import { } from "@t3tools/client-runtime/state/filesystem"; import { appendBrowsePathSegment, + ensureBrowseDirectoryPath, inferProjectTitleFromPath, isWindowsPlatform, } from "@t3tools/client-runtime/state/projects"; @@ -113,6 +114,7 @@ interface EnvironmentOption { readonly platform: string; readonly machine: EnvironmentMachineKind; readonly baseDirectory: string | null; + readonly searchDirectories?: ReadonlyArray; readonly newProjectsRoot: string | null; readonly connectionState: EnvironmentConnectionPhase; readonly connectionError: string | null; @@ -408,6 +410,7 @@ function useEnvironmentOptions( platform: platformFromOs(config?.environment.platform.os ?? null), machine: resolveEnvironmentMachineKind(config ?? null), baseDirectory: config?.settings.addProjectBaseDirectory ?? null, + searchDirectories: config?.settings.projectSearchDirectories ?? [], newProjectsRoot: config?.newProjectsRoot ?? null, connectionState: runtime?.connectionState ?? "available", connectionError: runtime?.connectionError ?? null, @@ -926,9 +929,24 @@ function FolderBrowser(props: { readonly pinnedDirectoryName?: string; readonly cloning?: boolean; }) { - const browsePath = getFilesystemBrowsePath(props.pathInput, props.environment.platform); + const browsePath = getFilesystemBrowsePath( + props.pathInput, + props.environment.platform, + true, + props.cloning ? undefined : props.environment.baseDirectory || "~/", + ); const browseInput = - browsePath.directoryPath.length > 0 ? { partialPath: browsePath.directoryPath } : null; + browsePath.directoryPath.length > 0 + ? { + partialPath: browsePath.directoryPath, + ...(!props.cloning && browsePath.filterQuery && !browsePath.filterQuery.startsWith(".") + ? { + searchQuery: browsePath.filterQuery, + searchRoots: props.environment.searchDirectories ?? [], + } + : {}), + } + : null; const fileAccessSession = useEnvironmentQuery( environmentSession.sessionStateAtom(props.environment.environmentId), ); @@ -953,10 +971,10 @@ function FolderBrowser(props: { ? browsePath.filterQuery.toLowerCase() === pinnedDirectoryName.toLowerCase() : browsePath.filterQuery === pinnedDirectoryName; const browseFilterQuery = pinnedDirectoryMatches ? "" : browsePath.filterQuery; - const { visibleEntries: visibleBrowseEntries } = filterFilesystemBrowseEntries( - browseState.data?.entries ?? [], - browseFilterQuery, - ); + const visibleBrowseEntries = browseState.data?.recursive + ? browseState.data.entries + : filterFilesystemBrowseEntries(browseState.data?.entries ?? [], browseFilterQuery) + .visibleEntries; return ( <> @@ -976,8 +994,13 @@ function FolderBrowser(props: { )} {fileAccess.isPending || (browseState.isPending && browseState.data === null) ? ( - + + + {browseInput?.searchQuery + ? `Searching folders for “${browseInput.searchQuery}”…` + : "Loading folders…"} + ) : null} {browsePath.canBrowseUp ? ( @@ -1018,8 +1041,10 @@ function FolderBrowser(props: { right={null} onPress={() => { void props.navigateToBrowsePath({ - browseDirectoryPath: browsePath.directoryPath, - selectedDirectoryName: entry.name, + browseDirectoryPath: browseState.data?.recursive + ? ensureBrowseDirectoryPath(entry.fullPath) + : browsePath.directoryPath, + ...(browseState.data?.recursive ? {} : { selectedDirectoryName: entry.name }), }); }} /> diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx index 7d22b2e27eac..4c0052938976 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx @@ -69,7 +69,7 @@ vi.mock("../threads/thread-settings-options", () => ({ withManagedRuntimeModeLimits: (choices: readonly unknown[]) => choices, })); vi.mock("../../components/ui/ScreenScrollView", () => ({ ScreenScrollView: Children })); -vi.mock("../../components/ui/AppText", () => ({ AppText: Children })); +vi.mock("../../components/ui/AppText", () => ({ AppText: Children, AppTextInput: () => null })); vi.mock("../../components/ui/Button", () => ({ Button: ({ label, diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index 793e3ae75412..0bdee6229b63 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -3,7 +3,7 @@ import { SettingsRow } from "./components/SettingsRow"; import { AuthSettingsWriteScope } from "@t3tools/contracts"; import { readEnvironmentScope, useEnvironmentsWithScope } from "../../state/session"; import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView"; -import { AppText as Text } from "../../components/ui/AppText"; +import { AppText as Text, AppTextInput } from "../../components/ui/AppText"; import { type ResponseStreamingMode, type ServerSettings, @@ -398,6 +398,67 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { ) : null} + {props.page === "threads" ? ( + + {(["projectSearchDirectories", "addProjectBaseDirectory"] as const).map((key) => { + const saved = uniform(key); + const value = Array.isArray(saved) + ? saved.join("; ") + : typeof saved === "string" + ? saved + : ""; + const label = + key === "projectSearchDirectories" + ? "Project search folders" + : "Add project starts in"; + return ( + + {label} + + {key === "projectSearchDirectories" + ? "Search nested folders. Separate base paths with semicolons." + : "Leave empty to start in your home folder."} + + { + const text = event.nativeEvent.text.trim(); + if (text === value) return; + write( + key === "projectSearchDirectories" + ? { + projectSearchDirectories: [ + ...new Set( + text + .split(";") + .map((path) => path.trim()) + .filter(Boolean), + ), + ].slice(0, 32), + } + : { [key]: text }, + ); + }} + /> + + ); + })} + + ) : null} + {props.page === "source-control" ? ( <> {projectSelected ? null : ( diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index b0633dac04cc..9a822802a02c 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -792,6 +792,26 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }); describe("browse", () => { + it.effect("finds nested folders with fuzzy queries across roots", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const path = yield* Path.Path; + const cwd = yield* makeTempDir({ prefix: "t3code-folder-search-" }); + const other = yield* makeTempDir({ prefix: "t3code-folder-search-other-" }); + yield* writeTextFile(cwd, "team/hello-world/package.json", "{}"); + yield* writeTextFile(cwd, "node_modules/hello-world/package.json", "{}"); + yield* writeTextFile(other, "hello-web/package.json", "{}"); + const result = yield* workspaceEntries.browse({ + partialPath: `${cwd}${path.sep}`, + searchQuery: "hlw", + searchRoots: [cwd, other, cwd], + }); + expect(result.entries.map((entry) => entry.fullPath).toSorted()).toEqual( + [path.join(cwd, "team/hello-world"), path.join(other, "hello-web")].toSorted(), + ); + }), + ); + it.effect("shows dot directories in directory mode and hidden-prefix mode", () => 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 7b2a0aa0a7c7..56bb84cd1ae4 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -2,6 +2,7 @@ import * as NodeFSP from "node:fs/promises"; import * as Context from "effect/Context"; +import * as Cache from "effect/Cache"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; @@ -23,7 +24,7 @@ import type { } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; -import { normalizeSearchQuery } from "@t3tools/shared/searchRanking"; +import { scoreSubsequenceMatch, normalizeSearchQuery } from "@t3tools/shared/searchRanking"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as VcsProcess from "../vcs/VcsProcess.ts"; @@ -205,9 +206,65 @@ export const make = Effect.gen(function* () { }, ); + const folderIndex = yield* Cache.make({ + capacity: 32, + timeToLive: "30 seconds", + lookup: (root: string) => + Effect.promise(async () => { + const folders: string[] = []; + const queue = [root]; + // Bound scans of broad roots and never follow directory symlinks. + for (let index = 0; index < queue.length && index < 20_000; index += 1) { + const parent = queue[index]!; + const entries = await NodeFSP.readdir(parent, { withFileTypes: true }).catch(() => []); + for (const entry of entries) { + if ( + !entry.isDirectory() || + entry.name.startsWith(".") || + entry.name === "node_modules" || + entry.name === "vendor" || + queue.length >= 20_000 + ) + continue; + const fullPath = path.join(parent, entry.name); + folders.push(fullPath); + queue.push(fullPath); + } + } + return folders; + }), + }); + const browse: WorkspaceEntries["Service"]["browse"] = Effect.fn("WorkspaceEntries.browse")( function* (input) { const resolvedInputPath = yield* resolveBrowseTarget(input, path); + if (input.searchQuery) { + const roots = [ + ...new Set(input.searchRoots?.length ? input.searchRoots : [resolvedInputPath]), + ]; + const home = yield* HostProcess.HomeDirectory; + const folders = yield* Effect.forEach( + roots, + (root) => Cache.get(folderIndex, path.resolve(expandHomePath(root, home))), + { concurrency: 2 }, + ); + const query = input.searchQuery.toLowerCase(); + const ranked = [...new Set(folders.flat())] + .flatMap((fullPath) => { + const score = scoreSubsequenceMatch(path.basename(fullPath).toLowerCase(), query); + return score === null ? [] : [{ fullPath, score }]; + }) + .sort((a, b) => a.score - b.score || a.fullPath.localeCompare(b.fullPath)); + return { + parentPath: resolvedInputPath, + recursive: true, + entries: ranked.slice(0, 100).map(({ fullPath }) => ({ + name: path.relative(resolvedInputPath, fullPath), + fullPath, + })), + }; + } + const endsWithSeparator = /[\\/]$/.test(input.partialPath) || input.partialPath === "~"; const parentPath = endsWithSeparator ? resolvedInputPath : path.dirname(resolvedInputPath); const prefix = endsWithSeparator ? "" : path.basename(resolvedInputPath); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 7c0821678e51..94325915c915 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -253,6 +253,7 @@ import { primaryServerKeybindingsAtom } from "../state/server"; import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings"; import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "./ui/command"; import { Button } from "./ui/button"; +import { Spinner } from "./ui/spinner"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { suggestAppAccessForRemote } from "./sourceControlAppAccessToast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; @@ -652,7 +653,14 @@ function OpenCommandPaletteDialog(props: { : ""; const canBrowsePaths = browseEnvironmentId !== null && !isRemoteProjectRepositoryStep && newProjectFlow === null; - const browsePath = getFilesystemBrowsePath(query, browseEnvironmentPlatform, canBrowsePaths); + const browsePath = getFilesystemBrowsePath( + query, + browseEnvironmentPlatform, + canBrowsePaths, + addProjectEnvironmentId !== null && !isRemoteProjectCloneFlow + ? browseEnvironment?.serverConfig?.settings.addProjectBaseDirectory || "~/" + : undefined, + ); const isBrowsing = browsePath.isBrowsing; const browseDirectoryPath = browsePath.directoryPath; const paletteMode = getCommandPaletteMode({ currentView, isBrowsing }); @@ -712,12 +720,22 @@ function OpenCommandPaletteDialog(props: { browsePath.directoryPath.length > 0 && browseEnvironmentId !== null && !relativePathNeedsActiveProject; + const folderSearchRoots = + environments.find((environment) => environment.environmentId === browseEnvironmentId) + ?.serverConfig?.settings.projectSearchDirectories ?? []; + const folderSearchQuery = + !isRemoteProjectCloneFlow && !browsePath.filterQuery.startsWith(".") + ? browsePath.filterQuery + : ""; const browseQuery = useEnvironmentQuery( browseAccess.canReadFiles && hasBrowseTarget ? filesystemEnvironment.browse({ environmentId: browseEnvironmentId, input: { partialPath: browsePath.directoryPath, + ...(folderSearchQuery + ? { searchQuery: folderSearchQuery, searchRoots: folderSearchRoots } + : {}), ...(currentProjectCwdForBrowse ? { cwd: currentProjectCwdForBrowse } : {}), }, }) @@ -738,7 +756,15 @@ function OpenCommandPaletteDialog(props: { pinnedDirectoryName: pinnedCloneDirectoryName, caseSensitive: !isWindowsPlatform(browseEnvironmentPlatform), }) - : filterFilesystemBrowseEntries(browseEntries, browsePath.filterQuery); + : browseResult?.recursive + ? { + visibleEntries: browseEntries, + exactEntry: + browseEntries.find( + (entry) => entry.fullPath === query.trim() || entry.name === browsePath.filterQuery, + ) ?? null, + } + : filterFilesystemBrowseEntries(browseEntries, browsePath.filterQuery); const prefetchBrowsePath = async ( partialPath: string, @@ -934,8 +960,11 @@ function OpenCommandPaletteDialog(props: { if ( source instanceof HTMLInputElement && resolvedQuery === nextQuery && - (canBrowsePaths && isFilesystemBrowseQuery(resolvedQuery, browseEnvironmentPlatform)) !== - isBrowsing + (canBrowsePaths && + (isFilesystemBrowseQuery(resolvedQuery, browseEnvironmentPlatform) || + (addProjectEnvironmentId !== null && + !isRemoteProjectCloneFlow && + resolvedQuery.trim().length > 0))) !== isBrowsing ) { remountSelectionRef.current = { value: resolvedQuery, @@ -2181,7 +2210,11 @@ function OpenCommandPaletteDialog(props: { cloneDirectoryName: pinnedCloneDirectoryName, caseSensitive: !isWindowsPlatform(browseEnvironmentPlatform), }) - : appendBrowsePathSegment(query, name); + : browseResult?.recursive + ? ensureBrowseDirectoryPath( + browseEntries.find((entry) => entry.name === name)?.fullPath ?? name, + ) + : appendBrowsePathSegment(query, name); await browseNavigation.run( () => prefetchBrowsePath(getBrowseDirectoryPath(nextQuery)), () => { @@ -2393,6 +2426,7 @@ function OpenCommandPaletteDialog(props: { const isCloneDestinationStep = addProjectCloneFlow?.step === "confirm"; const canSubmitBrowsePath = isBrowsing && + (isFilesystemBrowseQuery(query, browseEnvironmentPlatform) || exactBrowseEntry !== null) && !relativePathNeedsActiveProject && canCreateProject && (!isCloneDestinationStep || canCloneProject) && @@ -2589,7 +2623,7 @@ function OpenCommandPaletteDialog(props: { : getFilesystemBrowseTabCompletion(visibleBrowseEntries, browsePath.filterQuery); if (completion?.isComplete) { void browseTo(completion.name); - } else if (completion && !pinnedCloneDirectoryName) { + } else if (completion && !pinnedCloneDirectoryName && !browseResult?.recursive) { setHighlightedItemValue(null); setQuery(`${getBrowseDirectoryPath(query)}${completion.name}`); } @@ -2905,10 +2939,8 @@ function OpenCommandPaletteDialog(props: {
{browseAccessError}
- ) : isBrowsePending && browseResult === null ? ( -
- Loading folders... -
+ ) : isBrowsePending && browseResult === null && resultRows.rows.length > 0 ? ( + ) : null} + ), + } + : isThreadSearchPending + ? { emptyStateMessage: "Searching thread messages…" } + : {})} /> ); } + +function FolderBrowseStatus(props: { searchQuery: string; searchRoots: ReadonlyArray }) { + const roots = props.searchRoots.length > 0 ? props.searchRoots.join(", ") : null; + return ( +
+ + + {props.searchQuery ? ( + <> + Searching folders for {props.searchQuery} + {roots ? ` in ${roots}` : ""}… + + ) : ( + "Loading folders…" + )} + +
+ ); +} diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index e3cc3a3791af..674421be4579 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -26,7 +26,7 @@ import { ProjectPinButton } from "./ProjectPinButton"; import { SearchMatchHintLine, ThreadSearchMatchExcerpt } from "./ThreadSearchMatch"; interface CommandPaletteResultsProps { - emptyStateMessage?: string; + emptyStateMessage?: ReactNode; groups: ReadonlyArray; highlightedItemValue?: string | null; isActionsOnly: boolean; @@ -34,7 +34,10 @@ interface CommandPaletteResultsProps { onExecuteItem: (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => void; } -function CommandPaletteEmptyState(props: { emptyStateMessage?: string; isActionsOnly: boolean }) { +function CommandPaletteEmptyState(props: { + emptyStateMessage?: ReactNode; + isActionsOnly: boolean; +}) { return (
{props.emptyStateMessage ?? diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index c53ca8a2dc71..db6c9053dda3 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -552,6 +552,7 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory ? ["Add project base directory"] : []), + ...(settings.projectSearchDirectories.length ? ["Project search folders"] : []), ...(settings.ghqCloneLayout !== DEFAULT_UNIFIED_SETTINGS.ghqCloneLayout ? ["Clone into host folders"] : []), @@ -668,6 +669,7 @@ export function useSettingsRestore(onRestored?: () => void) { defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, + projectSearchDirectories: DEFAULT_UNIFIED_SETTINGS.projectSearchDirectories, ghqCloneLayout: DEFAULT_UNIFIED_SETTINGS.ghqCloneLayout, confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, @@ -1552,12 +1554,44 @@ export function ThreadsSettingsPanel() { settings, textGenerationModelInstanceEntries, ); + const mixedProjectSearchDirectories = useScopedSettingsMixed(["projectSearchDirectories"]); const mixedAddProjectBaseDirectory = useScopedSettingsMixed(["addProjectBaseDirectory"]); const mixedTextGenerationModel = useScopedSettingsMixed(["textGenerationModelSelection"]); return ( + + updateSettings({ + projectSearchDirectories: [ + ...new Set( + next + .split(";") + .map((entry) => entry.trim()) + .filter(Boolean), + ), + ].slice(0, 32), + }) + } + placeholder={mixedProjectSearchDirectories ? "Mixed" : "~/projects; ~/work"} + spellCheck={false} + aria-label="Project search folders" + /> + } + /> + Threads > New projects, set Project search folders to the base folders +that contain your projects. Separate paths with semicolons. In Add Project's local +folder browser, type part of a folder name to find nested folders across those locations. +Letters can be skipped, so `hlw` finds `hello-world`. With no search folders configured, +search uses the folder you are browsing. + +Search skips hidden folders, `node_modules`, `vendor`, and symlinks. It scans up to +20,000 folders per root, returns the best 100 matches, and caches each scan for 30 seconds. +Use narrower base folders for large directory trees. + +Search folders belong to each environment, including when you connect remotely. diff --git a/packages/client-runtime/src/state/filesystem.test.ts b/packages/client-runtime/src/state/filesystem.test.ts index 6c21c5d92691..dc8a316c5798 100644 --- a/packages/client-runtime/src/state/filesystem.test.ts +++ b/packages/client-runtime/src/state/filesystem.test.ts @@ -77,6 +77,13 @@ describe("filesystem browse model", () => { parentPath: "~/", canBrowseUp: true, }); + expect(getFilesystemBrowsePath("hlw", "", true, "~/projects")).toEqual({ + isBrowsing: true, + directoryPath: "~/projects/", + filterQuery: "hlw", + parentPath: "~/", + canBrowseUp: true, + }); expect(getFilesystemBrowsePath("C:\\Users\\test", "MacIntel").isBrowsing).toBe(false); expect(getFilesystemBrowsePath("~/projects/", "", false).isBrowsing).toBe(false); }); diff --git a/packages/client-runtime/src/state/filesystem.ts b/packages/client-runtime/src/state/filesystem.ts index 4d423d0ed360..f82d6b01d8c5 100644 --- a/packages/client-runtime/src/state/filesystem.ts +++ b/packages/client-runtime/src/state/filesystem.ts @@ -15,6 +15,7 @@ import type { EnvironmentRegistry } from "../connection/registry.ts"; import { canNavigateUp, getBrowseDirectoryPath, + ensureBrowseDirectoryPath, getBrowseLeafPathSegment, getBrowseParentPath, hasTrailingPathSeparator, @@ -54,11 +55,28 @@ export function resolveFilesystemReadAccess(input: { }; } -export function getFilesystemBrowsePath(query: string, platform = "", enabled = true) { - const isBrowsing = enabled && isFilesystemBrowseQuery(query, platform); - const directoryPath = isBrowsing ? getBrowseDirectoryPath(query) : ""; - const filterQuery = - isBrowsing && !hasTrailingPathSeparator(query) ? getBrowseLeafPathSegment(query) : ""; +export function getFilesystemBrowsePath( + query: string, + platform = "", + enabled = true, + searchBaseDirectory?: string, +) { + const isSearch = + enabled && + searchBaseDirectory !== undefined && + query.trim().length > 0 && + !isFilesystemBrowseQuery(query, platform); + const isBrowsing = enabled && (isSearch || isFilesystemBrowseQuery(query, platform)); + const directoryPath = isSearch + ? ensureBrowseDirectoryPath(searchBaseDirectory || "~/") + : isBrowsing + ? getBrowseDirectoryPath(query) + : ""; + const filterQuery = isSearch + ? query.trim() + : isBrowsing && !hasTrailingPathSeparator(query) + ? getBrowseLeafPathSegment(query) + : ""; const parentPath = isBrowsing ? getBrowseParentPath(directoryPath) : null; return { diff --git a/packages/contracts/src/filesystem.ts b/packages/contracts/src/filesystem.ts index a1f6c293e90b..2b63cd455195 100644 --- a/packages/contracts/src/filesystem.ts +++ b/packages/contracts/src/filesystem.ts @@ -27,6 +27,12 @@ export const FilesystemGetMetadataResult = Schema.Struct({ export type FilesystemGetMetadataResult = typeof FilesystemGetMetadataResult.Type; export const FilesystemBrowseInput = Schema.Struct({ + searchQuery: Schema.optionalKey(TrimmedNonEmptyString.check(Schema.isMaxLength(256))), + searchRoots: Schema.optionalKey( + Schema.Array(TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH))).check( + Schema.isMaxLength(32), + ), + ), partialPath: TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH)), cwd: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH))), }); @@ -39,6 +45,7 @@ export const FilesystemBrowseEntry = Schema.Struct({ export type FilesystemBrowseEntry = typeof FilesystemBrowseEntry.Type; export const FilesystemBrowseResult = Schema.Struct({ + recursive: Schema.optionalKey(Schema.Boolean), parentPath: TrimmedNonEmptyString, entries: Schema.Array(FilesystemBrowseEntry), }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index b98ebe0fd7e1..cfd5f68be9aa 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1394,6 +1394,9 @@ export const ServerSettings = Schema.Struct({ worktreeSubmodules: ForwardCompatibleNullable(WorktreeSubmodules).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), + projectSearchDirectories: Schema.Array(TrimmedString) + .check(Schema.isMaxLength(32)) + .pipe(Schema.withDecodingDefault(Effect.succeed([]))), addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), /** Clones default to `///`, like ghq. */ ghqCloneLayout: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -1768,6 +1771,9 @@ export const ServerSettingsPatch = Schema.Struct({ defaultThreadEnvMode: Schema.optionalKey(Schema.NullOr(ThreadEnvMode)), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), worktreeSubmodules: Schema.optionalKey(Schema.NullOr(WorktreeSubmodules)), + projectSearchDirectories: Schema.optionalKey( + Schema.Array(TrimmedString).check(Schema.isMaxLength(32)), + ), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), ghqCloneLayout: Schema.optionalKey(Schema.Boolean), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), From 57f34c3466f692662226be33829611c8283c08a1 Mon Sep 17 00:00:00 2001 From: Fabio Plunser Date: Sun, 8 Nov 2026 13:13:02 +0100 Subject: [PATCH 2/8] feat(files): use fuzzy matching across file and folder searches --- .../features/files/ZipArchiveView.test.tsx | 7 +- apps/mobile/src/features/files/zipTree.ts | 4 +- .../src/workspace/WorkspaceEntries.test.ts | 4 ++ apps/server/src/workspace/WorkspaceEntries.ts | 13 ++-- .../files/FileBrowserPanel.browser.test.tsx | 26 +++++++ .../src/components/files/FileBrowserPanel.tsx | 71 ++++++++++++------- docs/user/project-settings.md | 3 + .../src/state/filesystem.test.ts | 17 +++++ .../client-runtime/src/state/filesystem.ts | 17 +++-- packages/shared/src/searchRanking.ts | 15 ++++ 10 files changed, 136 insertions(+), 41 deletions(-) diff --git a/apps/mobile/src/features/files/ZipArchiveView.test.tsx b/apps/mobile/src/features/files/ZipArchiveView.test.tsx index 5b9dc2406973..a2a03e31ea6c 100644 --- a/apps/mobile/src/features/files/ZipArchiveView.test.tsx +++ b/apps/mobile/src/features/files/ZipArchiveView.test.tsx @@ -259,10 +259,7 @@ it("keeps the ZIP filter and archive open when native Back closes a selected ent '[aria-label="Filter files in ZIP"]', )!; await act(async () => { - Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call( - filter, - "first", - ); + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(filter, "frst"); filter.dispatchEvent(new Event("input", { bubbles: true })); }); await act(async () => @@ -279,7 +276,7 @@ it("keeps the ZIP filter and archive open when native Back closes a selected ent expect(screen.container.querySelector('[aria-label="first.txt"]')).not.toBeNull(); expect( screen.container.querySelector('[aria-label="Filter files in ZIP"]')?.value, - ).toBe("first"); + ).toBe("frst"); }); it("explains why an encrypted ZIP cannot be browsed", async () => { diff --git a/apps/mobile/src/features/files/zipTree.ts b/apps/mobile/src/features/files/zipTree.ts index dcc9552c5c12..e3cc86841cf6 100644 --- a/apps/mobile/src/features/files/zipTree.ts +++ b/apps/mobile/src/features/files/zipTree.ts @@ -1,3 +1,5 @@ +import { scoreFilePathMatch } from "@t3tools/shared/searchRanking"; + export interface ZipEntry { readonly path: string; readonly name: string; @@ -89,7 +91,7 @@ function flatten( query: string, ): boolean { if (node.entry) { - if (query && !node.entry.path.toLowerCase().includes(query)) return false; + if (query && scoreFilePathMatch(node.entry.path, query) === null) return false; output.push({ node, depth }); return true; } diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 9a822802a02c..139d996c0b52 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -828,6 +828,10 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { partialPath: `${cwdWithSeparator}.c`, }); + const fuzzyResult = yield* workspaceEntries.browse({ + partialPath: `${cwdWithSeparator}cnfg`, + }); + expect(fuzzyResult.entries.map((entry) => entry.name)).toEqual(["config"]); expect(directoryResult.entries.map((entry) => entry.name)).toEqual([".config", "config"]); expect(hiddenPrefixResult).toEqual({ parentPath: cwd, diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index 56bb84cd1ae4..de3b822d9416 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -24,7 +24,7 @@ import type { } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; -import { scoreSubsequenceMatch, normalizeSearchQuery } from "@t3tools/shared/searchRanking"; +import { scoreFilePathMatch, normalizeSearchQuery } from "@t3tools/shared/searchRanking"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as VcsProcess from "../vcs/VcsProcess.ts"; @@ -251,7 +251,7 @@ export const make = Effect.gen(function* () { const query = input.searchQuery.toLowerCase(); const ranked = [...new Set(folders.flat())] .flatMap((fullPath) => { - const score = scoreSubsequenceMatch(path.basename(fullPath).toLowerCase(), query); + const score = scoreFilePathMatch(path.basename(fullPath), query); return score === null ? [] : [{ fullPath, score }]; }) .sort((a, b) => a.score - b.score || a.fullPath.localeCompare(b.fullPath)); @@ -294,7 +294,7 @@ export const make = Effect.gen(function* () { for (const dirent of dirents) { if ( dirent.isDirectory() && - dirent.name.toLowerCase().startsWith(lowerPrefix) && + scoreFilePathMatch(dirent.name, lowerPrefix) !== null && (showHidden || !dirent.name.startsWith(".")) ) { entries.push({ @@ -306,7 +306,12 @@ export const make = Effect.gen(function* () { return { parentPath, - entries: entries.toSorted((left, right) => left.name.localeCompare(right.name)), + entries: entries.toSorted( + (left, right) => + (scoreFilePathMatch(left.name, lowerPrefix) ?? Infinity) - + (scoreFilePathMatch(right.name, lowerPrefix) ?? Infinity) || + left.name.localeCompare(right.name), + ), }; }, ); diff --git a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx index 1c0e1bc914be..663a2c980a23 100644 --- a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx @@ -10,6 +10,7 @@ import FileBrowserPanel from "./FileBrowserPanel"; const entries: readonly ProjectEntry[] = [ { path: "src", kind: "directory" }, { path: "README.md", kind: "file" }, + { path: "src/FastFinder.tsx", kind: "file" }, ]; const loadingDirectories: ReadonlySet = new Set(); const directory = { @@ -116,3 +117,28 @@ describe("FileBrowserPanel", () => { await vi.waitFor(() => expect(onOpenFile).toHaveBeenCalledTimes(1)); }); }); + +it("finds a loaded file by subsequence in a host folder", async () => { + await using screen = await render( + {}} + workspaceMutationId={null} + hostFolder + />, + ); + await vi.waitFor(() => expect(findTreeRow("src/")).not.toBeNull()); + findTreeRow("src/")!.focus(); + await userEvent.keyboard("{ArrowRight}"); + await vi.waitFor(() => expect(findTreeRow("src/FastFinder.tsx")).not.toBeNull()); + const input = screen.getByRole("searchbox", { name: "Search repo files" }); + await userEvent.type(input, "rdm"); + await vi.waitFor(() => expect(findTreeRow("README.md")).not.toBeNull()); + await expect.element(screen.getByText("No matching files.")).not.toBeInTheDocument(); + await userEvent.clear(input); + await vi.waitFor(() => expect(findTreeRow("src/FastFinder.tsx")).not.toBeNull()); +}); diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index b02baf31b0ac..f41f5598b47f 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -1,10 +1,11 @@ +import { scoreFilePathMatch } from "@t3tools/shared/searchRanking"; import { RefreshIcon } from "~/components/ui/refresh-icon"; import type { ContextMenuItem as TreeContextMenuItem, ContextMenuOpenContext as TreeContextMenuOpenContext, } from "@pierre/trees"; import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; -import { FileTree, useFileTree, useFileTreeSearch, useFileTreeSelector } from "@pierre/trees/react"; +import { FileTree, useFileTree, useFileTreeSelector } from "@pierre/trees/react"; import { settlePromise } from "@t3tools/client-runtime/state/runtime"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import * as AsyncResult from "effect/reactivity/AsyncResult"; @@ -120,9 +121,31 @@ function buildTreeEntries( } } } - const entries = [...result.values()]; + const normalizedQuery = query.trim(); + const visiblePaths = new Set(); + if (normalizedQuery) { + for (const entry of result.values()) { + if (scoreFilePathMatch(entry.path, normalizedQuery) !== null) visiblePaths.add(entry.path); + } + if (!searchPending) { + for (const entry of searchEntries) visiblePaths.add(entry.path); + } + for (const matchedPath of visiblePaths) { + const segments = matchedPath.split("/"); + for (let index = 1; index < segments.length; index++) { + const parentPath = segments.slice(0, index).join("/"); + if (!parentPath) continue; + visiblePaths.add(parentPath); + if (!result.has(parentPath)) + result.set(parentPath, { path: parentPath, kind: "directory" }); + } + } + } + const entries = [...result.values()].filter( + (entry) => !normalizedQuery || visiblePaths.has(entry.path), + ); const entryKinds: ReadonlyMap = new Map( - entries.map((entry) => [entry.path, entry.kind] as const), + [...result.values()].map((entry) => [entry.path, entry.kind] as const), ); const treePaths = entries.map(treePath); const directories = entries.filter((entry) => entry.kind === "directory"); @@ -293,7 +316,9 @@ export default function FileBrowserPanel({ ? { icon: "t3-tree-icon-loading", title: "Loading…" } : null, search: false, - onSearchChange: (value) => setQuery(value ?? ""), + onSearchChange: (value) => { + if (value === null) setQuery(""); + }, unsafeCSS: PIERRE_TREE_UNSAFE_CSS, }); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- makes the Pierre tree model re-render row decorations when loading directories change @@ -302,9 +327,7 @@ export default function FileBrowserPanel({ loadingDirectoriesRef.current = loadingDirectories; model.setComposition(model.getComposition()); }, [loadingDirectories, model]); - const search = useFileTreeSearch(model); - // Pierre restores the unfiltered tree when a search has no matches. - const hasNoSearchMatches = search.value.trim().length > 0 && search.matchingPaths.length === 0; + const hasNoSearchMatches = query.trim().length > 0 && treePaths.length === 0; const allDirectoriesExpanded = useFileTreeSelector(model, (currentModel) => areAllDirectoriesExpanded(currentModel, expandableDirectoryPaths), ); @@ -319,7 +342,6 @@ export default function FileBrowserPanel({ }; const closeSearch = () => { setQuery(""); - search.close(); }; const expandedPathsRef = useRef(new Set()); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to the Pierre tree model to load directories as they are expanded @@ -366,14 +388,6 @@ export default function FileBrowserPanel({ controller.abort(); }; }, [load, selectedPath]); - const handleSearchValueChange = (value: string) => { - setQuery(value); - if (value.trim().length === 0) { - search.close(); - return; - } - search.setValue(value); - }; const handleRefresh = () => { refresh(); if (query.trim()) pathSearch.refresh(); @@ -395,13 +409,17 @@ export default function FileBrowserPanel({ entryKindsRef.current = entryKinds; const previousTreePaths = previousTreePathsRef.current; previousTreePathsRef.current = treePaths; + // Keep Pierre's expansion snapshot, while external matching owns the filtered paths. + if (query.trim() && !model.isSearchOpen()) model.openSearch(""); if (previousTreePaths === null) { model.resetPaths(treePaths); - return; + } else { + const updates = buildFileTreePathUpdates(previousTreePaths, treePaths); + if (updates.length > 0) model.batch(updates); } - const updates = buildFileTreePathUpdates(previousTreePaths, treePaths); - if (updates.length > 0) model.batch(updates); - }, [ready, entryKinds, model, treePaths]); + if (query.trim()) setAllDirectoriesExpanded(model, directoryPaths, true); + else model.closeSearch(); + }, [ready, entryKinds, model, treePaths, query, directoryPaths]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- expands every directory in the Pierre tree model while expand-all is on and no search is active useEffect(() => { @@ -431,7 +449,11 @@ export default function FileBrowserPanel({ } const selectedTreePath = selectedKind === "directory" ? `${selectedPath}/` : selectedPath; const selectedItem = model.getItem(selectedTreePath); - if (!selectedItem) return; + if (!selectedItem) { + // oxlint-disable-next-line react/set-state-in-effect -- clears filtering when an external reveal targets a row absent from the imperative tree model + if (query.trim()) setQuery(""); + return; + } // Reveal only external selections; tree selections are visible and re-revealing would close search. const selectedInTree = model @@ -447,7 +469,6 @@ export default function FileBrowserPanel({ syncingSelectionRef.current = true; setQuery(""); - model.closeSearch(); for (const path of model.getSelectedPaths()) { model.getItem(path)?.deselect(); } @@ -470,7 +491,7 @@ export default function FileBrowserPanel({ queueMicrotask(() => { syncingSelectionRef.current = false; }); - }, [entryKinds, model, selectedPath, selectedPathRevealId]); + }, [entryKinds, model, selectedPath, selectedPathRevealId, query]); return (
{directoryPaths.length > 0 && !hostFolder ? ( diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index a800c02c0c5e..b81bf7425c38 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -271,4 +271,7 @@ Search skips hidden folders, `node_modules`, `vendor`, and symlinks. It scans up 20,000 folders per root, returns the best 100 matches, and caches each scan for 30 seconds. Use narrower base folders for large directory trees. +Folder pickers, the Files tree, and mobile ZIP filters also accept fuzzy name queries. +Content searches keep their existing matching options. + Search folders belong to each environment, including when you connect remotely. diff --git a/packages/client-runtime/src/state/filesystem.test.ts b/packages/client-runtime/src/state/filesystem.test.ts index dc8a316c5798..415a1fbab585 100644 --- a/packages/client-runtime/src/state/filesystem.test.ts +++ b/packages/client-runtime/src/state/filesystem.test.ts @@ -3,6 +3,7 @@ import { AuthFilesystemReadScope, AuthOrchestrationReadScope } from "@t3tools/co import { createBrowseNavigationCoordinator, + filterFilesystemBrowseEntries, getFilesystemBrowsePath, getFilesystemBrowseTabCompletion, resolveFilesystemReadAccess, @@ -140,3 +141,19 @@ describe("browse navigation", () => { expect(commits).toEqual(["second"]); }); }); + +it("fuzzy-matches and ranks folder names for every folder browser", () => { + const entries = ["my-t3code", "t3code", "cotton", "T3Code-old", ".t3", "docs"].map((name) => ({ + name, + fullPath: `/code/${name}`, + })); + expect( + filterFilesystemBrowseEntries(entries, "tcd").visibleEntries.map((entry) => entry.name), + ).toEqual(["t3code", "T3Code-old", "my-t3code"]); + expect(filterFilesystemBrowseEntries(entries, "t3code").exactEntry?.name).toBe("t3code"); + expect( + filterFilesystemBrowseEntries(entries, "t3").visibleEntries.some( + (entry) => entry.name === ".t3", + ), + ).toBe(false); +}); diff --git a/packages/client-runtime/src/state/filesystem.ts b/packages/client-runtime/src/state/filesystem.ts index f82d6b01d8c5..3a29edde0b06 100644 --- a/packages/client-runtime/src/state/filesystem.ts +++ b/packages/client-runtime/src/state/filesystem.ts @@ -1,3 +1,4 @@ +import { scoreFilePathMatch } from "@t3tools/shared/searchRanking"; import { AuthFilesystemReadScope, type FilesystemBrowseEntry, @@ -92,13 +93,17 @@ 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 candidates = entries.filter((entry) => showHidden || !entry.name.startsWith(".")); + const visibleEntries = !query.trim() + ? candidates + : candidates + .flatMap((entry) => { + const score = scoreFilePathMatch(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; diff --git a/packages/shared/src/searchRanking.ts b/packages/shared/src/searchRanking.ts index 1b8d710fef73..af9862d175e5 100644 --- a/packages/shared/src/searchRanking.ts +++ b/packages/shared/src/searchRanking.ts @@ -195,3 +195,18 @@ export function insertRankedSearchResult( rankedEntries.splice(insertionIndex, 0, candidate); rankedEntries.pop(); } + +export function scoreFilePathMatch(value: string, query: string): number | null { + const normalizedQuery = normalizeSearchQuery(query); + if (!normalizedQuery) return 0; + return scoreQueryMatch({ + value: value.toLowerCase(), + query: normalizedQuery, + exactBase: 0, + prefixBase: 1000, + boundaryBase: 2000, + includesBase: 3000, + fuzzyBase: 4000, + boundaryMarkers: ["/", "\\", "-", "_", ".", " "], + }); +} From b2d6c8ba450c28bdef614b9ce37786fe0dba3956 Mon Sep 17 00:00:00 2001 From: Fabio Plunser Date: Sun, 8 Nov 2026 17:14:43 +0100 Subject: [PATCH 3/8] fix(files): keep user-collapsed search folders closed and cap search roots Co-Authored-By: Claude Sonnet 5.5 --- apps/web/src/components/CommandPalette.tsx | 8 ++++++-- apps/web/src/components/files/FileBrowserPanel.tsx | 12 ++++++++++-- packages/contracts/src/filesystem.ts | 2 +- packages/contracts/src/settings.ts | 5 ++++- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 94325915c915..5aa62b3b7a05 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -45,6 +45,7 @@ import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope, AuthFilesystemReadScope, + FILESYSTEM_PATH_MAX_LENGTH, type DesktopWslState, type EnvironmentId, type FilesystemBrowseResult, @@ -721,8 +722,11 @@ function OpenCommandPaletteDialog(props: { browseEnvironmentId !== null && !relativePathNeedsActiveProject; const folderSearchRoots = - environments.find((environment) => environment.environmentId === browseEnvironmentId) - ?.serverConfig?.settings.projectSearchDirectories ?? []; + environments + .find((environment) => environment.environmentId === browseEnvironmentId) + ?.serverConfig?.settings.projectSearchDirectories?.filter( + (root) => root.length > 0 && root.length <= FILESYSTEM_PATH_MAX_LENGTH, + ) ?? []; const folderSearchQuery = !isRemoteProjectCloneFlow && !browsePath.filterQuery.startsWith(".") ? browsePath.filterQuery diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index f41f5598b47f..7a8b451ed69b 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -191,6 +191,7 @@ export default function FileBrowserPanel({ buildTreeEntries(directoryEntries, pathSearch.entries, pathSearch.isPending, query); const entryKindsRef = useRef(entryKinds); const previousTreePathsRef = useRef(null); + const searchExpandedRef = useRef(new Set()); const syncingSelectionRef = useRef(false); const treeSelectionPathRef = useRef(null); const handledRevealRef = useRef<{ path: string; revealId: number } | null>(null); @@ -417,8 +418,15 @@ export default function FileBrowserPanel({ const updates = buildFileTreePathUpdates(previousTreePaths, treePaths); if (updates.length > 0) model.batch(updates); } - if (query.trim()) setAllDirectoriesExpanded(model, directoryPaths, true); - else model.closeSearch(); + if (query.trim()) { + // Only directories new to the results open, so one the user collapsed stays closed. + const fresh = directoryPaths.filter((path) => !searchExpandedRef.current.has(path)); + for (const path of fresh) searchExpandedRef.current.add(path); + setAllDirectoriesExpanded(model, fresh, true); + } else { + searchExpandedRef.current.clear(); + model.closeSearch(); + } }, [ready, entryKinds, model, treePaths, query, directoryPaths]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- expands every directory in the Pierre tree model while expand-all is on and no search is active diff --git a/packages/contracts/src/filesystem.ts b/packages/contracts/src/filesystem.ts index 2b63cd455195..ccccf7ec49c1 100644 --- a/packages/contracts/src/filesystem.ts +++ b/packages/contracts/src/filesystem.ts @@ -1,7 +1,7 @@ import * as Schema from "effect/Schema"; import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; -const FILESYSTEM_PATH_MAX_LENGTH = 512; +export const FILESYSTEM_PATH_MAX_LENGTH = 512; export const FILESYSTEM_METADATA_BATCH_LIMIT = 64; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index cfd5f68be9aa..da9f8a4ffa5d 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -24,6 +24,7 @@ import { UsageLimitSourceId } from "./usageLimitSourceId.ts"; import { UsageModelFilter } from "./usage.ts"; import { SecretProviderId, SecretProviderSlug } from "./secretReference.ts"; import { SettingsSecretValue } from "./settingsSecretValue.ts"; +import { FILESYSTEM_PATH_MAX_LENGTH } from "./filesystem.ts"; import { EnvironmentMachineKind, ThreadEnvMode, WorktreeSubmodules } from "./environment.ts"; import { KeybindingShortcut } from "./keybindings.ts"; import { @@ -1772,7 +1773,9 @@ export const ServerSettingsPatch = Schema.Struct({ newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), worktreeSubmodules: Schema.optionalKey(Schema.NullOr(WorktreeSubmodules)), projectSearchDirectories: Schema.optionalKey( - Schema.Array(TrimmedString).check(Schema.isMaxLength(32)), + Schema.Array(TrimmedString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH))).check( + Schema.isMaxLength(32), + ), ), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), ghqCloneLayout: Schema.optionalKey(Schema.Boolean), From 45891842e53254737735cab61380b6bcbfec8b16 Mon Sep 17 00:00:00 2001 From: Fabio Plunser Date: Sun, 8 Nov 2026 21:53:31 +0100 Subject: [PATCH 4/8] fix(web): reopen folders that return to search results --- .../src/components/files/FileBrowserPanel.browser.test.tsx | 7 ++++++- apps/web/src/components/files/FileBrowserPanel.tsx | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx index 663a2c980a23..b506fe73fcf3 100644 --- a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx @@ -136,9 +136,14 @@ it("finds a loaded file by subsequence in a host folder", async () => { await userEvent.keyboard("{ArrowRight}"); await vi.waitFor(() => expect(findTreeRow("src/FastFinder.tsx")).not.toBeNull()); const input = screen.getByRole("searchbox", { name: "Search repo files" }); - await userEvent.type(input, "rdm"); + await userEvent.type(input, "fstf"); + await vi.waitFor(() => expect(findTreeRow("src/FastFinder.tsx")).not.toBeNull()); + await userEvent.fill(input, "rdm"); await vi.waitFor(() => expect(findTreeRow("README.md")).not.toBeNull()); await expect.element(screen.getByText("No matching files.")).not.toBeInTheDocument(); + await vi.waitFor(() => expect(findTreeRow("src/")).toBeNull()); + await userEvent.fill(input, "fstf"); + await vi.waitFor(() => expect(findTreeRow("src/FastFinder.tsx")).not.toBeNull()); await userEvent.clear(input); await vi.waitFor(() => expect(findTreeRow("src/FastFinder.tsx")).not.toBeNull()); }); diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 7a8b451ed69b..3f7934c2c251 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -420,6 +420,10 @@ export default function FileBrowserPanel({ } if (query.trim()) { // Only directories new to the results open, so one the user collapsed stays closed. + const currentDirectories = new Set(directoryPaths); + for (const path of searchExpandedRef.current) { + if (!currentDirectories.has(path)) searchExpandedRef.current.delete(path); + } const fresh = directoryPaths.filter((path) => !searchExpandedRef.current.has(path)); for (const path of fresh) searchExpandedRef.current.add(path); setAllDirectoriesExpanded(model, fresh, true); From c0e30e499e1456adfd262d819e4dd2fb4fe4d522 Mon Sep 17 00:00:00 2001 From: Fabio Plunser Date: Mon, 9 Nov 2026 19:30:31 +0100 Subject: [PATCH 5/8] fix(files): keep folder searches separate from explicit paths --- .../projects/AddProjectScreen.test.ts | 22 ++++++++++++++++--- .../features/projects/AddProjectScreen.tsx | 15 ++++++++++--- .../SettingsServerControlsRouteScreen.tsx | 7 ++++-- .../settings/settings-scoped-server.test.ts | 15 +++++++++++++ .../settings/settings-scoped-server.ts | 10 ++++++++- apps/web/src/components/CommandPalette.tsx | 16 ++++++-------- .../src/state/filesystem.test.ts | 12 ++++++++++ .../client-runtime/src/state/filesystem.ts | 11 ++++++++++ 8 files changed, 90 insertions(+), 18 deletions(-) diff --git a/apps/mobile/src/features/projects/AddProjectScreen.test.ts b/apps/mobile/src/features/projects/AddProjectScreen.test.ts index ab331d0149e1..5df3565a9c7a 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.test.ts +++ b/apps/mobile/src/features/projects/AddProjectScreen.test.ts @@ -660,7 +660,7 @@ describe("local project navigation", () => { describe("local folder path", () => { function findPathInput( node: ReactNode, - ): { value: string; onChangeText: (value: string) => void } | null { + ): { value: string; onChangeText: (value: string) => void; onSubmit: () => void } | null { if (Array.isArray(node)) { for (const child of node) { const input = findPathInput(child); @@ -679,7 +679,8 @@ describe("local folder path", () => { return null; } const { value, onChangeText, onSubmit } = node.props; - if (typeof value === "string" && onChangeText && onSubmit) return { value, onChangeText }; + if (typeof value === "string" && onChangeText && onSubmit) + return { value, onChangeText, onSubmit }; return findPathInput(node.props.children); } @@ -695,13 +696,18 @@ describe("local folder path", () => { const root = createRoot(document.createElement("div")); const renderedPaths: string[] = []; let input: ReturnType = null; + let submit: (() => void) | null = null; function LocalFolderObserver(props: { environmentId?: string }) { - input = findPathInput(AddProjectLocalFolderScreen(props)); + const content = AddProjectLocalFolderScreen(props); + input = findPathInput(content); + submit = findAction(content, "Add project", true); if (input) renderedPaths.push(input.value); return null; } return { renderedPaths, + submit: () => act(() => submit!()), + submitKeyboard: () => act(() => input!.onSubmit()), get path() { return input?.value; }, @@ -716,6 +722,16 @@ describe("local folder path", () => { }; } + it("does not submit a bare folder search through the action or keyboard", async () => { + await using screen = mountLocalFolder(); + await screen.render("environment"); + await screen.type("project"); + state.dispatch.mockReset(); + await screen.submit(); + await screen.submitKeyboard(); + expect(state.dispatch).not.toHaveBeenCalled(); + }); + it("shows the new host's default path on the first render after the host changes", async () => { await using screen = mountLocalFolder(); await screen.render("environment"); diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index aa55502ca3f0..84f6a619b581 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -48,6 +48,7 @@ import { ensureBrowseDirectoryPath, inferProjectTitleFromPath, isWindowsPlatform, + isFilesystemBrowseQuery, } from "@t3tools/client-runtime/state/projects"; import { resolvePastedPath } from "@t3tools/shared/path"; import { @@ -939,7 +940,10 @@ function FolderBrowser(props: { browsePath.directoryPath.length > 0 ? { partialPath: browsePath.directoryPath, - ...(!props.cloning && browsePath.filterQuery && !browsePath.filterQuery.startsWith(".") + ...(!props.cloning && + !isFilesystemBrowseQuery(props.pathInput, props.environment.platform) && + browsePath.filterQuery && + !browsePath.filterQuery.startsWith(".") ? { searchQuery: browsePath.filterQuery, searchRoots: props.environment.searchDirectories ?? [], @@ -1332,8 +1336,13 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); + const canSubmitPath = + canCreateProject && + environment !== null && + isFilesystemBrowseQuery(pathInput, environment?.platform); + const submitPath = async () => { - if (!environment || isBrowseNavigating || isSubmitting) return; + if (!environment || !canSubmitPath || isBrowseNavigating || isSubmitting) return; setError(null); const resolved = resolveAddProjectPath({ rawPath: pathInput, @@ -1364,7 +1373,7 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index 0bdee6229b63..62fa7a11e098 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -38,6 +38,7 @@ import { planMobileScopedSettingsPatch, resolveMobileSettingsTargets, uniformMobileSetting, + mobileSettingValuesEqual, type ScopedMobileSettingsTarget, } from "./settings-scoped-server"; import { PullRequestGlyph } from "../pull-requests/pullRequestGlyphs"; @@ -206,7 +207,9 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { // `uniform` folds a real null into "mixed"; nullable keys need the distinction. const isMixed = (key: keyof ServerSettings) => reference === null || - displayTargets.some((entry) => entry.settings[key] !== reference.settings[key]); + displayTargets.some( + (entry) => !mobileSettingValuesEqual(entry.settings[key], reference.settings[key]), + ); const writeSettings = useSettingsWriter(); const write = (patch: ServerSettingsPatch) => { if (writeInFlight.current) { @@ -436,7 +439,7 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { className="min-h-10 rounded-xl px-3 py-2 text-base text-foreground" onEndEditing={(event) => { const text = event.nativeEvent.text.trim(); - if (text === value) return; + if (text === value && !isMixed(key)) return; write( key === "projectSearchDirectories" ? { diff --git a/apps/mobile/src/features/settings/settings-scoped-server.test.ts b/apps/mobile/src/features/settings/settings-scoped-server.test.ts index 4af458c8c2f5..c7c955de32a0 100644 --- a/apps/mobile/src/features/settings/settings-scoped-server.test.ts +++ b/apps/mobile/src/features/settings/settings-scoped-server.test.ts @@ -188,3 +188,18 @@ describe("managed settings across environments", () => { expect(managedSettingReason(targets.slice(0, 1), "storageCleanup")).toBeUndefined(); }); }); + +it("shows equal search-folder arrays as uniform and allows clearing them", () => { + const targets = resolveMobileSettingsTargets( + [ + environment(firstId, { ...DEFAULT_SERVER_SETTINGS, projectSearchDirectories: ["/work"] }), + environment(secondId, { ...DEFAULT_SERVER_SETTINGS, projectSearchDirectories: ["/work"] }), + ], + null, + ); + expect(uniformMobileSetting(targets, "projectSearchDirectories")).toEqual(["/work"]); + expect(planMobileScopedSettingsPatch(targets, false, { projectSearchDirectories: [] })).toEqual([ + { environmentId: firstId, patch: { projectSearchDirectories: [] } }, + { environmentId: secondId, patch: { projectSearchDirectories: [] } }, + ]); +}); diff --git a/apps/mobile/src/features/settings/settings-scoped-server.ts b/apps/mobile/src/features/settings/settings-scoped-server.ts index 2e16dc73a0ad..999adf721693 100644 --- a/apps/mobile/src/features/settings/settings-scoped-server.ts +++ b/apps/mobile/src/features/settings/settings-scoped-server.ts @@ -102,6 +102,12 @@ export function planMobileScopedSettingsPatch( })); } +export function mobileSettingValuesEqual(left: unknown, right: unknown): boolean { + return Array.isArray(left) && Array.isArray(right) + ? left.length === right.length && left.every((value, index) => value === right[index]) + : left === right; +} + export function uniformMobileSetting( targets: readonly Pick[], key: K, @@ -109,7 +115,9 @@ export function uniformMobileSetting( const reference = targets[0]; if (!reference) return null; const value = reference.settings[key]; - return targets.every((target) => target.settings[key] === value) ? value : null; + return targets.every((target) => mobileSettingValuesEqual(target.settings[key], value)) + ? value + : null; } export function planMobileScopedSettingsClear( diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 5aa62b3b7a05..3dab227cc8ce 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -33,6 +33,7 @@ import { canPreloadBrowsePath, createBrowseNavigationCoordinator, filterFilesystemBrowseEntries, + findExactFilesystemBrowseEntry, getFilesystemBrowsePath, getFilesystemBrowseTabCompletion, } from "@t3tools/client-runtime/state/filesystem"; @@ -728,7 +729,9 @@ function OpenCommandPaletteDialog(props: { (root) => root.length > 0 && root.length <= FILESYSTEM_PATH_MAX_LENGTH, ) ?? []; const folderSearchQuery = - !isRemoteProjectCloneFlow && !browsePath.filterQuery.startsWith(".") + !isRemoteProjectCloneFlow && + !isFilesystemBrowseQuery(query, browseEnvironmentPlatform) && + !browsePath.filterQuery.startsWith(".") ? browsePath.filterQuery : ""; const browseQuery = useEnvironmentQuery( @@ -763,10 +766,7 @@ function OpenCommandPaletteDialog(props: { : browseResult?.recursive ? { visibleEntries: browseEntries, - exactEntry: - browseEntries.find( - (entry) => entry.fullPath === query.trim() || entry.name === browsePath.filterQuery, - ) ?? null, + exactEntry: findExactFilesystemBrowseEntry(browseEntries, browsePath.filterQuery), } : filterFilesystemBrowseEntries(browseEntries, browsePath.filterQuery); @@ -2821,13 +2821,11 @@ function OpenCommandPaletteDialog(props: { : "This connection cannot add projects." } disabled={ - !canCreateProject || - !canCreateProjectInEnvironment(browseEnvironment?.connection.phase) || - relativePathNeedsActiveProject || + !canSubmitBrowsePath || (isCloneDestinationStep && (!canCloneProject || isRemoteProjectPending)) } onSubmit={() => { - if (relativePathNeedsActiveProject) { + if (!canSubmitBrowsePath || (isCloneDestinationStep && isRemoteProjectPending)) { return; } if (isCloneDestinationStep) { diff --git a/packages/client-runtime/src/state/filesystem.test.ts b/packages/client-runtime/src/state/filesystem.test.ts index 415a1fbab585..1cfbbba5c2af 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, filterFilesystemBrowseEntries, + findExactFilesystemBrowseEntry, getFilesystemBrowsePath, getFilesystemBrowseTabCompletion, resolveFilesystemReadAccess, @@ -157,3 +158,14 @@ it("fuzzy-matches and ranks folder names for every folder browser", () => { ), ).toBe(false); }); + +it("resolves only a unique exact basename from recursive folder results", () => { + const entries = [ + { name: "team/project", fullPath: "/work/team/project" }, + { name: "other/project", fullPath: "/work/other/project" }, + ]; + expect(findExactFilesystemBrowseEntry(entries.slice(0, 1), "project")).toEqual(entries[0]); + expect(findExactFilesystemBrowseEntry(entries, "project")).toBeNull(); + expect(findExactFilesystemBrowseEntry(entries, "proj")).toBeNull(); + expect(findExactFilesystemBrowseEntry(entries, "/work/team/project")).toEqual(entries[0]); +}); diff --git a/packages/client-runtime/src/state/filesystem.ts b/packages/client-runtime/src/state/filesystem.ts index 3a29edde0b06..e9320f1245d9 100644 --- a/packages/client-runtime/src/state/filesystem.ts +++ b/packages/client-runtime/src/state/filesystem.ts @@ -89,6 +89,17 @@ export function getFilesystemBrowsePath( }; } +export function findExactFilesystemBrowseEntry( + entries: ReadonlyArray, + query: string, +) { + if (!query.trim()) return null; + const fullPathMatch = entries.find((entry) => entry.fullPath === query.trim()); + if (fullPathMatch) return fullPathMatch; + const matches = entries.filter((entry) => entry.name.split(/[\\/]/).at(-1) === query); + return matches.length === 1 ? matches[0]! : null; +} + export function filterFilesystemBrowseEntries( entries: ReadonlyArray, query: string, From e3803307d675b2a938bf752ec587bf93e336ca6b Mon Sep 17 00:00:00 2001 From: Fabio Plunser Date: Mon, 9 Nov 2026 19:56:11 +0100 Subject: [PATCH 6/8] fix(mobile): preserve untouched mixed folder settings --- .../projects/AddProjectScreen.test.ts | 2 + ...SettingsServerControlsRouteScreen.test.tsx | 40 ++++++++++++++++++- .../SettingsServerControlsRouteScreen.tsx | 7 +++- 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/projects/AddProjectScreen.test.ts b/apps/mobile/src/features/projects/AddProjectScreen.test.ts index 5df3565a9c7a..da3f729bb52f 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.test.ts +++ b/apps/mobile/src/features/projects/AddProjectScreen.test.ts @@ -726,9 +726,11 @@ describe("local folder path", () => { await using screen = mountLocalFolder(); await screen.render("environment"); await screen.type("project"); + state.projects = []; state.dispatch.mockReset(); await screen.submit(); await screen.submitKeyboard(); + expect(state.projects).toEqual([]); expect(state.dispatch).not.toHaveBeenCalled(); }); diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx index 4c0052938976..70a9652b9a1b 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx @@ -5,11 +5,19 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +type DirectoryInput = { + accessibilityLabel: string; + onChangeText?: (text: string) => void; + onFocus?: () => void; + onEndEditing: (event: { nativeEvent: { text: string } }) => void; +}; + const { Children, mocks } = vi.hoisted(() => ({ Children: ({ children }: { children?: ReactNode }) => children, mocks: { branchNaming: { onChange: (_patch: ServerSettingsPatch) => {} }, updateSettings: vi.fn(), + inputs: new Map(), alert: vi.fn(), targets: [] as ReturnType[], readOnly: new Set(), @@ -69,7 +77,13 @@ vi.mock("../threads/thread-settings-options", () => ({ withManagedRuntimeModeLimits: (choices: readonly unknown[]) => choices, })); vi.mock("../../components/ui/ScreenScrollView", () => ({ ScreenScrollView: Children })); -vi.mock("../../components/ui/AppText", () => ({ AppText: Children, AppTextInput: () => null })); +vi.mock("../../components/ui/AppText", () => ({ + AppText: Children, + AppTextInput: (props: DirectoryInput) => { + mocks.inputs.set(props.accessibilityLabel, props); + return null; + }, +})); vi.mock("../../components/ui/Button", () => ({ Button: ({ label, @@ -174,6 +188,7 @@ beforeEach(() => { mocks.targets = [environment("local", "Local")]; mocks.projectSelected = false; mocks.readOnly = new Set(); + mocks.inputs.clear(); mocks.alert.mockReset(); mocks.updateSettings.mockReset(); mocks.updateSettings.mockResolvedValue(AsyncResult.success(undefined)); @@ -184,6 +199,29 @@ afterEach(() => { }); describe("SettingsServerControlsRouteScreen", () => { + it("leaves mixed directories untouched on blur and saves an intentional empty edit", async () => { + const local = environment("local", "Local"); + const remote = environment("remote", "Remote"); + local.serverConfig.settings.projectSearchDirectories = ["/work"]; + remote.serverConfig.settings.projectSearchDirectories = ["/other"]; + mocks.targets = [local, remote]; + act(() => root.render()); + const input = mocks.inputs.get("Project search folders")!; + act(() => input.onFocus?.()); + await act(() => input.onEndEditing({ nativeEvent: { text: "" } })); + expect(mocks.updateSettings).not.toHaveBeenCalled(); + + act(() => { + input.onChangeText!("/temporary"); + input.onChangeText!(""); + }); + await act(() => input.onEndEditing({ nativeEvent: { text: "" } })); + expect(mocks.updateSettings.mock.calls).toEqual([ + [{ environmentId: "local", input: { patch: { projectSearchDirectories: [] } } }], + [{ environmentId: "remote", input: { patch: { projectSearchDirectories: [] } } }], + ]); + }); + it("sends a draft saved during a pending write once that write settles", async () => { const pending = Promise.withResolvers>(); mocks.updateSettings.mockReturnValueOnce(pending.promise); diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index 62fa7a11e098..fffd733071ad 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -188,6 +188,7 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { ); const [pendingWrites, setPendingWrites] = useState(0); const writeInFlight = useRef(false); + const editedDirectoryInputs = useRef(new Set()); // Closing views can save after controls disable; queue those patches behind the pending write. const queuedPatch = useRef<{ key: string; patch: ServerSettingsPatch } | null>(null); const targetsKey = targets @@ -410,6 +411,7 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { : typeof saved === "string" ? saved : ""; + const inputKey = `${targetsKey}:${key}:${value}`; const label = key === "projectSearchDirectories" ? "Project search folders" @@ -423,7 +425,7 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { : "Leave empty to start in your home folder."} editedDirectoryInputs.current.delete(inputKey)} + onChangeText={() => editedDirectoryInputs.current.add(inputKey)} onEndEditing={(event) => { + if (!editedDirectoryInputs.current.delete(inputKey)) return; const text = event.nativeEvent.text.trim(); if (text === value && !isMixed(key)) return; write( From dfd464a6ae2d821a9fc459709030e445a55c926c Mon Sep 17 00:00:00 2001 From: Fabio Plunser Date: Mon, 9 Nov 2026 20:12:07 +0100 Subject: [PATCH 7/8] fix(mobile): filter invalid project search roots --- apps/mobile/src/features/projects/AddProjectScreen.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 84f6a619b581..ff2db2f88b7d 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -52,6 +52,7 @@ import { } from "@t3tools/client-runtime/state/projects"; import { resolvePastedPath } from "@t3tools/shared/path"; import { + FILESYSTEM_PATH_MAX_LENGTH, AuthOrchestrationOperateScope, AuthSourceControlWriteScope, AuthFilesystemReadScope, @@ -946,7 +947,10 @@ function FolderBrowser(props: { !browsePath.filterQuery.startsWith(".") ? { searchQuery: browsePath.filterQuery, - searchRoots: props.environment.searchDirectories ?? [], + searchRoots: + props.environment.searchDirectories?.filter( + (root) => root.length > 0 && root.length <= FILESYSTEM_PATH_MAX_LENGTH, + ) ?? [], } : {}), } From 94bf06c21a59bebf28a69bffb3d0cf5aca2c8196 Mon Sep 17 00:00:00 2001 From: Fabio Plunser Date: Wed, 11 Nov 2026 10:05:10 +0100 Subject: [PATCH 8/8] fix(files): search only server-configured project folders The server walked whatever roots the client sent, and with no search folders configured it recursively scanned the browsed folder, which defaults to the home folder. The server now reads Project search folders from its own settings, so clients send only the query. Recursive search runs only across configured folders; otherwise a query matches the browsed folder's direct children. Relative search folders resolve against home, and the walk yields between directory reads so the cache can interrupt it when no search is waiting. Co-Authored-By: Claude Opus 5.5 --- .../features/projects/AddProjectScreen.tsx | 11 +-- apps/server/src/server.ts | 5 +- .../src/workspace/WorkspaceEntries.test.ts | 29 +++++-- apps/server/src/workspace/WorkspaceEntries.ts | 82 +++++++++++-------- .../src/workspace/WorkspaceFileSystem.test.ts | 15 +++- apps/web/src/components/CommandPalette.tsx | 9 +- docs/user/project-settings.md | 8 +- packages/contracts/src/filesystem.ts | 5 -- 8 files changed, 97 insertions(+), 67 deletions(-) diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index ff2db2f88b7d..c703785b71a0 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -52,7 +52,6 @@ import { } from "@t3tools/client-runtime/state/projects"; import { resolvePastedPath } from "@t3tools/shared/path"; import { - FILESYSTEM_PATH_MAX_LENGTH, AuthOrchestrationOperateScope, AuthSourceControlWriteScope, AuthFilesystemReadScope, @@ -116,7 +115,6 @@ interface EnvironmentOption { readonly platform: string; readonly machine: EnvironmentMachineKind; readonly baseDirectory: string | null; - readonly searchDirectories?: ReadonlyArray; readonly newProjectsRoot: string | null; readonly connectionState: EnvironmentConnectionPhase; readonly connectionError: string | null; @@ -412,7 +410,6 @@ function useEnvironmentOptions( platform: platformFromOs(config?.environment.platform.os ?? null), machine: resolveEnvironmentMachineKind(config ?? null), baseDirectory: config?.settings.addProjectBaseDirectory ?? null, - searchDirectories: config?.settings.projectSearchDirectories ?? [], newProjectsRoot: config?.newProjectsRoot ?? null, connectionState: runtime?.connectionState ?? "available", connectionError: runtime?.connectionError ?? null, @@ -945,13 +942,7 @@ function FolderBrowser(props: { !isFilesystemBrowseQuery(props.pathInput, props.environment.platform) && browsePath.filterQuery && !browsePath.filterQuery.startsWith(".") - ? { - searchQuery: browsePath.filterQuery, - searchRoots: - props.environment.searchDirectories?.filter( - (root) => root.length > 0 && root.length <= FILESYSTEM_PATH_MAX_LENGTH, - ) ?? [], - } + ? { searchQuery: browsePath.filterQuery } : {}), } : null; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e7baa0a91164..6b4d077966f2 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -508,7 +508,10 @@ const layerDevice = DeviceService.layer.pipe( Layer.provide(NetService.layer), ); -const layerWorkspaceEntries = WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer)); +const layerWorkspaceEntries = WorkspaceEntries.layer.pipe( + Layer.provide(WorkspacePaths.layer), + Layer.provide(layerServerSettings), +); const layerWorkspaceFileSystem = WorkspaceFileSystem.layer.pipe( Layer.provide(WorkspacePaths.layer), diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 139d996c0b52..58179a2e88c1 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -12,6 +12,7 @@ import { vi } from "vite-plus/test"; import * as ServerConfig from "../config.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as ServerSettings from "../serverSettings.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as WorkspaceEntries from "./WorkspaceEntries.ts"; import * as WorkspacePaths from "./WorkspacePaths.ts"; @@ -25,6 +26,7 @@ const layerTest = Layer.empty.pipe( Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer))), Layer.provideMerge(WorkspacePaths.layer), Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(ServerSettings.ServerSettingsService.layerTest()), Layer.provide( ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-workspace-entries-test-", @@ -792,22 +794,35 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }); describe("browse", () => { - it.effect("finds nested folders with fuzzy queries across roots", () => + it.effect("walks only the server's configured search roots for fuzzy folder queries", () => Effect.gen(function* () { const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const serverSettings = yield* ServerSettings.ServerSettingsService; const path = yield* Path.Path; const cwd = yield* makeTempDir({ prefix: "t3code-folder-search-" }); const other = yield* makeTempDir({ prefix: "t3code-folder-search-other-" }); + yield* writeTextFile(cwd, "hello-wall/package.json", "{}"); yield* writeTextFile(cwd, "team/hello-world/package.json", "{}"); yield* writeTextFile(cwd, "node_modules/hello-world/package.json", "{}"); yield* writeTextFile(other, "hello-web/package.json", "{}"); - const result = yield* workspaceEntries.browse({ - partialPath: `${cwd}${path.sep}`, - searchQuery: "hlw", - searchRoots: [cwd, other, cwd], + const search = workspaceEntries + .browse({ partialPath: `${cwd}${path.sep}`, searchQuery: "hlw" }) + .pipe( + Effect.map((result) => result.entries.map((entry) => entry.fullPath).toSorted()), + Effect.provideService(HostProcess.HomeDirectory, path.dirname(other)), + ); + + expect(yield* search).toEqual([path.join(cwd, "hello-wall")]); + + yield* serverSettings.updateSettings({ + projectSearchDirectories: [cwd, path.basename(other), cwd], }); - expect(result.entries.map((entry) => entry.fullPath).toSorted()).toEqual( - [path.join(cwd, "team/hello-world"), path.join(other, "hello-web")].toSorted(), + expect(yield* search).toEqual( + [ + path.join(cwd, "hello-wall"), + path.join(cwd, "team/hello-world"), + path.join(other, "hello-web"), + ].toSorted(), ); }), ); diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index de3b822d9416..bdaf81a7600f 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -27,6 +27,7 @@ import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/p import { scoreFilePathMatch, normalizeSearchQuery } from "@t3tools/shared/searchRanking"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; +import * as ServerSettings from "../serverSettings.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as WorkspacePaths from "./WorkspacePaths.ts"; import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; @@ -122,6 +123,9 @@ const IGNORED_FILES_LIMIT = 200; /** Paths sent to one `git check-ignore` call when labelling a folder's children. */ const IGNORE_CHECK_CHUNK_SIZE = 1000; +/** Folders indexed per project search root; broad roots stop here. */ +const SEARCH_ROOT_FOLDER_LIMIT = 20_000; + /** Children classified at once when listing a folder; each symlink costs a realpath and a stat. */ const SYMLINK_RESOLVE_BATCH_SIZE = 64; @@ -159,6 +163,7 @@ export const make = Effect.gen(function* () { const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const workspaceSearchIndexes = yield* WorkspaceSearchIndex.WorkspaceSearchIndexMap; const vcsProcess = yield* VcsProcess.VcsProcess; + const serverSettings = yield* ServerSettings.ServerSettingsService; const normalizeWorkspaceRoot = Effect.fn("WorkspaceEntries.normalizeWorkspaceRoot")(function* ( cwd: string, @@ -206,50 +211,63 @@ export const make = Effect.gen(function* () { }, ); + const readChildFolders = (parent: string) => + Effect.promise(() => NodeFSP.readdir(parent, { withFileTypes: true }).catch(() => [])).pipe( + Effect.map((entries) => + entries.flatMap((entry) => + entry.isDirectory() && + !entry.name.startsWith(".") && + entry.name !== "node_modules" && + entry.name !== "vendor" + ? [path.join(parent, entry.name)] + : [], + ), + ), + ); + + // Breadth-first and never follows directory symlinks. Each readdir is an interruption + // point, so the cache stops the walk once no search is waiting on it. const folderIndex = yield* Cache.make({ capacity: 32, timeToLive: "30 seconds", - lookup: (root: string) => - Effect.promise(async () => { - const folders: string[] = []; - const queue = [root]; - // Bound scans of broad roots and never follow directory symlinks. - for (let index = 0; index < queue.length && index < 20_000; index += 1) { - const parent = queue[index]!; - const entries = await NodeFSP.readdir(parent, { withFileTypes: true }).catch(() => []); - for (const entry of entries) { - if ( - !entry.isDirectory() || - entry.name.startsWith(".") || - entry.name === "node_modules" || - entry.name === "vendor" || - queue.length >= 20_000 - ) - continue; - const fullPath = path.join(parent, entry.name); - folders.push(fullPath); - queue.push(fullPath); - } - } - return folders; - }), + lookup: Effect.fn("WorkspaceEntries.indexSearchRoot")(function* (root: string) { + const queue = [root]; + for ( + let index = 0; + index < queue.length && queue.length <= SEARCH_ROOT_FOLDER_LIMIT; + index += 1 + ) { + for (const folder of yield* readChildFolders(queue[index]!)) queue.push(folder); + } + return queue.slice(1, SEARCH_ROOT_FOLDER_LIMIT + 1); + }), }); const browse: WorkspaceEntries["Service"]["browse"] = Effect.fn("WorkspaceEntries.browse")( function* (input) { const resolvedInputPath = yield* resolveBrowseTarget(input, path); if (input.searchQuery) { - const roots = [ - ...new Set(input.searchRoots?.length ? input.searchRoots : [resolvedInputPath]), - ]; const home = yield* HostProcess.HomeDirectory; - const folders = yield* Effect.forEach( - roots, - (root) => Cache.get(folderIndex, path.resolve(expandHomePath(root, home))), - { concurrency: 2 }, + const configuredRoots = yield* serverSettings.getSettings.pipe( + Effect.map((settings) => settings.projectSearchDirectories), + Effect.orElseSucceed((): ReadonlyArray => []), ); + const roots = [ + ...new Set( + configuredRoots + .filter((root) => root.length > 0) + .map((root) => path.resolve(home, expandHomePath(root, home))), + ), + ]; + // Only configured roots are walked; otherwise match the browsed folder's children. + const folders = + roots.length > 0 + ? (yield* Effect.forEach(roots, (root) => Cache.get(folderIndex, root), { + concurrency: 2, + })).flat() + : yield* readChildFolders(resolvedInputPath); const query = input.searchQuery.toLowerCase(); - const ranked = [...new Set(folders.flat())] + const ranked = [...new Set(folders)] .flatMap((fullPath) => { const score = scoreFilePathMatch(path.basename(fullPath), query); return score === null ? [] : [{ fullPath, score }]; diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index 3306e420b35c..3e9cd1020cc6 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -15,6 +15,7 @@ import * as ServerConfig from "../config.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as KnownWorkspaces from "./KnownWorkspaces.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as WorkspaceEntries from "./WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./WorkspaceFileSystem.ts"; import * as WorkspacePaths from "./WorkspacePaths.ts"; @@ -54,12 +55,22 @@ const layerKnownWorkspacesTest = Layer.mock(KnownWorkspaces.KnownWorkspaces)({ const layerProject = WorkspaceFileSystem.layer.pipe( Layer.provide(layerKnownWorkspacesTest), Layer.provide(WorkspacePaths.layer), - Layer.provide(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer))), + Layer.provide( + WorkspaceEntries.layer.pipe( + Layer.provide(WorkspacePaths.layer), + Layer.provide(ServerSettings.ServerSettingsService.layerTest()), + ), + ), ); const layerTest = Layer.empty.pipe( Layer.provideMerge(layerProject), - Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer))), + Layer.provideMerge( + WorkspaceEntries.layer.pipe( + Layer.provide(WorkspacePaths.layer), + Layer.provide(ServerSettings.ServerSettingsService.layerTest()), + ), + ), Layer.provideMerge(WorkspacePaths.layer), Layer.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))), Layer.provideMerge( diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 3dab227cc8ce..89a33cbc15d3 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -46,7 +46,6 @@ import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope, AuthFilesystemReadScope, - FILESYSTEM_PATH_MAX_LENGTH, type DesktopWslState, type EnvironmentId, type FilesystemBrowseResult, @@ -725,9 +724,7 @@ function OpenCommandPaletteDialog(props: { const folderSearchRoots = environments .find((environment) => environment.environmentId === browseEnvironmentId) - ?.serverConfig?.settings.projectSearchDirectories?.filter( - (root) => root.length > 0 && root.length <= FILESYSTEM_PATH_MAX_LENGTH, - ) ?? []; + ?.serverConfig?.settings.projectSearchDirectories?.filter((root) => root.length > 0) ?? []; const folderSearchQuery = !isRemoteProjectCloneFlow && !isFilesystemBrowseQuery(query, browseEnvironmentPlatform) && @@ -740,9 +737,7 @@ function OpenCommandPaletteDialog(props: { environmentId: browseEnvironmentId, input: { partialPath: browsePath.directoryPath, - ...(folderSearchQuery - ? { searchQuery: folderSearchQuery, searchRoots: folderSearchRoots } - : {}), + ...(folderSearchQuery ? { searchQuery: folderSearchQuery } : {}), ...(currentProjectCwdForBrowse ? { cwd: currentProjectCwdForBrowse } : {}), }, }) diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index b81bf7425c38..440a16789055 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -265,11 +265,13 @@ In Settings > Threads > New projects, set Project search folders to the base fol that contain your projects. Separate paths with semicolons. In Add Project's local folder browser, type part of a folder name to find nested folders across those locations. Letters can be skipped, so `hlw` finds `hello-world`. With no search folders configured, -search uses the folder you are browsing. +search matches only the folders directly inside the one you are browsing. Relative search +folders start from your home folder. Search skips hidden folders, `node_modules`, `vendor`, and symlinks. It scans up to -20,000 folders per root, returns the best 100 matches, and caches each scan for 30 seconds. -Use narrower base folders for large directory trees. +20,000 folders per search folder and returns the best 100 matches. Results are reused for +30 seconds, so a folder created in that time can take a moment to appear. Use narrower search +folders for large directory trees. Folder pickers, the Files tree, and mobile ZIP filters also accept fuzzy name queries. Content searches keep their existing matching options. diff --git a/packages/contracts/src/filesystem.ts b/packages/contracts/src/filesystem.ts index ccccf7ec49c1..cc0c464b03a0 100644 --- a/packages/contracts/src/filesystem.ts +++ b/packages/contracts/src/filesystem.ts @@ -28,11 +28,6 @@ export type FilesystemGetMetadataResult = typeof FilesystemGetMetadataResult.Typ export const FilesystemBrowseInput = Schema.Struct({ searchQuery: Schema.optionalKey(TrimmedNonEmptyString.check(Schema.isMaxLength(256))), - searchRoots: Schema.optionalKey( - Schema.Array(TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH))).check( - Schema.isMaxLength(32), - ), - ), partialPath: TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH)), cwd: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH))), });