diff --git a/apps/mobile/src/features/files/FileTreeBrowser.tsx b/apps/mobile/src/features/files/FileTreeBrowser.tsx index 321b3c66e7..6cfeb76403 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, + loadedDirectories, + onPreviewFile, + selectedPath, + toggleDirectory, + ], ); // SPIKE: render the FlatList as the screen's DIRECT content (no wrapping View), and @@ -248,11 +261,18 @@ export function FileTreeBrowser(props: { refreshControl={} renderItem={renderItem} ListHeaderComponent={ - props.error && props.entries.length > 0 ? ( - - {props.error} - - ) : null + <> + {props.error && props.entries.length > 0 ? ( + + {props.error} + + ) : null} + {props.searchTruncated ? ( + + More search results available. Refine your search to see them. + + ) : null} + } ListEmptyComponent={ diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 669762b60a..fca93daf7f 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 9e99fc9580..e2362e9e04 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({ @@ -72,10 +69,14 @@ export function ThreadFileNavigatorPane(props: { const fileTree = ( ({ + fixture: { + root: null as ProjectListEntriesResult | null, + pages: new Map(), + requests: [] as string[], + }, +})); +vi.mock("@t3tools/client-runtime/state/runtime", () => ({ + executeAtomQuery: async ( + _registry: unknown, + atom: { input: { directoryPath: string; directoryCursor?: string } }, + ) => { + const key = `${atom.input.directoryPath}:${atom.input.directoryCursor ?? ""}`; + fixture.requests.push(key); + const value = fixture.pages.get(key); + if (value === undefined) throw new Error(`Missing fixture for ${key}`); + return { _tag: "Success", value }; + }, +})); +vi.mock("../../state/atom-registry", () => ({ appAtomRegistry: { refresh: () => {} } })); +vi.mock("../../state/projects", () => ({ + projectEnvironment: { + listEntries: ({ input }: { input: { directoryPath: string; directoryCursor?: string } }) => ({ + kind: "list", + input, + }), + searchEntries: () => ({ kind: "search" }), + }, +})); +vi.mock("../../state/queries", () => ({ useDebouncedValue: (value: string) => value })); +vi.mock("../../state/query", () => ({ + useEnvironmentQuery: (atom: { kind: string } | null) => ({ + data: atom?.kind === "list" ? fixture.root : null, + error: null, + isPending: false, + refresh: () => {}, + }), +})); + +import { useFileTreeEntries } from "./useFileTreeEntries"; + +let renderer: ReactTestRenderer | null = null; +function Probe(_props: { readonly state: ReturnType }) { + return null; +} +function Surface() { + return ( + + ); +} +function current() { + return renderer!.root.findByType(Probe).props.state as ReturnType; +} + +afterEach(async () => { + await act(async () => renderer?.unmount()); + renderer = null; + fixture.root = null; + fixture.pages.clear(); + fixture.requests.length = 0; +}); + +it("refreshes a paginated root instead of retaining its old cached entries", async () => { + fixture.root = { + entries: [{ path: "old.txt", kind: "file" }], + truncated: true, + directoryPath: "", + nextDirectoryCursor: "old.txt", + }; + fixture.pages.set(":", fixture.root); + fixture.pages.set(":old.txt", { + entries: [{ path: "older.txt", kind: "file" }], + truncated: false, + directoryPath: "", + }); + await act(async () => { + renderer = create(); + }); + expect(current().entries.map((entry) => entry.path)).toEqual(["old.txt", "older.txt"]); + + fixture.pages.set(":", { + entries: [{ path: "new.txt", kind: "file" }], + truncated: true, + directoryPath: "", + nextDirectoryCursor: "new.txt", + }); + fixture.pages.set(":new.txt", { + entries: [{ path: "newer.txt", kind: "file" }], + truncated: false, + directoryPath: "", + }); + await act(async () => current().refresh()); + + expect(fixture.requests).toEqual([":", ":old.txt", ":", ":new.txt"]); + expect(current().entries.map((entry) => entry.path)).toEqual(["new.txt", "newer.txt"]); +}); diff --git a/apps/mobile/src/features/files/useFileTreeEntries.ts b/apps/mobile/src/features/files/useFileTreeEntries.ts new file mode 100644 index 0000000000..65d338276b --- /dev/null +++ b/apps/mobile/src/features/files/useFileTreeEntries.ts @@ -0,0 +1,236 @@ +import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { useCallback, useEffect, useMemo, useReducer, useRef } from "react"; + +import { appAtomRegistry } from "../../state/atom-registry"; +import { projectEnvironment } from "../../state/projects"; +import { useDebouncedValue } from "../../state/queries"; +import { useEnvironmentQuery } from "../../state/query"; + +const MAX_DIRECTORY_PAGES = 50; + +export function useFileTreeEntries(input: { + readonly cwd: string | null; + readonly environmentId: EnvironmentId | null; + readonly searchQuery: string; +}) { + const { cwd, environmentId } = input; + const searching = input.searchQuery.trim().length > 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, + ); + // Older servers ignore directoryPath and return the complete indexed tree. Keep rendering that + // answer as a legacy tree instead of treating it as one folder's immediate children. + const legacyEntries = + root.data !== null && root.data.directoryPath === undefined ? root.data.entries : 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, refresh = false) => { + if ( + cwd === null || + environmentId === null || + legacyEntries !== null || + (!refresh && 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(); + return (async () => { + const collected: ProjectEntry[] = []; + let cursor: string | undefined; + for (let page = 0; page < MAX_DIRECTORY_PAGES; page++) { + const atom = projectEnvironment.listEntries({ + environmentId, + input: { + cwd, + directoryPath, + ...(cursor === undefined ? {} : { directoryCursor: cursor }), + }, + }); + appAtomRegistry.refresh(atom); + const result = await executeAtomQuery(appAtomRegistry, atom, { + signal: controller.signal, + reportFailure: false, + reportDefect: false, + }); + if (controller.signal.aborted) return; + if (result._tag !== "Success") { + const cause = Cause.squash(result.cause); + throw cause instanceof Error ? cause : new Error("Files unavailable"); + } + // A legacy server returns its indexed full tree without the marker. The root query + // renders that answer; never misinterpret a nested copy as one folder's children. + if (result.value.directoryPath !== directoryPath) return; + collected.push( + ...result.value.entries.filter( + (entry) => + entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))) === directoryPath, + ), + ); + if (!result.value.truncated) { + directories.entries.set(directoryPath, collected); + return; + } + if ( + result.value.nextDirectoryCursor === undefined || + result.value.nextDirectoryCursor === cursor + ) { + throw new Error("Folder listing stopped before all entries arrived. Refresh to retry."); + } + cursor = result.value.nextDirectoryCursor; + } + throw new Error("This folder has too many items to show. Search for a narrower path."); + })() + .catch((error: unknown) => { + if (!controller.signal.aborted) + directories.errors.set( + directoryPath, + error instanceof Error ? error.message : "Files unavailable", + ); + }) + .finally(() => { + if (controller.signal.aborted) return; + directories.pending.delete(directoryPath); + render(); + }); + }, + [cwd, directories, environmentId, legacyEntries], + ); + const { refresh: refreshRoot, data: rootData } = root; + useEffect(() => { + if ( + rootData?.directoryPath === "" && + rootData.nextDirectoryCursor !== undefined && + !directories.entries.has("") + ) { + void loadDirectory(""); + } + }, [directories, loadDirectory, rootData]); + 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) ?? []); + } + } + }; + if (legacyEntries !== null) { + for (const entry of legacyEntries) merged.set(entry.path, entry); + for (const entry of legacyEntries) + if (entry.kind === "directory") reachableDirectories.add(entry.path); + } else { + visit( + (directories.entries.get("") ?? rootData?.entries ?? []).filter( + (entry) => !entry.path.includes("/"), + ), + ); + } + return { revision, entries: [...merged.values()], reachableDirectories }; + }, [directories, legacyEntries, revision, rootData, searchData, searching]); + + const refresh = useCallback(() => { + refreshRoot(); + if (searching) refreshSearch(); + const paths = new Set( + [...directories.requested].filter( + (path) => path === "" || snapshot.reachableDirectories.has(path), + ), + ); + if (legacyEntries !== null) { + render(); + return; + } + for (const controller of directories.pending.values()) controller.abort(); + directories.pending.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, true); + } + }; + for (let index = 0; index < Math.min(4, paths.size); index++) void worker(); + render(); + }, [ + directories, + legacyEntries, + loadDirectory, + refreshRoot, + refreshSearch, + searching, + snapshot.reachableDirectories, + ]); + + return { + entries: snapshot.entries, + error: + root.error ?? + (searching ? search.error : null) ?? + [...directories.errors].find(([path]) => snapshot.reachableDirectories.has(path))?.[1] ?? + null, + isPending: + root.isPending || + directories.pending.size > 0 || + (searching && (query !== debouncedQuery || search.isPending)), + searchTruncated: searching && (search.data?.truncated ?? false), + loadedDirectories: + legacyEntries === null + ? new Set(directories.entries.keys()) + : new Set( + [...legacyEntries] + .filter((entry) => entry.kind === "directory") + .map((entry) => entry.path), + ), + loadDirectory, + refresh, + }; +} diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index ff3343c371..71e96a73d1 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -96,6 +96,102 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { }); describe("list", () => { + it.effect( + "lists immediate children including ignored paths without changing legacy listing", + () => + 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", "private"); + yield* writeTextFile(cwd, "node_modules/pkg/index.js"); + yield* writeTextFile(cwd, "folder with spaces/file.ts"); + yield* fileSystem.makeDirectory(path.join(cwd, "empty")); + + const service = yield* WorkspaceEntries.WorkspaceEntries; + const root = yield* service.list({ cwd, directoryPath: "" }); + expect(root.directoryPath).toBe(""); + expect(root.entries).toEqual( + expect.arrayContaining([ + { path: ".env", kind: "file", ignored: true }, + { path: "node_modules", kind: "directory", ignored: true }, + { path: "folder with spaces", kind: "directory" }, + { path: "empty", kind: "directory" }, + ]), + ); + expect(root.entries.every((entry) => !entry.path.includes("/"))).toBe(true); + expect(root.entries.some((entry) => entry.path === ".git")).toBe(false); + expect(yield* service.list({ cwd, directoryPath: "folder with spaces" })).toEqual({ + entries: [{ path: "folder with spaces/file.ts", kind: "file" }], + truncated: false, + directoryPath: "folder with spaces", + }); + const legacy = yield* service.list({ cwd }); + expect(legacy).not.toHaveProperty("directoryPath"); + expect(legacy.entries.some((entry) => entry.path === ".env")).toBe(false); + }), + ); + + it.effect("rejects traversal, git internals, and outside symlink directories", () => + 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, "outside-link")); + const service = yield* WorkspaceEntries.WorkspaceEntries; + for (const directoryPath of [ + "../", + outside, + ".git", + "missing", + ...(platform === "win32" ? [] : ["outside-link"]), + ]) { + const error = yield* service.list({ cwd, directoryPath }).pipe(Effect.flip); + expect(error._tag).toBe("WorkspaceEntriesReadDirectoryError"); + } + }), + ); + + it.effect("pages a large folder with stable child-name cursors", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir(); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* Effect.forEach( + Array.from({ length: 1002 }, (_, index) => index), + (index) => + fileSystem.writeFileString( + path.join(cwd, `file-${String(index).padStart(4, "0")}.txt`), + "", + ), + { concurrency: 32, discard: true }, + ); + const service = yield* WorkspaceEntries.WorkspaceEntries; + const first = yield* service.list({ cwd, directoryPath: "" }); + expect(first.entries).toHaveLength(1000); + expect(first.truncated).toBe(true); + expect(first.nextDirectoryCursor).toBe("file-0999.txt"); + const second = yield* service.list({ + cwd, + directoryPath: "", + directoryCursor: first.nextDirectoryCursor, + }); + expect(second).toEqual({ + entries: [ + { path: "file-1000.txt", kind: "file" }, + { path: "file-1001.txt", kind: "file" }, + ], + truncated: false, + directoryPath: "", + }); + }), + ); + 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 4bdf4d45a8..a28e68d8da 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,9 +24,12 @@ 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"; +const DIRECTORY_PAGE_SIZE = 1_000; + export class WorkspaceEntriesWindowsPathUnsupportedError extends Schema.TaggedError()( "WorkspaceEntriesWindowsPathUnsupportedError", { @@ -74,6 +78,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 +138,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 +272,94 @@ 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 }); + const eligible = children + .filter( + (child) => + child.name !== ".git" && + (child.isDirectory() || child.isFile()) && + (input.directoryCursor === undefined || child.name > input.directoryCursor), + ) + .sort((left, right) => + left.name < right.name ? -1 : left.name > right.name ? 1 : 0, + ); + const hasMore = eligible.length > DIRECTORY_PAGE_SIZE; + const page = eligible.slice(0, DIRECTORY_PAGE_SIZE); + const entries = page.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", + }, + ]; + }); + return { entries, nextDirectoryCursor: hasMore ? page.at(-1)?.name : undefined }; + }, + catch: toError, + }); + // Classify ignored entries without letting git availability decide whether a file exists. + const ignored = new Set(); + for (let offset = 0; offset < entries.entries.length; offset += 1000) { + const chunk = entries.entries.slice(offset, offset + 1000); + const result = yield* vcsProcess + .run({ + operation: "WorkspaceEntries.list", + command: "git", + args: ["-c", "core.fsmonitor=false", "check-ignore", "-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.entries.map((entry) => + ignored.has(entry.path) ? { ...entry, ignored: true } : entry, + ), + truncated: entries.nextDirectoryCursor !== undefined, + directoryPath, + ...(entries.nextDirectoryCursor === undefined + ? {} + : { nextDirectoryCursor: entries.nextDirectoryCursor }), + }; + } return yield* Effect.gen(function* () { const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; return yield* searchIndex.list(); @@ -284,4 +378,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 72ca9317a7..17f9fbf701 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -260,6 +260,12 @@ function projectEntriesFailureContext(error: WorkspaceEntries.WorkspaceEntriesEr readonly detail?: string; } { switch (error._tag) { + case "WorkspaceEntriesReadDirectoryError": + return { + failure: "directory_list_failed", + ...(error.cwd === undefined ? {} : { normalizedCwd: error.cwd }), + detail: error.message, + }; case "WorkspaceRootNotExistsError": return { failure: "workspace_root_not_found", diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 51c6bb172c..f9a4300812 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, useLayoutEffect, useMemo, useRef } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Button } from "~/components/ui/button"; import { InputGroup, InputGroupInput } from "~/components/ui/input-group"; @@ -17,6 +17,7 @@ import { useComposerHandleContext } from "~/composerHandleContext"; import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { useTheme } from "~/hooks/useTheme"; import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; +import { useProjectPathSearch } from "~/state/queries"; import { readLocalApi } from "~/localApi"; import { useFileContextMenu, type FileContextMenuAction } from "~/fileContextMenu"; import { T3_PIERRE_ICONS } from "~/pierre-icons"; @@ -25,7 +26,7 @@ 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"; interface FileBrowserPanelProps { environmentId: EnvironmentId; @@ -93,7 +94,7 @@ function FileSearchField(props: { ); } -export default function FileBrowserPanel({ +function FileBrowserPanelScoped({ environmentId, cwd, projectName, @@ -106,8 +107,31 @@ export default function FileBrowserPanel({ const { resolvedTheme } = useTheme(); const composerRef = useComposerHandleContext(); const fileContextMenu = useFileContextMenu(environmentId, cwd); - 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], @@ -243,7 +267,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, @@ -265,6 +289,7 @@ export default function FileBrowserPanel({ }, paths: [], search: false, + onSearchChange: (value) => setQuery(value ?? ""), unsafeCSS: PIERRE_TREE_UNSAFE_CSS, }); const search = useFileTreeSearch(model); @@ -272,9 +297,61 @@ 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(); + }; + 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) { + const item = model.getItem(path); + if (item?.isDirectory() && "isExpanded" in item && item.isExpanded()) { + if (!expandedPathsRef.current.has(path)) { + expandedPathsRef.current.add(path); + void load(path.replace(/\/$/, "")); + } + } else { + if (item?.isDirectory() && expandedPathsRef.current.has(path)) setExpandAll(false); + expandedPathsRef.current.delete(path); + } + } + }; + 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; @@ -282,16 +359,20 @@ 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: () => { + refresh(); + if (query.trim()) pathSearch.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; @@ -302,7 +383,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) { @@ -344,6 +429,7 @@ export default function FileBrowserPanel({ handledRevealRef.current = revealRequest; syncingSelectionRef.current = true; + setQuery(""); model.closeSearch(); for (const path of model.getSelectedPaths()) { model.getItem(path)?.deselect(); @@ -405,13 +491,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 ? ( @@ -422,34 +508,57 @@ 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} /> } > - {allDirectoriesExpanded ? ( + {expandAll || allDirectoriesExpanded ? ( ) : ( )} - {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… +
)} + ); } + +export default function FileBrowserPanel(props: FileBrowserPanelProps) { + return ( + + ); +} diff --git a/apps/web/src/components/files/useDirectoryEntries.test.tsx b/apps/web/src/components/files/useDirectoryEntries.test.tsx new file mode 100644 index 0000000000..c3ee16e13b --- /dev/null +++ b/apps/web/src/components/files/useDirectoryEntries.test.tsx @@ -0,0 +1,112 @@ +import { EnvironmentId, type ProjectListEntriesResult } from "@t3tools/contracts"; +import { StrictMode, act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, expect, it, vi } from "vite-plus/test"; + +const { fixture } = vi.hoisted(() => ({ + fixture: { + answers: new Map(), + requests: [] as string[], + }, +})); +vi.mock("@t3tools/client-runtime/state/runtime", () => ({ + executeAtomQuery: async ( + _registry: unknown, + atom: { input: { directoryPath: string; directoryCursor?: string } }, + ) => { + const key = `${atom.input.directoryPath}:${atom.input.directoryCursor ?? ""}`; + fixture.requests.push(key); + const value = fixture.answers.get(key); + if (value === undefined) throw new Error(`Missing fixture for ${key}`); + return { _tag: "Success", value }; + }, +})); +vi.mock("~/rpc/atomRegistry", () => ({ appAtomRegistry: {} })); +vi.mock("~/state/projects", () => ({ + projectEnvironment: { + listEntries: ({ input }: { input: { directoryPath: string; directoryCursor?: string } }) => ({ + input, + }), + }, +})); + +import { useDirectoryEntries } from "./useDirectoryEntries"; + +const environmentId = EnvironmentId.make("web-files-test"); +let renderer: ReactTestRenderer | null = null; +function Probe(props: { readonly state: ReturnType }) { + return null; +} +function Surface() { + return ; +} +function current() { + return renderer!.root.findByType(Probe).props.state as ReturnType; +} + +afterEach(async () => { + await act(async () => renderer?.unmount()); + renderer = null; + fixture.answers.clear(); + fixture.requests.length = 0; +}); + +it("keeps an old server's indexed answer whole and avoids child-folder requests", async () => { + fixture.answers.set(":", { + entries: [ + { path: "src", kind: "directory" }, + { path: "src/deep.ts", kind: "file" }, + ], + truncated: false, + }); + await act(async () => { + renderer = create(); + }); + expect(current().entries.map((entry) => entry.path)).toEqual(["src", "src/deep.ts"]); + await act(async () => current().load("src")); + expect(fixture.requests).toEqual([":"]); +}); + +it("loads every page of an expanded folder only when requested", async () => { + fixture.answers.set(":", { + entries: [{ path: "src", kind: "directory" }], + truncated: false, + directoryPath: "", + }); + fixture.answers.set("src:", { + entries: [{ path: "src/a.ts", kind: "file" }], + truncated: true, + directoryPath: "src", + nextDirectoryCursor: "a.ts", + }); + fixture.answers.set("src:a.ts", { + entries: [{ path: "src/b.ts", kind: "file" }], + truncated: false, + directoryPath: "src", + }); + await act(async () => { + renderer = create(); + }); + expect(current().entries.map((entry) => entry.path)).toEqual(["src"]); + await act(async () => current().load("src")); + expect(fixture.requests).toEqual([":", "src:", "src:a.ts"]); + expect(current().entries.map((entry) => entry.path)).toEqual(["src", "src/a.ts", "src/b.ts"]); +}); + +it("reissues the root request after a StrictMode effect restart", async () => { + fixture.answers.set(":", { + entries: [{ path: "src", kind: "directory" }], + truncated: false, + directoryPath: "", + }); + await act(async () => { + renderer = create( + + + , + ); + }); + expect(current().entries.map((entry) => entry.path)).toEqual(["src"]); + expect(fixture.requests).toEqual([":", ":"]); + expect(current().isPending).toBe(false); +}); diff --git a/apps/web/src/components/files/useDirectoryEntries.ts b/apps/web/src/components/files/useDirectoryEntries.ts new file mode 100644 index 0000000000..ac676c2ddc --- /dev/null +++ b/apps/web/src/components/files/useDirectoryEntries.ts @@ -0,0 +1,213 @@ +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"; + +const MAX_DIRECTORY_PAGES = 50; + +/** 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 [legacyEntries, setLegacyEntries] = useState(null); + const legacyEntriesRef = useRef(null); + const [errors, setErrors] = useState(new Map()); + const [pending, setPending] = useState(0); + const requests = useRef(new Map>()); + const controllers = useRef(new Set()); + const loaded = useRef(new Set()); + const requested = useRef(new Set()); + const active = useRef(true); + const generation = useRef(0); + 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 (legacyEntriesRef.current !== null && directoryPath !== "") return Promise.resolve(); + if (!refresh && loaded.current.has(directoryPath)) return Promise.resolve(); + loaded.current.add(directoryPath); + requested.current.add(directoryPath); + const controller = new AbortController(); + controllers.current.add(controller); + const requestedGeneration = generation.current; + 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 || + controller.signal.aborted || + requestedGeneration !== generation.current + ) + return undefined; + const collected: ProjectEntry[] = []; + let cursor: string | undefined; + for (let page = 0; page < MAX_DIRECTORY_PAGES; page++) { + const atom = projectEnvironment.listEntries({ + environmentId, + input: { + cwd, + directoryPath, + ...(cursor === undefined ? {} : { directoryCursor: cursor }), + }, + }); + const result = await executeAtomQuery(appAtomRegistry, atom, { + refresh: true, + signal: controller.signal, + reportFailure: false, + reportDefect: false, + }); + if ( + !active.current || + controller.signal.aborted || + requestedGeneration !== generation.current + ) + return undefined; + if (result._tag !== "Success") { + const cause = Cause.squash(result.cause); + throw cause instanceof Error ? cause : new Error("Unable to load folder."); + } + if (result.value.directoryPath === undefined) { + return { legacy: result.value.entries }; + } + if (result.value.directoryPath !== directoryPath) { + throw new Error("The server returned a different folder. Refresh to retry."); + } + collected.push( + ...result.value.entries.filter( + (entry) => + entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))) === directoryPath, + ), + ); + if (!result.value.truncated) return { entries: collected }; + if ( + result.value.nextDirectoryCursor === undefined || + result.value.nextDirectoryCursor === cursor + ) { + throw new Error( + "Folder listing stopped before all entries arrived. Refresh to retry.", + ); + } + cursor = result.value.nextDirectoryCursor; + } + throw new Error("This folder has too many items to show. Search for a narrower path."); + } finally { + const next = waiting.current.shift(); + if (next) next(); + else running.current--; + } + })() + .then((result) => { + if (!active.current || requestedGeneration !== generation.current || !result) return; + if ("legacy" in result) { + if (directoryPath === "") { + legacyEntriesRef.current = result.legacy; + setLegacyEntries(result.legacy); + } + return; + } + if (directoryPath === "") { + legacyEntriesRef.current = null; + setLegacyEntries(null); + } + setDirectories((previous) => new Map(previous).set(directoryPath, result.entries)); + setErrors((previous) => { + const next = new Map(previous); + next.delete(directoryPath); + return next; + }); + }) + .catch((error: unknown) => { + if (!active.current || requestedGeneration !== generation.current) return; + loaded.current.delete(directoryPath); + setErrors((previous) => + new Map(previous).set( + directoryPath, + error instanceof Error ? error.message : "Unable to load folder.", + ), + ); + }) + .finally(() => { + controllers.current.delete(controller); + if (requests.current.get(directoryPath) === request) + requests.current.delete(directoryPath); + if (active.current && requestedGeneration === generation.current) + setPending((count) => count - 1); + }); + requests.current.set(directoryPath, request); + return request; + }, + [cwd, environmentId], + ); + + useEffect(() => { + active.current = true; + setPending(0); + void load(""); + return () => { + active.current = false; + generation.current++; + for (const controller of controllers.current) controller.abort(); + controllers.current.clear(); + requests.current.clear(); + loaded.current.clear(); + }; + }, [load]); + + const entries = useMemo(() => { + if (legacyEntries !== null) return legacyEntries; + 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, legacyEntries]); + + 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)); + for (const controller of controllers.current) controller.abort(); + controllers.current.clear(); + requests.current.clear(); + generation.current++; + setPending(0); + 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: legacyEntries !== null || directories.has(""), + error: [...errors].find(([path]) => reachableDirectories.has(path))?.[1] ?? null, + }; +} diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 1792c5e9e5..3b8f99cf51 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -259,6 +259,7 @@ export function useProjectPathSearch( return { entries: result.data?.entries ?? [], + truncated: result.data?.truncated ?? false, error: result.error, isPending: !areProjectPathSearchTargetsEqual(normalizedTarget, debouncedTarget) || result.isPending, diff --git a/packages/contracts/src/project.test.ts b/packages/contracts/src/project.test.ts index 8e6771cba8..fbe9d07415 100644 --- a/packages/contracts/src/project.test.ts +++ b/packages/contracts/src/project.test.ts @@ -3,12 +3,15 @@ import { describe, expect, it } from "vite-plus/test"; import { ProjectReadFileError, + ProjectListEntriesInput, + ProjectListEntriesResult, ProjectSearchContentsError, ProjectSearchContentsInput, ProjectSearchEntriesError, ProjectSearchEntriesInput, ProjectWriteFileError, } from "./project.ts"; +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; const decodeSearchEntriesInput = Schema.decodeUnknownSync(ProjectSearchEntriesInput); const decodeSearchContentsInput = Schema.decodeUnknownSync(ProjectSearchContentsInput); @@ -37,6 +40,41 @@ describe("project search inputs", () => { }); }); +describe("project directory listing compatibility", () => { + it("marks opt-in responses while leaving legacy listing shapes intact", () => { + const decodeInput = Schema.decodeUnknownSync(ProjectListEntriesInput); + const decodeResult = Schema.decodeUnknownSync(ProjectListEntriesResult); + expect(decodeInput({ cwd: "/workspace", directoryPath: "" })).toEqual({ + cwd: "/workspace", + directoryPath: "", + }); + expect(decodeResult({ entries: [], truncated: false })).toEqual({ + entries: [], + truncated: false, + }); + expect(decodeResult({ entries: [], truncated: false, directoryPath: "" }).directoryPath).toBe( + "", + ); + }); + + it("decodes a new request with the pre-change RPC payload schema", () => { + // This is the exact projects.listEntries payload schema at the frozen origin/pylon base. + // Rpc.make uses it to decode a request before the old list handler receives the payload. + const oldInput = Schema.Struct({ cwd: TrimmedNonEmptyString }); + const oldServerPayload = Schema.decodeUnknownSync(oldInput)({ + cwd: "/workspace", + directoryPath: "src", + directoryCursor: "a.ts", + }); + expect(oldServerPayload).toEqual({ cwd: "/workspace" }); + const oldResponse = Schema.decodeUnknownSync(ProjectListEntriesResult)({ + entries: [{ path: "src/a.ts", kind: "file" }], + truncated: false, + }); + expect(oldResponse.directoryPath).toBeUndefined(); + }); +}); + describe("project RPC errors", () => { it("derives stable messages from structured request context while retaining causes", () => { const cause = new Error("sensitive platform detail"); diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index bee2e70d57..4dfc7d836e 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,12 +73,19 @@ export type ProjectSearchContentsResult = typeof ProjectSearchContentsResult.Typ export const ProjectListEntriesInput = Schema.Struct({ cwd: TrimmedNonEmptyString, + /** Empty string requests immediate children of the workspace root. Omitted retains indexed listing. */ + directoryPath: Schema.optional(TrimmedString), + /** Resume an immediate-child listing after this exact child name. */ + directoryCursor: Schema.optional(TrimmedNonEmptyString), }); export type ProjectListEntriesInput = typeof ProjectListEntriesInput.Type; export const ProjectListEntriesResult = Schema.Struct({ entries: Schema.Array(ProjectEntry), truncated: Schema.Boolean, + /** Present only when the server understood and served the directory-listing request. */ + directoryPath: Schema.optional(TrimmedString), + nextDirectoryCursor: Schema.optional(TrimmedNonEmptyString), }); export type ProjectListEntriesResult = typeof ProjectListEntriesResult.Type; @@ -89,6 +97,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;