| author | |
| committer | |
| log | 21d53ca2eecbf9f1e481454e8084459b422de714 |
| tree | 65c62cd552c6036a2ca015d25e4c24289432b064 |
| parent | 2ec59ca1fad6d9eb6ae36714b76b6130e63201e6 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 11 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; |
| 12 | 12 | import { |
| 13 | 13 | buildFileTree, |
| 14 | defaultExpandedTreePaths, | |
| 15 | 14 | flattenFileTree, |
| 16 | 15 | type FileTreeNode, |
| 17 | 16 | type VisibleFileTreeNode, |
| ... | ... | @@ -45,6 +44,7 @@ const FileTreeRow = memo(function FileTreeRow(props: { |
| 45 | 44 | readonly item: VisibleFileTreeNode; |
| 46 | 45 | readonly selected: boolean; |
| 47 | 46 | readonly expanded: boolean; |
| 47 | readonly loaded: boolean; | |
| 48 | 48 | readonly onPressDirectory: (path: string) => void; |
| 49 | 49 | readonly onPreviewFile?: (path: string) => void; |
| 50 | 50 | readonly onPressFile: (path: string) => void; |
| ... | ... | @@ -89,13 +89,15 @@ const FileTreeRow = memo(function FileTreeRow(props: { |
| 89 | 89 | "min-w-0 flex-1 text-sm leading-normal", |
| 90 | 90 | props.selected |
| 91 | 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 | 96 | numberOfLines={1} |
| 95 | 97 | > |
| 96 | 98 | {node.name} |
| 97 | 99 | </Text> |
| 98 | {node.kind === "directory" ? ( | |
| 100 | {node.kind === "directory" && props.loaded ? ( | |
| 99 | 101 | <Text className="text-2xs font-t3-medium text-foreground-tertiary"> |
| 100 | 102 | {node.children.length} |
| 101 | 103 | </Text> |
| ... | ... | @@ -109,7 +111,10 @@ export function FileTreeBrowser(props: { |
| 109 | 111 | readonly error: string | null; |
| 110 | 112 | readonly isPending: boolean; |
| 111 | 113 | readonly searchQuery: string; |
| 114 | readonly searchTruncated: boolean; | |
| 112 | 115 | readonly selectedPath: string | null; |
| 116 | readonly loadedDirectories: ReadonlySet<string>; | |
| 117 | readonly onLoadDirectory: (path: string) => void; | |
| 113 | 118 | readonly onPreviewFile?: (path: string) => void; |
| 114 | 119 | readonly onRefresh: () => void; |
| 115 | 120 | readonly onSelectFile: (path: string) => void; |
| ... | ... | @@ -123,7 +128,13 @@ export function FileTreeBrowser(props: { |
| 123 | 128 | // Native transparent-header height ≈ safe-area top + nav bar (~44). Matches the |
| 124 | 129 | // observed adjustedContentInset bottom (~102) seen in the native trace. |
| 125 | 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 | 138 | const controlledSelectedPathRef = useRef(controlledSelectedPath); |
| 128 | 139 | const pendingSelectionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 129 | 140 | controlledSelectedPathRef.current = controlledSelectedPath; |
| ... | ... | @@ -133,7 +144,6 @@ export function FileTreeBrowser(props: { |
| 133 | 144 | ? pendingSelection.path |
| 134 | 145 | : controlledSelectedPath; |
| 135 | 146 | const tree = useMemo(() => cachedFileTree(props.entries), [props.entries]); |
| 136 | const defaultExpanded = useMemo(() => defaultExpandedTreePaths(tree), [tree]); | |
| 137 | 147 | const visibleNodes = useMemo( |
| 138 | 148 | () => |
| 139 | 149 | flattenFileTree({ |
| ... | ... | @@ -144,15 +154,6 @@ export function FileTreeBrowser(props: { |
| 144 | 154 | [expandedPaths, props.searchQuery, tree], |
| 145 | 155 | ); |
| 146 | 156 | |
| 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 | 157 | useEffect(() => { |
| 157 | 158 | if (!controlledSelectedPath) { |
| 158 | 159 | return; |
| ... | ... | @@ -170,6 +171,10 @@ export function FileTreeBrowser(props: { |
| 170 | 171 | }); |
| 171 | 172 | }, [controlledSelectedPath]); |
| 172 | 173 | |
| 174 | useEffect(() => { | |
| 175 | for (const path of expandedPaths) onLoadDirectory(path); | |
| 176 | }, [expandedPaths, onLoadDirectory]); | |
| 177 | ||
| 173 | 178 | useEffect( |
| 174 | 179 | () => () => { |
| 175 | 180 | if (pendingSelectionTimeoutRef.current !== null) { |
| ... | ... | @@ -213,12 +218,20 @@ export function FileTreeBrowser(props: { |
| 213 | 218 | item={item} |
| 214 | 219 | selected={item.node.kind === "file" && item.node.path === selectedPath} |
| 215 | 220 | expanded={expandedPaths.has(item.node.path)} |
| 221 | loaded={loadedDirectories.has(item.node.path)} | |
| 216 | 222 | onPressDirectory={toggleDirectory} |
| 217 | 223 | onPreviewFile={onPreviewFile} |
| 218 | 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 | ); |
| 223 | 236 | |
| 224 | 237 | if (props.error && props.entries.length === 0) { |
| ... | ... | @@ -255,6 +268,20 @@ export function FileTreeBrowser(props: { |
| 255 | 268 | contentContainerStyle={{ paddingTop: 8, paddingBottom: 8 }} |
| 256 | 269 | refreshControl={<RefreshControl refreshing={props.isPending} onRefresh={props.onRefresh} />} |
| 257 | 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 | 285 | ListEmptyComponent={ |
| 259 | 286 | <View className="px-4 py-5"> |
| 260 | 287 | {props.isPending ? ( |
| ... | ... | @@ -265,7 +292,7 @@ export function FileTreeBrowser(props: { |
| 265 | 292 | <Text className="mt-1 text-xs leading-normal text-foreground-muted"> |
| 266 | 293 | {props.searchQuery.trim().length > 0 |
| 267 | 294 | ? "Try a different search." |
| 268 | : "The workspace file index is empty."} | |
| 295 | : "The workspace is empty."} | |
| 269 | 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 | 5 | import { ActivityIndicator, Platform, View } from "react-native"; |
| 6 | 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 7 | 7 | import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; |
| 8 | import { | |
| 9 | EnvironmentId, | |
| 10 | type ProjectListEntriesResult, | |
| 11 | type ProjectReadFileResult, | |
| 12 | ThreadId, | |
| 13 | } from "@t3tools/contracts"; | |
| 8 | import { EnvironmentId, type ProjectReadFileResult, ThreadId } from "@t3tools/contracts"; | |
| 14 | 9 | import { videoMimeType } from "@t3tools/shared/video"; |
| 15 | 10 | import { |
| 16 | 11 | isWorkspaceBrowserPreviewPath, |
| ... | ... | @@ -53,6 +48,7 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe |
| 53 | 48 | import { ThreadRouteScreen } from "../threads/ThreadRouteScreen"; |
| 54 | 49 | import { FileMarkdownPreview } from "./FileMarkdownPreview"; |
| 55 | 50 | import { FileTreeBrowser } from "./FileTreeBrowser"; |
| 51 | import { useFileTreeEntries } from "./useFileTreeEntries"; | |
| 56 | 52 | import { preloadWorkspaceFileContents } from "./preload-workspace-file"; |
| 57 | 53 | import { SourceFileSurface } from "./SourceFileSurface"; |
| 58 | 54 | import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane"; |
| ... | ... | @@ -341,15 +337,11 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { |
| 341 | 337 | props.route.params, |
| 342 | 338 | ); |
| 343 | 339 | const revealedInspectorRef = useRef(false); |
| 344 | const entriesQuery = useEnvironmentQuery( | |
| 345 | environmentId !== null && cwd !== null && !fileInspector.supported | |
| 346 | ? projectEnvironment.listEntries({ | |
| 347 | environmentId, | |
| 348 | input: { cwd }, | |
| 349 | }) | |
| 350 | : null, | |
| 351 | ); | |
| 352 | const entriesData = entriesQuery.data as ProjectListEntriesResult | null; | |
| 340 | const entriesQuery = useFileTreeEntries({ | |
| 341 | environmentId, | |
| 342 | cwd: fileInspector.supported ? null : cwd, | |
| 343 | searchQuery, | |
| 344 | }); | |
| 353 | 345 | const handleReturnToThread = useCallback(() => { |
| 354 | 346 | if (navigation.canGoBack()) { |
| 355 | 347 | navigation.goBack(); |
| ... | ... | @@ -557,10 +549,14 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { |
| 557 | 549 | </> |
| 558 | 550 | )} |
| 559 | 551 | <FileTreeBrowser |
| 560 | entries={entriesData?.entries ?? []} | |
| 552 | key={JSON.stringify([environmentId, cwd])} | |
| 553 | entries={entriesQuery.entries} | |
| 554 | loadedDirectories={entriesQuery.loadedDirectories} | |
| 555 | onLoadDirectory={entriesQuery.loadDirectory} | |
| 561 | 556 | error={entriesQuery.error} |
| 562 | 557 | isPending={entriesQuery.isPending} |
| 563 | 558 | searchQuery={searchQuery} |
| 559 | searchTruncated={entriesQuery.searchTruncated} | |
| 564 | 560 | selectedPath={null} |
| 565 | 561 | onPreviewFile={handlePreviewFile} |
| 566 | 562 | onRefresh={entriesQuery.refresh} |
apps/mobile/src/features/files/fileTree.ts+4| ... | ... | @@ -5,6 +5,7 @@ export interface FileTreeNode { |
| 5 | 5 | readonly path: string; |
| 6 | 6 | readonly name: string; |
| 7 | 7 | readonly kind: ProjectEntry["kind"]; |
| 8 | readonly ignored?: boolean; | |
| 8 | 9 | readonly children: ReadonlyArray<FileTreeNode>; |
| 9 | 10 | readonly searchSegments: ReadonlyArray<string>; |
| 10 | 11 | readonly searchWords: ReadonlyArray<string>; |
| ... | ... | @@ -19,6 +20,7 @@ interface MutableFileTreeNode { |
| 19 | 20 | path: string; |
| 20 | 21 | name: string; |
| 21 | 22 | kind: ProjectEntry["kind"]; |
| 23 | ignored?: boolean; | |
| 22 | 24 | children: Map<string, MutableFileTreeNode>; |
| 23 | 25 | } |
| 24 | 26 | |
| ... | ... | @@ -68,6 +70,7 @@ function freezeNode(node: MutableFileTreeNode): FileTreeNode { |
| 68 | 70 | path: node.path, |
| 69 | 71 | name: node.name, |
| 70 | 72 | kind: node.kind, |
| 73 | ...(node.ignored ? { ignored: true } : {}), | |
| 71 | 74 | children: [...node.children.values()].sort(compareNodes).map(freezeNode), |
| 72 | 75 | searchSegments: searchTerms.segments, |
| 73 | 76 | searchWords: searchTerms.words, |
| ... | ... | @@ -110,6 +113,7 @@ export function buildFileTree(entries: ReadonlyArray<ProjectEntry>): ReadonlyArr |
| 110 | 113 | } else if (isLeaf) { |
| 111 | 114 | child.kind = entry.kind; |
| 112 | 115 | } |
| 116 | if (isLeaf && entry.ignored) child.ignored = true; | |
| 113 | 117 | current = child; |
| 114 | 118 | } |
| 115 | 119 | } |
apps/mobile/src/features/files/thread-file-navigator-pane.tsx+12-11| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { EnvironmentId, ProjectListEntriesResult } from "@t3tools/contracts"; | |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | 2 | import { SymbolView } from "../../components/AppSymbol"; |
| 3 | 3 | import { useCallback, useMemo, useState, type ComponentProps } from "react"; |
| 4 | 4 | import { Platform, Pressable, View, type NativeSyntheticEvent } from "react-native"; |
| ... | ... | @@ -13,10 +13,9 @@ import { |
| 13 | 13 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 14 | 14 | import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; |
| 15 | 15 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 16 | import { projectEnvironment } from "../../state/projects"; | |
| 17 | import { useEnvironmentQuery } from "../../state/query"; | |
| 18 | 16 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 19 | 17 | import { FileTreeBrowser } from "./FileTreeBrowser"; |
| 18 | import { useFileTreeEntries } from "./useFileTreeEntries"; | |
| 20 | 19 | import { preloadWorkspaceFileContents } from "./preload-workspace-file"; |
| 21 | 20 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; |
| 22 | 21 | |
| ... | ... | @@ -35,13 +34,11 @@ export function ThreadFileNavigatorPane(props: { |
| 35 | 34 | const foregroundColor = theme["--color-foreground"]; |
| 36 | 35 | const sheetColor = theme["--color-sheet"]; |
| 37 | 36 | const headerScrollEdgeEffects = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); |
| 38 | const entriesQuery = useEnvironmentQuery( | |
| 39 | projectEnvironment.listEntries({ | |
| 40 | environmentId: props.environmentId, | |
| 41 | input: { cwd: props.cwd }, | |
| 42 | }), | |
| 43 | ); | |
| 44 | const entriesData = entriesQuery.data as ProjectListEntriesResult | null; | |
| 37 | const entriesQuery = useFileTreeEntries({ | |
| 38 | environmentId: props.environmentId, | |
| 39 | cwd: props.cwd, | |
| 40 | searchQuery, | |
| 41 | }); | |
| 45 | 42 | const handlePreviewFile = useCallback( |
| 46 | 43 | (relativePath: string) => { |
| 47 | 44 | preloadWorkspaceFileContents({ |
| ... | ... | @@ -82,10 +79,14 @@ export function ThreadFileNavigatorPane(props: { |
| 82 | 79 | |
| 83 | 80 | const fileTree = ( |
| 84 | 81 | <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 | 86 | error={entriesQuery.error} |
| 87 | 87 | isPending={entriesQuery.isPending} |
| 88 | 88 | searchQuery={searchQuery} |
| 89 | searchTruncated={entriesQuery.searchTruncated} | |
| 89 | 90 | selectedPath={props.selectedPath} |
| 90 | 91 | onPreviewFile={handlePreviewFile} |
| 91 | 92 | onRefresh={entriesQuery.refresh} |
apps/mobile/src/features/files/useFileTreeEntries.ts created+164| ... | ... | @@ -0,0 +1,164 @@ |
| 1 | import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; | |
| 2 | import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; | |
| 3 | import * as Cause from "effect/Cause"; | |
| 4 | import { useCallback, useEffect, useMemo, useReducer, useRef } from "react"; | |
| 5 | ||
| 6 | import { appAtomRegistry } from "../../state/atom-registry"; | |
| 7 | import { projectEnvironment } from "../../state/projects"; | |
| 8 | import { useDebouncedValue } from "../../state/queries"; | |
| 9 | import { useEnvironmentQuery } from "../../state/query"; | |
| 10 | ||
| 11 | export 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 | 96 | }); |
| 97 | 97 | |
| 98 | 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 | 195 | it.effect("returns the complete cached workspace index", () => |
| 100 | 196 | Effect.gen(function* () { |
| 101 | 197 | const cwd = yield* makeTempDir(); |
apps/server/src/workspace/WorkspaceEntries.ts+77| ... | ... | @@ -11,6 +11,7 @@ import * as Schema from "effect/Schema"; |
| 11 | 11 | import type { |
| 12 | 12 | FilesystemBrowseInput, |
| 13 | 13 | FilesystemBrowseResult, |
| 14 | ProjectEntry, | |
| 14 | 15 | ProjectListEntriesInput, |
| 15 | 16 | ProjectListEntriesResult, |
| 16 | 17 | ProjectSearchContentsInput, |
| ... | ... | @@ -23,6 +24,7 @@ import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/p |
| 23 | 24 | import { normalizeSearchQuery } from "@t3tools/shared/searchRanking"; |
| 24 | 25 | |
| 25 | 26 | import { expandHomePathWith } from "../pathExpansion.ts"; |
| 27 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 26 | 28 | import * as WorkspacePaths from "./WorkspacePaths.ts"; |
| 27 | 29 | import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; |
| 28 | 30 | |
| ... | ... | @@ -74,6 +76,7 @@ export const WorkspaceEntriesBrowseError = Schema.Union([ |
| 74 | 76 | export type WorkspaceEntriesBrowseError = typeof WorkspaceEntriesBrowseError.Type; |
| 75 | 77 | |
| 76 | 78 | export const WorkspaceEntriesError = Schema.Union([ |
| 79 | WorkspaceEntriesReadDirectoryError, | |
| 77 | 80 | WorkspacePaths.WorkspaceRootNotExistsError, |
| 78 | 81 | WorkspacePaths.WorkspaceRootCreateFailedError, |
| 79 | 82 | WorkspacePaths.WorkspaceRootStatFailedError, |
| ... | ... | @@ -133,6 +136,7 @@ export const make = Effect.gen(function* () { |
| 133 | 136 | const path = yield* Path.Path; |
| 134 | 137 | const workspacePaths = yield* WorkspacePaths.WorkspacePaths; |
| 135 | 138 | const workspaceSearchIndexes = yield* WorkspaceSearchIndex.WorkspaceSearchIndexMap; |
| 139 | const vcsProcess = yield* VcsProcess.VcsProcess; | |
| 136 | 140 | |
| 137 | 141 | const normalizeWorkspaceRoot = Effect.fn("WorkspaceEntries.normalizeWorkspaceRoot")(function* ( |
| 138 | 142 | cwd: string, |
| ... | ... | @@ -266,6 +270,78 @@ export const make = Effect.gen(function* () { |
| 266 | 270 | const list: WorkspaceEntries["Service"]["list"] = Effect.fn("WorkspaceEntries.list")( |
| 267 | 271 | function* (input) { |
| 268 | 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 | 345 | return yield* Effect.gen(function* () { |
| 270 | 346 | const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; |
| 271 | 347 | return yield* searchIndex.list(); |
| ... | ... | @@ -284,4 +360,5 @@ export const make = Effect.gen(function* () { |
| 284 | 360 | |
| 285 | 361 | export const layer = Layer.effect(WorkspaceEntries, make).pipe( |
| 286 | 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 | 231 | failure: "workspace_root_not_directory", |
| 232 | 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 | 240 | case "WorkspaceSearchIndexCreateFailed": |
| 235 | 241 | return { |
| 236 | 242 | failure: "search_index_create_failed", |
apps/web/src/components/files/FileBreadcrumbs.tsx+6-5| ... | ... | @@ -78,7 +78,7 @@ function BreadcrumbMenuContent(props: { |
| 78 | 78 | readonly rootPath: string; |
| 79 | 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 | 82 | useWorkspaceMutationRefresh({ |
| 83 | 83 | mutationId: props.workspaceMutationId, |
| 84 | 84 | refresh: entriesQuery.refresh, |
| ... | ... | @@ -91,9 +91,7 @@ function BreadcrumbMenuContent(props: { |
| 91 | 91 | () => fileBreadcrumbChildren(entries, props.directoryPath), |
| 92 | 92 | [entries, props.directoryPath], |
| 93 | 93 | ); |
| 94 | const directoryAvailable = | |
| 95 | props.directoryPath === "" || | |
| 96 | entries.some((entry) => entry.kind === "directory" && entry.path === props.directoryPath); | |
| 94 | const directoryAvailable = entriesQuery.data !== null; | |
| 97 | 95 | const parentPath = fileBreadcrumbParent(props.directoryPath); |
| 98 | 96 | const canGoBack = |
| 99 | 97 | props.directoryPath !== props.rootPath && |
| ... | ... | @@ -150,7 +148,10 @@ function BreadcrumbMenuContent(props: { |
| 150 | 148 | key={entry.path} |
| 151 | 149 | closeOnClick={entry.kind === "file"} |
| 152 | 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 | 155 | onClick={() => { |
| 155 | 156 | if (entry.kind === "directory") { |
| 156 | 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 | import { FileTree, useFileTree, useFileTreeSearch, useFileTreeSelector } from "@pierre/trees/react"; |
| 8 | 8 | import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; |
| 9 | 9 | import { ChevronsDownUpIcon, ChevronsUpDownIcon } from "lucide-react"; |
| 10 | import { useEffect, useMemo, useRef } from "react"; | |
| 10 | import { useEffect, useMemo, useRef, useState } from "react"; | |
| 11 | 11 | |
| 12 | 12 | import { Button } from "~/components/ui/button"; |
| 13 | 13 | import { InputGroup, InputGroupInput } from "~/components/ui/input-group"; |
| ... | ... | @@ -24,7 +24,8 @@ import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme"; |
| 24 | 24 | import { createFileTreeDragMentionController } from "./fileTreeDragMention"; |
| 25 | 25 | import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion"; |
| 26 | 26 | import { buildFileTreePathUpdates } from "./fileTreePathReconciliation"; |
| 27 | import { useProjectEntriesQuery } from "./projectFilesQueryState"; | |
| 27 | import { useDirectoryEntries } from "./useDirectoryEntries"; | |
| 28 | import { useProjectPathSearch } from "~/state/queries"; | |
| 28 | 29 | |
| 29 | 30 | interface FileBrowserPanelProps { |
| 30 | 31 | environmentId: EnvironmentId; |
| ... | ... | @@ -104,8 +105,31 @@ export default function FileBrowserPanel({ |
| 104 | 105 | }: FileBrowserPanelProps) { |
| 105 | 106 | const { resolvedTheme } = useTheme(); |
| 106 | 107 | const composerRef = useComposerHandleContext(); |
| 107 | const entriesQuery = useProjectEntriesQuery(environmentId, cwd); | |
| 108 | const entries = entriesQuery.data?.entries ?? []; | |
| 108 | const { | |
| 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 | 133 | const entryKinds = useMemo( |
| 110 | 134 | () => new Map(entries.map((entry) => [entry.path, entry.kind] as const)), |
| 111 | 135 | [entries], |
| ... | ... | @@ -222,7 +246,7 @@ export default function FileBrowserPanel({ |
| 222 | 246 | density: "compact", |
| 223 | 247 | fileTreeSearchMode: "hide-non-matches", |
| 224 | 248 | flattenEmptyDirectories: true, |
| 225 | initialExpansion: 1, | |
| 249 | initialExpansion: "closed", | |
| 226 | 250 | icons: T3_PIERRE_ICONS, |
| 227 | 251 | onSelectionChange: (selectedPaths) => { |
| 228 | 252 | // The drag controller's selection cache must track every change, |
| ... | ... | @@ -244,6 +268,7 @@ export default function FileBrowserPanel({ |
| 244 | 268 | }, |
| 245 | 269 | paths: [], |
| 246 | 270 | search: false, |
| 271 | onSearchChange: (value) => setQuery(value ?? ""), | |
| 247 | 272 | unsafeCSS: PIERRE_TREE_UNSAFE_CSS, |
| 248 | 273 | }); |
| 249 | 274 | const search = useFileTreeSearch(model); |
| ... | ... | @@ -251,9 +276,63 @@ export default function FileBrowserPanel({ |
| 251 | 276 | areAllDirectoriesExpanded(currentModel, directoryPaths), |
| 252 | 277 | ); |
| 253 | 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 | 334 | const handleSearchValueChange = (value: string) => { |
| 335 | setQuery(value); | |
| 257 | 336 | if (value.trim().length === 0) { |
| 258 | 337 | search.close(); |
| 259 | 338 | return; |
| ... | ... | @@ -261,17 +340,21 @@ export default function FileBrowserPanel({ |
| 261 | 340 | search.setValue(value); |
| 262 | 341 | }; |
| 263 | 342 | const handleRefresh = () => { |
| 264 | entriesQuery.refresh(); | |
| 343 | refresh(); | |
| 344 | if (query.trim()) pathSearch.refresh(); | |
| 265 | 345 | onRefreshSelectedFile?.(); |
| 266 | 346 | }; |
| 267 | 347 | useWorkspaceMutationRefresh({ |
| 268 | 348 | mutationId: workspaceMutationId, |
| 269 | refresh: entriesQuery.refresh, | |
| 349 | refresh: () => { | |
| 350 | refresh(); | |
| 351 | if (query.trim()) pathSearch.refresh(); | |
| 352 | }, | |
| 270 | 353 | resourceKey: `files:${environmentId}:${cwd}`, |
| 271 | 354 | }); |
| 272 | 355 | |
| 273 | 356 | useEffect(() => { |
| 274 | if (entriesQuery.data === null) return; | |
| 357 | if (!ready) return; | |
| 275 | 358 | if (previousTreePathsRef.current === treePaths) return; |
| 276 | 359 | entryKindsRef.current = entryKinds; |
| 277 | 360 | const previousTreePaths = previousTreePathsRef.current; |
| ... | ... | @@ -282,13 +365,21 @@ export default function FileBrowserPanel({ |
| 282 | 365 | } |
| 283 | 366 | const updates = buildFileTreePathUpdates(previousTreePaths, treePaths); |
| 284 | 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]); | |
| 286 | 373 | |
| 287 | 374 | useEffect(() => { |
| 288 | 375 | if (!selectedPath) { |
| 289 | 376 | handledRevealRef.current = null; |
| 290 | 377 | return; |
| 291 | 378 | } |
| 379 | if (entryKinds.get(selectedPath) !== "file") { | |
| 380 | handledRevealRef.current = null; | |
| 381 | return; | |
| 382 | } | |
| 292 | 383 | const revealRequest = { path: selectedPath, revealId: selectedPathRevealId }; |
| 293 | 384 | const handledReveal = handledRevealRef.current; |
| 294 | 385 | // Entry refreshes rebuild treePaths while the same preview stays open. |
| ... | ... | @@ -299,7 +390,6 @@ export default function FileBrowserPanel({ |
| 299 | 390 | ) { |
| 300 | 391 | return; |
| 301 | 392 | } |
| 302 | if (entryKinds.get(selectedPath) !== "file") return; | |
| 303 | 393 | const selectedItem = model.getItem(selectedPath); |
| 304 | 394 | if (!selectedItem) return; |
| 305 | 395 | |
| ... | ... | @@ -319,6 +409,7 @@ export default function FileBrowserPanel({ |
| 319 | 409 | handledRevealRef.current = revealRequest; |
| 320 | 410 | |
| 321 | 411 | syncingSelectionRef.current = true; |
| 412 | setQuery(""); | |
| 322 | 413 | model.closeSearch(); |
| 323 | 414 | for (const path of model.getSelectedPaths()) { |
| 324 | 415 | model.getItem(path)?.deselect(); |
| ... | ... | @@ -339,7 +430,7 @@ export default function FileBrowserPanel({ |
| 339 | 430 | queueMicrotask(() => { |
| 340 | 431 | syncingSelectionRef.current = false; |
| 341 | 432 | }); |
| 342 | }, [entryKinds, model, selectedPath, selectedPathRevealId, treePaths]); | |
| 433 | }, [entryKinds, model, selectedPath, selectedPathRevealId]); | |
| 343 | 434 | |
| 344 | 435 | // Tag tree drags with the composer mention payload. The row is read from |
| 345 | 436 | // the composed event path (the tree's shadow root is open), so this does |
| ... | ... | @@ -376,13 +467,13 @@ export default function FileBrowserPanel({ |
| 376 | 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 | 468 | data-surface-subheader |
| 378 | 469 | > |
| 379 | <RefreshFilesButton isPending={entriesQuery.isPending} onRefresh={handleRefresh} /> | |
| 470 | <RefreshFilesButton isPending={isPending} onRefresh={handleRefresh} /> | |
| 380 | 471 | <FileSearchField |
| 381 | 472 | name="project-files-search" |
| 382 | 473 | ariaLabel={`Search ${projectName} files`} |
| 383 | 474 | value={search.value} |
| 384 | 475 | onValueChange={handleSearchValueChange} |
| 385 | onClose={search.close} | |
| 476 | onClose={closeSearch} | |
| 386 | 477 | /> |
| 387 | 478 | {directoryPaths.length > 0 ? ( |
| 388 | 479 | <Tooltip> |
| ... | ... | @@ -393,7 +484,9 @@ export default function FileBrowserPanel({ |
| 393 | 484 | size="icon-xs" |
| 394 | 485 | variant="ghost" |
| 395 | 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 | 491 | onClick={toggleAllDirectories} |
| 399 | 492 | /> |
| ... | ... | @@ -406,21 +499,36 @@ export default function FileBrowserPanel({ |
| 406 | 499 | )} |
| 407 | 500 | </TooltipTrigger> |
| 408 | 501 | <TooltipPopup> |
| 409 | {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"} | |
| 502 | {expandAll || allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"} | |
| 410 | 503 | </TooltipPopup> |
| 411 | 504 | </Tooltip> |
| 412 | 505 | ) : null} |
| 413 | 506 | </div> |
| 414 | {entriesQuery.error && entriesQuery.data === null ? ( | |
| 415 | <div className="p-4 text-xs leading-relaxed text-destructive">{entriesQuery.error}</div> | |
| 416 | ) : ( | |
| 417 | <FileTree | |
| 418 | model={model} | |
| 419 | aria-label={`${projectName} files`} | |
| 420 | className="min-h-0 flex-1 overflow-hidden" | |
| 421 | style={pierreTreeStyle(resolvedTheme)} | |
| 422 | /> | |
| 507 | {error || pathSearch.error ? ( | |
| 508 | <button | |
| 509 | type="button" | |
| 510 | onClick={handleRefresh} | |
| 511 | className="p-4 text-left text-xs leading-relaxed text-destructive" | |
| 512 | > | |
| 513 | {error ?? pathSearch.error} Click to retry. | |
| 514 | </button> | |
| 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 | 532 | </div> |
| 425 | 533 | ); |
| 426 | 534 | } |
apps/web/src/components/files/projectFilesQueryState.ts+11-3| ... | ... | @@ -33,8 +33,15 @@ interface ProjectQueryState<A> { |
| 33 | 33 | readonly refresh: () => void; |
| 34 | 34 | } |
| 35 | 35 | |
| 36 | function getProjectEntriesQueryAtom(environmentId: EnvironmentId, cwd: string) { | |
| 37 | return projectEnvironment.listEntries({ environmentId, input: { cwd } }); | |
| 36 | function getProjectEntriesQueryAtom( | |
| 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 | } |
| 39 | 46 | |
| 40 | 47 | export function getProjectFileQueryAtom( |
| ... | ... | @@ -128,8 +135,9 @@ function errorMessage<A>(result: AsyncResult.AsyncResult<A, unknown>): string | |
| 128 | 135 | export function useProjectEntriesQuery( |
| 129 | 136 | environmentId: EnvironmentId, |
| 130 | 137 | cwd: string, |
| 138 | directoryPath?: string, | |
| 131 | 139 | ): ProjectQueryState<ProjectListEntriesResult> { |
| 132 | const atom = getProjectEntriesQueryAtom(environmentId, cwd); | |
| 140 | const atom = getProjectEntriesQueryAtom(environmentId, cwd, directoryPath); | |
| 133 | 141 | const result = useAtomValue(atom); |
| 134 | 142 | const refreshAtom = useAtomRefresh(atom); |
| 135 | 143 | const refresh = useCallback(() => refreshAtom(), [refreshAtom]); |
apps/web/src/components/files/useDirectoryEntries.ts created+136| ... | ... | @@ -0,0 +1,136 @@ |
| 1 | import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; | |
| 2 | import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; | |
| 3 | import * as Cause from "effect/Cause"; | |
| 4 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 5 | ||
| 6 | import { appAtomRegistry } from "~/rpc/atomRegistry"; | |
| 7 | import { projectEnvironment } from "~/state/projects"; | |
| 8 | ||
| 9 | /** Loads only requested directories; collapsing a folder keeps its children cached. */ | |
| 10 | export 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 | 263 | isPending: |
| 264 | 264 | !areProjectPathSearchTargetsEqual(normalizedTarget, debouncedTarget) || result.isPending, |
| 265 | 265 | searchedQuery: debouncedTarget.query ?? "", |
| 266 | truncated: result.data?.truncated ?? false, | |
| 266 | 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 | 28 | export const ProjectEntry = Schema.Struct({ |
| 29 | 29 | path: TrimmedNonEmptyString, |
| 30 | 30 | kind: ProjectEntryKind, |
| 31 | ignored: Schema.optional(Schema.Boolean), | |
| 31 | 32 | }); |
| 32 | 33 | export type ProjectEntry = typeof ProjectEntry.Type; |
| 33 | 34 | |
| ... | ... | @@ -72,6 +73,9 @@ export type ProjectSearchContentsResult = typeof ProjectSearchContentsResult.Typ |
| 72 | 73 | |
| 73 | 74 | export const ProjectListEntriesInput = Schema.Struct({ |
| 74 | 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 | }); |
| 76 | 80 | export type ProjectListEntriesInput = typeof ProjectListEntriesInput.Type; |
| 77 | 81 | |
| ... | ... | @@ -89,6 +93,7 @@ export const ProjectEntriesFailure = Schema.Literals([ |
| 89 | 93 | "search_index_create_failed", |
| 90 | 94 | "search_index_scan_timed_out", |
| 91 | 95 | "search_index_search_failed", |
| 96 | "directory_list_failed", | |
| 92 | 97 | ]); |
| 93 | 98 | export type ProjectEntriesFailure = typeof ProjectEntriesFailure.Type; |
| 94 | 99 |