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, isPiBoardKind } from '../../types/board'; import { importProjectFile, PROJECT_FILE_ACCEPT } from '../../utils/importProject'; import { showMessageDialog, showConfirmDialog } from '../../store/useMessageDialogStore'; import './FileExplorer.css'; /** Neutral chip glyph for overlay-registered boards without a bespoke icon. */ const PRO_FALLBACK_ICON = ( ); // 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 }) => { const { t } = useTranslation(); // 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'; const confirmed = await showConfirmDialog( t('editor.fileExplorer.confirmLoad.message', { type: friendlyName }), { kind: 'error', title: t('editor.fileExplorer.confirmLoad.title'), confirmLabel: t('editor.fileExplorer.confirmLoad.confirm'), cancelLabel: t('editor.fileTabs.cancel'), danger: true, }, ); if (!confirmed) 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) { showMessageDialog((err as Error).message, { kind: 'error' }); } }, [t]); const { fileGroups, activeFileId, activeGroupId, openFile, createFile, deleteFile, renameFile, setActiveGroup, manifestViewBoardId, setManifestView, } = 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 = async (fileId: string, groupId: string) => { setContextMenu(null); const files = fileGroups[groupId] ?? []; if (files.length <= 1) return; const confirmed = await showConfirmDialog(t('editor.fileExplorer.confirmDelete'), { kind: 'error', title: t('editor.fileExplorer.contextMenu.delete'), confirmLabel: t('editor.fileExplorer.contextMenu.delete'), cancelLabel: t('editor.fileTabs.cancel'), danger: true, }); if (!confirmed) 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] ?? '#8b5cf6'; // 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] ?? PRO_FALLBACK_ICON} {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()} />
)} {/* velxio.json — THIS board's declared library manifest (compile scope), grouped with the board's code so it is clear which board it belongs to. There is one per board. Clicking switches to the board and opens the Library Manager on its list. QEMU-Linux boards run Python in a guest OS — no arduino-cli manifest, so no row. */} {!isPiBoardKind(board.boardKind) && (
{ switchToBoard(board.id, groupId); // Open the READ-ONLY libraries.json view (not the modal). // Library actions happen in the Library Manager modal. setManifestView(board.id); }} title={`libraries.json — ${boardDisplayName(board)}'s declared libraries (read-only; manage from the Library Manager)`} > libraries.json {board.libraries?.length ?? 0}
)}
)}
); })} {/* 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()} >
)}
); };