import React, { useState, useRef, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useEditorStore } from '../../store/useEditorStore'; import { useSimulatorStore } from '../../store/useSimulatorStore'; import type { BoardKind } from '../../types/board'; import { BOARD_KIND_LABELS } from '../../types/board'; import { importProjectFile, PROJECT_FILE_ACCEPT } from '../../utils/importProject'; import './FileExplorer.css'; // SVG icons — same style as EditorToolbar (stroke-based, 16x16) const IcoFile = () => ( ); const IcoHeader = () => ( ); const IcoNewFile = () => ( ); const IcoNewWorkspace = () => ( ); const IcoSave = () => ( ); const IcoOpen = () => ( // Folder with an "open / upload arrow" — matches Save visually (both // are project-IO actions) but points the opposite way to signal load. ); const IcoChevron = ({ open }: { open: boolean }) => ( ); // Board emoji icons — mirrors BoardPickerModal const BOARD_ICON: Record = { 'arduino-uno': '⬤', 'arduino-nano': '▪', 'arduino-mega': '▬', 'raspberry-pi-pico': '◆', 'raspberry-pi-3': '⬛', esp32: '⬡', 'esp32-s3': '⬡', 'esp32-c3': '⬡', 'stm32-bluepill': '◈', 'stm32-blackpill': '◈', 'stm32-bluepill-f103cb': '◈', 'stm32-blackpill-f401': '◈', 'stm32-f4-discovery': '◈', 'stm32-olimex-h405': '◈', 'stm32-netduino-plus2': '◈', 'stm32-netduino2': '◈', }; // Color accent per board family const BOARD_COLOR: Record = { 'arduino-uno': '#4fc3f7', 'arduino-nano': '#4fc3f7', 'arduino-mega': '#4fc3f7', 'raspberry-pi-pico': '#ce93d8', 'raspberry-pi-3': '#ef9a9a', esp32: '#a5d6a7', 'esp32-s3': '#a5d6a7', 'esp32-c3': '#a5d6a7', 'stm32-bluepill': '#80cbc4', 'stm32-blackpill': '#b0bec5', 'stm32-bluepill-f103cb': '#80cbc4', 'stm32-blackpill-f401': '#b0bec5', 'stm32-f4-discovery': '#90caf9', 'stm32-olimex-h405': '#a5d6a7', 'stm32-netduino-plus2': '#ce93d8', 'stm32-netduino2': '#ce93d8', }; function FileIcon({ name }: { name: string }) { const ext = name.split('.').pop()?.toLowerCase() ?? ''; if (['h', 'hpp'].includes(ext)) return ; return ; } interface ContextMenu { fileId: string; boardGroupId: string; x: number; y: number; } interface FileExplorerProps { onSaveClick: () => void; onNewClick: () => void; } export const FileExplorer: React.FC = ({ onSaveClick, onNewClick }) => { // Hidden we trigger via ref when the user clicks // the Open project button. Accepts both .vlx (Velxio native) and .zip // (Wokwi bundle); the dispatcher in utils/importProject.ts decides which // loader to run based on the file extension. Kept outside React state so // the change event still fires when the user picks the same file twice. const fileInputRef = useRef(null); const handleOpenProjectClick = useCallback(() => { fileInputRef.current?.click(); }, []); const handleProjectFilePicked = useCallback(async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; // Reset so picking the SAME file again later still fires onchange. e.target.value = ''; if (!file) return; const friendlyName = file.name.toLowerCase().endsWith('.zip') ? 'Wokwi .zip' : '.vlx'; if ( !window.confirm( `Load this ${friendlyName} project? Your current workspace will be replaced. ` + `This cannot be undone.`, ) ) { return; } try { const result = await importProjectFile(file); // .zip needs the caller to apply the payload to the stores (we keep // that asymmetry so the toolbar's import flow can also pop the // install-libraries modal afterwards). Here in the file explorer we // don't have that modal, so we apply the payload silently and just // warn in the console if the project references uninstalled libs. if (result.kind === 'zip') { const { loadFiles } = useEditorStore.getState(); const { setComponents, setWires, setBoardType, setBoardPosition, stopSimulation } = useSimulatorStore.getState(); stopSimulation(); if (result.boardType) setBoardType(result.boardType); setBoardPosition(result.boardPosition); setComponents(result.components); setWires(result.wires); if (result.files.length > 0) loadFiles(result.files); if (result.libraries.length > 0) { console.warn( '[FileExplorer] Imported Wokwi zip references libraries you may need to install:', result.libraries, ); } } } catch (err) { window.alert((err as Error).message); } }, []); const { t } = useTranslation(); const { fileGroups, activeFileId, activeGroupId, openFile, createFile, deleteFile, renameFile, setActiveGroup, } = useEditorStore(); const boards = useSimulatorStore((s) => s.boards); const activeBoardId = useSimulatorStore((s) => s.activeBoardId); const setActiveBoardId = useSimulatorStore((s) => s.setActiveBoardId); const [contextMenu, setContextMenu] = useState(null); const [renamingId, setRenamingId] = useState(null); const [renameValue, setRenameValue] = useState(''); // Track which board group is creating a file: boardGroupId or null const [creatingInGroup, setCreatingInGroup] = useState(null); const [newFileName, setNewFileName] = useState(''); // Collapsed state per board ID const [collapsed, setCollapsed] = useState>({}); const renameInputRef = useRef(null); const newFileInputRef = useRef(null); useEffect(() => { if (renamingId && renameInputRef.current) { renameInputRef.current.focus(); renameInputRef.current.select(); } }, [renamingId]); useEffect(() => { if (creatingInGroup && newFileInputRef.current) { newFileInputRef.current.focus(); } }, [creatingInGroup]); // Close context menu on click outside useEffect(() => { if (!contextMenu) return; const handler = () => setContextMenu(null); document.addEventListener('click', handler); return () => document.removeEventListener('click', handler); }, [contextMenu]); const switchToBoard = useCallback( (boardId: string, groupId: string) => { setActiveBoardId(boardId); // setActiveBoardId already calls setActiveGroup internally via the store // but we make sure the editor group is also in sync setActiveGroup(groupId); }, [setActiveBoardId, setActiveGroup], ); const handleFileClick = useCallback( (fileId: string, boardId: string, groupId: string) => { if (boardId !== activeBoardId) { switchToBoard(boardId, groupId); } openFile(fileId); }, [activeBoardId, switchToBoard, openFile], ); const handleContextMenu = (e: React.MouseEvent, fileId: string, boardGroupId: string) => { e.preventDefault(); e.stopPropagation(); setContextMenu({ fileId, boardGroupId, x: e.clientX, y: e.clientY }); }; const startRename = (fileId: string, groupId: string) => { const files = fileGroups[groupId] ?? []; const file = files.find((f) => f.id === fileId); if (!file) return; setRenamingId(fileId); setRenameValue(file.name); setContextMenu(null); }; const commitRename = useCallback(() => { if (renamingId && renameValue.trim()) { renameFile(renamingId, renameValue.trim()); } setRenamingId(null); }, [renamingId, renameValue, renameFile]); const handleDelete = (fileId: string, groupId: string) => { setContextMenu(null); const files = fileGroups[groupId] ?? []; if (files.length <= 1) return; if (!window.confirm(t('editor.fileExplorer.confirmDelete'))) return; deleteFile(fileId); }; const startCreateFile = (boardId: string, groupId: string) => { // Switch to this board first so createFile targets the right group switchToBoard(boardId, groupId); setCreatingInGroup(groupId); setNewFileName(''); setContextMenu(null); }; const commitCreateFile = useCallback(() => { const name = newFileName.trim(); if (name) createFile(name); setCreatingInGroup(null); setNewFileName(''); }, [newFileName, createFile]); const toggleCollapse = (boardId: string) => { setCollapsed((prev) => ({ ...prev, [boardId]: !prev[boardId] })); }; return (
{t('editor.fileExplorer.workspace')}
{boards.map((board) => { const groupId = board.activeFileGroupId; const groupFiles = fileGroups[groupId] ?? []; const isActiveBoard = board.id === activeBoardId; const isOpen = !collapsed[board.id]; const color = BOARD_COLOR[board.boardKind]; // Status dot color const statusColor = board.running ? '#22c55e' : board.compiledProgram ? '#f59e0b' : '#6b7280'; return (
{/* Board section header */}
{ switchToBoard(board.id, groupId); if (!isOpen) toggleCollapse(board.id); }} title={`${BOARD_KIND_LABELS[board.boardKind]} — ${t('editor.fileExplorer.clickToEdit')}`} > {BOARD_ICON[board.boardKind]} {BOARD_KIND_LABELS[board.boardKind]} {/* New file button — visible on hover */}
{/* Files under this board */} {isOpen && (
{groupFiles.map((file) => { const isActiveFile = isActiveBoard && file.id === activeFileId; return (
handleFileClick(file.id, board.id, groupId)} onContextMenu={(e) => handleContextMenu(e, file.id, groupId)} onDoubleClick={() => { switchToBoard(board.id, groupId); startRename(file.id, groupId); }} title={`${file.name}${file.modified ? ` (${t('editor.fileExplorer.unsavedSuffix')})` : ''}`} > {renamingId === file.id ? ( setRenameValue(e.target.value)} onBlur={commitRename} onKeyDown={(e) => { if (e.key === 'Enter') commitRename(); if (e.key === 'Escape') setRenamingId(null); }} onClick={(e) => e.stopPropagation()} /> ) : ( {file.name} )} {file.modified && ( )}
); })} {/* Inline new-file input for this group */} {creatingInGroup === groupId && (
setNewFileName(e.target.value)} onBlur={commitCreateFile} onKeyDown={(e) => { if (e.key === 'Enter') commitCreateFile(); if (e.key === 'Escape') { setCreatingInGroup(null); setNewFileName(''); } }} onClick={(e) => e.stopPropagation()} />
)}
)}
); })} {/* Fallback: no boards yet */} {boards.length === 0 && (
{t('editor.fileExplorer.emptyState')}
)}
{contextMenu && (
e.stopPropagation()} >
)}
); };