());
+ 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;
@@ -261,17 +340,21 @@ 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;
@@ -282,13 +365,21 @@ 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) {
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.
@@ -299,7 +390,6 @@ export default function FileBrowserPanel({
) {
return;
}
- if (entryKinds.get(selectedPath) !== "file") return;
const selectedItem = model.getItem(selectedPath);
if (!selectedItem) return;
@@ -319,6 +409,7 @@ export default function FileBrowserPanel({
handledRevealRef.current = revealRequest;
syncingSelectionRef.current = true;
+ setQuery("");
model.closeSearch();
for (const path of model.getSelectedPaths()) {
model.getItem(path)?.deselect();
@@ -339,7 +430,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
@@ -376,13 +467,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 +484,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 +499,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..c7a1db0207cb
--- /dev/null
+++ b/apps/web/src/components/files/useDirectoryEntries.ts
@@ -0,0 +1,136 @@
+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 requested = 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);
+ requested.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: true,
+ 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 {
+ loaded.current.delete(directoryPath);
+ 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 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]);
+
+ 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].find(([path]) => reachableDirectories.has(path))?.[1] ?? 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;