velxio/frontend/src/components/editor/FileExplorer.tsx

962 lines
35 KiB
TypeScript

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 { registerEditorCommand } from '../../lib/editorCommands';
import './FileExplorer.css';
/** Neutral chip glyph for overlay-registered boards without a bespoke icon. */
const PRO_FALLBACK_ICON = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="3" y="3" width="10" height="10" rx="2" fill="#8b5cf6" />
<rect x="5.5" y="5.5" width="5" height="5" rx="1" fill="#1e1b2e" />
</svg>
);
// SVG icons — same style as EditorToolbar (stroke-based, 16x16)
const IcoFile = () => (
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
);
const IcoHeader = () => (
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="9" y1="13" x2="15" y2="13" />
<line x1="9" y1="17" x2="13" y2="17" />
</svg>
);
const IcoNewFile = () => (
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="12" y1="18" x2="12" y2="12" />
<line x1="9" y1="15" x2="15" y2="15" />
</svg>
);
const IcoNewWorkspace = () => (
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
<line x1="12" y1="11" x2="12" y2="17" />
<line x1="9" y1="14" x2="15" y2="14" />
</svg>
);
const IcoSave = () => (
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
<polyline points="17 21 17 13 7 13 7 21" />
<polyline points="7 3 7 8 15 8" />
</svg>
);
const IcoOpen = () => (
// Folder with an "open / upload arrow" — matches Save visually (both
// are project-IO actions) but points the opposite way to signal load.
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
<polyline points="12 11 12 17" />
<polyline points="9 14 12 11 15 14" />
</svg>
);
const IcoChevron = ({ open }: { open: boolean }) => (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
style={{ transform: open ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform 0.15s' }}
>
<polyline points="9 18 15 12 9 6" />
</svg>
);
// Pencil icon — the rename affordance on a board/chip section header.
const IcoPencil = () => (
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
</svg>
);
// Integrated-circuit (chip) icon — a DIP package with pins. Marks a
// programmable custom-chip's program section, distinct from board sections.
const IcoChip = () => (
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="7" y="7" width="10" height="10" rx="1" />
<line x1="10" y1="3" x2="10" y2="7" />
<line x1="14" y1="3" x2="14" y2="7" />
<line x1="10" y1="17" x2="10" y2="21" />
<line x1="14" y1="17" x2="14" y2="21" />
<line x1="3" y1="10" x2="7" y2="10" />
<line x1="3" y1="14" x2="7" y2="14" />
<line x1="17" y1="10" x2="21" y2="10" />
<line x1="17" y1="14" x2="21" y2="14" />
</svg>
);
// Board emoji icons — mirrors BoardPickerModal
const BOARD_ICON: Record<BoardKind, string> = {
'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<BoardKind, string> = {
'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 <IcoHeader />;
return <IcoFile />;
}
interface ContextMenu {
fileId: string;
boardGroupId: string;
x: number;
y: number;
}
interface FileExplorerProps {
onSaveClick: () => void;
onNewClick: () => void;
}
export const FileExplorer: React.FC<FileExplorerProps> = ({ onSaveClick, onNewClick }) => {
const { t } = useTranslation();
// Hidden <input type="file"> 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<HTMLInputElement | null>(null);
const handleOpenProjectClick = useCallback(() => {
fileInputRef.current?.click();
}, []);
const handleProjectFilePicked = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
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<string, unknown>),
);
// 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<string, unknown>;
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<ContextMenu | null>(null);
const [renamingId, setRenamingId] = useState<string | null>(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<string | null>(null);
const [newFileName, setNewFileName] = useState('');
// Collapsed state per board ID
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const renameInputRef = useRef<HTMLInputElement>(null);
const sectionRenameInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>(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);
};
// File-menu commands owned by the explorer: Open project… and New file
// (which targets the ACTIVE board's file group, same as its + button).
useEffect(() => {
const offOpen = registerEditorCommand('project.open', handleOpenProjectClick);
const offNewFile = registerEditorCommand('file.new', () => {
const st = useSimulatorStore.getState();
const board = st.boards.find((b) => b.id === st.activeBoardId) ?? st.boards[0];
if (!board) return;
switchToBoard(board.id, board.activeFileGroupId);
setCreatingInGroup(board.activeFileGroupId);
setNewFileName('');
});
return () => {
offOpen();
offNewFile();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [handleOpenProjectClick]);
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 (
<div className="file-explorer">
<div className="file-explorer-header">
<span className="file-explorer-title">{t('editor.fileExplorer.workspace')}</span>
<div className="file-explorer-header-actions">
<button
className="file-explorer-new-btn"
title={t('editor.fileExplorer.newWorkspace')}
onClick={onNewClick}
>
<IcoNewWorkspace />
</button>
<button
className="file-explorer-save-btn"
title="Open project (.vlx Velxio or .zip Wokwi)"
onClick={handleOpenProjectClick}
>
<IcoOpen />
</button>
<input
ref={fileInputRef}
type="file"
accept={PROJECT_FILE_ACCEPT}
onChange={handleProjectFilePicked}
style={{ display: 'none' }}
/>
<button
className="file-explorer-save-btn"
title={t('editor.fileExplorer.saveProject')}
onClick={onSaveClick}
>
<IcoSave />
</button>
</div>
</div>
<div className="file-explorer-list">
{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 (
<div key={board.id} className="fe-board-section">
{/* Board section header */}
<div
className={`fe-board-header${isActiveBoard ? ' fe-board-header-active' : ''}`}
onClick={() => {
switchToBoard(board.id, groupId);
if (!isOpen) toggleCollapse(board.id);
}}
title={`${boardDisplayName(board)}${t('editor.fileExplorer.clickToEdit')}`}
>
<button
className="fe-collapse-btn"
onClick={(e) => {
e.stopPropagation();
toggleCollapse(board.id);
}}
title={isOpen ? t('editor.fileExplorer.collapse') : t('editor.fileExplorer.expand')}
>
<IcoChevron open={isOpen} />
</button>
<span className="fe-board-icon" style={{ color }}>
{BOARD_ICON[board.boardKind] ?? PRO_FALLBACK_ICON}
</span>
{renamingSection?.id === board.id && renamingSection.kind === 'board' ? (
<input
ref={sectionRenameInputRef}
className="file-explorer-rename-input fe-section-rename-input"
value={sectionRenameValue}
onChange={(e) => setSectionRenameValue(e.target.value)}
onBlur={commitSectionRename}
onKeyDown={(e) => {
if (e.key === 'Enter') commitSectionRename();
if (e.key === 'Escape') cancelSectionRename();
}}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span
className="fe-board-label"
onDoubleClick={(e) => {
e.stopPropagation();
startBoardRename(board);
}}
title="Double-click to rename"
>
{boardDisplayName(board)}
</span>
)}
<span
className="fe-status-dot"
style={{ background: statusColor }}
title={
board.running
? t('editor.fileExplorer.status.running')
: board.compiledProgram
? t('editor.fileExplorer.status.compiled')
: t('editor.fileExplorer.status.idle')
}
/>
{/* Rename + new-file buttons — visible on hover */}
<button
className="fe-board-new-btn"
title="Rename board (or double-click the name)"
onClick={(e) => {
e.stopPropagation();
startBoardRename(board);
}}
>
<IcoPencil />
</button>
<button
className="fe-board-new-btn"
title={t('editor.fileExplorer.newFileInBoard')}
onClick={(e) => {
e.stopPropagation();
startCreateFile(board.id, groupId);
}}
>
<IcoNewFile />
</button>
</div>
{/* Files under this board */}
{isOpen && (
<div className="fe-board-files">
{groupFiles.map((file) => {
const isActiveFile = isActiveBoard && file.id === activeFileId;
return (
<div
key={file.id}
className={`file-explorer-item fe-file-item${isActiveFile ? ' file-explorer-item-active' : ''}`}
onClick={() => 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')})` : ''}`}
>
<span className="file-explorer-icon">
<FileIcon name={file.name} />
</span>
{renamingId === file.id ? (
<input
ref={renameInputRef}
className="file-explorer-rename-input"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={commitRename}
onKeyDown={(e) => {
if (e.key === 'Enter') commitRename();
if (e.key === 'Escape') setRenamingId(null);
}}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className="file-explorer-name">{file.name}</span>
)}
{file.modified && (
<span className="file-explorer-dot" title={t('editor.fileExplorer.unsavedChanges')} />
)}
</div>
);
})}
{/* Inline new-file input for this group */}
{creatingInGroup === groupId && (
<div className="file-explorer-item file-explorer-item-new fe-file-item">
<span className="file-explorer-icon">
<IcoFile />
</span>
<input
ref={newFileInputRef}
className="file-explorer-rename-input"
value={newFileName}
placeholder="filename.ino"
onChange={(e) => setNewFileName(e.target.value)}
onBlur={commitCreateFile}
onKeyDown={(e) => {
if (e.key === 'Enter') commitCreateFile();
if (e.key === 'Escape') {
setCreatingInGroup(null);
setNewFileName('');
}
}}
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
{/* 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) && (
<div
className={`file-explorer-item fe-file-item${
manifestViewBoardId === board.id ? ' file-explorer-item-active' : ''
}`}
onClick={() => {
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)`}
>
<span className="file-explorer-icon" style={{ color: '#ffd60a' }}>
<FileIcon name="libraries.json" />
</span>
<span className="file-explorer-name">libraries.json</span>
<span
style={{
marginLeft: 'auto',
fontSize: 10,
color: '#9d9d9d',
background: '#2d2d2d',
borderRadius: 8,
padding: '1px 7px',
}}
title={
board.libraries && board.libraries.length
? `${board.libraries.length} declared: ${board.libraries.join(', ')}`
: 'No libraries declared for this board'
}
>
{board.libraries?.length ?? 0}
</span>
</div>
)}
</div>
)}
</div>
);
})}
{/* 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<string, unknown>)?.chipName ?? '').trim() ||
'Custom Chip';
return (
<div key={chip.id} className="fe-board-section">
<div
className={`fe-board-header${isActiveGroup ? ' fe-board-header-active' : ''}`}
onClick={() => {
switchToChip(groupId);
if (!isOpen) toggleCollapse(chip.id);
}}
title={`${chipName}${t('editor.fileExplorer.clickToEdit')}`}
>
<button
className="fe-collapse-btn"
onClick={(e) => {
e.stopPropagation();
toggleCollapse(chip.id);
}}
title={isOpen ? t('editor.fileExplorer.collapse') : t('editor.fileExplorer.expand')}
>
<IcoChevron open={isOpen} />
</button>
<span className="fe-board-icon" style={{ color: '#c4b5fd' }}>
<IcoChip />
</span>
{renamingSection?.id === chip.id && renamingSection.kind === 'chip' ? (
<input
ref={sectionRenameInputRef}
className="file-explorer-rename-input fe-section-rename-input"
value={sectionRenameValue}
onChange={(e) => setSectionRenameValue(e.target.value)}
onBlur={commitSectionRename}
onKeyDown={(e) => {
if (e.key === 'Enter') commitSectionRename();
if (e.key === 'Escape') cancelSectionRename();
}}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span
className="fe-board-label"
onDoubleClick={(e) => {
e.stopPropagation();
startChipRename(chip.id, chipName);
}}
title="Double-click to rename"
>
{chipName}
</span>
)}
{!(renamingSection?.id === chip.id && renamingSection.kind === 'chip') && (
<button
className="fe-board-new-btn"
title="Rename chip (or double-click the name)"
onClick={(e) => {
e.stopPropagation();
startChipRename(chip.id, chipName);
}}
>
<IcoPencil />
</button>
)}
</div>
{isOpen && (
<div className="fe-board-files">
{groupFiles.map((file) => {
const isActiveFile = isActiveGroup && file.id === activeFileId;
return (
<div
key={file.id}
className={`file-explorer-item fe-file-item${isActiveFile ? ' file-explorer-item-active' : ''}`}
onClick={() => handleChipFileClick(file.id, groupId)}
title={`${file.name}${file.modified ? ` (${t('editor.fileExplorer.unsavedSuffix')})` : ''}`}
>
<span className="file-explorer-icon">
<FileIcon name={file.name} />
</span>
<span className="file-explorer-name">{file.name}</span>
{file.modified && (
<span className="file-explorer-dot" title={t('editor.fileExplorer.unsavedChanges')} />
)}
</div>
);
})}
</div>
)}
</div>
);
})}
{/* Fallback: nothing on the canvas yet */}
{boards.length === 0 && programmableChips.length === 0 && (
<div style={{ color: '#666', fontSize: 11, padding: '12px 12px', lineHeight: 1.5 }}>
{t('editor.fileExplorer.emptyState')}
</div>
)}
</div>
{contextMenu && (
<div
className="file-explorer-context-menu"
style={{ top: contextMenu.y, left: contextMenu.x }}
onClick={(e) => e.stopPropagation()}
>
<button onClick={() => startRename(contextMenu.fileId, contextMenu.boardGroupId)}>
{t('editor.fileExplorer.contextMenu.rename')}
</button>
<button
className="ctx-delete"
onClick={() => handleDelete(contextMenu.fileId, contextMenu.boardGroupId)}
disabled={(fileGroups[contextMenu.boardGroupId] ?? []).length <= 1}
>
{t('editor.fileExplorer.contextMenu.delete')}
</button>
</div>
)}
</div>
);
};