import React, { useState, useRef, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useEditorStore, chipFileGroupId } from '../../store/useEditorStore'; import { useSimulatorStore } from '../../store/useSimulatorStore'; import { isProgrammableChip, targetForChip, DEFAULT_CHIP_PROGRAM_FILE, DEFAULT_CHIP_PROGRAM_C, } from '../../services/romCompileService'; import type { BoardKind } from '../../types/board'; import { boardDisplayName } 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 }) => ( ); // Pencil icon — the rename affordance on a board/chip section header. const IcoPencil = () => ( ); // Integrated-circuit (chip) icon — a DIP package with pins. Marks a // programmable custom-chip's program section, distinct from board sections. const IcoChip = () => ( ); // 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 updateBoard = useSimulatorStore((s) => s.updateBoard); const updateComponent = useSimulatorStore((s) => s.updateComponent); const components = useSimulatorStore((s) => s.components); // Programmable custom-chips (CPU emulators whose chip.json declares // programTargets) own a program the user can edit — a ROM source / C — // shown as its own section below the boards. Behaviour/driver chips and // predefined chips declare no programTargets and don't appear here (they're // edited in the chip designer). const programmableChips = components.filter( (c) => c.metadataId === 'custom-chip' && isProgrammableChip(c.properties as Record), ); // Ensure each programmable chip has an editable program AND its editor group. // loadExample seeds groups from an example's files; THIS is the path for a // chip dropped fresh from the gallery (and older projects): a fresh chip has // no program yet, so seed a default program.c the user can edit and persist // programFile/programTarget onto the component so Compile/Run can build it. useEffect(() => { const ed = useEditorStore.getState(); const updateComponent = useSimulatorStore.getState().updateComponent; for (const chip of programmableChips) { const gid = chipFileGroupId(chip.id); if (ed.fileGroups[gid]) continue; const props = chip.properties as Record; const existing = String(props.programFile ?? '').trim(); if (existing) { // Chip already names its program (e.g. an example) — seed from its // saved source if any, else empty (loadExample usually filled it). ed.createFileGroup(gid, [ { name: existing, content: String(props.programSource ?? '') }, ]); } else { // Fresh chip from the gallery — give it a starter program.c and // remember its target CPU for the ROM compiler. const target = targetForChip(String(props.chipJson ?? '{}')); updateComponent(chip.id, { properties: { ...props, programFile: DEFAULT_CHIP_PROGRAM_FILE, programTarget: target }, }); ed.createFileGroup(gid, [ { name: DEFAULT_CHIP_PROGRAM_FILE, content: DEFAULT_CHIP_PROGRAM_C }, ]); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [components]); const [contextMenu, setContextMenu] = useState(null); const [renamingId, setRenamingId] = useState(null); const [renameValue, setRenameValue] = useState(''); // Inline rename of a SECTION header (a board or a chip). Kept separate from // file rename (renamingId) so the two never collide. const [renamingSection, setRenamingSection] = useState<{ id: string; kind: 'board' | 'chip'; } | null>(null); const [sectionRenameValue, setSectionRenameValue] = 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 sectionRenameInputRef = useRef(null); // Set true by Escape so the input's onBlur (which fires when Escape unmounts // the input) discards instead of committing the typed value. const sectionRenameCancelledRef = useRef(false); const newFileInputRef = useRef(null); useEffect(() => { if (renamingId && renameInputRef.current) { renameInputRef.current.focus(); renameInputRef.current.select(); } }, [renamingId]); useEffect(() => { if (renamingSection && sectionRenameInputRef.current) { sectionRenameInputRef.current.focus(); sectionRenameInputRef.current.select(); } }, [renamingSection]); const startBoardRename = useCallback((board: { id: string; name?: string; boardKind: BoardKind }) => { sectionRenameCancelledRef.current = false; setRenamingSection({ id: board.id, kind: 'board' }); setSectionRenameValue(boardDisplayName(board)); }, []); const startChipRename = useCallback((chipId: string, currentName: string) => { sectionRenameCancelledRef.current = false; setRenamingSection({ id: chipId, kind: 'chip' }); setSectionRenameValue(currentName); }, []); const cancelSectionRename = useCallback(() => { sectionRenameCancelledRef.current = true; setRenamingSection(null); }, []); const commitSectionRename = useCallback(() => { // Escape cancelled this edit (it unmounts the input, firing onBlur) — discard. if (sectionRenameCancelledRef.current) { sectionRenameCancelledRef.current = false; return; } const target = renamingSection; if (target) { const value = sectionRenameValue.trim(); if (target.kind === 'board') { // Empty clears the custom name -> boardDisplayName falls back to kind. updateBoard(target.id, { name: value }); } else { const comp = useSimulatorStore.getState().components.find((c) => c.id === target.id); if (comp) { updateComponent(target.id, { properties: { ...comp.properties, chipName: value || 'Custom Chip' }, }); } } } setRenamingSection(null); }, [renamingSection, sectionRenameValue, updateBoard, updateComponent]); 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], ); // Chip program groups aren't tied to a board — switching to one just makes // the chip's group active in the editor (no activeBoardId change). const switchToChip = useCallback( (groupId: string) => { setActiveGroup(groupId); }, [setActiveGroup], ); const handleChipFileClick = useCallback( (fileId: string, groupId: string) => { if (groupId !== activeGroupId) switchToChip(groupId); openFile(fileId); }, [activeGroupId, switchToChip, 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={`${boardDisplayName(board)} — ${t('editor.fileExplorer.clickToEdit')}`} > {BOARD_ICON[board.boardKind]} {renamingSection?.id === board.id && renamingSection.kind === 'board' ? ( setSectionRenameValue(e.target.value)} onBlur={commitSectionRename} onKeyDown={(e) => { if (e.key === 'Enter') commitSectionRename(); if (e.key === 'Escape') cancelSectionRename(); }} onClick={(e) => e.stopPropagation()} /> ) : ( { e.stopPropagation(); startBoardRename(board); }} title="Double-click to rename" > {boardDisplayName(board)} )} {/* Rename + new-file buttons — 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()} />
)}
)}
); })} {/* Programmable custom-chip program sections — one per chip, each its own collapsible group (the chip's ROM source / C), separate from the board sketch above. */} {programmableChips.map((chip) => { const groupId = chipFileGroupId(chip.id); const groupFiles = fileGroups[groupId] ?? []; if (groupFiles.length === 0) return null; const isActiveGroup = activeGroupId === groupId; const isOpen = !collapsed[chip.id]; const chipName = String((chip.properties as Record)?.chipName ?? '').trim() || 'Custom Chip'; return (
{ switchToChip(groupId); if (!isOpen) toggleCollapse(chip.id); }} title={`${chipName} — ${t('editor.fileExplorer.clickToEdit')}`} > {renamingSection?.id === chip.id && renamingSection.kind === 'chip' ? ( setSectionRenameValue(e.target.value)} onBlur={commitSectionRename} onKeyDown={(e) => { if (e.key === 'Enter') commitSectionRename(); if (e.key === 'Escape') cancelSectionRename(); }} onClick={(e) => e.stopPropagation()} /> ) : ( { e.stopPropagation(); startChipRename(chip.id, chipName); }} title="Double-click to rename" > {chipName} )} {!(renamingSection?.id === chip.id && renamingSection.kind === 'chip') && ( )}
{isOpen && (
{groupFiles.map((file) => { const isActiveFile = isActiveGroup && file.id === activeFileId; return (
handleChipFileClick(file.id, groupId)} title={`${file.name}${file.modified ? ` (${t('editor.fileExplorer.unsavedSuffix')})` : ''}`} > {file.name} {file.modified && ( )}
); })}
)}
); })} {/* Fallback: nothing on the canvas yet */} {boards.length === 0 && programmableChips.length === 0 && (
{t('editor.fileExplorer.emptyState')}
)}
{contextMenu && (
e.stopPropagation()} >
)}
); };