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/mobile/src/features/projects/AddProjectScreen.test.ts b/apps/mobile/src/features/projects/AddProjectScreen.test.ts index ab331d0149e1..da3f729bb52f 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,18 @@ 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.projects = []; + state.dispatch.mockReset(); + await screen.submit(); + await screen.submitKeyboard(); + expect(state.projects).toEqual([]); + 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 93680aa38159..c703785b71a0 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -45,8 +45,10 @@ import { } from "@t3tools/client-runtime/state/filesystem"; import { appendBrowsePathSegment, + ensureBrowseDirectoryPath, inferProjectTitleFromPath, isWindowsPlatform, + isFilesystemBrowseQuery, } from "@t3tools/client-runtime/state/projects"; import { resolvePastedPath } from "@t3tools/shared/path"; import { @@ -926,9 +928,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 && + !isFilesystemBrowseQuery(props.pathInput, props.environment.platform) && + browsePath.filterQuery && + !browsePath.filterQuery.startsWith(".") + ? { searchQuery: browsePath.filterQuery } + : {}), + } + : null; const fileAccessSession = useEnvironmentQuery( environmentSession.sessionStateAtom(props.environment.environmentId), ); @@ -953,10 +970,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 +993,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 +1040,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 }), }); }} /> @@ -1307,8 +1331,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, @@ -1339,7 +1368,7 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx index 7d22b2e27eac..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 })); +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 793e3ae75412..fffd733071ad 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, @@ -38,6 +38,7 @@ import { planMobileScopedSettingsPatch, resolveMobileSettingsTargets, uniformMobileSetting, + mobileSettingValuesEqual, type ScopedMobileSettingsTarget, } from "./settings-scoped-server"; import { PullRequestGlyph } from "../pull-requests/pullRequestGlyphs"; @@ -187,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 @@ -206,7 +208,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) { @@ -398,6 +402,71 @@ 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 inputKey = `${targetsKey}:${key}:${value}`; + 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."} + + 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( + 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/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/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 b0633dac04cc..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,6 +794,39 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }); describe("browse", () => { + 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 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(yield* search).toEqual( + [ + path.join(cwd, "hello-wall"), + 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; @@ -808,6 +843,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 7b2a0aa0a7c7..bdaf81a7600f 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,9 +24,10 @@ 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 { 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"; @@ -121,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; @@ -158,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, @@ -205,9 +211,78 @@ 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: 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 home = yield* HostProcess.HomeDirectory; + 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)] + .flatMap((fullPath) => { + const score = scoreFilePathMatch(path.basename(fullPath), 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); @@ -237,7 +312,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({ @@ -249,7 +324,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/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 7c0821678e51..89a33cbc15d3 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"; @@ -253,6 +254,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 +654,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 +721,23 @@ function OpenCommandPaletteDialog(props: { browsePath.directoryPath.length > 0 && browseEnvironmentId !== null && !relativePathNeedsActiveProject; + const folderSearchRoots = + environments + .find((environment) => environment.environmentId === browseEnvironmentId) + ?.serverConfig?.settings.projectSearchDirectories?.filter((root) => root.length > 0) ?? []; + const folderSearchQuery = + !isRemoteProjectCloneFlow && + !isFilesystemBrowseQuery(query, browseEnvironmentPlatform) && + !browsePath.filterQuery.startsWith(".") + ? browsePath.filterQuery + : ""; const browseQuery = useEnvironmentQuery( browseAccess.canReadFiles && hasBrowseTarget ? filesystemEnvironment.browse({ environmentId: browseEnvironmentId, input: { partialPath: browsePath.directoryPath, + ...(folderSearchQuery ? { searchQuery: folderSearchQuery } : {}), ...(currentProjectCwdForBrowse ? { cwd: currentProjectCwdForBrowse } : {}), }, }) @@ -738,7 +758,12 @@ function OpenCommandPaletteDialog(props: { pinnedDirectoryName: pinnedCloneDirectoryName, caseSensitive: !isWindowsPlatform(browseEnvironmentPlatform), }) - : filterFilesystemBrowseEntries(browseEntries, browsePath.filterQuery); + : browseResult?.recursive + ? { + visibleEntries: browseEntries, + exactEntry: findExactFilesystemBrowseEntry(browseEntries, browsePath.filterQuery), + } + : filterFilesystemBrowseEntries(browseEntries, browsePath.filterQuery); const prefetchBrowsePath = async ( partialPath: string, @@ -934,8 +959,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 +2209,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 +2425,7 @@ function OpenCommandPaletteDialog(props: { const isCloneDestinationStep = addProjectCloneFlow?.step === "confirm"; const canSubmitBrowsePath = isBrowsing && + (isFilesystemBrowseQuery(query, browseEnvironmentPlatform) || exactBrowseEntry !== null) && !relativePathNeedsActiveProject && canCreateProject && (!isCloneDestinationStep || canCloneProject) && @@ -2589,7 +2622,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}`); } @@ -2783,13 +2816,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) { @@ -2905,10 +2936,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/files/FileBrowserPanel.browser.test.tsx b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx index 1c0e1bc914be..b506fe73fcf3 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,33 @@ 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, "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 b02baf31b0ac..3f7934c2c251 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"); @@ -168,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); @@ -293,7 +317,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 +328,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 +343,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 +389,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 +410,28 @@ 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()) { + // 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); + } 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 useEffect(() => { @@ -431,7 +461,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 +481,6 @@ export default function FileBrowserPanel({ syncingSelectionRef.current = true; setQuery(""); - model.closeSearch(); for (const path of model.getSelectedPaths()) { model.getItem(path)?.deselect(); } @@ -470,7 +503,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/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 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 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. + +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..1cfbbba5c2af 100644 --- a/packages/client-runtime/src/state/filesystem.test.ts +++ b/packages/client-runtime/src/state/filesystem.test.ts @@ -3,6 +3,8 @@ import { AuthFilesystemReadScope, AuthOrchestrationReadScope } from "@t3tools/co import { createBrowseNavigationCoordinator, + filterFilesystemBrowseEntries, + findExactFilesystemBrowseEntry, getFilesystemBrowsePath, getFilesystemBrowseTabCompletion, resolveFilesystemReadAccess, @@ -77,6 +79,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); }); @@ -133,3 +142,30 @@ 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); +}); + +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 4d423d0ed360..e9320f1245d9 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, @@ -15,6 +16,7 @@ import type { EnvironmentRegistry } from "../connection/registry.ts"; import { canNavigateUp, getBrowseDirectoryPath, + ensureBrowseDirectoryPath, getBrowseLeafPathSegment, getBrowseParentPath, hasTrailingPathSeparator, @@ -54,11 +56,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 { @@ -70,17 +89,32 @@ export function getFilesystemBrowsePath(query: string, platform = "", enabled = }; } +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, ) { - 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/contracts/src/filesystem.ts b/packages/contracts/src/filesystem.ts index a1f6c293e90b..cc0c464b03a0 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; @@ -27,6 +27,7 @@ export const FilesystemGetMetadataResult = Schema.Struct({ export type FilesystemGetMetadataResult = typeof FilesystemGetMetadataResult.Type; export const FilesystemBrowseInput = Schema.Struct({ + searchQuery: Schema.optionalKey(TrimmedNonEmptyString.check(Schema.isMaxLength(256))), partialPath: TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH)), cwd: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH))), }); @@ -39,6 +40,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..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 { @@ -1394,6 +1395,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 +1772,11 @@ 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(FILESYSTEM_PATH_MAX_LENGTH))).check( + Schema.isMaxLength(32), + ), + ), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), ghqCloneLayout: Schema.optionalKey(Schema.Boolean), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), 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: ["/", "\\", "-", "_", ".", " "], + }); +}