Phase 1 - Foundation:
- Add NodeConfigPanelV2 using useSpecStore for AtomizerSpec v2.0 mode
- Deprecate AtomizerCanvas and useCanvasStore with migration docs
- Add VITE_USE_LEGACY_CANVAS env var for emergency fallback
- Enhance NodePalette with collapse support, filtering, exports
- Add drag-drop support to SpecRenderer with default node data
- Setup test infrastructure (Vitest + Playwright configs)
- Add useSpecStore unit tests (15 tests)
Phase 2 - File Structure & Model:
- Create FileStructurePanel with tree view of study files
- Add ModelNodeV2 with collapsible file dependencies
- Add tabbed left sidebar (Components/Files tabs)
- Add GET /api/files/structure/{study_id} backend endpoint
- Auto-expand 1_setup folders in file tree
- Show model file introspection with solver type and expressions
Technical:
- All TypeScript checks pass
- All 15 unit tests pass
- Production build successful
311 lines
9.0 KiB
TypeScript
311 lines
9.0 KiB
TypeScript
/**
|
|
* FileStructurePanel - Shows study file structure in the canvas sidebar
|
|
*
|
|
* Features:
|
|
* - Tree view of study directory
|
|
* - Highlights model files (.prt, .fem, .sim)
|
|
* - Shows file dependencies
|
|
* - One-click to set as model source
|
|
* - Refresh button to reload
|
|
*/
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import {
|
|
Folder,
|
|
FolderOpen,
|
|
FileBox,
|
|
ChevronRight,
|
|
RefreshCw,
|
|
Box,
|
|
Cpu,
|
|
FileCode,
|
|
AlertCircle,
|
|
CheckCircle,
|
|
Plus,
|
|
} from 'lucide-react';
|
|
|
|
interface FileNode {
|
|
name: string;
|
|
path: string;
|
|
type: 'file' | 'directory';
|
|
extension?: string;
|
|
size?: number;
|
|
children?: FileNode[];
|
|
isModelFile?: boolean;
|
|
isSelected?: boolean;
|
|
}
|
|
|
|
interface FileStructurePanelProps {
|
|
studyId: string | null;
|
|
onModelSelect?: (filePath: string, fileType: string) => void;
|
|
selectedModelPath?: string;
|
|
className?: string;
|
|
}
|
|
|
|
// File type to icon mapping
|
|
const FILE_ICONS: Record<string, { icon: typeof FileBox; color: string }> = {
|
|
'.prt': { icon: Box, color: 'text-blue-400' },
|
|
'.sim': { icon: Cpu, color: 'text-violet-400' },
|
|
'.fem': { icon: FileCode, color: 'text-emerald-400' },
|
|
'.afem': { icon: FileCode, color: 'text-emerald-400' },
|
|
'.dat': { icon: FileBox, color: 'text-amber-400' },
|
|
'.bdf': { icon: FileBox, color: 'text-amber-400' },
|
|
'.op2': { icon: FileBox, color: 'text-rose-400' },
|
|
'.f06': { icon: FileBox, color: 'text-dark-400' },
|
|
};
|
|
|
|
const MODEL_EXTENSIONS = ['.prt', '.sim', '.fem', '.afem'];
|
|
|
|
export function FileStructurePanel({
|
|
studyId,
|
|
onModelSelect,
|
|
selectedModelPath,
|
|
className = '',
|
|
}: FileStructurePanelProps) {
|
|
const [files, setFiles] = useState<FileNode[]>([]);
|
|
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Load study file structure
|
|
const loadFileStructure = useCallback(async () => {
|
|
if (!studyId) {
|
|
setFiles([]);
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const response = await fetch(`/api/files/structure/${encodeURIComponent(studyId)}`);
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 404) {
|
|
setError('Study not found');
|
|
} else {
|
|
throw new Error(`Failed to load: ${response.status}`);
|
|
}
|
|
setFiles([]);
|
|
return;
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
// Process the file tree to mark model files
|
|
const processNode = (node: FileNode): FileNode => {
|
|
if (node.type === 'directory' && node.children) {
|
|
return {
|
|
...node,
|
|
children: node.children.map(processNode),
|
|
};
|
|
}
|
|
|
|
const ext = '.' + node.name.split('.').pop()?.toLowerCase();
|
|
return {
|
|
...node,
|
|
extension: ext,
|
|
isModelFile: MODEL_EXTENSIONS.includes(ext),
|
|
isSelected: node.path === selectedModelPath,
|
|
};
|
|
};
|
|
|
|
const processedFiles = (data.files || []).map(processNode);
|
|
setFiles(processedFiles);
|
|
|
|
// Auto-expand 1_setup and root directories
|
|
const toExpand = new Set<string>();
|
|
processedFiles.forEach((node: FileNode) => {
|
|
if (node.type === 'directory') {
|
|
toExpand.add(node.path);
|
|
if (node.name === '1_setup' && node.children) {
|
|
node.children.forEach((child: FileNode) => {
|
|
if (child.type === 'directory') {
|
|
toExpand.add(child.path);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
});
|
|
setExpandedPaths(toExpand);
|
|
} catch (err) {
|
|
console.error('Failed to load file structure:', err);
|
|
setError('Failed to load files');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [studyId, selectedModelPath]);
|
|
|
|
useEffect(() => {
|
|
loadFileStructure();
|
|
}, [loadFileStructure]);
|
|
|
|
// Toggle directory expansion
|
|
const toggleExpand = (path: string) => {
|
|
setExpandedPaths((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(path)) {
|
|
next.delete(path);
|
|
} else {
|
|
next.add(path);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
// Handle file selection
|
|
const handleFileClick = (node: FileNode) => {
|
|
if (node.type === 'directory') {
|
|
toggleExpand(node.path);
|
|
} else if (node.isModelFile && onModelSelect) {
|
|
onModelSelect(node.path, node.extension || '');
|
|
}
|
|
};
|
|
|
|
// Render a file/folder node
|
|
const renderNode = (node: FileNode, depth: number = 0) => {
|
|
const isExpanded = expandedPaths.has(node.path);
|
|
const isDirectory = node.type === 'directory';
|
|
const fileInfo = node.extension ? FILE_ICONS[node.extension] : null;
|
|
const Icon = isDirectory
|
|
? isExpanded
|
|
? FolderOpen
|
|
: Folder
|
|
: fileInfo?.icon || FileBox;
|
|
const iconColor = isDirectory
|
|
? 'text-amber-400'
|
|
: fileInfo?.color || 'text-dark-400';
|
|
|
|
const isSelected = node.path === selectedModelPath;
|
|
|
|
return (
|
|
<div key={node.path}>
|
|
<button
|
|
onClick={() => handleFileClick(node)}
|
|
className={`
|
|
w-full flex items-center gap-2 px-2 py-1.5 text-left text-sm rounded-md
|
|
transition-colors group
|
|
${isSelected ? 'bg-primary-500/20 text-primary-400' : 'hover:bg-dark-700/50'}
|
|
${node.isModelFile ? 'cursor-pointer' : isDirectory ? 'cursor-pointer' : 'cursor-default opacity-60'}
|
|
`}
|
|
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
|
>
|
|
{/* Expand/collapse chevron for directories */}
|
|
{isDirectory ? (
|
|
<ChevronRight
|
|
size={14}
|
|
className={`text-dark-500 transition-transform flex-shrink-0 ${
|
|
isExpanded ? 'rotate-90' : ''
|
|
}`}
|
|
/>
|
|
) : (
|
|
<span className="w-3.5 flex-shrink-0" />
|
|
)}
|
|
|
|
{/* Icon */}
|
|
<Icon size={16} className={`${iconColor} flex-shrink-0`} />
|
|
|
|
{/* Name */}
|
|
<span
|
|
className={`flex-1 truncate ${
|
|
isSelected ? 'text-primary-400 font-medium' : 'text-dark-200'
|
|
}`}
|
|
>
|
|
{node.name}
|
|
</span>
|
|
|
|
{/* Model file indicator */}
|
|
{node.isModelFile && !isSelected && (
|
|
<span title="Set as model">
|
|
<Plus
|
|
size={14}
|
|
className="text-dark-500 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
|
/>
|
|
</span>
|
|
)}
|
|
|
|
{/* Selected indicator */}
|
|
{isSelected && (
|
|
<CheckCircle size={14} className="text-primary-400 flex-shrink-0" />
|
|
)}
|
|
</button>
|
|
|
|
{/* Children */}
|
|
{isDirectory && isExpanded && node.children && (
|
|
<div>
|
|
{node.children.map((child) => renderNode(child, depth + 1))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// No study selected state
|
|
if (!studyId) {
|
|
return (
|
|
<div className={`p-4 ${className}`}>
|
|
<div className="text-center text-dark-400 text-sm">
|
|
<Folder size={32} className="mx-auto mb-2 text-dark-500" />
|
|
<p>No study selected</p>
|
|
<p className="text-xs text-dark-500 mt-1">
|
|
Load a study to see its files
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={`flex flex-col h-full ${className}`}>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-3 py-2 border-b border-dark-700">
|
|
<div className="flex items-center gap-2">
|
|
<Folder size={16} className="text-amber-400" />
|
|
<span className="text-sm font-medium text-white">Files</span>
|
|
</div>
|
|
<button
|
|
onClick={loadFileStructure}
|
|
disabled={isLoading}
|
|
className="p-1 rounded hover:bg-dark-700 text-dark-400 hover:text-white transition-colors"
|
|
title="Refresh"
|
|
>
|
|
<RefreshCw size={14} className={isLoading ? 'animate-spin' : ''} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 overflow-y-auto p-2">
|
|
{isLoading && files.length === 0 ? (
|
|
<div className="flex items-center justify-center h-20 text-dark-400 text-sm">
|
|
<RefreshCw size={16} className="animate-spin mr-2" />
|
|
Loading...
|
|
</div>
|
|
) : error ? (
|
|
<div className="flex items-center justify-center h-20 text-red-400 text-sm gap-2">
|
|
<AlertCircle size={16} />
|
|
{error}
|
|
</div>
|
|
) : files.length === 0 ? (
|
|
<div className="text-center text-dark-400 text-sm py-4">
|
|
<p>No files found</p>
|
|
<p className="text-xs text-dark-500 mt-1">
|
|
Add model files to 1_setup/
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-0.5">
|
|
{files.map((node) => renderNode(node))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer hint */}
|
|
<div className="px-3 py-2 border-t border-dark-700 text-xs text-dark-500">
|
|
Click a model file to select it
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default FileStructurePanel;
|