authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-13 03:00:59-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-13 03:00:59-03:00
log21d53ca2eecbf9f1e481454e8084459b422de714
tree65c62cd552c6036a2ca015d25e4c24289432b064
parent2ec59ca1fad6d9eb6ae36714b76b6130e63201e6
signature Signed by PGP key B5690EEEBB952194

fix(files): browse ignored files and load folders on demand (#11527)


14 files changed, 706 insertions(+), 76 deletions(-)

apps/mobile/src/features/files/FileTreeBrowser.tsx+43-16
...@@ -11,7 +11,6 @@ import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";...@@ -11,7 +11,6 @@ import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";
11import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";11import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
12import {12import {
13 buildFileTree,13 buildFileTree,
14 defaultExpandedTreePaths,
15 flattenFileTree,14 flattenFileTree,
16 type FileTreeNode,15 type FileTreeNode,
17 type VisibleFileTreeNode,16 type VisibleFileTreeNode,
...@@ -45,6 +44,7 @@ const FileTreeRow = memo(function FileTreeRow(props: {...@@ -45,6 +44,7 @@ const FileTreeRow = memo(function FileTreeRow(props: {
45 readonly item: VisibleFileTreeNode;44 readonly item: VisibleFileTreeNode;
46 readonly selected: boolean;45 readonly selected: boolean;
47 readonly expanded: boolean;46 readonly expanded: boolean;
47 readonly loaded: boolean;
48 readonly onPressDirectory: (path: string) => void;48 readonly onPressDirectory: (path: string) => void;
49 readonly onPreviewFile?: (path: string) => void;49 readonly onPreviewFile?: (path: string) => void;
50 readonly onPressFile: (path: string) => void;50 readonly onPressFile: (path: string) => void;
...@@ -89,13 +89,15 @@ const FileTreeRow = memo(function FileTreeRow(props: {...@@ -89,13 +89,15 @@ const FileTreeRow = memo(function FileTreeRow(props: {
89 "min-w-0 flex-1 text-sm leading-normal",89 "min-w-0 flex-1 text-sm leading-normal",
90 props.selected90 props.selected
91 ? "font-t3-bold text-foreground"91 ? "font-t3-bold text-foreground"
92 : "font-t3-medium text-foreground-secondary",92 : node.ignored
93 ? "font-t3-medium text-foreground-tertiary"
94 : "font-t3-medium text-foreground-secondary",
93 )}95 )}
94 numberOfLines={1}96 numberOfLines={1}
95 >97 >
96 {node.name}98 {node.name}
97 </Text>99 </Text>
98 {node.kind === "directory" ? (100 {node.kind === "directory" && props.loaded ? (
99 <Text className="text-2xs font-t3-medium text-foreground-tertiary">101 <Text className="text-2xs font-t3-medium text-foreground-tertiary">
100 {node.children.length}102 {node.children.length}
101 </Text>103 </Text>
...@@ -109,7 +111,10 @@ export function FileTreeBrowser(props: {...@@ -109,7 +111,10 @@ export function FileTreeBrowser(props: {
109 readonly error: string | null;111 readonly error: string | null;
110 readonly isPending: boolean;112 readonly isPending: boolean;
111 readonly searchQuery: string;113 readonly searchQuery: string;
114 readonly searchTruncated: boolean;
112 readonly selectedPath: string | null;115 readonly selectedPath: string | null;
116 readonly loadedDirectories: ReadonlySet<string>;
117 readonly onLoadDirectory: (path: string) => void;
113 readonly onPreviewFile?: (path: string) => void;118 readonly onPreviewFile?: (path: string) => void;
114 readonly onRefresh: () => void;119 readonly onRefresh: () => void;
115 readonly onSelectFile: (path: string) => void;120 readonly onSelectFile: (path: string) => void;
...@@ -123,7 +128,13 @@ export function FileTreeBrowser(props: {...@@ -123,7 +128,13 @@ export function FileTreeBrowser(props: {
123 // Native transparent-header height ≈ safe-area top + nav bar (~44). Matches the128 // Native transparent-header height ≈ safe-area top + nav bar (~44). Matches the
124 // observed adjustedContentInset bottom (~102) seen in the native trace.129 // observed adjustedContentInset bottom (~102) seen in the native trace.
125 const headerInset = NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + IOS_NAV_BAR_HEIGHT : 0;130 const headerInset = NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + IOS_NAV_BAR_HEIGHT : 0;
126 const { onPreviewFile, onSelectFile, selectedPath: controlledSelectedPath } = props;131 const {
132 onLoadDirectory,
133 onPreviewFile,
134 onSelectFile,
135 loadedDirectories,
136 selectedPath: controlledSelectedPath,
137 } = props;
127 const controlledSelectedPathRef = useRef(controlledSelectedPath);138 const controlledSelectedPathRef = useRef(controlledSelectedPath);
128 const pendingSelectionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);139 const pendingSelectionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
129 controlledSelectedPathRef.current = controlledSelectedPath;140 controlledSelectedPathRef.current = controlledSelectedPath;
...@@ -133,7 +144,6 @@ export function FileTreeBrowser(props: {...@@ -133,7 +144,6 @@ export function FileTreeBrowser(props: {
133 ? pendingSelection.path144 ? pendingSelection.path
134 : controlledSelectedPath;145 : controlledSelectedPath;
135 const tree = useMemo(() => cachedFileTree(props.entries), [props.entries]);146 const tree = useMemo(() => cachedFileTree(props.entries), [props.entries]);
136 const defaultExpanded = useMemo(() => defaultExpandedTreePaths(tree), [tree]);
137 const visibleNodes = useMemo(147 const visibleNodes = useMemo(
138 () =>148 () =>
139 flattenFileTree({149 flattenFileTree({
...@@ -144,15 +154,6 @@ export function FileTreeBrowser(props: {...@@ -144,15 +154,6 @@ export function FileTreeBrowser(props: {
144 [expandedPaths, props.searchQuery, tree],154 [expandedPaths, props.searchQuery, tree],
145 );155 );
146156
147 useEffect(() => {
148 setExpandedPaths((current) => {
149 if (current.size > 0 || defaultExpanded.size === 0) {
150 return current;
151 }
152 return new Set(defaultExpanded);
153 });
154 }, [defaultExpanded]);
155
156 useEffect(() => {157 useEffect(() => {
157 if (!controlledSelectedPath) {158 if (!controlledSelectedPath) {
158 return;159 return;
...@@ -170,6 +171,10 @@ export function FileTreeBrowser(props: {...@@ -170,6 +171,10 @@ export function FileTreeBrowser(props: {
170 });171 });
171 }, [controlledSelectedPath]);172 }, [controlledSelectedPath]);
172173
174 useEffect(() => {
175 for (const path of expandedPaths) onLoadDirectory(path);
176 }, [expandedPaths, onLoadDirectory]);
177
173 useEffect(178 useEffect(
174 () => () => {179 () => () => {
175 if (pendingSelectionTimeoutRef.current !== null) {180 if (pendingSelectionTimeoutRef.current !== null) {
...@@ -213,12 +218,20 @@ export function FileTreeBrowser(props: {...@@ -213,12 +218,20 @@ export function FileTreeBrowser(props: {
213 item={item}218 item={item}
214 selected={item.node.kind === "file" && item.node.path === selectedPath}219 selected={item.node.kind === "file" && item.node.path === selectedPath}
215 expanded={expandedPaths.has(item.node.path)}220 expanded={expandedPaths.has(item.node.path)}
221 loaded={loadedDirectories.has(item.node.path)}
216 onPressDirectory={toggleDirectory}222 onPressDirectory={toggleDirectory}
217 onPreviewFile={onPreviewFile}223 onPreviewFile={onPreviewFile}
218 onPressFile={handleSelectFile}224 onPressFile={handleSelectFile}
219 />225 />
220 ),226 ),
221 [expandedPaths, handleSelectFile, onPreviewFile, selectedPath, toggleDirectory],227 [
228 expandedPaths,
229 handleSelectFile,
230 onPreviewFile,
231 loadedDirectories,
232 selectedPath,
233 toggleDirectory,
234 ],
222 );235 );
223236
224 if (props.error && props.entries.length === 0) {237 if (props.error && props.entries.length === 0) {
...@@ -255,6 +268,20 @@ export function FileTreeBrowser(props: {...@@ -255,6 +268,20 @@ export function FileTreeBrowser(props: {
255 contentContainerStyle={{ paddingTop: 8, paddingBottom: 8 }}268 contentContainerStyle={{ paddingTop: 8, paddingBottom: 8 }}
256 refreshControl={<RefreshControl refreshing={props.isPending} onRefresh={props.onRefresh} />}269 refreshControl={<RefreshControl refreshing={props.isPending} onRefresh={props.onRefresh} />}
257 renderItem={renderItem}270 renderItem={renderItem}
271 ListHeaderComponent={
272 <>
273 {props.error ? (
274 <Text accessibilityRole="alert" className="mx-4 my-2 text-xs text-foreground-muted">
275 {props.error}
276 </Text>
277 ) : null}
278 {props.searchTruncated ? (
279 <Text className="mx-4 my-2 text-xs text-foreground-muted">
280 More search results available. Refine your search to see them.
281 </Text>
282 ) : null}
283 </>
284 }
258 ListEmptyComponent={285 ListEmptyComponent={
259 <View className="px-4 py-5">286 <View className="px-4 py-5">
260 {props.isPending ? (287 {props.isPending ? (
...@@ -265,7 +292,7 @@ export function FileTreeBrowser(props: {...@@ -265,7 +292,7 @@ export function FileTreeBrowser(props: {
265 <Text className="mt-1 text-xs leading-normal text-foreground-muted">292 <Text className="mt-1 text-xs leading-normal text-foreground-muted">
266 {props.searchQuery.trim().length > 0293 {props.searchQuery.trim().length > 0
267 ? "Try a different search."294 ? "Try a different search."
268 : "The workspace file index is empty."}295 : "The workspace is empty."}
269 </Text>296 </Text>
270 </>297 </>
271 )}298 )}
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+12-16
...@@ -5,12 +5,7 @@ import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"...@@ -5,12 +5,7 @@ import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"
5import { ActivityIndicator, Platform, View } from "react-native";5import { ActivityIndicator, Platform, View } from "react-native";
6import { useSafeAreaInsets } from "react-native-safe-area-context";6import { useSafeAreaInsets } from "react-native-safe-area-context";
7import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";7import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
8import {8import { EnvironmentId, type ProjectReadFileResult, ThreadId } from "@t3tools/contracts";
9 EnvironmentId,
10 type ProjectListEntriesResult,
11 type ProjectReadFileResult,
12 ThreadId,
13} from "@t3tools/contracts";
14import { videoMimeType } from "@t3tools/shared/video";9import { videoMimeType } from "@t3tools/shared/video";
15import {10import {
16 isWorkspaceBrowserPreviewPath,11 isWorkspaceBrowserPreviewPath,
...@@ -53,6 +48,7 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe...@@ -53,6 +48,7 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe
53import { ThreadRouteScreen } from "../threads/ThreadRouteScreen";48import { ThreadRouteScreen } from "../threads/ThreadRouteScreen";
54import { FileMarkdownPreview } from "./FileMarkdownPreview";49import { FileMarkdownPreview } from "./FileMarkdownPreview";
55import { FileTreeBrowser } from "./FileTreeBrowser";50import { FileTreeBrowser } from "./FileTreeBrowser";
51import { useFileTreeEntries } from "./useFileTreeEntries";
56import { preloadWorkspaceFileContents } from "./preload-workspace-file";52import { preloadWorkspaceFileContents } from "./preload-workspace-file";
57import { SourceFileSurface } from "./SourceFileSurface";53import { SourceFileSurface } from "./SourceFileSurface";
58import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane";54import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane";
...@@ -341,15 +337,11 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {...@@ -341,15 +337,11 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
341 props.route.params,337 props.route.params,
342 );338 );
343 const revealedInspectorRef = useRef(false);339 const revealedInspectorRef = useRef(false);
344 const entriesQuery = useEnvironmentQuery(340 const entriesQuery = useFileTreeEntries({
345 environmentId !== null && cwd !== null && !fileInspector.supported341 environmentId,
346 ? projectEnvironment.listEntries({342 cwd: fileInspector.supported ? null : cwd,
347 environmentId,343 searchQuery,
348 input: { cwd },344 });
349 })
350 : null,
351 );
352 const entriesData = entriesQuery.data as ProjectListEntriesResult | null;
353 const handleReturnToThread = useCallback(() => {345 const handleReturnToThread = useCallback(() => {
354 if (navigation.canGoBack()) {346 if (navigation.canGoBack()) {
355 navigation.goBack();347 navigation.goBack();
...@@ -557,10 +549,14 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {...@@ -557,10 +549,14 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
557 </>549 </>
558 )}550 )}
559 <FileTreeBrowser551 <FileTreeBrowser
560 entries={entriesData?.entries ?? []}552 key={JSON.stringify([environmentId, cwd])}
553 entries={entriesQuery.entries}
554 loadedDirectories={entriesQuery.loadedDirectories}
555 onLoadDirectory={entriesQuery.loadDirectory}
561 error={entriesQuery.error}556 error={entriesQuery.error}
562 isPending={entriesQuery.isPending}557 isPending={entriesQuery.isPending}
563 searchQuery={searchQuery}558 searchQuery={searchQuery}
559 searchTruncated={entriesQuery.searchTruncated}
564 selectedPath={null}560 selectedPath={null}
565 onPreviewFile={handlePreviewFile}561 onPreviewFile={handlePreviewFile}
566 onRefresh={entriesQuery.refresh}562 onRefresh={entriesQuery.refresh}
apps/mobile/src/features/files/fileTree.ts+4
...@@ -5,6 +5,7 @@ export interface FileTreeNode {...@@ -5,6 +5,7 @@ export interface FileTreeNode {
5 readonly path: string;5 readonly path: string;
6 readonly name: string;6 readonly name: string;
7 readonly kind: ProjectEntry["kind"];7 readonly kind: ProjectEntry["kind"];
8 readonly ignored?: boolean;
8 readonly children: ReadonlyArray<FileTreeNode>;9 readonly children: ReadonlyArray<FileTreeNode>;
9 readonly searchSegments: ReadonlyArray<string>;10 readonly searchSegments: ReadonlyArray<string>;
10 readonly searchWords: ReadonlyArray<string>;11 readonly searchWords: ReadonlyArray<string>;
...@@ -19,6 +20,7 @@ interface MutableFileTreeNode {...@@ -19,6 +20,7 @@ interface MutableFileTreeNode {
19 path: string;20 path: string;
20 name: string;21 name: string;
21 kind: ProjectEntry["kind"];22 kind: ProjectEntry["kind"];
23 ignored?: boolean;
22 children: Map<string, MutableFileTreeNode>;24 children: Map<string, MutableFileTreeNode>;
23}25}
2426
...@@ -68,6 +70,7 @@ function freezeNode(node: MutableFileTreeNode): FileTreeNode {...@@ -68,6 +70,7 @@ function freezeNode(node: MutableFileTreeNode): FileTreeNode {
68 path: node.path,70 path: node.path,
69 name: node.name,71 name: node.name,
70 kind: node.kind,72 kind: node.kind,
73 ...(node.ignored ? { ignored: true } : {}),
71 children: [...node.children.values()].sort(compareNodes).map(freezeNode),74 children: [...node.children.values()].sort(compareNodes).map(freezeNode),
72 searchSegments: searchTerms.segments,75 searchSegments: searchTerms.segments,
73 searchWords: searchTerms.words,76 searchWords: searchTerms.words,
...@@ -110,6 +113,7 @@ export function buildFileTree(entries: ReadonlyArray<ProjectEntry>): ReadonlyArr...@@ -110,6 +113,7 @@ export function buildFileTree(entries: ReadonlyArray<ProjectEntry>): ReadonlyArr
110 } else if (isLeaf) {113 } else if (isLeaf) {
111 child.kind = entry.kind;114 child.kind = entry.kind;
112 }115 }
116 if (isLeaf && entry.ignored) child.ignored = true;
113 current = child;117 current = child;
114 }118 }
115 }119 }
apps/mobile/src/features/files/thread-file-navigator-pane.tsx+12-11
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import type { EnvironmentId, ProjectListEntriesResult } from "@t3tools/contracts";1import type { EnvironmentId } from "@t3tools/contracts";
2import { SymbolView } from "../../components/AppSymbol";2import { SymbolView } from "../../components/AppSymbol";
3import { useCallback, useMemo, useState, type ComponentProps } from "react";3import { useCallback, useMemo, useState, type ComponentProps } from "react";
4import { Platform, Pressable, View, type NativeSyntheticEvent } from "react-native";4import { Platform, Pressable, View, type NativeSyntheticEvent } from "react-native";
...@@ -13,10 +13,9 @@ import {...@@ -13,10 +13,9 @@ import {
13import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";13import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
14import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";14import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
15import { useUniwindTheme } from "../../lib/useUniwindTheme";15import { useUniwindTheme } from "../../lib/useUniwindTheme";
16import { projectEnvironment } from "../../state/projects";
17import { useEnvironmentQuery } from "../../state/query";
18import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";16import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
19import { FileTreeBrowser } from "./FileTreeBrowser";17import { FileTreeBrowser } from "./FileTreeBrowser";
18import { useFileTreeEntries } from "./useFileTreeEntries";
20import { preloadWorkspaceFileContents } from "./preload-workspace-file";19import { preloadWorkspaceFileContents } from "./preload-workspace-file";
21import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";20import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
2221
...@@ -35,13 +34,11 @@ export function ThreadFileNavigatorPane(props: {...@@ -35,13 +34,11 @@ export function ThreadFileNavigatorPane(props: {
35 const foregroundColor = theme["--color-foreground"];34 const foregroundColor = theme["--color-foreground"];
36 const sheetColor = theme["--color-sheet"];35 const sheetColor = theme["--color-sheet"];
37 const headerScrollEdgeEffects = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);36 const headerScrollEdgeEffects = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
38 const entriesQuery = useEnvironmentQuery(37 const entriesQuery = useFileTreeEntries({
39 projectEnvironment.listEntries({38 environmentId: props.environmentId,
40 environmentId: props.environmentId,39 cwd: props.cwd,
41 input: { cwd: props.cwd },40 searchQuery,
42 }),41 });
43 );
44 const entriesData = entriesQuery.data as ProjectListEntriesResult | null;
45 const handlePreviewFile = useCallback(42 const handlePreviewFile = useCallback(
46 (relativePath: string) => {43 (relativePath: string) => {
47 preloadWorkspaceFileContents({44 preloadWorkspaceFileContents({
...@@ -82,10 +79,14 @@ export function ThreadFileNavigatorPane(props: {...@@ -82,10 +79,14 @@ export function ThreadFileNavigatorPane(props: {
8279
83 const fileTree = (80 const fileTree = (
84 <FileTreeBrowser81 <FileTreeBrowser
85 entries={entriesData?.entries ?? []}82 key={JSON.stringify([props.environmentId, props.cwd])}
83 entries={entriesQuery.entries}
84 loadedDirectories={entriesQuery.loadedDirectories}
85 onLoadDirectory={entriesQuery.loadDirectory}
86 error={entriesQuery.error}86 error={entriesQuery.error}
87 isPending={entriesQuery.isPending}87 isPending={entriesQuery.isPending}
88 searchQuery={searchQuery}88 searchQuery={searchQuery}
89 searchTruncated={entriesQuery.searchTruncated}
89 selectedPath={props.selectedPath}90 selectedPath={props.selectedPath}
90 onPreviewFile={handlePreviewFile}91 onPreviewFile={handlePreviewFile}
91 onRefresh={entriesQuery.refresh}92 onRefresh={entriesQuery.refresh}
apps/mobile/src/features/files/useFileTreeEntries.ts created+164
...@@ -0,0 +1,164 @@
1import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";
2import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";
3import * as Cause from "effect/Cause";
4import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
5
6import { appAtomRegistry } from "../../state/atom-registry";
7import { projectEnvironment } from "../../state/projects";
8import { useDebouncedValue } from "../../state/queries";
9import { useEnvironmentQuery } from "../../state/query";
10
11export function useFileTreeEntries(input: {
12 readonly cwd: string | null;
13 readonly environmentId: EnvironmentId | null;
14 readonly searchQuery: string;
15}) {
16 const { cwd, environmentId } = input;
17 const searching = input.searchQuery.trim().length > 0;
18 const query = input.searchQuery.trim().slice(0, 256);
19 const debouncedQuery = useDebouncedValue(query, 200);
20 const root = useEnvironmentQuery(
21 cwd !== null && environmentId !== null
22 ? projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath: "" } })
23 : null,
24 );
25 const search = useEnvironmentQuery(
26 searching && debouncedQuery.length > 0 && cwd !== null && environmentId !== null
27 ? projectEnvironment.searchEntries({
28 environmentId,
29 input: { cwd, query: debouncedQuery, limit: 200 },
30 })
31 : null,
32 );
33 const [revision, render] = useReducer((value: number) => value + 1, 0);
34 const refreshVersion = useRef(0);
35 const directories = useMemo(
36 () => ({
37 cwd,
38 environmentId,
39 entries: new Map<string, ReadonlyArray<ProjectEntry>>(),
40 requested: new Set<string>(),
41 pending: new Map<string, AbortController>(),
42 errors: new Map<string, string>(),
43 }),
44 [cwd, environmentId],
45 );
46 useEffect(
47 () => () => {
48 refreshVersion.current++;
49 for (const controller of directories.pending.values()) controller.abort();
50 directories.pending.clear();
51 },
52 [directories],
53 );
54 const loadDirectory = useCallback(
55 (directoryPath: string, refresh = false) => {
56 if (
57 cwd === null ||
58 environmentId === null ||
59 (!refresh && directories.entries.has(directoryPath)) ||
60 directories.pending.has(directoryPath)
61 ) {
62 return;
63 }
64 const controller = new AbortController();
65 directories.requested.add(directoryPath);
66 directories.pending.set(directoryPath, controller);
67 directories.errors.delete(directoryPath);
68 render();
69 const atom = projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath } });
70 appAtomRegistry.refresh(atom);
71 return executeAtomQuery(appAtomRegistry, atom, {
72 signal: controller.signal,
73 reportFailure: false,
74 reportDefect: false,
75 }).then((result) => {
76 if (controller.signal.aborted) return;
77 directories.pending.delete(directoryPath);
78 if (result._tag === "Success") {
79 directories.entries.set(
80 directoryPath,
81 result.value.entries.filter(
82 (entry) =>
83 entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))) === directoryPath,
84 ),
85 );
86 } else {
87 const error = Cause.squash(result.cause);
88 directories.errors.set(
89 directoryPath,
90 error instanceof Error ? error.message : "Files unavailable",
91 );
92 }
93 render();
94 });
95 },
96 [cwd, directories, environmentId],
97 );
98 const { refresh: refreshRoot, data: rootData } = root;
99 const { refresh: refreshSearch, data: searchData } = search;
100 const snapshot = useMemo(() => {
101 const merged = new Map<string, ProjectEntry>();
102 if (searching) {
103 for (const entry of searchData?.entries ?? []) merged.set(entry.path, entry);
104 }
105 const reachableDirectories = new Set<string>();
106 const visit = (items: ReadonlyArray<ProjectEntry>) => {
107 for (const entry of items) {
108 merged.set(entry.path, entry);
109 if (entry.kind === "directory") {
110 reachableDirectories.add(entry.path);
111 visit(directories.entries.get(entry.path) ?? []);
112 }
113 }
114 };
115 visit((rootData?.entries ?? []).filter((entry) => !entry.path.includes("/")));
116 return { revision, entries: [...merged.values()], reachableDirectories };
117 }, [directories, revision, rootData, searchData, searching]);
118
119 const refresh = useCallback(() => {
120 refreshRoot();
121 if (searching) refreshSearch();
122 const paths = new Set(
123 [...directories.requested].filter((path) => snapshot.reachableDirectories.has(path)),
124 );
125 for (const controller of directories.pending.values()) controller.abort();
126 directories.pending.clear();
127 directories.errors.clear();
128 const version = ++refreshVersion.current;
129 const remaining = paths.values();
130 const worker = async () => {
131 while (version === refreshVersion.current) {
132 const next = remaining.next();
133 if (next.done) return;
134 await loadDirectory(next.value, true);
135 }
136 };
137 for (let index = 0; index < Math.min(4, paths.size); index++) void worker();
138 render();
139 }, [
140 directories,
141 loadDirectory,
142 refreshRoot,
143 refreshSearch,
144 searching,
145 snapshot.reachableDirectories,
146 ]);
147
148 return {
149 entries: snapshot.entries,
150 error:
151 root.error ??
152 (searching ? search.error : null) ??
153 [...directories.errors].find(([path]) => snapshot.reachableDirectories.has(path))?.[1] ??
154 null,
155 isPending:
156 root.isPending ||
157 directories.pending.size > 0 ||
158 (searching && (query !== debouncedQuery || search.isPending)),
159 searchTruncated: searching && (search.data?.truncated ?? false),
160 loadedDirectories: new Set(directories.entries.keys()),
161 loadDirectory,
162 refresh,
163 };
164}
apps/server/src/workspace/WorkspaceEntries.test.ts+96
...@@ -96,6 +96,102 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => {...@@ -96,6 +96,102 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => {
96 });96 });
9797
98 describe("list", () => {98 describe("list", () => {
99 it.effect("lists immediate children including ignored and empty directories", () =>
100 Effect.gen(function* () {
101 const cwd = yield* makeTempDir({ git: true });
102 const fileSystem = yield* FileSystem.FileSystem;
103 const path = yield* Path.Path;
104 yield* writeTextFile(cwd, "tracked.txt");
105 yield* git(cwd, ["add", "tracked.txt"]);
106 yield* writeTextFile(cwd, ".gitignore", "node_modules/\n.env\ntracked.txt\n");
107 yield* writeTextFile(cwd, ".env", "secret=value");
108 yield* writeTextFile(cwd, "node_modules/pkg/index.js");
109 yield* writeTextFile(cwd, "src/index.ts");
110 yield* fileSystem.makeDirectory(path.join(cwd, "empty"));
111
112 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
113 const root = yield* workspaceEntries.list({ cwd, directoryPath: "" });
114 expect(root.entries).toEqual(
115 expect.arrayContaining([
116 { path: ".env", kind: "file", ignored: true },
117 { path: "node_modules", kind: "directory", ignored: true },
118 { path: "src", kind: "directory" },
119 { path: "empty", kind: "directory" },
120 { path: "tracked.txt", kind: "file" },
121 ]),
122 );
123 expect(root.entries.some((entry) => entry.path.includes("/"))).toBe(false);
124 expect(root.entries.some((entry) => entry.path === ".git")).toBe(false);
125 expect(root.truncated).toBe(false);
126 expect(yield* workspaceEntries.list({ cwd, directoryPath: "node_modules/pkg" })).toEqual({
127 entries: [{ path: "node_modules/pkg/index.js", kind: "file", ignored: true }],
128 truncated: false,
129 });
130 expect(yield* workspaceEntries.list({ cwd, directoryPath: "empty" })).toEqual({
131 entries: [],
132 truncated: false,
133 });
134 }),
135 );
136
137 it.effect(
138 "rejects directory traversal, git internals, and symlinks outside the workspace",
139 () =>
140 Effect.gen(function* () {
141 const cwd = yield* makeTempDir();
142 const outside = yield* makeTempDir();
143 const fileSystem = yield* FileSystem.FileSystem;
144 const path = yield* Path.Path;
145 yield* writeTextFile(cwd, ".git/HEAD");
146 const platform = yield* HostProcessPlatform;
147 if (platform !== "win32") yield* fileSystem.symlink(outside, path.join(cwd, "external"));
148 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
149 for (const directoryPath of [
150 "../",
151 outside,
152 ".git",
153 "missing",
154 ...(platform !== "win32" ? ["external"] : []),
155 ]) {
156 const error = yield* workspaceEntries.list({ cwd, directoryPath }).pipe(Effect.flip);
157 expect(error._tag).toBe("WorkspaceEntriesReadDirectoryError");
158 }
159 }),
160 );
161
162 it.effect(
163 "browses a workspace with more than 25,000 entries without truncation",
164 () =>
165 Effect.gen(function* () {
166 const cwd = yield* makeTempDir();
167 const fileSystem = yield* FileSystem.FileSystem;
168 const path = yield* Path.Path;
169 for (let directory = 0; directory < 26; directory++) {
170 const directoryPath = path.join(cwd, `folder-${directory}`);
171 yield* fileSystem.makeDirectory(directoryPath);
172 yield* Effect.forEach(
173 Array.from({ length: 1000 }, (_, i) => i),
174 (i) => fileSystem.writeFileString(path.join(directoryPath, `file-${i}.txt`), ""),
175 { concurrency: 32, discard: true },
176 );
177 }
178 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
179 const root = yield* workspaceEntries.list({ cwd, directoryPath: "" });
180 expect(root.entries).toHaveLength(26);
181 expect(root.truncated).toBe(false);
182 for (const directory of root.entries) {
183 const result = yield* workspaceEntries.list({ cwd, directoryPath: directory.path });
184 expect(result.entries).toHaveLength(1000);
185 expect(result.truncated).toBe(false);
186 expect(result.entries).toContainEqual({
187 path: `${directory.path}/file-999.txt`,
188 kind: "file",
189 });
190 }
191 }),
192 60_000,
193 );
194
99 it.effect("returns the complete cached workspace index", () =>195 it.effect("returns the complete cached workspace index", () =>
100 Effect.gen(function* () {196 Effect.gen(function* () {
101 const cwd = yield* makeTempDir();197 const cwd = yield* makeTempDir();
apps/server/src/workspace/WorkspaceEntries.ts+77
...@@ -11,6 +11,7 @@ import * as Schema from "effect/Schema";...@@ -11,6 +11,7 @@ import * as Schema from "effect/Schema";
11import type {11import type {
12 FilesystemBrowseInput,12 FilesystemBrowseInput,
13 FilesystemBrowseResult,13 FilesystemBrowseResult,
14 ProjectEntry,
14 ProjectListEntriesInput,15 ProjectListEntriesInput,
15 ProjectListEntriesResult,16 ProjectListEntriesResult,
16 ProjectSearchContentsInput,17 ProjectSearchContentsInput,
...@@ -23,6 +24,7 @@ import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/p...@@ -23,6 +24,7 @@ import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/p
23import { normalizeSearchQuery } from "@t3tools/shared/searchRanking";24import { normalizeSearchQuery } from "@t3tools/shared/searchRanking";
2425
25import { expandHomePathWith } from "../pathExpansion.ts";26import { expandHomePathWith } from "../pathExpansion.ts";
27import * as VcsProcess from "../vcs/VcsProcess.ts";
26import * as WorkspacePaths from "./WorkspacePaths.ts";28import * as WorkspacePaths from "./WorkspacePaths.ts";
27import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts";29import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts";
2830
...@@ -74,6 +76,7 @@ export const WorkspaceEntriesBrowseError = Schema.Union([...@@ -74,6 +76,7 @@ export const WorkspaceEntriesBrowseError = Schema.Union([
74export type WorkspaceEntriesBrowseError = typeof WorkspaceEntriesBrowseError.Type;76export type WorkspaceEntriesBrowseError = typeof WorkspaceEntriesBrowseError.Type;
7577
76export const WorkspaceEntriesError = Schema.Union([78export const WorkspaceEntriesError = Schema.Union([
79 WorkspaceEntriesReadDirectoryError,
77 WorkspacePaths.WorkspaceRootNotExistsError,80 WorkspacePaths.WorkspaceRootNotExistsError,
78 WorkspacePaths.WorkspaceRootCreateFailedError,81 WorkspacePaths.WorkspaceRootCreateFailedError,
79 WorkspacePaths.WorkspaceRootStatFailedError,82 WorkspacePaths.WorkspaceRootStatFailedError,
...@@ -133,6 +136,7 @@ export const make = Effect.gen(function* () {...@@ -133,6 +136,7 @@ export const make = Effect.gen(function* () {
133 const path = yield* Path.Path;136 const path = yield* Path.Path;
134 const workspacePaths = yield* WorkspacePaths.WorkspacePaths;137 const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
135 const workspaceSearchIndexes = yield* WorkspaceSearchIndex.WorkspaceSearchIndexMap;138 const workspaceSearchIndexes = yield* WorkspaceSearchIndex.WorkspaceSearchIndexMap;
139 const vcsProcess = yield* VcsProcess.VcsProcess;
136140
137 const normalizeWorkspaceRoot = Effect.fn("WorkspaceEntries.normalizeWorkspaceRoot")(function* (141 const normalizeWorkspaceRoot = Effect.fn("WorkspaceEntries.normalizeWorkspaceRoot")(function* (
138 cwd: string,142 cwd: string,
...@@ -266,6 +270,78 @@ export const make = Effect.gen(function* () {...@@ -266,6 +270,78 @@ export const make = Effect.gen(function* () {
266 const list: WorkspaceEntries["Service"]["list"] = Effect.fn("WorkspaceEntries.list")(270 const list: WorkspaceEntries["Service"]["list"] = Effect.fn("WorkspaceEntries.list")(
267 function* (input) {271 function* (input) {
268 const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd);272 const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd);
273 if (input.directoryPath !== undefined) {
274 const directoryPath = input.directoryPath;
275 const toError = (cause: unknown) =>
276 new WorkspaceEntriesReadDirectoryError({
277 cwd: normalizedCwd,
278 partialPath: directoryPath,
279 parentPath: path.resolve(normalizedCwd, directoryPath),
280 cause,
281 });
282 const target =
283 directoryPath === ""
284 ? { absolutePath: normalizedCwd, relativePath: "" }
285 : yield* workspacePaths
286 .resolveRelativePathWithinRoot({
287 workspaceRoot: normalizedCwd,
288 relativePath: directoryPath,
289 })
290 .pipe(Effect.mapError(toError));
291 const entries = yield* Effect.tryPromise({
292 try: async () => {
293 const root = await NodeFSP.realpath(normalizedCwd);
294 const directory = await NodeFSP.realpath(target.absolutePath);
295 const relative = path.relative(root, directory);
296 if (
297 relative === ".." ||
298 relative.startsWith(`..${path.sep}`) ||
299 path.isAbsolute(relative) ||
300 relative.split(path.sep).includes(".git") ||
301 target.relativePath.split("/").includes(".git")
302 ) {
303 throw new Error("Directory must be inside the workspace and outside .git.");
304 }
305 const children = await NodeFSP.readdir(directory, { withFileTypes: true });
306 return children.flatMap((child): ProjectEntry[] => {
307 if (child.name === ".git" || (!child.isDirectory() && !child.isFile())) return [];
308 return [
309 {
310 path: target.relativePath ? `${target.relativePath}/${child.name}` : child.name,
311 kind: child.isDirectory() ? "directory" : "file",
312 },
313 ];
314 });
315 },
316 catch: toError,
317 });
318 // Use stdin so large directories cannot exceed the command-line argument limit.
319 // Ignore classification is optional in non-git workspaces or when git is unavailable.
320 const ignored = new Set<string>();
321 for (let offset = 0; offset < entries.length; offset += 1000) {
322 const chunk = entries.slice(offset, offset + 1000);
323 const result = yield* vcsProcess
324 .run({
325 operation: "WorkspaceEntries.list",
326 command: "git",
327 args: ["-c", "core.fsmonitor=false", "check-ignore", "-z", "--stdin"],
328 cwd: normalizedCwd,
329 stdin: `${chunk.map((entry) => entry.path).join("\0")}\0`,
330 allowNonZeroExit: true,
331 timeoutMs: 10_000,
332 maxOutputBytes: 16 * 1024 * 1024,
333 })
334 .pipe(Effect.orElseSucceed(() => undefined));
335 if (!result || (result.exitCode !== 0 && result.exitCode !== 1)) break;
336 for (const ignoredPath of result.stdout.split("\0")) ignored.add(ignoredPath);
337 }
338 return {
339 entries: entries.map((entry) =>
340 ignored.has(entry.path) ? { ...entry, ignored: true } : entry,
341 ),
342 truncated: false,
343 };
344 }
269 return yield* Effect.gen(function* () {345 return yield* Effect.gen(function* () {
270 const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex;346 const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex;
271 return yield* searchIndex.list();347 return yield* searchIndex.list();
...@@ -284,4 +360,5 @@ export const make = Effect.gen(function* () {...@@ -284,4 +360,5 @@ export const make = Effect.gen(function* () {
284360
285export const layer = Layer.effect(WorkspaceEntries, make).pipe(361export const layer = Layer.effect(WorkspaceEntries, make).pipe(
286 Layer.provide(WorkspaceSearchIndex.WorkspaceSearchIndexMap.layer),362 Layer.provide(WorkspaceSearchIndex.WorkspaceSearchIndexMap.layer),
363 Layer.provide(VcsProcess.layer),
287);364);
apps/server/src/ws.ts+6
...@@ -231,6 +231,12 @@ function projectEntriesFailureContext(error: WorkspaceEntries.WorkspaceEntriesEr...@@ -231,6 +231,12 @@ function projectEntriesFailureContext(error: WorkspaceEntries.WorkspaceEntriesEr
231 failure: "workspace_root_not_directory",231 failure: "workspace_root_not_directory",
232 normalizedCwd: error.normalizedWorkspaceRoot,232 normalizedCwd: error.normalizedWorkspaceRoot,
233 };233 };
234 case "WorkspaceEntriesReadDirectoryError":
235 return {
236 failure: "directory_list_failed",
237 ...(error.cwd !== undefined ? { normalizedCwd: error.cwd } : {}),
238 detail: error.message,
239 };
234 case "WorkspaceSearchIndexCreateFailed":240 case "WorkspaceSearchIndexCreateFailed":
235 return {241 return {
236 failure: "search_index_create_failed",242 failure: "search_index_create_failed",
apps/web/src/components/files/FileBreadcrumbs.tsx+6-5
...@@ -78,7 +78,7 @@ function BreadcrumbMenuContent(props: {...@@ -78,7 +78,7 @@ function BreadcrumbMenuContent(props: {
78 readonly rootPath: string;78 readonly rootPath: string;
79 readonly workspaceMutationId: string | null;79 readonly workspaceMutationId: string | null;
80}) {80}) {
81 const entriesQuery = useProjectEntriesQuery(props.environmentId, props.cwd);81 const entriesQuery = useProjectEntriesQuery(props.environmentId, props.cwd, props.directoryPath);
82 useWorkspaceMutationRefresh({82 useWorkspaceMutationRefresh({
83 mutationId: props.workspaceMutationId,83 mutationId: props.workspaceMutationId,
84 refresh: entriesQuery.refresh,84 refresh: entriesQuery.refresh,
...@@ -91,9 +91,7 @@ function BreadcrumbMenuContent(props: {...@@ -91,9 +91,7 @@ function BreadcrumbMenuContent(props: {
91 () => fileBreadcrumbChildren(entries, props.directoryPath),91 () => fileBreadcrumbChildren(entries, props.directoryPath),
92 [entries, props.directoryPath],92 [entries, props.directoryPath],
93 );93 );
94 const directoryAvailable =94 const directoryAvailable = entriesQuery.data !== null;
95 props.directoryPath === "" ||
96 entries.some((entry) => entry.kind === "directory" && entry.path === props.directoryPath);
97 const parentPath = fileBreadcrumbParent(props.directoryPath);95 const parentPath = fileBreadcrumbParent(props.directoryPath);
98 const canGoBack =96 const canGoBack =
99 props.directoryPath !== props.rootPath &&97 props.directoryPath !== props.rootPath &&
...@@ -150,7 +148,10 @@ function BreadcrumbMenuContent(props: {...@@ -150,7 +148,10 @@ function BreadcrumbMenuContent(props: {
150 key={entry.path}148 key={entry.path}
151 closeOnClick={entry.kind === "file"}149 closeOnClick={entry.kind === "file"}
152 aria-current={isCurrentFile ? "page" : undefined}150 aria-current={isCurrentFile ? "page" : undefined}
153 className={cn(isCurrentFile && "bg-foreground/[0.08]")}151 className={cn(
152 isCurrentFile && "bg-foreground/[0.08]",
153 entry.ignored && "text-muted-foreground",
154 )}
154 onClick={() => {155 onClick={() => {
155 if (entry.kind === "directory") {156 if (entry.kind === "directory") {
156 props.onDirectoryChange(entry.path);157 props.onDirectoryChange(entry.path);
apps/web/src/components/files/FileBrowserPanel.tsx+133-25
...@@ -7,7 +7,7 @@ import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";...@@ -7,7 +7,7 @@ import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";
7import { FileTree, useFileTree, useFileTreeSearch, useFileTreeSelector } from "@pierre/trees/react";7import { FileTree, useFileTree, useFileTreeSearch, useFileTreeSelector } from "@pierre/trees/react";
8import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";8import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
9import { ChevronsDownUpIcon, ChevronsUpDownIcon } from "lucide-react";9import { ChevronsDownUpIcon, ChevronsUpDownIcon } from "lucide-react";
10import { useEffect, useMemo, useRef } from "react";10import { useEffect, useMemo, useRef, useState } from "react";
1111
12import { Button } from "~/components/ui/button";12import { Button } from "~/components/ui/button";
13import { InputGroup, InputGroupInput } from "~/components/ui/input-group";13import { InputGroup, InputGroupInput } from "~/components/ui/input-group";
...@@ -24,7 +24,8 @@ import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme";...@@ -24,7 +24,8 @@ import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme";
24import { createFileTreeDragMentionController } from "./fileTreeDragMention";24import { createFileTreeDragMentionController } from "./fileTreeDragMention";
25import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion";25import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion";
26import { buildFileTreePathUpdates } from "./fileTreePathReconciliation";26import { buildFileTreePathUpdates } from "./fileTreePathReconciliation";
27import { useProjectEntriesQuery } from "./projectFilesQueryState";27import { useDirectoryEntries } from "./useDirectoryEntries";
28import { useProjectPathSearch } from "~/state/queries";
2829
29interface FileBrowserPanelProps {30interface FileBrowserPanelProps {
30 environmentId: EnvironmentId;31 environmentId: EnvironmentId;
...@@ -104,8 +105,31 @@ export default function FileBrowserPanel({...@@ -104,8 +105,31 @@ export default function FileBrowserPanel({
104}: FileBrowserPanelProps) {105}: FileBrowserPanelProps) {
105 const { resolvedTheme } = useTheme();106 const { resolvedTheme } = useTheme();
106 const composerRef = useComposerHandleContext();107 const composerRef = useComposerHandleContext();
107 const entriesQuery = useProjectEntriesQuery(environmentId, cwd);108 const {
108 const entries = entriesQuery.data?.entries ?? [];109 entries: directoryEntries,
110 load,
111 refresh,
112 ready,
113 error,
114 isPending,
115 } = useDirectoryEntries(environmentId, cwd);
116 const [query, setQuery] = useState("");
117 const [expandAll, setExpandAll] = useState(false);
118 const pathSearch = useProjectPathSearch({ environmentId, cwd, query: query.slice(0, 256) }, 200);
119 const entries = useMemo(() => {
120 const result = new Map(directoryEntries.map((entry) => [entry.path, entry]));
121 if (query.trim() && !pathSearch.isPending) {
122 for (const entry of pathSearch.entries) {
123 if (!result.has(entry.path)) result.set(entry.path, entry);
124 const segments = entry.path.split("/");
125 for (let index = 1; index < segments.length; index++) {
126 const path = segments.slice(0, index).join("/");
127 if (!result.has(path)) result.set(path, { path, kind: "directory" });
128 }
129 }
130 }
131 return [...result.values()];
132 }, [directoryEntries, pathSearch.entries, pathSearch.isPending, query]);
109 const entryKinds = useMemo(133 const entryKinds = useMemo(
110 () => new Map(entries.map((entry) => [entry.path, entry.kind] as const)),134 () => new Map(entries.map((entry) => [entry.path, entry.kind] as const)),
111 [entries],135 [entries],
...@@ -222,7 +246,7 @@ export default function FileBrowserPanel({...@@ -222,7 +246,7 @@ export default function FileBrowserPanel({
222 density: "compact",246 density: "compact",
223 fileTreeSearchMode: "hide-non-matches",247 fileTreeSearchMode: "hide-non-matches",
224 flattenEmptyDirectories: true,248 flattenEmptyDirectories: true,
225 initialExpansion: 1,249 initialExpansion: "closed",
226 icons: T3_PIERRE_ICONS,250 icons: T3_PIERRE_ICONS,
227 onSelectionChange: (selectedPaths) => {251 onSelectionChange: (selectedPaths) => {
228 // The drag controller's selection cache must track every change,252 // The drag controller's selection cache must track every change,
...@@ -244,6 +268,7 @@ export default function FileBrowserPanel({...@@ -244,6 +268,7 @@ export default function FileBrowserPanel({
244 },268 },
245 paths: [],269 paths: [],
246 search: false,270 search: false,
271 onSearchChange: (value) => setQuery(value ?? ""),
247 unsafeCSS: PIERRE_TREE_UNSAFE_CSS,272 unsafeCSS: PIERRE_TREE_UNSAFE_CSS,
248 });273 });
249 const search = useFileTreeSearch(model);274 const search = useFileTreeSearch(model);
...@@ -251,9 +276,63 @@ export default function FileBrowserPanel({...@@ -251,9 +276,63 @@ export default function FileBrowserPanel({
251 areAllDirectoriesExpanded(currentModel, directoryPaths),276 areAllDirectoriesExpanded(currentModel, directoryPaths),
252 );277 );
253 const toggleAllDirectories = () => {278 const toggleAllDirectories = () => {
254 setAllDirectoriesExpanded(model, directoryPaths, !allDirectoriesExpanded);279 const expanded = !(expandAll || allDirectoriesExpanded);
280 setExpandAll(expanded);
281 setAllDirectoriesExpanded(model, directoryPaths, expanded);
255 };282 };
283 const closeSearch = () => {
284 setQuery("");
285 search.close();
286 };
287 const expandedPathsRef = useRef(new Set<string>());
288 useEffect(() => {
289 const currentPaths = new Set(directoryPaths);
290 for (const path of expandedPathsRef.current) {
291 if (!currentPaths.has(path)) expandedPathsRef.current.delete(path);
292 }
293 const loadExpanded = () => {
294 if (model.isSearchOpen()) return;
295 for (const path of directoryPaths) {
296 const item = model.getItem(path);
297 if (item?.isDirectory() && "isExpanded" in item && item.isExpanded()) {
298 if (!expandedPathsRef.current.has(path)) {
299 expandedPathsRef.current.add(path);
300 void load(path.replace(/\/$/, ""));
301 }
302 } else {
303 if (item?.isDirectory() && expandedPathsRef.current.has(path)) setExpandAll(false);
304 expandedPathsRef.current.delete(path);
305 }
306 }
307 };
308 loadExpanded();
309 return model.subscribe(loadExpanded);
310 }, [directoryPaths, load, model]);
311 useEffect(() => {
312 model.setGitStatus(
313 entries
314 .filter((entry) => entry.ignored)
315 .map((entry) => ({
316 path: treePath(entry),
317 status: "ignored",
318 })),
319 );
320 }, [entries, model]);
321 useEffect(() => {
322 if (!selectedPath) return;
323 const controller = new AbortController();
324 void (async () => {
325 const segments = selectedPath.split("/");
326 for (let index = 0; index < segments.length && !controller.signal.aborted; index++) {
327 await load(segments.slice(0, index).join("/"));
328 }
329 })();
330 return () => {
331 controller.abort();
332 };
333 }, [load, selectedPath]);
256 const handleSearchValueChange = (value: string) => {334 const handleSearchValueChange = (value: string) => {
335 setQuery(value);
257 if (value.trim().length === 0) {336 if (value.trim().length === 0) {
258 search.close();337 search.close();
259 return;338 return;
...@@ -261,17 +340,21 @@ export default function FileBrowserPanel({...@@ -261,17 +340,21 @@ export default function FileBrowserPanel({
261 search.setValue(value);340 search.setValue(value);
262 };341 };
263 const handleRefresh = () => {342 const handleRefresh = () => {
264 entriesQuery.refresh();343 refresh();
344 if (query.trim()) pathSearch.refresh();
265 onRefreshSelectedFile?.();345 onRefreshSelectedFile?.();
266 };346 };
267 useWorkspaceMutationRefresh({347 useWorkspaceMutationRefresh({
268 mutationId: workspaceMutationId,348 mutationId: workspaceMutationId,
269 refresh: entriesQuery.refresh,349 refresh: () => {
350 refresh();
351 if (query.trim()) pathSearch.refresh();
352 },
270 resourceKey: `files:${environmentId}:${cwd}`,353 resourceKey: `files:${environmentId}:${cwd}`,
271 });354 });
272355
273 useEffect(() => {356 useEffect(() => {
274 if (entriesQuery.data === null) return;357 if (!ready) return;
275 if (previousTreePathsRef.current === treePaths) return;358 if (previousTreePathsRef.current === treePaths) return;
276 entryKindsRef.current = entryKinds;359 entryKindsRef.current = entryKinds;
277 const previousTreePaths = previousTreePathsRef.current;360 const previousTreePaths = previousTreePathsRef.current;
...@@ -282,13 +365,21 @@ export default function FileBrowserPanel({...@@ -282,13 +365,21 @@ export default function FileBrowserPanel({
282 }365 }
283 const updates = buildFileTreePathUpdates(previousTreePaths, treePaths);366 const updates = buildFileTreePathUpdates(previousTreePaths, treePaths);
284 if (updates.length > 0) model.batch(updates);367 if (updates.length > 0) model.batch(updates);
285 }, [entriesQuery.data, entryKinds, model, treePaths]);368 }, [ready, entryKinds, model, treePaths]);
369
370 useEffect(() => {
371 if (expandAll && !query.trim()) setAllDirectoriesExpanded(model, directoryPaths, true);
372 }, [directoryPaths, expandAll, model, query]);
286373
287 useEffect(() => {374 useEffect(() => {
288 if (!selectedPath) {375 if (!selectedPath) {
289 handledRevealRef.current = null;376 handledRevealRef.current = null;
290 return;377 return;
291 }378 }
379 if (entryKinds.get(selectedPath) !== "file") {
380 handledRevealRef.current = null;
381 return;
382 }
292 const revealRequest = { path: selectedPath, revealId: selectedPathRevealId };383 const revealRequest = { path: selectedPath, revealId: selectedPathRevealId };
293 const handledReveal = handledRevealRef.current;384 const handledReveal = handledRevealRef.current;
294 // Entry refreshes rebuild treePaths while the same preview stays open.385 // Entry refreshes rebuild treePaths while the same preview stays open.
...@@ -299,7 +390,6 @@ export default function FileBrowserPanel({...@@ -299,7 +390,6 @@ export default function FileBrowserPanel({
299 ) {390 ) {
300 return;391 return;
301 }392 }
302 if (entryKinds.get(selectedPath) !== "file") return;
303 const selectedItem = model.getItem(selectedPath);393 const selectedItem = model.getItem(selectedPath);
304 if (!selectedItem) return;394 if (!selectedItem) return;
305395
...@@ -319,6 +409,7 @@ export default function FileBrowserPanel({...@@ -319,6 +409,7 @@ export default function FileBrowserPanel({
319 handledRevealRef.current = revealRequest;409 handledRevealRef.current = revealRequest;
320410
321 syncingSelectionRef.current = true;411 syncingSelectionRef.current = true;
412 setQuery("");
322 model.closeSearch();413 model.closeSearch();
323 for (const path of model.getSelectedPaths()) {414 for (const path of model.getSelectedPaths()) {
324 model.getItem(path)?.deselect();415 model.getItem(path)?.deselect();
...@@ -339,7 +430,7 @@ export default function FileBrowserPanel({...@@ -339,7 +430,7 @@ export default function FileBrowserPanel({
339 queueMicrotask(() => {430 queueMicrotask(() => {
340 syncingSelectionRef.current = false;431 syncingSelectionRef.current = false;
341 });432 });
342 }, [entryKinds, model, selectedPath, selectedPathRevealId, treePaths]);433 }, [entryKinds, model, selectedPath, selectedPathRevealId]);
343434
344 // Tag tree drags with the composer mention payload. The row is read from435 // Tag tree drags with the composer mention payload. The row is read from
345 // the composed event path (the tree's shadow root is open), so this does436 // the composed event path (the tree's shadow root is open), so this does
...@@ -376,13 +467,13 @@ export default function FileBrowserPanel({...@@ -376,13 +467,13 @@ export default function FileBrowserPanel({
376 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"467 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"
377 data-surface-subheader468 data-surface-subheader
378 >469 >
379 <RefreshFilesButton isPending={entriesQuery.isPending} onRefresh={handleRefresh} />470 <RefreshFilesButton isPending={isPending} onRefresh={handleRefresh} />
380 <FileSearchField471 <FileSearchField
381 name="project-files-search"472 name="project-files-search"
382 ariaLabel={`Search ${projectName} files`}473 ariaLabel={`Search ${projectName} files`}
383 value={search.value}474 value={search.value}
384 onValueChange={handleSearchValueChange}475 onValueChange={handleSearchValueChange}
385 onClose={search.close}476 onClose={closeSearch}
386 />477 />
387 {directoryPaths.length > 0 ? (478 {directoryPaths.length > 0 ? (
388 <Tooltip>479 <Tooltip>
...@@ -393,7 +484,9 @@ export default function FileBrowserPanel({...@@ -393,7 +484,9 @@ export default function FileBrowserPanel({
393 size="icon-xs"484 size="icon-xs"
394 variant="ghost"485 variant="ghost"
395 aria-label={486 aria-label={
396 allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"487 expandAll || allDirectoriesExpanded
488 ? "Collapse all folders"
489 : "Expand all folders"
397 }490 }
398 onClick={toggleAllDirectories}491 onClick={toggleAllDirectories}
399 />492 />
...@@ -406,21 +499,36 @@ export default function FileBrowserPanel({...@@ -406,21 +499,36 @@ export default function FileBrowserPanel({
406 )}499 )}
407 </TooltipTrigger>500 </TooltipTrigger>
408 <TooltipPopup>501 <TooltipPopup>
409 {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"}502 {expandAll || allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"}
410 </TooltipPopup>503 </TooltipPopup>
411 </Tooltip>504 </Tooltip>
412 ) : null}505 ) : null}
413 </div>506 </div>
414 {entriesQuery.error && entriesQuery.data === null ? (507 {error || pathSearch.error ? (
415 <div className="p-4 text-xs leading-relaxed text-destructive">{entriesQuery.error}</div>508 <button
416 ) : (509 type="button"
417 <FileTree510 onClick={handleRefresh}
418 model={model}511 className="p-4 text-left text-xs leading-relaxed text-destructive"
419 aria-label={`${projectName} files`}512 >
420 className="min-h-0 flex-1 overflow-hidden"513 {error ?? pathSearch.error} Click to retry.
421 style={pierreTreeStyle(resolvedTheme)}514 </button>
422 />515 ) : null}
516 {query.trim() && pathSearch.truncated && !pathSearch.isPending ? (
517 <div className="px-3 py-1 text-xs text-muted-foreground">
518 More matches available. Refine your search.
519 </div>
520 ) : null}
521 {(isPending || pathSearch.isPending) && (
522 <div role="status" className="px-3 py-1 text-xs text-muted-foreground">
523 Loading files…
524 </div>
423 )}525 )}
526 <FileTree
527 model={model}
528 aria-label={`${projectName} files`}
529 className="min-h-0 flex-1 overflow-hidden"
530 style={pierreTreeStyle(resolvedTheme)}
531 />
424 </div>532 </div>
425 );533 );
426}534}
apps/web/src/components/files/projectFilesQueryState.ts+11-3
...@@ -33,8 +33,15 @@ interface ProjectQueryState<A> {...@@ -33,8 +33,15 @@ interface ProjectQueryState<A> {
33 readonly refresh: () => void;33 readonly refresh: () => void;
34}34}
3535
36function getProjectEntriesQueryAtom(environmentId: EnvironmentId, cwd: string) {36function getProjectEntriesQueryAtom(
37 return projectEnvironment.listEntries({ environmentId, input: { cwd } });37 environmentId: EnvironmentId,
38 cwd: string,
39 directoryPath?: string,
40) {
41 return projectEnvironment.listEntries({
42 environmentId,
43 input: { cwd, ...(directoryPath !== undefined ? { directoryPath } : {}) },
44 });
38}45}
3946
40export function getProjectFileQueryAtom(47export function getProjectFileQueryAtom(
...@@ -128,8 +135,9 @@ function errorMessage<A>(result: AsyncResult.AsyncResult<A, unknown>): string |...@@ -128,8 +135,9 @@ function errorMessage<A>(result: AsyncResult.AsyncResult<A, unknown>): string |
128export function useProjectEntriesQuery(135export function useProjectEntriesQuery(
129 environmentId: EnvironmentId,136 environmentId: EnvironmentId,
130 cwd: string,137 cwd: string,
138 directoryPath?: string,
131): ProjectQueryState<ProjectListEntriesResult> {139): ProjectQueryState<ProjectListEntriesResult> {
132 const atom = getProjectEntriesQueryAtom(environmentId, cwd);140 const atom = getProjectEntriesQueryAtom(environmentId, cwd, directoryPath);
133 const result = useAtomValue(atom);141 const result = useAtomValue(atom);
134 const refreshAtom = useAtomRefresh(atom);142 const refreshAtom = useAtomRefresh(atom);
135 const refresh = useCallback(() => refreshAtom(), [refreshAtom]);143 const refresh = useCallback(() => refreshAtom(), [refreshAtom]);
apps/web/src/components/files/useDirectoryEntries.ts created+136
...@@ -0,0 +1,136 @@
1import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";
2import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";
3import * as Cause from "effect/Cause";
4import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
6import { appAtomRegistry } from "~/rpc/atomRegistry";
7import { projectEnvironment } from "~/state/projects";
8
9/** Loads only requested directories; collapsing a folder keeps its children cached. */
10export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) {
11 const [directories, setDirectories] = useState(new Map<string, readonly ProjectEntry[]>());
12 const [errors, setErrors] = useState(new Map<string, string>());
13 const [pending, setPending] = useState(0);
14 const requests = useRef(new Map<string, Promise<void>>());
15 const loaded = useRef(new Set<string>());
16 const requested = useRef(new Set<string>());
17 const active = useRef(true);
18 const running = useRef(0);
19 const waiting = useRef<Array<() => void>>([]);
20
21 const load = useCallback(
22 function loadDirectory(directoryPath: string, refresh = false): Promise<void> {
23 const existing = requests.current.get(directoryPath);
24 if (existing)
25 return refresh ? existing.then(() => loadDirectory(directoryPath, true)) : existing;
26 if (!refresh && loaded.current.has(directoryPath)) return Promise.resolve();
27 loaded.current.add(directoryPath);
28 requested.current.add(directoryPath);
29 const atom = projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath } });
30 setPending((count) => count + 1);
31 const request = (async () => {
32 if (running.current >= 4)
33 await new Promise<void>((resolve) => waiting.current.push(resolve));
34 else running.current++;
35 try {
36 if (!active.current) return undefined;
37 return await executeAtomQuery(appAtomRegistry, atom, {
38 refresh: true,
39 reportFailure: false,
40 reportDefect: false,
41 });
42 } finally {
43 const next = waiting.current.shift();
44 if (next) next();
45 else running.current--;
46 }
47 })()
48 .then((result) => {
49 if (!active.current || !result) return;
50 if (result._tag === "Success") {
51 setDirectories((previous) =>
52 new Map(previous).set(
53 directoryPath,
54 result.value.entries.filter(
55 (entry) =>
56 entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))) === directoryPath,
57 ),
58 ),
59 );
60 setErrors((previous) => {
61 const next = new Map(previous);
62 next.delete(directoryPath);
63 return next;
64 });
65 } else {
66 loaded.current.delete(directoryPath);
67 const cause = Cause.squash(result.cause);
68 setErrors((previous) =>
69 new Map(previous).set(
70 directoryPath,
71 cause instanceof Error ? cause.message : "Unable to load folder.",
72 ),
73 );
74 }
75 })
76 .finally(() => {
77 requests.current.delete(directoryPath);
78 if (active.current) setPending((count) => count - 1);
79 });
80 requests.current.set(directoryPath, request);
81 return request;
82 },
83 [cwd, environmentId],
84 );
85
86 useEffect(() => {
87 active.current = true;
88 void load("");
89 return () => {
90 active.current = false;
91 };
92 }, [load]);
93
94 const entries = useMemo(() => {
95 const result: ProjectEntry[] = [];
96 const visit = (path: string) => {
97 for (const entry of directories.get(path) ?? []) {
98 result.push(entry);
99 if (entry.kind === "directory") visit(entry.path);
100 }
101 };
102 visit("");
103 return result;
104 }, [directories]);
105
106 const reachableDirectories = useMemo(
107 () =>
108 new Set([
109 "",
110 ...entries.filter((entry) => entry.kind === "directory").map((entry) => entry.path),
111 ]),
112 [entries],
113 );
114
115 const refresh = useCallback(() => {
116 // Refresh folders already visited, preserving the current expansion state.
117 const paths = [...requested.current].filter((path) => reachableDirectories.has(path));
118 let next = 0;
119 const worker = async () => {
120 while (next < paths.length && active.current) {
121 const path = paths[next++];
122 if (path !== undefined) await load(path, true);
123 }
124 };
125 for (let index = 0; index < Math.min(4, paths.length); index++) void worker();
126 }, [load, reachableDirectories]);
127
128 return {
129 entries,
130 load,
131 refresh,
132 isPending: pending > 0,
133 ready: directories.has(""),
134 error: [...errors].find(([path]) => reachableDirectories.has(path))?.[1] ?? null,
135 };
136}
apps/web/src/state/queries.ts+1
...@@ -263,6 +263,7 @@ export function useProjectPathSearch(...@@ -263,6 +263,7 @@ export function useProjectPathSearch(
263 isPending:263 isPending:
264 !areProjectPathSearchTargetsEqual(normalizedTarget, debouncedTarget) || result.isPending,264 !areProjectPathSearchTargetsEqual(normalizedTarget, debouncedTarget) || result.isPending,
265 searchedQuery: debouncedTarget.query ?? "",265 searchedQuery: debouncedTarget.query ?? "",
266 truncated: result.data?.truncated ?? false,
266 refresh: result.refresh,267 refresh: result.refresh,
267 };268 };
268}269}
packages/contracts/src/project.ts+5
...@@ -28,6 +28,7 @@ export type ProjectSearchEntriesInput = typeof ProjectSearchEntriesInput.Type;...@@ -28,6 +28,7 @@ export type ProjectSearchEntriesInput = typeof ProjectSearchEntriesInput.Type;
28export const ProjectEntry = Schema.Struct({28export const ProjectEntry = Schema.Struct({
29 path: TrimmedNonEmptyString,29 path: TrimmedNonEmptyString,
30 kind: ProjectEntryKind,30 kind: ProjectEntryKind,
31 ignored: Schema.optional(Schema.Boolean),
31});32});
32export type ProjectEntry = typeof ProjectEntry.Type;33export type ProjectEntry = typeof ProjectEntry.Type;
3334
...@@ -72,6 +73,9 @@ export type ProjectSearchContentsResult = typeof ProjectSearchContentsResult.Typ...@@ -72,6 +73,9 @@ export type ProjectSearchContentsResult = typeof ProjectSearchContentsResult.Typ
7273
73export const ProjectListEntriesInput = Schema.Struct({74export const ProjectListEntriesInput = Schema.Struct({
74 cwd: TrimmedNonEmptyString,75 cwd: TrimmedNonEmptyString,
76 // Present for immediate filesystem children, including ignored entries; empty means root.
77 // Omitted preserves the indexed recursive listing used by older clients.
78 directoryPath: Schema.optional(TrimmedString),
75});79});
76export type ProjectListEntriesInput = typeof ProjectListEntriesInput.Type;80export type ProjectListEntriesInput = typeof ProjectListEntriesInput.Type;
7781
...@@ -89,6 +93,7 @@ export const ProjectEntriesFailure = Schema.Literals([...@@ -89,6 +93,7 @@ export const ProjectEntriesFailure = Schema.Literals([
89 "search_index_create_failed",93 "search_index_create_failed",
90 "search_index_scan_timed_out",94 "search_index_scan_timed_out",
91 "search_index_search_failed",95 "search_index_search_failed",
96 "directory_list_failed",
92]);97]);
93export type ProjectEntriesFailure = typeof ProjectEntriesFailure.Type;98export type ProjectEntriesFailure = typeof ProjectEntriesFailure.Type;
9499