import React, { useState, useRef, useEffect, useCallback } from 'react'; import { useEditorStore } from '../../store/useEditorStore'; import { useSimulatorStore } from '../../store/useSimulatorStore'; import type { BoardKind } from '../../types/board'; import { BOARD_KIND_LABELS } from '../../types/board'; import './FileExplorer.css'; // SVG icons — same style as EditorToolbar (stroke-based, 16x16) const IcoFile = () => ( ); const IcoHeader = () => ( ); const IcoNewFile = () => ( ); const IcoSave = () => ( ); 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': '⬡', }; // 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', }; 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; } export const FileExplorer: React.FC = ({ onSaveClick }) => { 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('Delete this file?')) 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 (
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]} — click to edit`} > {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 ? ' (unsaved)' : ''}`} > {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 && (
Add a board to the canvas to start editing code.
)}
{contextMenu && (
e.stopPropagation()} >
)}
); };