From 423e4fe11cdb9b5ea33b91004fd8ef29a7a7c4fa Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 13 Sep 2026 04:48:43 +0000 Subject: [PATCH 1/3] fix(files): browse ignored files and load folders on demand --- .../src/features/files/FileTreeBrowser.tsx | 59 +++++-- .../features/files/ThreadFilesRouteScreen.tsx | 28 ++-- apps/mobile/src/features/files/fileTree.ts | 4 + .../files/thread-file-navigator-pane.tsx | 23 +-- .../src/features/files/useFileTreeEntries.ts | 151 ++++++++++++++++++ .../src/workspace/WorkspaceEntries.test.ts | 93 +++++++++++ apps/server/src/workspace/WorkspaceEntries.ts | 77 +++++++++ apps/server/src/ws.ts | 6 + .../src/components/files/FileBreadcrumbs.tsx | 11 +- .../src/components/files/FileBrowserPanel.tsx | 137 +++++++++++++--- .../files/projectFilesQueryState.ts | 14 +- .../components/files/useDirectoryEntries.ts | 124 ++++++++++++++ apps/web/src/state/queries.ts | 1 + packages/contracts/src/project.ts | 5 + 14 files changed, 659 insertions(+), 74 deletions(-) create mode 100644 apps/mobile/src/features/files/useFileTreeEntries.ts create mode 100644 apps/web/src/components/files/useDirectoryEntries.ts diff --git a/apps/mobile/src/features/files/FileTreeBrowser.tsx b/apps/mobile/src/features/files/FileTreeBrowser.tsx index bce58d838a7d..f2dfd3f15a97 100644 --- a/apps/mobile/src/features/files/FileTreeBrowser.tsx +++ b/apps/mobile/src/features/files/FileTreeBrowser.tsx @@ -11,7 +11,6 @@ import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { buildFileTree, - defaultExpandedTreePaths, flattenFileTree, type FileTreeNode, type VisibleFileTreeNode, @@ -45,6 +44,7 @@ const FileTreeRow = memo(function FileTreeRow(props: { readonly item: VisibleFileTreeNode; readonly selected: boolean; readonly expanded: boolean; + readonly loaded: boolean; readonly onPressDirectory: (path: string) => void; readonly onPreviewFile?: (path: string) => void; readonly onPressFile: (path: string) => void; @@ -89,13 +89,15 @@ const FileTreeRow = memo(function FileTreeRow(props: { "min-w-0 flex-1 text-sm leading-normal", props.selected ? "font-t3-bold text-foreground" - : "font-t3-medium text-foreground-secondary", + : node.ignored + ? "font-t3-medium text-foreground-tertiary" + : "font-t3-medium text-foreground-secondary", )} numberOfLines={1} > {node.name} - {node.kind === "directory" ? ( + {node.kind === "directory" && props.loaded ? ( {node.children.length} @@ -109,7 +111,10 @@ export function FileTreeBrowser(props: { readonly error: string | null; readonly isPending: boolean; readonly searchQuery: string; + readonly searchTruncated: boolean; readonly selectedPath: string | null; + readonly loadedDirectories: ReadonlySet; + readonly onLoadDirectory: (path: string) => void; readonly onPreviewFile?: (path: string) => void; readonly onRefresh: () => void; readonly onSelectFile: (path: string) => void; @@ -123,7 +128,13 @@ export function FileTreeBrowser(props: { // Native transparent-header height ≈ safe-area top + nav bar (~44). Matches the // observed adjustedContentInset bottom (~102) seen in the native trace. const headerInset = NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + IOS_NAV_BAR_HEIGHT : 0; - const { onPreviewFile, onSelectFile, selectedPath: controlledSelectedPath } = props; + const { + onLoadDirectory, + onPreviewFile, + onSelectFile, + loadedDirectories, + selectedPath: controlledSelectedPath, + } = props; const controlledSelectedPathRef = useRef(controlledSelectedPath); const pendingSelectionTimeoutRef = useRef | null>(null); controlledSelectedPathRef.current = controlledSelectedPath; @@ -133,7 +144,6 @@ export function FileTreeBrowser(props: { ? pendingSelection.path : controlledSelectedPath; const tree = useMemo(() => cachedFileTree(props.entries), [props.entries]); - const defaultExpanded = useMemo(() => defaultExpandedTreePaths(tree), [tree]); const visibleNodes = useMemo( () => flattenFileTree({ @@ -144,15 +154,6 @@ export function FileTreeBrowser(props: { [expandedPaths, props.searchQuery, tree], ); - useEffect(() => { - setExpandedPaths((current) => { - if (current.size > 0 || defaultExpanded.size === 0) { - return current; - } - return new Set(defaultExpanded); - }); - }, [defaultExpanded]); - useEffect(() => { if (!controlledSelectedPath) { return; @@ -170,6 +171,10 @@ export function FileTreeBrowser(props: { }); }, [controlledSelectedPath]); + useEffect(() => { + for (const path of expandedPaths) onLoadDirectory(path); + }, [expandedPaths, onLoadDirectory]); + useEffect( () => () => { if (pendingSelectionTimeoutRef.current !== null) { @@ -213,12 +218,20 @@ export function FileTreeBrowser(props: { item={item} selected={item.node.kind === "file" && item.node.path === selectedPath} expanded={expandedPaths.has(item.node.path)} + loaded={loadedDirectories.has(item.node.path)} onPressDirectory={toggleDirectory} onPreviewFile={onPreviewFile} onPressFile={handleSelectFile} /> ), - [expandedPaths, handleSelectFile, onPreviewFile, selectedPath, toggleDirectory], + [ + expandedPaths, + handleSelectFile, + onPreviewFile, + loadedDirectories, + selectedPath, + toggleDirectory, + ], ); if (props.error && props.entries.length === 0) { @@ -255,6 +268,20 @@ export function FileTreeBrowser(props: { contentContainerStyle={{ paddingTop: 8, paddingBottom: 8 }} refreshControl={} renderItem={renderItem} + ListHeaderComponent={ + <> + {props.error ? ( + + {props.error} + + ) : null} + {props.searchTruncated ? ( + + More search results available. Refine your search to see them. + + ) : null} + + } ListEmptyComponent={ {props.isPending ? ( @@ -265,7 +292,7 @@ export function FileTreeBrowser(props: { {props.searchQuery.trim().length > 0 ? "Try a different search." - : "The workspace file index is empty."} + : "The workspace is empty."} )} diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index a8a0f860cfa8..624a214e43e0 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -5,12 +5,7 @@ import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react" import { ActivityIndicator, Platform, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; -import { - EnvironmentId, - type ProjectListEntriesResult, - type ProjectReadFileResult, - ThreadId, -} from "@t3tools/contracts"; +import { EnvironmentId, type ProjectReadFileResult, ThreadId } from "@t3tools/contracts"; import { videoMimeType } from "@t3tools/shared/video"; import { isWorkspaceBrowserPreviewPath, @@ -53,6 +48,7 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe import { ThreadRouteScreen } from "../threads/ThreadRouteScreen"; import { FileMarkdownPreview } from "./FileMarkdownPreview"; import { FileTreeBrowser } from "./FileTreeBrowser"; +import { useFileTreeEntries } from "./useFileTreeEntries"; import { preloadWorkspaceFileContents } from "./preload-workspace-file"; import { SourceFileSurface } from "./SourceFileSurface"; import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane"; @@ -341,15 +337,11 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { props.route.params, ); const revealedInspectorRef = useRef(false); - const entriesQuery = useEnvironmentQuery( - environmentId !== null && cwd !== null && !fileInspector.supported - ? projectEnvironment.listEntries({ - environmentId, - input: { cwd }, - }) - : null, - ); - const entriesData = entriesQuery.data as ProjectListEntriesResult | null; + const entriesQuery = useFileTreeEntries({ + environmentId, + cwd: fileInspector.supported ? null : cwd, + searchQuery, + }); const handleReturnToThread = useCallback(() => { if (navigation.canGoBack()) { navigation.goBack(); @@ -557,10 +549,14 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { )} ; readonly searchSegments: ReadonlyArray; readonly searchWords: ReadonlyArray; @@ -19,6 +20,7 @@ interface MutableFileTreeNode { path: string; name: string; kind: ProjectEntry["kind"]; + ignored?: boolean; children: Map; } @@ -68,6 +70,7 @@ function freezeNode(node: MutableFileTreeNode): FileTreeNode { path: node.path, name: node.name, kind: node.kind, + ...(node.ignored ? { ignored: true } : {}), children: [...node.children.values()].sort(compareNodes).map(freezeNode), searchSegments: searchTerms.segments, searchWords: searchTerms.words, @@ -110,6 +113,7 @@ export function buildFileTree(entries: ReadonlyArray): ReadonlyArr } else if (isLeaf) { child.kind = entry.kind; } + if (isLeaf && entry.ignored) child.ignored = true; current = child; } } diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx index 50c92ed1f590..4bb847dc546a 100644 --- a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx +++ b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx @@ -1,4 +1,4 @@ -import type { EnvironmentId, ProjectListEntriesResult } from "@t3tools/contracts"; +import type { EnvironmentId } from "@t3tools/contracts"; import { SymbolView } from "../../components/AppSymbol"; import { useCallback, useMemo, useState, type ComponentProps } from "react"; import { Platform, Pressable, View, type NativeSyntheticEvent } from "react-native"; @@ -13,10 +13,9 @@ import { import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; -import { projectEnvironment } from "../../state/projects"; -import { useEnvironmentQuery } from "../../state/query"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { FileTreeBrowser } from "./FileTreeBrowser"; +import { useFileTreeEntries } from "./useFileTreeEntries"; import { preloadWorkspaceFileContents } from "./preload-workspace-file"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; @@ -35,13 +34,11 @@ export function ThreadFileNavigatorPane(props: { const foregroundColor = theme["--color-foreground"]; const sheetColor = theme["--color-sheet"]; const headerScrollEdgeEffects = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); - const entriesQuery = useEnvironmentQuery( - projectEnvironment.listEntries({ - environmentId: props.environmentId, - input: { cwd: props.cwd }, - }), - ); - const entriesData = entriesQuery.data as ProjectListEntriesResult | null; + const entriesQuery = useFileTreeEntries({ + environmentId: props.environmentId, + cwd: props.cwd, + searchQuery, + }); const handlePreviewFile = useCallback( (relativePath: string) => { preloadWorkspaceFileContents({ @@ -82,10 +79,14 @@ export function ThreadFileNavigatorPane(props: { const fileTree = ( 0; + const query = input.searchQuery.trim().slice(0, 256); + const debouncedQuery = useDebouncedValue(query, 200); + const root = useEnvironmentQuery( + cwd !== null && environmentId !== null + ? projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath: "" } }) + : null, + ); + const search = useEnvironmentQuery( + searching && debouncedQuery.length > 0 && cwd !== null && environmentId !== null + ? projectEnvironment.searchEntries({ + environmentId, + input: { cwd, query: debouncedQuery, limit: 200 }, + }) + : null, + ); + const [revision, render] = useReducer((value: number) => value + 1, 0); + const refreshVersion = useRef(0); + const directories = useMemo( + () => ({ + cwd, + environmentId, + entries: new Map>(), + requested: new Set(), + pending: new Map(), + errors: new Map(), + }), + [cwd, environmentId], + ); + useEffect( + () => () => { + refreshVersion.current++; + for (const controller of directories.pending.values()) controller.abort(); + directories.pending.clear(); + }, + [directories], + ); + const loadDirectory = useCallback( + (directoryPath: string) => { + if ( + cwd === null || + environmentId === null || + directories.entries.has(directoryPath) || + directories.pending.has(directoryPath) + ) { + return; + } + const controller = new AbortController(); + directories.requested.add(directoryPath); + directories.pending.set(directoryPath, controller); + directories.errors.delete(directoryPath); + render(); + const atom = projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath } }); + appAtomRegistry.refresh(atom); + return executeAtomQuery(appAtomRegistry, atom, { + signal: controller.signal, + reportFailure: false, + reportDefect: false, + }).then((result) => { + if (controller.signal.aborted) return; + directories.pending.delete(directoryPath); + if (result._tag === "Success") { + directories.entries.set( + directoryPath, + result.value.entries.filter( + (entry) => + entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))) === directoryPath, + ), + ); + } else { + const error = Cause.squash(result.cause); + directories.errors.set( + directoryPath, + error instanceof Error ? error.message : "Files unavailable", + ); + } + render(); + }); + }, + [cwd, directories, environmentId], + ); + const { refresh: refreshRoot, data: rootData } = root; + const { refresh: refreshSearch, data: searchData } = search; + const refresh = useCallback(() => { + refreshRoot(); + if (searching) refreshSearch(); + const paths = new Set(directories.requested); + for (const controller of directories.pending.values()) controller.abort(); + directories.pending.clear(); + directories.entries.clear(); + directories.errors.clear(); + const version = ++refreshVersion.current; + const remaining = paths.values(); + const worker = async () => { + while (version === refreshVersion.current) { + const next = remaining.next(); + if (next.done) return; + await loadDirectory(next.value); + } + }; + for (let index = 0; index < Math.min(4, paths.size); index++) void worker(); + render(); + }, [directories, loadDirectory, refreshRoot, refreshSearch, searching]); + const snapshot = useMemo(() => { + const merged = new Map(); + if (searching) { + for (const entry of searchData?.entries ?? []) merged.set(entry.path, entry); + } + const visit = (items: ReadonlyArray) => { + for (const entry of items) { + merged.set(entry.path, entry); + if (entry.kind === "directory") visit(directories.entries.get(entry.path) ?? []); + } + }; + visit((rootData?.entries ?? []).filter((entry) => !entry.path.includes("/"))); + return { revision, entries: [...merged.values()] }; + }, [directories, revision, rootData, searchData, searching]); + + return { + entries: snapshot.entries, + error: + root.error ?? + (searching ? search.error : null) ?? + directories.errors.values().next().value ?? + null, + isPending: + root.isPending || + directories.pending.size > 0 || + (searching && (query !== debouncedQuery || search.isPending)), + searchTruncated: searching && (search.data?.truncated ?? false), + loadedDirectories: new Set(directories.entries.keys()), + loadDirectory, + refresh, + }; +} diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index ff3343c37138..e54e846b5d69 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -96,6 +96,99 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { }); describe("list", () => { + it.effect("lists immediate children including ignored and empty directories", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ git: true }); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* writeTextFile(cwd, ".gitignore", "node_modules/\n.env\n"); + yield* writeTextFile(cwd, ".env", "secret=value"); + yield* writeTextFile(cwd, "node_modules/pkg/index.js"); + yield* writeTextFile(cwd, "src/index.ts"); + yield* fileSystem.makeDirectory(path.join(cwd, "empty")); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const root = yield* workspaceEntries.list({ cwd, directoryPath: "" }); + expect(root.entries).toEqual( + expect.arrayContaining([ + { path: ".env", kind: "file", ignored: true }, + { path: "node_modules", kind: "directory", ignored: true }, + { path: "src", kind: "directory" }, + { path: "empty", kind: "directory" }, + ]), + ); + expect(root.entries.some((entry) => entry.path.includes("/"))).toBe(false); + expect(root.entries.some((entry) => entry.path === ".git")).toBe(false); + expect(root.truncated).toBe(false); + expect(yield* workspaceEntries.list({ cwd, directoryPath: "node_modules/pkg" })).toEqual({ + entries: [{ path: "node_modules/pkg/index.js", kind: "file", ignored: true }], + truncated: false, + }); + expect(yield* workspaceEntries.list({ cwd, directoryPath: "empty" })).toEqual({ + entries: [], + truncated: false, + }); + }), + ); + + it.effect( + "rejects directory traversal, git internals, and symlinks outside the workspace", + () => + Effect.gen(function* () { + const cwd = yield* makeTempDir(); + const outside = yield* makeTempDir(); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* writeTextFile(cwd, ".git/HEAD"); + const platform = yield* HostProcessPlatform; + if (platform !== "win32") yield* fileSystem.symlink(outside, path.join(cwd, "external")); + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + for (const directoryPath of [ + "../", + outside, + ".git", + "missing", + ...(platform !== "win32" ? ["external"] : []), + ]) { + const error = yield* workspaceEntries.list({ cwd, directoryPath }).pipe(Effect.flip); + expect(error._tag).toBe("WorkspaceEntriesReadDirectoryError"); + } + }), + ); + + it.effect( + "browses a workspace with more than 25,000 entries without truncation", + () => + Effect.gen(function* () { + const cwd = yield* makeTempDir(); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + for (let directory = 0; directory < 26; directory++) { + const directoryPath = path.join(cwd, `folder-${directory}`); + yield* fileSystem.makeDirectory(directoryPath); + yield* Effect.forEach( + Array.from({ length: 1000 }, (_, i) => i), + (i) => fileSystem.writeFileString(path.join(directoryPath, `file-${i}.txt`), ""), + { concurrency: 32, discard: true }, + ); + } + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const root = yield* workspaceEntries.list({ cwd, directoryPath: "" }); + expect(root.entries).toHaveLength(26); + expect(root.truncated).toBe(false); + for (const directory of root.entries) { + const result = yield* workspaceEntries.list({ cwd, directoryPath: directory.path }); + expect(result.entries).toHaveLength(1000); + expect(result.truncated).toBe(false); + expect(result.entries).toContainEqual({ + path: `${directory.path}/file-999.txt`, + kind: "file", + }); + } + }), + 60_000, + ); + it.effect("returns the complete cached workspace index", () => Effect.gen(function* () { const cwd = yield* makeTempDir(); diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index 4bdf4d45a8c9..18a964fc8a66 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -11,6 +11,7 @@ import * as Schema from "effect/Schema"; import type { FilesystemBrowseInput, FilesystemBrowseResult, + ProjectEntry, ProjectListEntriesInput, ProjectListEntriesResult, ProjectSearchContentsInput, @@ -23,6 +24,7 @@ import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/p import { normalizeSearchQuery } from "@t3tools/shared/searchRanking"; import { expandHomePathWith } from "../pathExpansion.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as WorkspacePaths from "./WorkspacePaths.ts"; import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; @@ -74,6 +76,7 @@ export const WorkspaceEntriesBrowseError = Schema.Union([ export type WorkspaceEntriesBrowseError = typeof WorkspaceEntriesBrowseError.Type; export const WorkspaceEntriesError = Schema.Union([ + WorkspaceEntriesReadDirectoryError, WorkspacePaths.WorkspaceRootNotExistsError, WorkspacePaths.WorkspaceRootCreateFailedError, WorkspacePaths.WorkspaceRootStatFailedError, @@ -133,6 +136,7 @@ export const make = Effect.gen(function* () { const path = yield* Path.Path; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const workspaceSearchIndexes = yield* WorkspaceSearchIndex.WorkspaceSearchIndexMap; + const vcsProcess = yield* VcsProcess.VcsProcess; const normalizeWorkspaceRoot = Effect.fn("WorkspaceEntries.normalizeWorkspaceRoot")(function* ( cwd: string, @@ -266,6 +270,78 @@ export const make = Effect.gen(function* () { const list: WorkspaceEntries["Service"]["list"] = Effect.fn("WorkspaceEntries.list")( function* (input) { const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); + if (input.directoryPath !== undefined) { + const directoryPath = input.directoryPath; + const toError = (cause: unknown) => + new WorkspaceEntriesReadDirectoryError({ + cwd: normalizedCwd, + partialPath: directoryPath, + parentPath: path.resolve(normalizedCwd, directoryPath), + cause, + }); + const target = + directoryPath === "" + ? { absolutePath: normalizedCwd, relativePath: "" } + : yield* workspacePaths + .resolveRelativePathWithinRoot({ + workspaceRoot: normalizedCwd, + relativePath: directoryPath, + }) + .pipe(Effect.mapError(toError)); + const entries = yield* Effect.tryPromise({ + try: async () => { + const root = await NodeFSP.realpath(normalizedCwd); + const directory = await NodeFSP.realpath(target.absolutePath); + const relative = path.relative(root, directory); + if ( + relative === ".." || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) || + relative.split(path.sep).includes(".git") || + target.relativePath.split("/").includes(".git") + ) { + throw new Error("Directory must be inside the workspace and outside .git."); + } + const children = await NodeFSP.readdir(directory, { withFileTypes: true }); + return children.flatMap((child): ProjectEntry[] => { + if (child.name === ".git" || (!child.isDirectory() && !child.isFile())) return []; + return [ + { + path: target.relativePath ? `${target.relativePath}/${child.name}` : child.name, + kind: child.isDirectory() ? "directory" : "file", + }, + ]; + }); + }, + catch: toError, + }); + // Use stdin so large directories cannot exceed the command-line argument limit. + // Ignore classification is optional in non-git workspaces or when git is unavailable. + const ignored = new Set(); + for (let offset = 0; offset < entries.length; offset += 1000) { + const chunk = entries.slice(offset, offset + 1000); + const result = yield* vcsProcess + .run({ + operation: "WorkspaceEntries.list", + command: "git", + args: ["-c", "core.fsmonitor=false", "check-ignore", "--no-index", "-z", "--stdin"], + cwd: normalizedCwd, + stdin: `${chunk.map((entry) => entry.path).join("\0")}\0`, + allowNonZeroExit: true, + timeoutMs: 10_000, + maxOutputBytes: 16 * 1024 * 1024, + }) + .pipe(Effect.orElseSucceed(() => undefined)); + if (!result || (result.exitCode !== 0 && result.exitCode !== 1)) break; + for (const ignoredPath of result.stdout.split("\0")) ignored.add(ignoredPath); + } + return { + entries: entries.map((entry) => + ignored.has(entry.path) ? { ...entry, ignored: true } : entry, + ), + truncated: false, + }; + } return yield* Effect.gen(function* () { const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; return yield* searchIndex.list(); @@ -284,4 +360,5 @@ export const make = Effect.gen(function* () { export const layer = Layer.effect(WorkspaceEntries, make).pipe( Layer.provide(WorkspaceSearchIndex.WorkspaceSearchIndexMap.layer), + Layer.provide(VcsProcess.layer), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 10d35e92040d..9ac919ea41bb 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -231,6 +231,12 @@ function projectEntriesFailureContext(error: WorkspaceEntries.WorkspaceEntriesEr failure: "workspace_root_not_directory", normalizedCwd: error.normalizedWorkspaceRoot, }; + case "WorkspaceEntriesReadDirectoryError": + return { + failure: "directory_list_failed", + ...(error.cwd !== undefined ? { normalizedCwd: error.cwd } : {}), + detail: error.message, + }; case "WorkspaceSearchIndexCreateFailed": return { failure: "search_index_create_failed", diff --git a/apps/web/src/components/files/FileBreadcrumbs.tsx b/apps/web/src/components/files/FileBreadcrumbs.tsx index 4896c9ce2df0..3021ff0fab6d 100644 --- a/apps/web/src/components/files/FileBreadcrumbs.tsx +++ b/apps/web/src/components/files/FileBreadcrumbs.tsx @@ -78,7 +78,7 @@ function BreadcrumbMenuContent(props: { readonly rootPath: string; readonly workspaceMutationId: string | null; }) { - const entriesQuery = useProjectEntriesQuery(props.environmentId, props.cwd); + const entriesQuery = useProjectEntriesQuery(props.environmentId, props.cwd, props.directoryPath); useWorkspaceMutationRefresh({ mutationId: props.workspaceMutationId, refresh: entriesQuery.refresh, @@ -91,9 +91,7 @@ function BreadcrumbMenuContent(props: { () => fileBreadcrumbChildren(entries, props.directoryPath), [entries, props.directoryPath], ); - const directoryAvailable = - props.directoryPath === "" || - entries.some((entry) => entry.kind === "directory" && entry.path === props.directoryPath); + const directoryAvailable = entriesQuery.data !== null; const parentPath = fileBreadcrumbParent(props.directoryPath); const canGoBack = props.directoryPath !== props.rootPath && @@ -150,7 +148,10 @@ function BreadcrumbMenuContent(props: { key={entry.path} closeOnClick={entry.kind === "file"} aria-current={isCurrentFile ? "page" : undefined} - className={cn(isCurrentFile && "bg-foreground/[0.08]")} + className={cn( + isCurrentFile && "bg-foreground/[0.08]", + entry.ignored && "text-muted-foreground", + )} onClick={() => { if (entry.kind === "directory") { props.onDirectoryChange(entry.path); diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 49894db3c8cf..0e59950d7578 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -7,7 +7,7 @@ import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; import { FileTree, useFileTree, useFileTreeSearch, useFileTreeSelector } from "@pierre/trees/react"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { ChevronsDownUpIcon, ChevronsUpDownIcon } from "lucide-react"; -import { useEffect, useMemo, useRef } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Button } from "~/components/ui/button"; import { InputGroup, InputGroupInput } from "~/components/ui/input-group"; @@ -24,7 +24,8 @@ import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme"; import { createFileTreeDragMentionController } from "./fileTreeDragMention"; import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion"; import { buildFileTreePathUpdates } from "./fileTreePathReconciliation"; -import { useProjectEntriesQuery } from "./projectFilesQueryState"; +import { useDirectoryEntries } from "./useDirectoryEntries"; +import { useProjectPathSearch } from "~/state/queries"; interface FileBrowserPanelProps { environmentId: EnvironmentId; @@ -104,8 +105,31 @@ export default function FileBrowserPanel({ }: FileBrowserPanelProps) { const { resolvedTheme } = useTheme(); const composerRef = useComposerHandleContext(); - const entriesQuery = useProjectEntriesQuery(environmentId, cwd); - const entries = entriesQuery.data?.entries ?? []; + const { + entries: directoryEntries, + load, + refresh, + ready, + error, + isPending, + } = useDirectoryEntries(environmentId, cwd); + const [query, setQuery] = useState(""); + const [expandAll, setExpandAll] = useState(false); + const pathSearch = useProjectPathSearch({ environmentId, cwd, query: query.slice(0, 256) }, 200); + const entries = useMemo(() => { + const result = new Map(directoryEntries.map((entry) => [entry.path, entry])); + if (query.trim() && !pathSearch.isPending) { + for (const entry of pathSearch.entries) { + if (!result.has(entry.path)) result.set(entry.path, entry); + const segments = entry.path.split("/"); + for (let index = 1; index < segments.length; index++) { + const path = segments.slice(0, index).join("/"); + if (!result.has(path)) result.set(path, { path, kind: "directory" }); + } + } + } + return [...result.values()]; + }, [directoryEntries, pathSearch.entries, pathSearch.isPending, query]); const entryKinds = useMemo( () => new Map(entries.map((entry) => [entry.path, entry.kind] as const)), [entries], @@ -222,7 +246,7 @@ export default function FileBrowserPanel({ density: "compact", fileTreeSearchMode: "hide-non-matches", flattenEmptyDirectories: true, - initialExpansion: 1, + initialExpansion: "closed", icons: T3_PIERRE_ICONS, onSelectionChange: (selectedPaths) => { // The drag controller's selection cache must track every change, @@ -244,6 +268,7 @@ export default function FileBrowserPanel({ }, paths: [], search: false, + onSearchChange: (value) => setQuery(value ?? ""), unsafeCSS: PIERRE_TREE_UNSAFE_CSS, }); const search = useFileTreeSearch(model); @@ -251,9 +276,52 @@ export default function FileBrowserPanel({ areAllDirectoriesExpanded(currentModel, directoryPaths), ); const toggleAllDirectories = () => { - setAllDirectoriesExpanded(model, directoryPaths, !allDirectoriesExpanded); + const expanded = !(expandAll || allDirectoriesExpanded); + setExpandAll(expanded); + setAllDirectoriesExpanded(model, directoryPaths, expanded); }; + const closeSearch = () => { + setQuery(""); + search.close(); + }; + useEffect(() => { + const loadExpanded = () => { + if (model.isSearchOpen()) return; + for (const path of directoryPaths) { + const item = model.getItem(path); + if (item?.isDirectory() && "isExpanded" in item && item.isExpanded()) { + void load(path.replace(/\/$/, "")); + } + } + }; + loadExpanded(); + return model.subscribe(loadExpanded); + }, [directoryPaths, load, model]); + useEffect(() => { + model.setGitStatus( + entries + .filter((entry) => entry.ignored) + .map((entry) => ({ + path: treePath(entry), + status: "ignored", + })), + ); + }, [entries, model]); + useEffect(() => { + if (!selectedPath) return; + const controller = new AbortController(); + void (async () => { + const segments = selectedPath.split("/"); + for (let index = 0; index < segments.length && !controller.signal.aborted; index++) { + await load(segments.slice(0, index).join("/")); + } + })(); + return () => { + controller.abort(); + }; + }, [load, selectedPath]); const handleSearchValueChange = (value: string) => { + setQuery(value); if (value.trim().length === 0) { search.close(); return; @@ -261,17 +329,18 @@ export default function FileBrowserPanel({ search.setValue(value); }; const handleRefresh = () => { - entriesQuery.refresh(); + refresh(); + if (query.trim()) pathSearch.refresh(); onRefreshSelectedFile?.(); }; useWorkspaceMutationRefresh({ mutationId: workspaceMutationId, - refresh: entriesQuery.refresh, + refresh, resourceKey: `files:${environmentId}:${cwd}`, }); useEffect(() => { - if (entriesQuery.data === null) return; + if (!ready) return; if (previousTreePathsRef.current === treePaths) return; entryKindsRef.current = entryKinds; const previousTreePaths = previousTreePathsRef.current; @@ -282,7 +351,11 @@ export default function FileBrowserPanel({ } const updates = buildFileTreePathUpdates(previousTreePaths, treePaths); if (updates.length > 0) model.batch(updates); - }, [entriesQuery.data, entryKinds, model, treePaths]); + }, [ready, entryKinds, model, treePaths]); + + useEffect(() => { + if (expandAll && !query.trim()) setAllDirectoriesExpanded(model, directoryPaths, true); + }, [directoryPaths, expandAll, model, query]); useEffect(() => { if (!selectedPath) { @@ -319,6 +392,7 @@ export default function FileBrowserPanel({ handledRevealRef.current = revealRequest; syncingSelectionRef.current = true; + setQuery(""); model.closeSearch(); for (const path of model.getSelectedPaths()) { model.getItem(path)?.deselect(); @@ -376,13 +450,13 @@ export default function FileBrowserPanel({ className="flex h-10 min-h-10 shrink-0 items-center gap-1 border-b border-border/60 bg-background px-2 in-data-[preview-panel-mode=inline]:mb-1 in-data-[preview-panel-mode=inline]:h-9 in-data-[preview-panel-mode=inline]:min-h-9 in-data-[preview-panel-mode=inline]:border-b-transparent" data-surface-subheader > - + {directoryPaths.length > 0 ? ( @@ -393,7 +467,9 @@ export default function FileBrowserPanel({ size="icon-xs" variant="ghost" aria-label={ - allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders" + expandAll || allDirectoriesExpanded + ? "Collapse all folders" + : "Expand all folders" } onClick={toggleAllDirectories} /> @@ -406,21 +482,36 @@ export default function FileBrowserPanel({ )} - {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"} + {expandAll || allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"} ) : null} - {entriesQuery.error && entriesQuery.data === null ? ( -
{entriesQuery.error}
- ) : ( - + {error || pathSearch.error ? ( + + ) : null} + {query.trim() && pathSearch.truncated && !pathSearch.isPending ? ( +
+ More matches available. Refine your search. +
+ ) : null} + {(isPending || pathSearch.isPending) && ( +
+ Loading files… +
)} + ); } diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index a12772920956..4129ac038c35 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -33,8 +33,15 @@ interface ProjectQueryState { readonly refresh: () => void; } -function getProjectEntriesQueryAtom(environmentId: EnvironmentId, cwd: string) { - return projectEnvironment.listEntries({ environmentId, input: { cwd } }); +function getProjectEntriesQueryAtom( + environmentId: EnvironmentId, + cwd: string, + directoryPath?: string, +) { + return projectEnvironment.listEntries({ + environmentId, + input: { cwd, ...(directoryPath !== undefined ? { directoryPath } : {}) }, + }); } export function getProjectFileQueryAtom( @@ -128,8 +135,9 @@ function errorMessage(result: AsyncResult.AsyncResult): string | export function useProjectEntriesQuery( environmentId: EnvironmentId, cwd: string, + directoryPath?: string, ): ProjectQueryState { - const atom = getProjectEntriesQueryAtom(environmentId, cwd); + const atom = getProjectEntriesQueryAtom(environmentId, cwd, directoryPath); const result = useAtomValue(atom); const refreshAtom = useAtomRefresh(atom); const refresh = useCallback(() => refreshAtom(), [refreshAtom]); diff --git a/apps/web/src/components/files/useDirectoryEntries.ts b/apps/web/src/components/files/useDirectoryEntries.ts new file mode 100644 index 000000000000..731b3f8f75ee --- /dev/null +++ b/apps/web/src/components/files/useDirectoryEntries.ts @@ -0,0 +1,124 @@ +import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; +import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; +import * as Cause from "effect/Cause"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { projectEnvironment } from "~/state/projects"; + +/** Loads only requested directories; collapsing a folder keeps its children cached. */ +export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) { + const [directories, setDirectories] = useState(new Map()); + const [errors, setErrors] = useState(new Map()); + const [pending, setPending] = useState(0); + const requests = useRef(new Map>()); + const loaded = useRef(new Set()); + const active = useRef(true); + const running = useRef(0); + const waiting = useRef void>>([]); + + const load = useCallback( + function loadDirectory(directoryPath: string, refresh = false): Promise { + const existing = requests.current.get(directoryPath); + if (existing) + return refresh ? existing.then(() => loadDirectory(directoryPath, true)) : existing; + if (!refresh && loaded.current.has(directoryPath)) return Promise.resolve(); + loaded.current.add(directoryPath); + const atom = projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath } }); + setPending((count) => count + 1); + const request = (async () => { + if (running.current >= 4) + await new Promise((resolve) => waiting.current.push(resolve)); + else running.current++; + try { + if (!active.current) return undefined; + return await executeAtomQuery(appAtomRegistry, atom, { + refresh, + reportFailure: false, + reportDefect: false, + }); + } finally { + const next = waiting.current.shift(); + if (next) next(); + else running.current--; + } + })() + .then((result) => { + if (!active.current || !result) return; + if (result._tag === "Success") { + setDirectories((previous) => + new Map(previous).set( + directoryPath, + result.value.entries.filter( + (entry) => + entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))) === directoryPath, + ), + ), + ); + setErrors((previous) => { + const next = new Map(previous); + next.delete(directoryPath); + return next; + }); + } else { + const cause = Cause.squash(result.cause); + setErrors((previous) => + new Map(previous).set( + directoryPath, + cause instanceof Error ? cause.message : "Unable to load folder.", + ), + ); + } + }) + .finally(() => { + requests.current.delete(directoryPath); + if (active.current) setPending((count) => count - 1); + }); + requests.current.set(directoryPath, request); + return request; + }, + [cwd, environmentId], + ); + + useEffect(() => { + active.current = true; + void load(""); + return () => { + active.current = false; + }; + }, [load]); + + const refresh = useCallback(() => { + // Refresh folders already visited, preserving the current expansion state. + const paths = [...loaded.current]; + let next = 0; + const worker = async () => { + while (next < paths.length && active.current) { + const path = paths[next++]; + if (path !== undefined) await load(path, true); + } + }; + for (let index = 0; index < Math.min(4, paths.length); index++) void worker(); + }, [load]); + + const entries = useMemo(() => { + const result: ProjectEntry[] = []; + const visit = (path: string) => { + for (const entry of directories.get(path) ?? []) { + result.push(entry); + if (entry.kind === "directory") visit(entry.path); + } + }; + visit(""); + return result; + }, [directories]); + + return { + entries, + load, + refresh, + isPending: pending > 0, + ready: directories.has(""), + error: errors.values().next().value ?? null, + }; +} diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 1792c5e9e599..a16c0921a7de 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -263,6 +263,7 @@ export function useProjectPathSearch( isPending: !areProjectPathSearchTargetsEqual(normalizedTarget, debouncedTarget) || result.isPending, searchedQuery: debouncedTarget.query ?? "", + truncated: result.data?.truncated ?? false, refresh: result.refresh, }; } diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index bee2e70d5746..3858c241c2d6 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -28,6 +28,7 @@ export type ProjectSearchEntriesInput = typeof ProjectSearchEntriesInput.Type; export const ProjectEntry = Schema.Struct({ path: TrimmedNonEmptyString, kind: ProjectEntryKind, + ignored: Schema.optional(Schema.Boolean), }); export type ProjectEntry = typeof ProjectEntry.Type; @@ -72,6 +73,9 @@ export type ProjectSearchContentsResult = typeof ProjectSearchContentsResult.Typ export const ProjectListEntriesInput = Schema.Struct({ cwd: TrimmedNonEmptyString, + // Present for immediate filesystem children, including ignored entries; empty means root. + // Omitted preserves the indexed recursive listing used by older clients. + directoryPath: Schema.optional(TrimmedString), }); export type ProjectListEntriesInput = typeof ProjectListEntriesInput.Type; @@ -89,6 +93,7 @@ export const ProjectEntriesFailure = Schema.Literals([ "search_index_create_failed", "search_index_scan_timed_out", "search_index_search_failed", + "directory_list_failed", ]); export type ProjectEntriesFailure = typeof ProjectEntriesFailure.Type; From 41c6248076d6dda23f73e125124734796eb6eb81 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 13 Sep 2026 05:02:12 +0000 Subject: [PATCH 2/3] fix(files): retry folders and preserve search selection --- .../src/workspace/WorkspaceEntries.test.ts | 5 ++++- apps/server/src/workspace/WorkspaceEntries.ts | 2 +- .../src/components/files/FileBrowserPanel.tsx | 20 +++++++++++++++---- .../components/files/useDirectoryEntries.ts | 7 +++++-- 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index e54e846b5d69..6013f978ed0c 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -101,7 +101,9 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { const cwd = yield* makeTempDir({ git: true }); const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - yield* writeTextFile(cwd, ".gitignore", "node_modules/\n.env\n"); + yield* writeTextFile(cwd, "tracked.txt"); + yield* git(cwd, ["add", "tracked.txt"]); + yield* writeTextFile(cwd, ".gitignore", "node_modules/\n.env\ntracked.txt\n"); yield* writeTextFile(cwd, ".env", "secret=value"); yield* writeTextFile(cwd, "node_modules/pkg/index.js"); yield* writeTextFile(cwd, "src/index.ts"); @@ -115,6 +117,7 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { { path: "node_modules", kind: "directory", ignored: true }, { path: "src", kind: "directory" }, { path: "empty", kind: "directory" }, + { path: "tracked.txt", kind: "file" }, ]), ); expect(root.entries.some((entry) => entry.path.includes("/"))).toBe(false); diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index 18a964fc8a66..d575f944d885 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -324,7 +324,7 @@ export const make = Effect.gen(function* () { .run({ operation: "WorkspaceEntries.list", command: "git", - args: ["-c", "core.fsmonitor=false", "check-ignore", "--no-index", "-z", "--stdin"], + args: ["-c", "core.fsmonitor=false", "check-ignore", "-z", "--stdin"], cwd: normalizedCwd, stdin: `${chunk.map((entry) => entry.path).join("\0")}\0`, allowNonZeroExit: true, diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 0e59950d7578..a75ab93594c8 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -284,13 +284,19 @@ export default function FileBrowserPanel({ setQuery(""); search.close(); }; + const expandedPathsRef = useRef(new Set()); useEffect(() => { const loadExpanded = () => { if (model.isSearchOpen()) return; for (const path of directoryPaths) { const item = model.getItem(path); if (item?.isDirectory() && "isExpanded" in item && item.isExpanded()) { - void load(path.replace(/\/$/, "")); + if (!expandedPathsRef.current.has(path)) { + expandedPathsRef.current.add(path); + void load(path.replace(/\/$/, "")); + } + } else { + expandedPathsRef.current.delete(path); } } }; @@ -335,7 +341,10 @@ export default function FileBrowserPanel({ }; useWorkspaceMutationRefresh({ mutationId: workspaceMutationId, - refresh, + refresh: () => { + refresh(); + if (query.trim()) pathSearch.refresh(); + }, resourceKey: `files:${environmentId}:${cwd}`, }); @@ -362,6 +371,10 @@ export default function FileBrowserPanel({ handledRevealRef.current = null; return; } + if (entryKinds.get(selectedPath) !== "file") { + handledRevealRef.current = null; + return; + } const revealRequest = { path: selectedPath, revealId: selectedPathRevealId }; const handledReveal = handledRevealRef.current; // Entry refreshes rebuild treePaths while the same preview stays open. @@ -372,7 +385,6 @@ export default function FileBrowserPanel({ ) { return; } - if (entryKinds.get(selectedPath) !== "file") return; const selectedItem = model.getItem(selectedPath); if (!selectedItem) return; @@ -413,7 +425,7 @@ export default function FileBrowserPanel({ queueMicrotask(() => { syncingSelectionRef.current = false; }); - }, [entryKinds, model, selectedPath, selectedPathRevealId, treePaths]); + }, [entryKinds, model, selectedPath, selectedPathRevealId]); // Tag tree drags with the composer mention payload. The row is read from // the composed event path (the tree's shadow root is open), so this does diff --git a/apps/web/src/components/files/useDirectoryEntries.ts b/apps/web/src/components/files/useDirectoryEntries.ts index 731b3f8f75ee..1b98ac2a4efc 100644 --- a/apps/web/src/components/files/useDirectoryEntries.ts +++ b/apps/web/src/components/files/useDirectoryEntries.ts @@ -13,6 +13,7 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) { const [pending, setPending] = useState(0); const requests = useRef(new Map>()); const loaded = useRef(new Set()); + const requested = useRef(new Set()); const active = useRef(true); const running = useRef(0); const waiting = useRef void>>([]); @@ -24,6 +25,7 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) { return refresh ? existing.then(() => loadDirectory(directoryPath, true)) : existing; if (!refresh && loaded.current.has(directoryPath)) return Promise.resolve(); loaded.current.add(directoryPath); + requested.current.add(directoryPath); const atom = projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath } }); setPending((count) => count + 1); const request = (async () => { @@ -33,7 +35,7 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) { try { if (!active.current) return undefined; return await executeAtomQuery(appAtomRegistry, atom, { - refresh, + refresh: true, reportFailure: false, reportDefect: false, }); @@ -61,6 +63,7 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) { return next; }); } else { + loaded.current.delete(directoryPath); const cause = Cause.squash(result.cause); setErrors((previous) => new Map(previous).set( @@ -90,7 +93,7 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) { const refresh = useCallback(() => { // Refresh folders already visited, preserving the current expansion state. - const paths = [...loaded.current]; + const paths = [...requested.current]; let next = 0; const worker = async () => { while (next < paths.length && active.current) { From 00b45de2674fd7e535289f7f1d11500475bb11c4 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 13 Sep 2026 05:09:34 +0000 Subject: [PATCH 3/3] fix(files): preserve folder state when refreshing --- .../src/features/files/useFileTreeEntries.ts | 55 ++++++++++++------- .../src/components/files/FileBrowserPanel.tsx | 5 ++ .../components/files/useDirectoryEntries.ts | 37 ++++++++----- 3 files changed, 62 insertions(+), 35 deletions(-) diff --git a/apps/mobile/src/features/files/useFileTreeEntries.ts b/apps/mobile/src/features/files/useFileTreeEntries.ts index f807a798a74a..604fb3bd5231 100644 --- a/apps/mobile/src/features/files/useFileTreeEntries.ts +++ b/apps/mobile/src/features/files/useFileTreeEntries.ts @@ -52,11 +52,11 @@ export function useFileTreeEntries(input: { [directories], ); const loadDirectory = useCallback( - (directoryPath: string) => { + (directoryPath: string, refresh = false) => { if ( cwd === null || environmentId === null || - directories.entries.has(directoryPath) || + (!refresh && directories.entries.has(directoryPath)) || directories.pending.has(directoryPath) ) { return; @@ -97,13 +97,33 @@ export function useFileTreeEntries(input: { ); const { refresh: refreshRoot, data: rootData } = root; const { refresh: refreshSearch, data: searchData } = search; + const snapshot = useMemo(() => { + const merged = new Map(); + if (searching) { + for (const entry of searchData?.entries ?? []) merged.set(entry.path, entry); + } + const reachableDirectories = new Set(); + const visit = (items: ReadonlyArray) => { + for (const entry of items) { + merged.set(entry.path, entry); + if (entry.kind === "directory") { + reachableDirectories.add(entry.path); + visit(directories.entries.get(entry.path) ?? []); + } + } + }; + visit((rootData?.entries ?? []).filter((entry) => !entry.path.includes("/"))); + return { revision, entries: [...merged.values()], reachableDirectories }; + }, [directories, revision, rootData, searchData, searching]); + const refresh = useCallback(() => { refreshRoot(); if (searching) refreshSearch(); - const paths = new Set(directories.requested); + const paths = new Set( + [...directories.requested].filter((path) => snapshot.reachableDirectories.has(path)), + ); for (const controller of directories.pending.values()) controller.abort(); directories.pending.clear(); - directories.entries.clear(); directories.errors.clear(); const version = ++refreshVersion.current; const remaining = paths.values(); @@ -111,33 +131,26 @@ export function useFileTreeEntries(input: { while (version === refreshVersion.current) { const next = remaining.next(); if (next.done) return; - await loadDirectory(next.value); + await loadDirectory(next.value, true); } }; for (let index = 0; index < Math.min(4, paths.size); index++) void worker(); render(); - }, [directories, loadDirectory, refreshRoot, refreshSearch, searching]); - const snapshot = useMemo(() => { - const merged = new Map(); - if (searching) { - for (const entry of searchData?.entries ?? []) merged.set(entry.path, entry); - } - const visit = (items: ReadonlyArray) => { - for (const entry of items) { - merged.set(entry.path, entry); - if (entry.kind === "directory") visit(directories.entries.get(entry.path) ?? []); - } - }; - visit((rootData?.entries ?? []).filter((entry) => !entry.path.includes("/"))); - return { revision, entries: [...merged.values()] }; - }, [directories, revision, rootData, searchData, searching]); + }, [ + directories, + loadDirectory, + refreshRoot, + refreshSearch, + searching, + snapshot.reachableDirectories, + ]); return { entries: snapshot.entries, error: root.error ?? (searching ? search.error : null) ?? - directories.errors.values().next().value ?? + [...directories.errors].find(([path]) => snapshot.reachableDirectories.has(path))?.[1] ?? null, isPending: root.isPending || diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index a75ab93594c8..750fa3c7d191 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -286,6 +286,10 @@ export default function FileBrowserPanel({ }; const expandedPathsRef = useRef(new Set()); useEffect(() => { + const currentPaths = new Set(directoryPaths); + for (const path of expandedPathsRef.current) { + if (!currentPaths.has(path)) expandedPathsRef.current.delete(path); + } const loadExpanded = () => { if (model.isSearchOpen()) return; for (const path of directoryPaths) { @@ -296,6 +300,7 @@ export default function FileBrowserPanel({ void load(path.replace(/\/$/, "")); } } else { + if (item?.isDirectory() && expandedPathsRef.current.has(path)) setExpandAll(false); expandedPathsRef.current.delete(path); } } diff --git a/apps/web/src/components/files/useDirectoryEntries.ts b/apps/web/src/components/files/useDirectoryEntries.ts index 1b98ac2a4efc..c7a1db0207cb 100644 --- a/apps/web/src/components/files/useDirectoryEntries.ts +++ b/apps/web/src/components/files/useDirectoryEntries.ts @@ -91,19 +91,6 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) { }; }, [load]); - const refresh = useCallback(() => { - // Refresh folders already visited, preserving the current expansion state. - const paths = [...requested.current]; - let next = 0; - const worker = async () => { - while (next < paths.length && active.current) { - const path = paths[next++]; - if (path !== undefined) await load(path, true); - } - }; - for (let index = 0; index < Math.min(4, paths.length); index++) void worker(); - }, [load]); - const entries = useMemo(() => { const result: ProjectEntry[] = []; const visit = (path: string) => { @@ -116,12 +103,34 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) { return result; }, [directories]); + const reachableDirectories = useMemo( + () => + new Set([ + "", + ...entries.filter((entry) => entry.kind === "directory").map((entry) => entry.path), + ]), + [entries], + ); + + const refresh = useCallback(() => { + // Refresh folders already visited, preserving the current expansion state. + const paths = [...requested.current].filter((path) => reachableDirectories.has(path)); + let next = 0; + const worker = async () => { + while (next < paths.length && active.current) { + const path = paths[next++]; + if (path !== undefined) await load(path, true); + } + }; + for (let index = 0; index < Math.min(4, paths.length); index++) void worker(); + }, [load, reachableDirectories]); + return { entries, load, refresh, isPending: pending > 0, ready: directories.has(""), - error: errors.values().next().value ?? null, + error: [...errors].find(([path]) => reachableDirectories.has(path))?.[1] ?? null, }; }