| 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,7 +11,6 @@ import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; |
| 11 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; | 11 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; |
| 12 | import { | 12 | import { |
| 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.selected | 90 | 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 the | 128 | // 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.path | 144 | ? 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 | ); |
| 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 | 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]); |
| 172 | 173 | ||
| 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 | ); |
| 223 | 236 | ||
| 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 > 0 | 293 | {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" |
| 5 | import { ActivityIndicator, Platform, View } from "react-native"; | 5 | import { ActivityIndicator, Platform, View } from "react-native"; |
| 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 7 | import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; | 7 | import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; |
| 8 | import { | 8 | import { EnvironmentId, type ProjectReadFileResult, ThreadId } from "@t3tools/contracts"; |
| 9 | EnvironmentId, | ||
| 10 | type ProjectListEntriesResult, | ||
| 11 | type ProjectReadFileResult, | ||
| 12 | ThreadId, | ||
| 13 | } from "@t3tools/contracts"; | ||
| 14 | import { videoMimeType } from "@t3tools/shared/video"; | 9 | import { videoMimeType } from "@t3tools/shared/video"; |
| 15 | import { | 10 | import { |
| 16 | isWorkspaceBrowserPreviewPath, | 11 | isWorkspaceBrowserPreviewPath, |
| ... | @@ -53,6 +48,7 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe | ... | @@ -53,6 +48,7 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe |
| 53 | import { ThreadRouteScreen } from "../threads/ThreadRouteScreen"; | 48 | import { ThreadRouteScreen } from "../threads/ThreadRouteScreen"; |
| 54 | import { FileMarkdownPreview } from "./FileMarkdownPreview"; | 49 | import { FileMarkdownPreview } from "./FileMarkdownPreview"; |
| 55 | import { FileTreeBrowser } from "./FileTreeBrowser"; | 50 | import { FileTreeBrowser } from "./FileTreeBrowser"; |
| 51 | import { useFileTreeEntries } from "./useFileTreeEntries"; | ||
| 56 | import { preloadWorkspaceFileContents } from "./preload-workspace-file"; | 52 | import { preloadWorkspaceFileContents } from "./preload-workspace-file"; |
| 57 | import { SourceFileSurface } from "./SourceFileSurface"; | 53 | import { SourceFileSurface } from "./SourceFileSurface"; |
| 58 | import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane"; | 54 | import { 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.supported | 341 | 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 | <FileTreeBrowser | 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 | 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 | } |
| 24 | 26 | ||
| ... | @@ -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 @@ |
| 1 | import type { EnvironmentId, ProjectListEntriesResult } from "@t3tools/contracts"; | 1 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { SymbolView } from "../../components/AppSymbol"; | 2 | import { SymbolView } from "../../components/AppSymbol"; |
| 3 | import { useCallback, useMemo, useState, type ComponentProps } from "react"; | 3 | import { useCallback, useMemo, useState, type ComponentProps } from "react"; |
| 4 | import { Platform, Pressable, View, type NativeSyntheticEvent } from "react-native"; | 4 | import { Platform, Pressable, View, type NativeSyntheticEvent } from "react-native"; |
| ... | @@ -13,10 +13,9 @@ import { | ... | @@ -13,10 +13,9 @@ import { |
| 13 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | 13 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 14 | import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; | 14 | import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; |
| 15 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; | 15 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 16 | import { projectEnvironment } from "../../state/projects"; | ||
| 17 | import { useEnvironmentQuery } from "../../state/query"; | ||
| 18 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | 16 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 19 | import { FileTreeBrowser } from "./FileTreeBrowser"; | 17 | import { FileTreeBrowser } from "./FileTreeBrowser"; |
| 18 | import { useFileTreeEntries } from "./useFileTreeEntries"; | ||
| 20 | import { preloadWorkspaceFileContents } from "./preload-workspace-file"; | 19 | import { preloadWorkspaceFileContents } from "./preload-workspace-file"; |
| 21 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; | 20 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; |
| 22 | 21 | ||
| ... | @@ -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: { |
| 82 | 79 | ||
| 83 | const fileTree = ( | 80 | const fileTree = ( |
| 84 | <FileTreeBrowser | 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 | 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 @@ | ||
| 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,6 +96,102 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { |
| 96 | }); | 96 | }); |
| 97 | 97 | ||
| 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"; |
| 11 | import type { | 11 | import 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 |
| 23 | import { normalizeSearchQuery } from "@t3tools/shared/searchRanking"; | 24 | import { normalizeSearchQuery } from "@t3tools/shared/searchRanking"; |
| 24 | 25 | ||
| 25 | import { expandHomePathWith } from "../pathExpansion.ts"; | 26 | import { expandHomePathWith } from "../pathExpansion.ts"; |
| 27 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | ||
| 26 | import * as WorkspacePaths from "./WorkspacePaths.ts"; | 28 | import * as WorkspacePaths from "./WorkspacePaths.ts"; |
| 27 | import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; | 29 | import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; |
| 28 | 30 | ||
| ... | @@ -74,6 +76,7 @@ export const WorkspaceEntriesBrowseError = Schema.Union([ | ... | @@ -74,6 +76,7 @@ export const WorkspaceEntriesBrowseError = Schema.Union([ |
| 74 | export type WorkspaceEntriesBrowseError = typeof WorkspaceEntriesBrowseError.Type; | 76 | export type WorkspaceEntriesBrowseError = typeof WorkspaceEntriesBrowseError.Type; |
| 75 | 77 | ||
| 76 | export const WorkspaceEntriesError = Schema.Union([ | 78 | export 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; | ||
| 136 | 140 | ||
| 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* () { |
| 284 | 360 | ||
| 285 | export const layer = Layer.effect(WorkspaceEntries, make).pipe( | 361 | export 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"; |
| 7 | import { FileTree, useFileTree, useFileTreeSearch, useFileTreeSelector } from "@pierre/trees/react"; | 7 | import { FileTree, useFileTree, useFileTreeSearch, useFileTreeSelector } from "@pierre/trees/react"; |
| 8 | import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; | 8 | import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; |
| 9 | import { ChevronsDownUpIcon, ChevronsUpDownIcon } from "lucide-react"; | 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 | import { Button } from "~/components/ui/button"; | 12 | import { Button } from "~/components/ui/button"; |
| 13 | import { InputGroup, InputGroupInput } from "~/components/ui/input-group"; | 13 | import { 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"; |
| 24 | import { createFileTreeDragMentionController } from "./fileTreeDragMention"; | 24 | import { createFileTreeDragMentionController } from "./fileTreeDragMention"; |
| 25 | import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion"; | 25 | import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion"; |
| 26 | import { buildFileTreePathUpdates } from "./fileTreePathReconciliation"; | 26 | import { buildFileTreePathUpdates } from "./fileTreePathReconciliation"; |
| 27 | import { useProjectEntriesQuery } from "./projectFilesQueryState"; | 27 | import { useDirectoryEntries } from "./useDirectoryEntries"; |
| 28 | import { useProjectPathSearch } from "~/state/queries"; | ||
| 28 | 29 | ||
| 29 | interface FileBrowserPanelProps { | 30 | interface 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 | }); |
| 272 | 355 | ||
| 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]); | ||
| 286 | 373 | ||
| 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; |
| 305 | 395 | ||
| ... | @@ -319,6 +409,7 @@ export default function FileBrowserPanel({ | ... | @@ -319,6 +409,7 @@ export default function FileBrowserPanel({ |
| 319 | handledRevealRef.current = revealRequest; | 409 | handledRevealRef.current = revealRequest; |
| 320 | 410 | ||
| 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]); |
| 343 | 434 | ||
| 344 | // Tag tree drags with the composer mention payload. The row is read from | 435 | // 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 does | 436 | // 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-subheader | 468 | data-surface-subheader |
| 378 | > | 469 | > |
| 379 | <RefreshFilesButton isPending={entriesQuery.isPending} onRefresh={handleRefresh} /> | 470 | <RefreshFilesButton isPending={isPending} onRefresh={handleRefresh} /> |
| 380 | <FileSearchField | 471 | <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 | <FileTree | 510 | 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 | } |
| 35 | 35 | ||
| 36 | function getProjectEntriesQueryAtom(environmentId: EnvironmentId, cwd: string) { | 36 | function 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 | } |
| 39 | 46 | ||
| 40 | export function getProjectFileQueryAtom( | 47 | export 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 | |
| 128 | export function useProjectEntriesQuery( | 135 | export 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 @@ | ||
| 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,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; |
| 28 | export const ProjectEntry = Schema.Struct({ | 28 | export const ProjectEntry = Schema.Struct({ |
| 29 | path: TrimmedNonEmptyString, | 29 | path: TrimmedNonEmptyString, |
| 30 | kind: ProjectEntryKind, | 30 | kind: ProjectEntryKind, |
| 31 | ignored: Schema.optional(Schema.Boolean), | ||
| 31 | }); | 32 | }); |
| 32 | export type ProjectEntry = typeof ProjectEntry.Type; | 33 | export type ProjectEntry = typeof ProjectEntry.Type; |
| 33 | 34 | ||
| ... | @@ -72,6 +73,9 @@ export type ProjectSearchContentsResult = typeof ProjectSearchContentsResult.Typ | ... | @@ -72,6 +73,9 @@ export type ProjectSearchContentsResult = typeof ProjectSearchContentsResult.Typ |
| 72 | 73 | ||
| 73 | export const ProjectListEntriesInput = Schema.Struct({ | 74 | export 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 | }); |
| 76 | export type ProjectListEntriesInput = typeof ProjectListEntriesInput.Type; | 80 | export type ProjectListEntriesInput = typeof ProjectListEntriesInput.Type; |
| 77 | 81 | ||
| ... | @@ -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 | ]); |
| 93 | export type ProjectEntriesFailure = typeof ProjectEntriesFailure.Type; | 98 | export type ProjectEntriesFailure = typeof ProjectEntriesFailure.Type; |
| 94 | 99 |