Files
kandev/apps/web/components/task/file-browser.tsx
T
2026-02-17 22:40:42 +00:00

878 lines
32 KiB
TypeScript

'use client';
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { IconChevronRight, IconChevronDown, IconFolder, IconFolderOpen, IconSearch, IconX, IconLoader2, IconTrash, IconRefresh, IconListTree, IconFolderShare, IconCopy, IconCheck, IconPlus } from '@tabler/icons-react';
import { ScrollArea } from '@kandev/ui/scroll-area';
import { Input } from '@kandev/ui/input';
import { FileIcon } from '@/components/ui/file-icon';
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
import { cn } from '@/lib/utils';
import { getWebSocketClient } from '@/lib/ws/connection';
import { requestFileTree, requestFileContent, searchWorkspaceFiles } from '@/lib/ws/workspace-files';
import type { FileTreeNode, FileContentResponse, OpenFileTab } from '@/lib/types/backend';
import { useSessionAgentctl } from '@/hooks/domains/session/use-session-agentctl';
import { useSession } from '@/hooks/domains/session/use-session';
import { useOpenSessionFolder } from '@/hooks/use-open-session-folder';
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
import {
getFilesPanelExpandedPaths,
setFilesPanelExpandedPaths,
getFilesPanelScrollPosition,
setFilesPanelScrollPosition,
} from '@/lib/local-storage';
import { useToast } from '@/components/toast-provider';
import { InlineFileInput } from './inline-file-input';
import { PanelHeaderBar, PanelHeaderBarSplit } from './panel-primitives';
/**
* Merge a freshly-fetched tree node into an existing one, preserving
* already-loaded children so expanded folders don't collapse.
*/
function mergeTreeNodes(existing: FileTreeNode, incoming: FileTreeNode): FileTreeNode {
// If the incoming node has no children list, keep existing subtree
if (!incoming.children) return { ...existing, ...incoming, children: existing.children };
// If existing has no children, just use incoming
if (!existing.children) return incoming;
// Build a lookup of existing children by path
const existingByPath = new Map(existing.children.map((c) => [c.path, c]));
const mergedChildren = incoming.children.map((inChild) => {
const exChild = existingByPath.get(inChild.path);
if (exChild && exChild.is_dir && inChild.is_dir) {
// Recursively merge directories to keep loaded subtrees
return mergeTreeNodes(exChild, inChild);
}
return inChild;
});
return { ...existing, ...incoming, children: mergedChildren };
}
/** Sort comparator: directories first, then alphabetical by name. */
const compareTreeNodes = (a: FileTreeNode, b: FileTreeNode): number => {
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
return a.name.localeCompare(b.name);
};
/** Insert a file node into a parent folder, keeping children sorted (dirs first, then alpha). */
function insertNodeInTree(root: FileTreeNode, parentPath: string, node: FileTreeNode): FileTreeNode {
if (root.path === parentPath || (parentPath === '' && root.path === '')) {
const children = [...(root.children ?? []), node].sort(compareTreeNodes);
return { ...root, children };
}
if (!root.children) return root;
return { ...root, children: root.children.map((c) => insertNodeInTree(c, parentPath, node)) };
}
/** Remove a node by path from the tree. */
function removeNodeFromTree(root: FileTreeNode, targetPath: string): FileTreeNode {
if (!root.children) return root;
const filtered = root.children.filter((c) => c.path !== targetPath);
return { ...root, children: filtered.map((c) => removeNodeFromTree(c, targetPath)) };
}
const MAX_RETRY_ATTEMPTS = 4;
const RETRY_DELAYS_MS = [1000, 2000, 5000, 10000];
type FileBrowserProps = {
sessionId: string;
onOpenFile: (file: OpenFileTab) => void;
onCreateFile?: (path: string) => Promise<boolean>;
onDeleteFile?: (path: string) => Promise<boolean>;
activeFilePath?: string | null;
};
export function FileBrowser({ sessionId, onOpenFile, onCreateFile, onDeleteFile, activeFilePath }: FileBrowserProps) {
const { toast } = useToast();
const [tree, setTree] = useState<FileTreeNode | null>(null);
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
const [creatingInPath, setCreatingInPath] = useState<string | null>(null);
const [activeFolderPath, setActiveFolderPath] = useState<string>('');
const [visibleLoadingPaths, setVisibleLoadingPaths] = useState<Set<string>>(new Set());
const loadingTimersRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
const [isLoadingTree, setIsLoadingTree] = useState(true);
const [loadState, setLoadState] = useState<'loading' | 'waiting' | 'loaded' | 'manual' | 'error'>('loading');
const [loadError, setLoadError] = useState<string | null>(null);
const agentctlStatus = useSessionAgentctl(sessionId);
const { session, isFailed: isSessionFailed, errorMessage: sessionError } = useSession(sessionId);
const { open: openFolder } = useOpenSessionFolder(sessionId);
const { copied, copy: copyPath } = useCopyToClipboard(1000);
// Search state
const [isSearchActive, setIsSearchActive] = useState(false);
const [localSearchQuery, setLocalSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<string[] | null>(null);
const [isSearching, setIsSearching] = useState(false);
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
// Scroll persistence
const scrollAreaRef = useRef<HTMLDivElement>(null);
const scrollSaveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const hasRestoredScrollRef = useRef<string | null>(null);
const hasInitializedExpandedRef = useRef<string | null>(null);
const retryAttemptRef = useRef(0);
const retryTimerRef = useRef<NodeJS.Timeout | null>(null);
const loadInFlightRef = useRef(false);
// Workspace path for header
const fullPath = session?.worktree_path ?? '';
const displayPath = fullPath.replace(/^\/(?:Users|home)\/[^/]+\//, '~/');
const collapseAll = useCallback(() => {
setExpandedPaths(new Set());
}, []);
const handleStartCreate = useCallback(() => {
const folderPath = activeFolderPath;
// Expand the folder if it's collapsed
if (folderPath && !expandedPaths.has(folderPath)) {
setExpandedPaths((prev) => new Set(prev).add(folderPath));
}
setCreatingInPath(folderPath);
}, [activeFolderPath, expandedPaths]);
const handleCreateFileSubmit = useCallback((parentPath: string, name: string) => {
setCreatingInPath(null);
const newPath = parentPath ? `${parentPath}/${name}` : name;
// Optimistically insert the new file node
const newNode: FileTreeNode = { name, path: newPath, is_dir: false, size: 0 };
setTree((prev) => {
if (!prev) return prev;
return insertNodeInTree(prev, parentPath, newNode);
});
// Fire the actual request; revert on failure
onCreateFile?.(newPath).then((ok) => {
if (!ok) {
setTree((prev) => (prev ? removeNodeFromTree(prev, newPath) : prev));
}
}).catch(() => {
setTree((prev) => (prev ? removeNodeFromTree(prev, newPath) : prev));
});
}, [onCreateFile]);
// Show loading spinner only after 150ms to avoid flash on fast loads
const showLoading = useCallback((path: string) => {
const timer = setTimeout(() => {
setVisibleLoadingPaths((prev) => new Set(prev).add(path));
loadingTimersRef.current.delete(path);
}, 150);
loadingTimersRef.current.set(path, timer);
}, []);
const hideLoading = useCallback((path: string) => {
const timer = loadingTimersRef.current.get(path);
if (timer) {
clearTimeout(timer);
loadingTimersRef.current.delete(path);
}
setVisibleLoadingPaths((prev) => {
const next = new Set(prev);
next.delete(path);
return next;
});
}, []);
// Focus search input when search opens
useEffect(() => {
if (isSearchActive && searchInputRef.current) {
searchInputRef.current.focus();
}
}, [isSearchActive]);
// Clear search when closing
useEffect(() => {
if (!isSearchActive) {
setLocalSearchQuery('');
setSearchResults(null);
setIsSearching(false);
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
}
}, [isSearchActive]);
const clearRetryTimer = useCallback(() => {
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
}, []);
const loadTree = useCallback(async (options?: { resetRetry?: boolean }) => {
if (loadInFlightRef.current) return;
loadInFlightRef.current = true;
setIsLoadingTree(true);
setLoadState('loading');
setLoadError(null);
if (options?.resetRetry) {
retryAttemptRef.current = 0;
clearRetryTimer();
}
try {
const client = getWebSocketClient();
if (!client) {
throw new Error('WebSocket client not available');
}
const response = await requestFileTree(client, sessionId, '', 1);
setTree(response.root ?? null);
setLoadState('loaded');
retryAttemptRef.current = 0;
clearRetryTimer();
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to load file tree';
setLoadError(message);
if (retryAttemptRef.current < MAX_RETRY_ATTEMPTS) {
const delay = RETRY_DELAYS_MS[Math.min(retryAttemptRef.current, RETRY_DELAYS_MS.length - 1)];
retryAttemptRef.current += 1;
setLoadState('waiting');
clearRetryTimer();
retryTimerRef.current = setTimeout(() => {
void loadTree();
}, delay);
} else {
setLoadState('manual');
}
} finally {
setIsLoadingTree(false);
loadInFlightRef.current = false;
}
}, [clearRetryTimer, sessionId]);
// Load initial tree - always try to load, don't wait for agentctl ready status
useEffect(() => {
setTree(null);
setIsLoadingTree(true);
setLoadState('loading');
setLoadError(null);
retryAttemptRef.current = 0;
clearRetryTimer();
// Reset the auto-expand ref so it will run for the new session
hasInitializedExpandedRef.current = null;
// Restore expanded paths from session storage or reset
const savedPaths = getFilesPanelExpandedPaths(sessionId);
if (savedPaths.length > 0) {
setExpandedPaths(new Set(savedPaths));
// Mark as initialized only if we have saved paths to restore
hasInitializedExpandedRef.current = sessionId;
} else {
setExpandedPaths(new Set());
// Leave hasInitializedExpandedRef.current as null so auto-expand will run
}
void loadTree({ resetRetry: true });
}, [clearRetryTimer, loadTree, sessionId]);
// When agentctl becomes ready, retry loading the tree
useEffect(() => {
if (!agentctlStatus.isReady) return;
if (loadState === 'loaded') return;
void loadTree({ resetRetry: true });
}, [agentctlStatus.isReady, loadState, loadTree]);
// Auto-expand first level folders and load their children
// Note: We intentionally use tree?.children?.length instead of tree to avoid re-running when tree updates
useEffect(() => {
if (!tree || isLoadingTree) {
return;
}
// Only auto-expand if we haven't initialized from saved state
if (hasInitializedExpandedRef.current === sessionId) {
return;
}
hasInitializedExpandedRef.current = sessionId;
// Root is no longer a visible node, start with no expanded paths
setExpandedPaths(new Set());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tree?.children?.length, isLoadingTree, sessionId]);
// Save expanded paths when they change
useEffect(() => {
if (expandedPaths.size > 0 || hasInitializedExpandedRef.current === sessionId) {
setFilesPanelExpandedPaths(sessionId, Array.from(expandedPaths));
}
}, [expandedPaths, sessionId]);
// Track if tree is loaded (used for scroll restore timing)
const isTreeLoaded = !isLoadingTree && tree !== null;
// Restore scroll position after tree loads
useEffect(() => {
if (!isTreeLoaded || hasRestoredScrollRef.current === sessionId) return;
const savedScroll = getFilesPanelScrollPosition(sessionId);
if (savedScroll > 0 && scrollAreaRef.current) {
// Find the viewport element within ScrollArea
const viewport = scrollAreaRef.current.querySelector('[data-radix-scroll-area-viewport]');
if (viewport) {
viewport.scrollTop = savedScroll;
hasRestoredScrollRef.current = sessionId;
}
} else {
hasRestoredScrollRef.current = sessionId;
}
}, [isTreeLoaded, sessionId]);
// Subscribe to file changes and refresh only affected folders
useEffect(() => {
const client = getWebSocketClient();
if (!client) return;
const unsubscribe = client.on('session.workspace.file.changes', async (msg) => {
const changes = msg.payload?.changes;
if (!changes || changes.length === 0) return;
// Collect candidate folders that may need refreshing:
// - the parent of each changed path (file created/deleted in that folder)
// - the path itself (in case backend reports a directory change)
const candidates = new Set<string>();
for (const change of changes) {
const p = change.path;
// Parent folder
const lastSlash = p.lastIndexOf('/');
candidates.add(lastSlash === -1 ? '' : p.substring(0, lastSlash));
// The path itself (could be a directory)
candidates.add(p);
}
// Only refresh folders we actually have loaded: root ('') or expanded
const foldersToRefresh = new Set<string>();
for (const c of candidates) {
if (c === '' || expandedPaths.has(c)) {
foldersToRefresh.add(c);
}
}
if (foldersToRefresh.size === 0) return;
try {
// Fetch fresh children for each affected folder
const folderUpdates = new Map<string, FileTreeNode[] | undefined>();
const fetches = Array.from(foldersToRefresh).map(async (folder) => {
try {
const res = await requestFileTree(client, sessionId, folder || '', 1);
folderUpdates.set(folder, res.root?.children);
} catch {
// Folder may have been removed
}
});
await Promise.all(fetches);
setTree((prev) => {
if (!prev) return prev;
// If root ('') was affected, merge root children
if (folderUpdates.has('')) {
const freshRootChildren = folderUpdates.get('');
const existingByPath = new Map(
(prev.children ?? []).map((c) => [c.path, c])
);
const mergedRootChildren = freshRootChildren?.map((incoming) => {
const existing = existingByPath.get(incoming.path);
if (existing && existing.is_dir && incoming.is_dir) {
return mergeTreeNodes(existing, incoming);
}
return incoming;
});
prev = { ...prev, children: mergedRootChildren };
}
// Patch subfolders with fresh children
const subFolders = Array.from(folderUpdates.keys()).filter((k) => k !== '');
if (subFolders.length === 0) return prev;
const patchNode = (node: FileTreeNode): FileTreeNode => {
if (node.is_dir && folderUpdates.has(node.path)) {
const freshChildren = folderUpdates.get(node.path);
return { ...node, children: freshChildren?.map(patchNode) };
}
if (node.children) {
return { ...node, children: node.children.map(patchNode) };
}
return node;
};
return patchNode(prev);
});
setLoadState('loaded');
} catch (error) {
console.error('[FileBrowser] Failed to refresh file tree:', error);
}
});
return unsubscribe;
}, [sessionId, expandedPaths]);
// Handle scroll events to save position
const handleScroll = useCallback((event: Event) => {
const target = event.target as HTMLElement;
const scrollTop = target.scrollTop;
// Debounce save
if (scrollSaveTimeoutRef.current) {
clearTimeout(scrollSaveTimeoutRef.current);
}
scrollSaveTimeoutRef.current = setTimeout(() => {
setFilesPanelScrollPosition(sessionId, scrollTop);
}, 150);
}, [sessionId]);
// Attach scroll listener to ScrollArea viewport
useEffect(() => {
const scrollArea = scrollAreaRef.current;
if (!scrollArea) return;
const viewport = scrollArea.querySelector('[data-radix-scroll-area-viewport]');
if (!viewport) return;
viewport.addEventListener('scroll', handleScroll);
return () => {
viewport.removeEventListener('scroll', handleScroll);
if (scrollSaveTimeoutRef.current) {
clearTimeout(scrollSaveTimeoutRef.current);
}
};
}, [handleScroll, tree]);
// Handle search input change with debounce
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setLocalSearchQuery(value);
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
if (!value.trim()) {
setSearchResults(null);
setIsSearching(false);
return;
}
setIsSearching(true);
searchTimeoutRef.current = setTimeout(async () => {
try {
const client = getWebSocketClient();
if (!client) return;
const response = await searchWorkspaceFiles(client, sessionId, value, 50);
setSearchResults(response.files || []);
} catch (error) {
console.error('Failed to search files:', error);
setSearchResults([]);
} finally {
setIsSearching(false);
}
}, 300);
}, [sessionId]);
// Close search and clear state
const handleCloseSearch = useCallback(() => {
setIsSearchActive(false);
setLocalSearchQuery('');
setSearchResults(null);
setIsSearching(false);
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
}, []);
// Cleanup search timeout on unmount
useEffect(() => {
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
};
}, []);
useEffect(() => {
return () => {
clearRetryTimer();
};
}, [clearRetryTimer]);
const toggleExpand = useCallback(async (node: FileTreeNode) => {
if (!node.is_dir) return;
// Track the last-clicked folder as active
setActiveFolderPath(node.path);
const newExpanded = new Set(expandedPaths);
if (newExpanded.has(node.path)) {
newExpanded.delete(node.path);
setExpandedPaths(newExpanded);
} else {
// If children not loaded yet, fetch them
if (!node.children || node.children.length === 0) {
showLoading(node.path);
try {
const client = getWebSocketClient();
if (!client) return;
const response = await requestFileTree(client, sessionId, node.path, 1);
// Update tree with new children
const updateNode = (n: FileTreeNode): FileTreeNode => {
if (n.path === node.path) {
return { ...n, children: response.root.children };
}
if (n.children) {
return { ...n, children: n.children.map(updateNode) };
}
return n;
};
if (tree) {
setTree(updateNode(tree));
}
} catch (error) {
console.error('Failed to load children:', error);
} finally {
hideLoading(node.path);
}
}
newExpanded.add(node.path);
setExpandedPaths(newExpanded);
}
}, [expandedPaths, sessionId, tree, showLoading, hideLoading]);
const openFileByPath = useCallback(async (path: string) => {
try {
const client = getWebSocketClient();
if (!client) return;
const response: FileContentResponse = await requestFileContent(client, sessionId, path);
const { calculateHash } = await import('@/lib/utils/file-diff');
const hash = await calculateHash(response.content);
const name = path.split('/').pop() || path;
onOpenFile({
path,
name,
content: response.content,
originalContent: response.content,
originalHash: hash,
isDirty: false,
isBinary: response.is_binary,
});
} catch (error) {
const reason = error instanceof Error ? error.message : 'Unknown error';
toast({
title: 'Failed to open file',
description: reason,
variant: 'error',
});
}
}, [sessionId, onOpenFile, toast]);
const renderTreeNode = (node: FileTreeNode, depth: number = 0): React.ReactNode => {
const isExpanded = expandedPaths.has(node.path);
const isLoading = visibleLoadingPaths.has(node.path);
const isActive = !node.is_dir && activeFilePath === node.path;
const isActiveFolder = node.is_dir && activeFolderPath === node.path;
return (
<div key={node.path}>
<div
className={cn(
"group flex w-full items-center gap-1 px-2 py-0.5 text-left text-sm cursor-pointer",
"hover:bg-muted",
isActive && "bg-muted",
isActiveFolder && "bg-muted/50"
)}
style={{ paddingLeft: `${depth * 12 + 8 + (node.is_dir ? 0 : 20)}px` }}
onClick={() => (node.is_dir ? toggleExpand(node) : openFileByPath(node.path))}
>
{node.is_dir && (
<span className="flex-shrink-0">
{isLoading ? (
<IconRefresh className="h-4 w-4 animate-spin text-muted-foreground shrink-0" />
) : isExpanded ? (
<IconChevronDown className="h-3 w-3 text-muted-foreground/60" />
) : (
<IconChevronRight className="h-3 w-3 text-muted-foreground/60" />
)}
</span>
)}
{node.is_dir ? (
isExpanded ? (
<IconFolderOpen className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
) : (
<IconFolder className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
)
) : (
<FileIcon
fileName={node.name}
filePath={node.path}
className="flex-shrink-0"
style={{
width: '14px',
height: '14px',
opacity: isActive ? 1 : 0.7
}}
/>
)}
<span className={`flex-1 truncate ${isActive ? 'text-foreground' : 'text-muted-foreground'} group-hover:text-foreground ${node.is_dir ? 'font-medium' : ''}`}>{node.name}</span>
{!node.is_dir && onDeleteFile && (
<div className="flex items-center gap-1 ml-auto opacity-0 group-hover:opacity-100">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-destructive cursor-pointer"
onClick={(e) => {
e.stopPropagation();
// Optimistically remove, revert on failure
const snapshot = tree;
setTree((prev) => (prev ? removeNodeFromTree(prev, node.path) : prev));
onDeleteFile(node.path).then((ok) => {
if (!ok) setTree(snapshot);
}).catch(() => {
setTree(snapshot);
});
}}
>
<IconTrash className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Delete file</TooltipContent>
</Tooltip>
</div>
)}
</div>
{node.is_dir && isExpanded && (
<div>
{creatingInPath === node.path && (
<InlineFileInput
depth={depth + 1}
onSubmit={(name) => handleCreateFileSubmit(node.path, name)}
onCancel={() => setCreatingInPath(null)}
/>
)}
{node.children && [...node.children]
.sort(compareTreeNodes)
.map((child) => renderTreeNode(child, depth + 1))}
</div>
)}
</div>
);
};
const renderSearchResults = () => {
if (!searchResults) return null;
if (searchResults.length === 0) {
return (
<div className="p-4 text-sm text-muted-foreground text-center">
No files found
</div>
);
}
return (
<div className="pb-2">
{searchResults.map((path) => {
const name = path.split('/').pop() || path;
const folder = path.includes('/') ? path.substring(0, path.lastIndexOf('/')) : '';
return (
<div
key={path}
className={cn(
"group flex w-full items-center gap-1 px-2 py-0.5 text-left text-sm cursor-pointer",
"hover:bg-muted"
)}
onClick={() => openFileByPath(path)}
>
<FileIcon
fileName={name}
filePath={path}
className="flex-shrink-0"
style={{ width: '14px', height: '14px' }}
/>
<span className="truncate text-muted-foreground group-hover:text-foreground">
{folder && <span>{folder}/</span>}
<span>{name}</span>
</span>
</div>
);
})}
</div>
);
};
return (
<div className="flex flex-col h-full">
{/* Header toolbar */}
{tree && loadState === 'loaded' && (
isSearchActive ? (
<PanelHeaderBar className="group/header">
{isSearching ? (
<IconLoader2 className="h-3.5 w-3.5 text-muted-foreground animate-spin shrink-0" />
) : (
<IconSearch className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
)}
<Input
ref={searchInputRef}
type="text"
value={localSearchQuery}
onChange={handleSearchChange}
onKeyDown={(e) => { if (e.key === 'Escape') handleCloseSearch(); }}
placeholder="Search files..."
className="flex-1 min-w-0 h-5 text-xs border-none bg-transparent shadow-none focus-visible:ring-0 px-2"
/>
<button
className="text-muted-foreground hover:text-foreground shrink-0 cursor-pointer"
onClick={handleCloseSearch}
>
<IconX className="h-3.5 w-3.5" />
</button>
</PanelHeaderBar>
) : (
<PanelHeaderBarSplit
className="group/header"
left={
<>
<Tooltip>
<TooltipTrigger asChild>
<button
className="relative shrink-0 cursor-pointer"
onClick={() => {
if (fullPath) void copyPath(fullPath);
}}
>
<IconFolderOpen className={cn("h-3.5 w-3.5 text-muted-foreground transition-opacity", copied ? "opacity-0" : "group-hover/header:opacity-0")} />
{copied ? (
<IconCheck className="absolute inset-0 h-3.5 w-3.5 text-green-600/70" />
) : (
<IconCopy className="absolute inset-0 h-3.5 w-3.5 text-muted-foreground opacity-0 group-hover/header:opacity-100 hover:text-foreground transition-opacity" />
)}
</button>
</TooltipTrigger>
<TooltipContent>Copy workspace path</TooltipContent>
</Tooltip>
<span className="min-w-0 truncate text-xs font-medium text-muted-foreground">
{displayPath}
</span>
</>
}
right={
<>
{onCreateFile && (
<Tooltip>
<TooltipTrigger asChild>
<button
className="text-muted-foreground hover:bg-muted hover:text-foreground rounded p-1 cursor-pointer"
onClick={handleStartCreate}
>
<IconPlus className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>New file</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<button
className="text-muted-foreground hover:bg-muted hover:text-foreground rounded p-1 cursor-pointer"
onClick={() => openFolder()}
>
<IconFolderShare className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Open workspace folder</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
className="text-muted-foreground hover:bg-muted hover:text-foreground rounded p-1 cursor-pointer"
onClick={() => setIsSearchActive(true)}
>
<IconSearch className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Search files</TooltipContent>
</Tooltip>
{expandedPaths.size > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<button
className="text-muted-foreground hover:bg-muted hover:text-foreground rounded p-1 cursor-pointer"
onClick={collapseAll}
>
<IconListTree className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Collapse all</TooltipContent>
</Tooltip>
)}
</>
}
/>
)
)}
<ScrollArea className="flex-1" ref={scrollAreaRef}>
{isSearchActive && searchResults !== null ? (
renderSearchResults()
) : isSessionFailed ? (
<div className="p-4 text-sm text-destructive/80 space-y-2">
<div>Session failed</div>
{sessionError && (
<div className="text-xs text-muted-foreground">{sessionError}</div>
)}
</div>
) : (loadState === 'loading' || isLoadingTree) && !tree ? (
<div className="p-4 text-sm text-muted-foreground">Loading files...</div>
) : loadState === 'waiting' ? (
<div className="p-4 text-sm text-muted-foreground flex items-center gap-2">
<IconLoader2 className="h-3.5 w-3.5 animate-spin" />
Preparing workspace...
</div>
) : loadState === 'manual' ? (
<div className="p-4 text-sm text-muted-foreground space-y-2">
<div>{loadError ?? 'Workspace is still starting.'}</div>
<button
type="button"
className="text-xs text-foreground underline cursor-pointer"
onClick={() => void loadTree({ resetRetry: true })}
>
Retry
</button>
</div>
) : tree ? (
<div className="pb-2">
{creatingInPath === '' && (
<InlineFileInput
depth={0}
onSubmit={(name) => handleCreateFileSubmit('', name)}
onCancel={() => setCreatingInPath(null)}
/>
)}
{tree.children && [...tree.children]
.sort(compareTreeNodes)
.map((child) => renderTreeNode(child, 0))}
</div>
) : (
<div className="p-4 text-sm text-muted-foreground">No files found</div>
)}
</ScrollArea>
</div>
);
}