- Change FilesPanel overflow-hidden to overflow-auto for scrolling - Add max-h-80 to FileTree to limit height while allowing scroll - Add select-none to FileTreeNode to prevent text selection - Cursor-pointer already present for clickable indication Now the file tree scrolls independently within the FILES panel, and text cannot be selected during rapid clicking.
219 lines
6.2 KiB
TypeScript
219 lines
6.2 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import { socketClient } from '../lib/socket';
|
|
import { WorkspaceMode } from '../lib/types';
|
|
import FileTreeNode from './FileTreeNode';
|
|
|
|
export interface FileTreeEntry {
|
|
name: string;
|
|
path: string;
|
|
type: 'file' | 'directory' | 'symlink';
|
|
size?: number;
|
|
modified?: string;
|
|
isHidden?: boolean;
|
|
}
|
|
|
|
interface FileTreeProps {
|
|
workspaceMode: WorkspaceMode;
|
|
onFileSelect?: (path: string) => void;
|
|
}
|
|
|
|
interface FileTreeState {
|
|
directoryCache: Map<string, FileTreeEntry[]>;
|
|
expandedPaths: Set<string>;
|
|
loadingPaths: Set<string>;
|
|
errors: Map<string, string>;
|
|
}
|
|
|
|
export default function FileTree({ workspaceMode, onFileSelect }: FileTreeProps) {
|
|
const [state, setState] = useState<FileTreeState>({
|
|
directoryCache: new Map(),
|
|
expandedPaths: new Set(),
|
|
loadingPaths: new Set(),
|
|
errors: new Map(),
|
|
});
|
|
|
|
const [rootLoaded, setRootLoaded] = useState(false);
|
|
|
|
// Load root directory on mount
|
|
useEffect(() => {
|
|
if (!rootLoaded) {
|
|
loadDirectory('');
|
|
setRootLoaded(true);
|
|
}
|
|
}, []);
|
|
|
|
const loadDirectory = useCallback(async (path: string) => {
|
|
// Don't reload if already cached
|
|
if (state.directoryCache.has(path)) {
|
|
return;
|
|
}
|
|
|
|
// Don't reload if already loading
|
|
if (state.loadingPaths.has(path)) {
|
|
return;
|
|
}
|
|
|
|
setState(prev => {
|
|
const newLoading = new Set(prev.loadingPaths);
|
|
newLoading.add(path);
|
|
const newErrors = new Map(prev.errors);
|
|
newErrors.delete(path);
|
|
return {
|
|
...prev,
|
|
loadingPaths: newLoading,
|
|
errors: newErrors,
|
|
};
|
|
});
|
|
|
|
try {
|
|
const result = await socketClient.requestFileTree({
|
|
path: path || undefined,
|
|
recursive: false,
|
|
maxDepth: 1,
|
|
});
|
|
|
|
if (result.success && result.entries) {
|
|
setState(prev => {
|
|
const newCache = new Map(prev.directoryCache);
|
|
newCache.set(path, result.entries!);
|
|
const newLoading = new Set(prev.loadingPaths);
|
|
newLoading.delete(path);
|
|
return {
|
|
...prev,
|
|
directoryCache: newCache,
|
|
loadingPaths: newLoading,
|
|
};
|
|
});
|
|
} else {
|
|
setState(prev => {
|
|
const newErrors = new Map(prev.errors);
|
|
newErrors.set(path, result.error || 'Failed to load directory');
|
|
const newLoading = new Set(prev.loadingPaths);
|
|
newLoading.delete(path);
|
|
return {
|
|
...prev,
|
|
errors: newErrors,
|
|
loadingPaths: newLoading,
|
|
};
|
|
});
|
|
}
|
|
} catch (error) {
|
|
setState(prev => {
|
|
const newErrors = new Map(prev.errors);
|
|
newErrors.set(path, error instanceof Error ? error.message : 'Unknown error');
|
|
const newLoading = new Set(prev.loadingPaths);
|
|
newLoading.delete(path);
|
|
return {
|
|
...prev,
|
|
errors: newErrors,
|
|
loadingPaths: newLoading,
|
|
};
|
|
});
|
|
}
|
|
}, [state.directoryCache, state.loadingPaths]);
|
|
|
|
const toggleExpand = useCallback((path: string) => {
|
|
setState(prev => {
|
|
const newExpanded = new Set(prev.expandedPaths);
|
|
if (newExpanded.has(path)) {
|
|
newExpanded.delete(path);
|
|
} else {
|
|
newExpanded.add(path);
|
|
// Load children if not already cached
|
|
if (!prev.directoryCache.has(path)) {
|
|
setTimeout(() => loadDirectory(path), 0);
|
|
}
|
|
}
|
|
return { ...prev, expandedPaths: newExpanded };
|
|
});
|
|
}, [loadDirectory]);
|
|
|
|
const handleFileSelect = useCallback((path: string) => {
|
|
if (onFileSelect) {
|
|
onFileSelect(path);
|
|
}
|
|
}, [onFileSelect]);
|
|
|
|
// Build visible nodes list based on expanded state
|
|
const buildVisibleNodes = useCallback(() => {
|
|
const visibleNodes: Array<{ entry: FileTreeEntry; depth: number }> = [];
|
|
|
|
const processNode = (entry: FileTreeEntry, depth: number) => {
|
|
visibleNodes.push({ entry, depth });
|
|
|
|
// If this is an expanded directory with cached children, process them
|
|
if (entry.type === 'directory' &&
|
|
state.expandedPaths.has(entry.path) &&
|
|
state.directoryCache.has(entry.path)) {
|
|
const children = state.directoryCache.get(entry.path)!;
|
|
for (const child of children) {
|
|
processNode(child, depth + 1);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Process root entries
|
|
const rootEntries = state.directoryCache.get('') || [];
|
|
for (const entry of rootEntries) {
|
|
processNode(entry, 0);
|
|
}
|
|
|
|
return visibleNodes;
|
|
}, [state.expandedPaths, state.directoryCache]);
|
|
|
|
const rootEntries = state.directoryCache.get('') || [];
|
|
const rootError = state.errors.get('');
|
|
const rootLoading = state.loadingPaths.has('');
|
|
|
|
return (
|
|
<div className="flex-1 overflow-auto p-2 max-h-80">
|
|
{rootLoading && (
|
|
<div className="flex items-center justify-center py-8">
|
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-brand"></div>
|
|
</div>
|
|
)}
|
|
|
|
{rootError && (
|
|
<div className="p-4 text-center text-red-500 text-sm">
|
|
<p>{rootError}</p>
|
|
<button
|
|
onClick={() => loadDirectory('')}
|
|
className="mt-2 px-3 py-1 bg-brand text-white rounded text-xs hover:bg-brand/80"
|
|
>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{!rootLoading && !rootError && rootEntries.length === 0 && (
|
|
<div className="p-4 text-center text-text-muted text-sm">
|
|
Empty directory
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-0.5">
|
|
{buildVisibleNodes().map(({ entry, depth }) => {
|
|
const isExpanded = state.expandedPaths.has(entry.path);
|
|
const isLoading = state.loadingPaths.has(entry.path);
|
|
const error = state.errors.get(entry.path);
|
|
const hasChildren = entry.type === 'directory';
|
|
|
|
return (
|
|
<FileTreeNode
|
|
key={entry.path}
|
|
entry={entry}
|
|
depth={depth}
|
|
isExpanded={isExpanded}
|
|
isLoading={isLoading}
|
|
hasChildren={hasChildren}
|
|
error={error}
|
|
onToggle={toggleExpand}
|
|
onSelect={handleFileSelect}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|