());
+ 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;