|
| 1 | +/** |
| 2 | + * ExplorerTab - VS Code-style file explorer tree view. |
| 3 | + * |
| 4 | + * Features: |
| 5 | + * - Lazy-load directories on expand |
| 6 | + * - Auto-refresh on file-modifying tool completion (debounced) |
| 7 | + * - Toolbar with Refresh and Collapse All buttons |
| 8 | + */ |
| 9 | + |
| 10 | +import React from "react"; |
| 11 | +import { useAPI } from "@/browser/contexts/API"; |
| 12 | +import { workspaceStore } from "@/browser/stores/WorkspaceStore"; |
| 13 | +import { |
| 14 | + ChevronDown, |
| 15 | + ChevronRight, |
| 16 | + ChevronsDownUp, |
| 17 | + FolderClosed, |
| 18 | + FolderOpen, |
| 19 | + RefreshCw, |
| 20 | +} from "lucide-react"; |
| 21 | +import { FileIcon } from "../FileIcon"; |
| 22 | +import { cn } from "@/common/lib/utils"; |
| 23 | +import type { FileTreeNode } from "@/common/utils/git/numstatParser"; |
| 24 | +import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; |
| 25 | + |
| 26 | +interface ExplorerTabProps { |
| 27 | + workspaceId: string; |
| 28 | + workspacePath: string; |
| 29 | +} |
| 30 | + |
| 31 | +interface ExplorerState { |
| 32 | + entries: Map<string, FileTreeNode[]>; // relativePath -> children |
| 33 | + expanded: Set<string>; |
| 34 | + loading: Set<string>; |
| 35 | + error: string | null; |
| 36 | +} |
| 37 | + |
| 38 | +const DEBOUNCE_MS = 2000; |
| 39 | +const INDENT_PX = 12; |
| 40 | + |
| 41 | +export const ExplorerTab: React.FC<ExplorerTabProps> = (props) => { |
| 42 | + const { api } = useAPI(); |
| 43 | + |
| 44 | + const [state, setState] = React.useState<ExplorerState>({ |
| 45 | + entries: new Map(), |
| 46 | + expanded: new Set(), |
| 47 | + loading: new Set(), // starts empty, set when fetch begins |
| 48 | + error: null, |
| 49 | + }); |
| 50 | + |
| 51 | + // Track if we've done initial load |
| 52 | + const initialLoadRef = React.useRef(false); |
| 53 | + |
| 54 | + // Fetch a directory's contents and return the entries (for recursive expand) |
| 55 | + const fetchDirectory = React.useCallback( |
| 56 | + async (relativePath: string, suppressErrors = false): Promise<FileTreeNode[] | null> => { |
| 57 | + if (!api) return null; |
| 58 | + |
| 59 | + const key = relativePath; // empty string = root directory |
| 60 | + |
| 61 | + setState((prev) => ({ |
| 62 | + ...prev, |
| 63 | + loading: new Set(prev.loading).add(key), |
| 64 | + error: null, |
| 65 | + })); |
| 66 | + |
| 67 | + try { |
| 68 | + const result = await api.general.listWorkspaceDirectory({ |
| 69 | + workspaceId: props.workspaceId, |
| 70 | + relativePath: relativePath || undefined, |
| 71 | + }); |
| 72 | + |
| 73 | + if (!result.success) { |
| 74 | + setState((prev) => { |
| 75 | + // On failure, remove from expanded set (dir may have been deleted) |
| 76 | + const newExpanded = new Set(prev.expanded); |
| 77 | + newExpanded.delete(key); |
| 78 | + // Remove stale entries |
| 79 | + const newEntries = new Map(prev.entries); |
| 80 | + newEntries.delete(key); |
| 81 | + return { |
| 82 | + ...prev, |
| 83 | + entries: newEntries, |
| 84 | + expanded: newExpanded, |
| 85 | + loading: new Set([...prev.loading].filter((k) => k !== key)), |
| 86 | + // Only set error for root or if not suppressing |
| 87 | + error: suppressErrors ? prev.error : result.error, |
| 88 | + }; |
| 89 | + }); |
| 90 | + return null; |
| 91 | + } |
| 92 | + |
| 93 | + setState((prev) => { |
| 94 | + const newEntries = new Map(prev.entries); |
| 95 | + newEntries.set(key, result.data); |
| 96 | + return { |
| 97 | + ...prev, |
| 98 | + entries: newEntries, |
| 99 | + loading: new Set([...prev.loading].filter((k) => k !== key)), |
| 100 | + }; |
| 101 | + }); |
| 102 | + |
| 103 | + return result.data; |
| 104 | + } catch (err) { |
| 105 | + setState((prev) => { |
| 106 | + // On error, remove from expanded set |
| 107 | + const newExpanded = new Set(prev.expanded); |
| 108 | + newExpanded.delete(key); |
| 109 | + const newEntries = new Map(prev.entries); |
| 110 | + newEntries.delete(key); |
| 111 | + return { |
| 112 | + ...prev, |
| 113 | + entries: newEntries, |
| 114 | + expanded: newExpanded, |
| 115 | + loading: new Set([...prev.loading].filter((k) => k !== key)), |
| 116 | + error: suppressErrors ? prev.error : err instanceof Error ? err.message : String(err), |
| 117 | + }; |
| 118 | + }); |
| 119 | + return null; |
| 120 | + } |
| 121 | + }, |
| 122 | + [api, props.workspaceId] |
| 123 | + ); |
| 124 | + |
| 125 | + // Initial load - retry when api becomes available |
| 126 | + React.useEffect(() => { |
| 127 | + if (!api) return; |
| 128 | + if (!initialLoadRef.current) { |
| 129 | + initialLoadRef.current = true; |
| 130 | + void fetchDirectory(""); |
| 131 | + } |
| 132 | + }, [api, fetchDirectory]); |
| 133 | + |
| 134 | + // Subscribe to file-modifying tool events and debounce refresh |
| 135 | + React.useEffect(() => { |
| 136 | + let timeoutId: ReturnType<typeof setTimeout> | null = null; |
| 137 | + |
| 138 | + const unsubscribe = workspaceStore.subscribeFileModifyingTool(() => { |
| 139 | + if (timeoutId) clearTimeout(timeoutId); |
| 140 | + timeoutId = setTimeout(() => { |
| 141 | + // Refresh root and all expanded directories |
| 142 | + // Suppress errors for non-root paths (dir may have been deleted) |
| 143 | + void fetchDirectory(""); |
| 144 | + for (const p of state.expanded) { |
| 145 | + void fetchDirectory(p, true); |
| 146 | + } |
| 147 | + }, DEBOUNCE_MS); |
| 148 | + }, props.workspaceId); |
| 149 | + |
| 150 | + return () => { |
| 151 | + unsubscribe(); |
| 152 | + if (timeoutId) clearTimeout(timeoutId); |
| 153 | + }; |
| 154 | + }, [props.workspaceId, state.expanded, fetchDirectory]); |
| 155 | + |
| 156 | + // Toggle expand/collapse |
| 157 | + const toggleExpand = (node: FileTreeNode) => { |
| 158 | + if (!node.isDirectory) return; |
| 159 | + |
| 160 | + const key = node.path; |
| 161 | + |
| 162 | + setState((prev) => { |
| 163 | + const newExpanded = new Set(prev.expanded); |
| 164 | + |
| 165 | + if (newExpanded.has(key)) { |
| 166 | + newExpanded.delete(key); |
| 167 | + return { ...prev, expanded: newExpanded }; |
| 168 | + } |
| 169 | + |
| 170 | + newExpanded.add(key); |
| 171 | + |
| 172 | + // Always fetch when expanding to ensure fresh data |
| 173 | + void fetchDirectory(key); |
| 174 | + |
| 175 | + return { ...prev, expanded: newExpanded }; |
| 176 | + }); |
| 177 | + }; |
| 178 | + |
| 179 | + // Refresh all expanded paths |
| 180 | + const handleRefresh = () => { |
| 181 | + const pathsToRefresh = ["", ...state.expanded]; |
| 182 | + void Promise.all(pathsToRefresh.map((p) => fetchDirectory(p))); |
| 183 | + }; |
| 184 | + |
| 185 | + // Collapse all |
| 186 | + const handleCollapseAll = () => { |
| 187 | + setState((prev) => ({ |
| 188 | + ...prev, |
| 189 | + expanded: new Set(), |
| 190 | + })); |
| 191 | + }; |
| 192 | + |
| 193 | + const hasExpandedDirs = state.expanded.size > 0; |
| 194 | + |
| 195 | + // Render a tree node recursively |
| 196 | + const renderNode = (node: FileTreeNode, depth: number): React.ReactNode => { |
| 197 | + const key = node.path; |
| 198 | + const isExpanded = state.expanded.has(key); |
| 199 | + const isLoading = state.loading.has(key); |
| 200 | + const children = state.entries.get(key) ?? []; |
| 201 | + const isIgnored = node.ignored === true; |
| 202 | + |
| 203 | + return ( |
| 204 | + <div key={key}> |
| 205 | + <button |
| 206 | + type="button" |
| 207 | + className={cn( |
| 208 | + "flex w-full cursor-pointer items-center gap-1 px-2 py-0.5 text-left text-sm hover:bg-accent/50", |
| 209 | + "focus:bg-accent/50 focus:outline-none", |
| 210 | + isIgnored && "opacity-50" |
| 211 | + )} |
| 212 | + style={{ paddingLeft: `${8 + depth * INDENT_PX}px` }} |
| 213 | + onClick={() => (node.isDirectory ? toggleExpand(node) : undefined)} |
| 214 | + > |
| 215 | + {node.isDirectory ? ( |
| 216 | + <> |
| 217 | + {isLoading ? ( |
| 218 | + <RefreshCw className="text-muted h-3 w-3 shrink-0 animate-spin" /> |
| 219 | + ) : isExpanded ? ( |
| 220 | + <ChevronDown className="text-muted h-3 w-3 shrink-0" /> |
| 221 | + ) : ( |
| 222 | + <ChevronRight className="text-muted h-3 w-3 shrink-0" /> |
| 223 | + )} |
| 224 | + {isExpanded ? ( |
| 225 | + <FolderOpen className="h-4 w-4 shrink-0 text-[var(--color-folder-icon)]" /> |
| 226 | + ) : ( |
| 227 | + <FolderClosed className="h-4 w-4 shrink-0 text-[var(--color-folder-icon)]" /> |
| 228 | + )} |
| 229 | + </> |
| 230 | + ) : ( |
| 231 | + <> |
| 232 | + <span className="w-3 shrink-0" /> |
| 233 | + <FileIcon fileName={node.name} style={{ fontSize: 18 }} className="h-4 w-4" /> |
| 234 | + </> |
| 235 | + )} |
| 236 | + <span className="truncate">{node.name}</span> |
| 237 | + </button> |
| 238 | + |
| 239 | + {node.isDirectory && isExpanded && ( |
| 240 | + <div>{children.map((child) => renderNode(child, depth + 1))}</div> |
| 241 | + )} |
| 242 | + </div> |
| 243 | + ); |
| 244 | + }; |
| 245 | + |
| 246 | + const rootEntries = state.entries.get("") ?? []; |
| 247 | + const isRootLoading = state.loading.has(""); |
| 248 | + |
| 249 | + // Shorten workspace path for display (replace home dir with ~) |
| 250 | + const shortenPath = (fullPath: string): string => { |
| 251 | + // Match home directory patterns across platforms: |
| 252 | + // Linux: /home/username/... |
| 253 | + // macOS: /Users/username/... |
| 254 | + // Windows: C:\Users\username\... (may come as forward slashes too) |
| 255 | + const homePatterns = [ |
| 256 | + /^\/home\/[^/]+/, // Linux |
| 257 | + /^\/Users\/[^/]+/, // macOS |
| 258 | + /^[A-Za-z]:[\\/]Users[\\/][^\\/]+/, // Windows |
| 259 | + ]; |
| 260 | + |
| 261 | + for (const pattern of homePatterns) { |
| 262 | + const match = fullPath.match(pattern); |
| 263 | + if (match) { |
| 264 | + return "~" + fullPath.slice(match[0].length); |
| 265 | + } |
| 266 | + } |
| 267 | + return fullPath; |
| 268 | + }; |
| 269 | + |
| 270 | + const displayPath = shortenPath(props.workspacePath); |
| 271 | + |
| 272 | + return ( |
| 273 | + <div className="flex h-full flex-col"> |
| 274 | + {/* Toolbar */} |
| 275 | + <div className="border-border-light flex items-center gap-1 border-b px-2 py-1"> |
| 276 | + <FolderOpen className="h-4 w-4 shrink-0 text-[var(--color-folder-icon)]" /> |
| 277 | + <Tooltip> |
| 278 | + <TooltipTrigger asChild> |
| 279 | + <span className="min-w-0 flex-1 truncate text-xs font-medium">{displayPath}</span> |
| 280 | + </TooltipTrigger> |
| 281 | + <TooltipContent side="bottom">{props.workspacePath}</TooltipContent> |
| 282 | + </Tooltip> |
| 283 | + <div className="flex shrink-0 items-center gap-0.5"> |
| 284 | + <Tooltip> |
| 285 | + <TooltipTrigger asChild> |
| 286 | + <button |
| 287 | + type="button" |
| 288 | + className="text-muted hover:bg-accent/50 hover:text-foreground rounded p-1" |
| 289 | + onClick={handleRefresh} |
| 290 | + disabled={isRootLoading} |
| 291 | + > |
| 292 | + <RefreshCw className={cn("h-3.5 w-3.5", isRootLoading && "animate-spin")} /> |
| 293 | + </button> |
| 294 | + </TooltipTrigger> |
| 295 | + <TooltipContent side="bottom">Refresh</TooltipContent> |
| 296 | + </Tooltip> |
| 297 | + {hasExpandedDirs && ( |
| 298 | + <Tooltip> |
| 299 | + <TooltipTrigger asChild> |
| 300 | + <button |
| 301 | + type="button" |
| 302 | + className="text-muted hover:bg-accent/50 hover:text-foreground rounded p-1" |
| 303 | + onClick={handleCollapseAll} |
| 304 | + aria-label="Collapse All" |
| 305 | + > |
| 306 | + <ChevronsDownUp className="h-3.5 w-3.5" /> |
| 307 | + </button> |
| 308 | + </TooltipTrigger> |
| 309 | + <TooltipContent side="bottom">Collapse All</TooltipContent> |
| 310 | + </Tooltip> |
| 311 | + )} |
| 312 | + </div> |
| 313 | + </div> |
| 314 | + |
| 315 | + {/* Tree */} |
| 316 | + <div className="flex-1 overflow-y-auto py-1"> |
| 317 | + {state.error && <div className="text-destructive px-3 py-2 text-sm">{state.error}</div>} |
| 318 | + {isRootLoading && rootEntries.length === 0 ? ( |
| 319 | + <div className="flex items-center justify-center py-4"> |
| 320 | + <RefreshCw className="text-muted h-5 w-5 animate-spin" /> |
| 321 | + </div> |
| 322 | + ) : ( |
| 323 | + rootEntries.map((node) => renderNode(node, 0)) |
| 324 | + )} |
| 325 | + {!isRootLoading && rootEntries.length === 0 && !state.error && ( |
| 326 | + <div className="text-muted px-3 py-2 text-sm">No files found</div> |
| 327 | + )} |
| 328 | + </div> |
| 329 | + </div> |
| 330 | + ); |
| 331 | +}; |
0 commit comments