gadget/gadget-code/frontend/src/components/FileTree.tsx
Rob Colbert a7c20d6105 fix: FileTree scrolling and text selection issues
- 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.
2026-05-12 16:38:27 -04:00

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>
);
}