2026-03-06 20:14:50 +07:00
|
|
|
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
2026-05-09 13:08:51 +07:00
|
|
|
import { useTranslation } from 'react-i18next';
|
2026-03-06 20:14:50 +07:00
|
|
|
import { useEditorStore } from '../../store/useEditorStore';
|
2026-03-16 00:04:01 +07:00
|
|
|
import { useSimulatorStore } from '../../store/useSimulatorStore';
|
|
|
|
|
import type { BoardKind } from '../../types/board';
|
|
|
|
|
import { BOARD_KIND_LABELS } from '../../types/board';
|
feat(oss): portable .vlx project export/import for self-hosters
Phase 4 of the OSS / pro split. The OSS image has no auth and no
server-side persistence — without this commit, the user's workspace
was ephemeral (lost on tab refresh). `.vlx` is a single-file JSON
snapshot of the entire workspace (boards, file groups, components,
wires, active board id) that the user can save to disk and reload
later.
New: utils/vlxFile.ts
- buildVlxPayload() / buildVlxBlob() — pure snapshot of the current
editor + simulator stores.
- triggerDownloadVlx({ name? }) — anchor-click download with a safe
filename. Returns the filename actually used.
- parseVlxFile(File) — async reader + validator. Checks
format === "velxio-project", version <= 1, and the required
arrays/objects are present. Throws VlxParseError with a human-
readable message on any issue.
- importVlxFile(File) — convenience wrapper that parses AND calls
useSimulatorStore.loadProjectState() with the result.
Format intentionally mirrors the server's POST /api/projects body so
a Pro user can export-from-pro and import-into-OSS losslessly (and
vice-versa once Pro adds an Export button — out of scope here).
lib/proSaveAction.ts: the default (no-overlay) implementation now
calls triggerDownloadVlx() instead of console.info'ing about the
missing handler. The Pro overlay still wins via installSaveActionImpl()
— Save in Pro keeps opening SaveProjectModal. The Save button in OSS
now actually saves.
components/editor/FileExplorer.tsx: new "Open .vlx" button next to
New + Save. Opens a hidden file input; confirms with the user before
replacing the workspace (loadProjectState is destructive); surfaces
VlxParseError messages via window.alert.
Verified with both builds:
- OSS-only: triggerSaveAction → download .vlx; FileExplorer shows
3 buttons (New, Open, Save).
- OSS + overlay: Pro's installSaveActionImpl overrides — Save opens
SaveProjectModal as before. Open .vlx still works (independent
button, not part of the save flow).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 02:33:00 +07:00
|
|
|
import { importVlxFile, VlxParseError } from '../../utils/vlxFile';
|
2026-03-06 20:14:50 +07:00
|
|
|
import './FileExplorer.css';
|
|
|
|
|
|
2026-03-06 20:24:03 +07:00
|
|
|
// SVG icons — same style as EditorToolbar (stroke-based, 16x16)
|
|
|
|
|
const IcoFile = () => (
|
2026-04-22 02:45:45 +07:00
|
|
|
<svg
|
|
|
|
|
width="22"
|
|
|
|
|
height="22"
|
|
|
|
|
viewBox="0 0 24 24"
|
|
|
|
|
fill="none"
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
strokeWidth="2"
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
strokeLinejoin="round"
|
|
|
|
|
>
|
2026-03-06 20:24:03 +07:00
|
|
|
<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 = () => (
|
2026-04-22 02:45:45 +07:00
|
|
|
<svg
|
|
|
|
|
width="22"
|
|
|
|
|
height="22"
|
|
|
|
|
viewBox="0 0 24 24"
|
|
|
|
|
fill="none"
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
strokeWidth="2"
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
strokeLinejoin="round"
|
|
|
|
|
>
|
2026-03-06 20:24:03 +07:00
|
|
|
<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 = () => (
|
2026-04-22 02:45:45 +07:00
|
|
|
<svg
|
|
|
|
|
width="22"
|
|
|
|
|
height="22"
|
|
|
|
|
viewBox="0 0 24 24"
|
|
|
|
|
fill="none"
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
strokeWidth="2"
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
strokeLinejoin="round"
|
|
|
|
|
>
|
2026-03-06 20:24:03 +07:00
|
|
|
<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>
|
|
|
|
|
);
|
|
|
|
|
|
2026-05-01 01:27:58 +07:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
|
2026-03-06 20:24:03 +07:00
|
|
|
const IcoSave = () => (
|
2026-04-22 02:45:45 +07:00
|
|
|
<svg
|
|
|
|
|
width="22"
|
|
|
|
|
height="22"
|
|
|
|
|
viewBox="0 0 24 24"
|
|
|
|
|
fill="none"
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
strokeWidth="2"
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
strokeLinejoin="round"
|
|
|
|
|
>
|
2026-03-06 20:24:03 +07:00
|
|
|
<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>
|
|
|
|
|
);
|
|
|
|
|
|
feat(oss): portable .vlx project export/import for self-hosters
Phase 4 of the OSS / pro split. The OSS image has no auth and no
server-side persistence — without this commit, the user's workspace
was ephemeral (lost on tab refresh). `.vlx` is a single-file JSON
snapshot of the entire workspace (boards, file groups, components,
wires, active board id) that the user can save to disk and reload
later.
New: utils/vlxFile.ts
- buildVlxPayload() / buildVlxBlob() — pure snapshot of the current
editor + simulator stores.
- triggerDownloadVlx({ name? }) — anchor-click download with a safe
filename. Returns the filename actually used.
- parseVlxFile(File) — async reader + validator. Checks
format === "velxio-project", version <= 1, and the required
arrays/objects are present. Throws VlxParseError with a human-
readable message on any issue.
- importVlxFile(File) — convenience wrapper that parses AND calls
useSimulatorStore.loadProjectState() with the result.
Format intentionally mirrors the server's POST /api/projects body so
a Pro user can export-from-pro and import-into-OSS losslessly (and
vice-versa once Pro adds an Export button — out of scope here).
lib/proSaveAction.ts: the default (no-overlay) implementation now
calls triggerDownloadVlx() instead of console.info'ing about the
missing handler. The Pro overlay still wins via installSaveActionImpl()
— Save in Pro keeps opening SaveProjectModal. The Save button in OSS
now actually saves.
components/editor/FileExplorer.tsx: new "Open .vlx" button next to
New + Save. Opens a hidden file input; confirms with the user before
replacing the workspace (loadProjectState is destructive); surfaces
VlxParseError messages via window.alert.
Verified with both builds:
- OSS-only: triggerSaveAction → download .vlx; FileExplorer shows
3 buttons (New, Open, Save).
- OSS + overlay: Pro's installSaveActionImpl overrides — Save opens
SaveProjectModal as before. Open .vlx still works (independent
button, not part of the save flow).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 02:33:00 +07:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
|
2026-03-16 00:04:01 +07:00
|
|
|
const IcoChevron = ({ open }: { open: boolean }) => (
|
|
|
|
|
<svg
|
2026-04-22 02:45:45 +07:00
|
|
|
width="12"
|
|
|
|
|
height="12"
|
|
|
|
|
viewBox="0 0 24 24"
|
|
|
|
|
fill="none"
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
strokeWidth="2.5"
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
strokeLinejoin="round"
|
2026-03-16 00:04:01 +07:00
|
|
|
style={{ transform: open ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform 0.15s' }}
|
|
|
|
|
>
|
|
|
|
|
<polyline points="9 18 15 12 9 6" />
|
|
|
|
|
</svg>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Board emoji icons — mirrors BoardPickerModal
|
|
|
|
|
const BOARD_ICON: Record<BoardKind, string> = {
|
2026-04-22 02:45:45 +07:00
|
|
|
'arduino-uno': '⬤',
|
|
|
|
|
'arduino-nano': '▪',
|
|
|
|
|
'arduino-mega': '▬',
|
2026-03-16 00:04:01 +07:00
|
|
|
'raspberry-pi-pico': '◆',
|
2026-04-22 02:45:45 +07:00
|
|
|
'raspberry-pi-3': '⬛',
|
|
|
|
|
esp32: '⬡',
|
2026-03-16 00:04:01 +07:00
|
|
|
'esp32-s3': '⬡',
|
|
|
|
|
'esp32-c3': '⬡',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Color accent per board family
|
|
|
|
|
const BOARD_COLOR: Record<BoardKind, string> = {
|
2026-04-22 02:45:45 +07:00
|
|
|
'arduino-uno': '#4fc3f7',
|
|
|
|
|
'arduino-nano': '#4fc3f7',
|
|
|
|
|
'arduino-mega': '#4fc3f7',
|
2026-03-16 00:04:01 +07:00
|
|
|
'raspberry-pi-pico': '#ce93d8',
|
2026-04-22 02:45:45 +07:00
|
|
|
'raspberry-pi-3': '#ef9a9a',
|
|
|
|
|
esp32: '#a5d6a7',
|
2026-03-16 00:04:01 +07:00
|
|
|
'esp32-s3': '#a5d6a7',
|
|
|
|
|
'esp32-c3': '#a5d6a7',
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-06 20:24:03 +07:00
|
|
|
function FileIcon({ name }: { name: string }) {
|
2026-03-06 20:14:50 +07:00
|
|
|
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
2026-03-06 20:24:03 +07:00
|
|
|
if (['h', 'hpp'].includes(ext)) return <IcoHeader />;
|
|
|
|
|
return <IcoFile />;
|
2026-03-06 20:14:50 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ContextMenu {
|
|
|
|
|
fileId: string;
|
2026-03-16 00:04:01 +07:00
|
|
|
boardGroupId: string;
|
2026-03-06 20:14:50 +07:00
|
|
|
x: number;
|
|
|
|
|
y: number;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 20:24:03 +07:00
|
|
|
interface FileExplorerProps {
|
|
|
|
|
onSaveClick: () => void;
|
2026-05-01 01:27:58 +07:00
|
|
|
onNewClick: () => void;
|
2026-03-06 20:24:03 +07:00
|
|
|
}
|
|
|
|
|
|
2026-05-01 01:27:58 +07:00
|
|
|
export const FileExplorer: React.FC<FileExplorerProps> = ({ onSaveClick, onNewClick }) => {
|
feat(oss): portable .vlx project export/import for self-hosters
Phase 4 of the OSS / pro split. The OSS image has no auth and no
server-side persistence — without this commit, the user's workspace
was ephemeral (lost on tab refresh). `.vlx` is a single-file JSON
snapshot of the entire workspace (boards, file groups, components,
wires, active board id) that the user can save to disk and reload
later.
New: utils/vlxFile.ts
- buildVlxPayload() / buildVlxBlob() — pure snapshot of the current
editor + simulator stores.
- triggerDownloadVlx({ name? }) — anchor-click download with a safe
filename. Returns the filename actually used.
- parseVlxFile(File) — async reader + validator. Checks
format === "velxio-project", version <= 1, and the required
arrays/objects are present. Throws VlxParseError with a human-
readable message on any issue.
- importVlxFile(File) — convenience wrapper that parses AND calls
useSimulatorStore.loadProjectState() with the result.
Format intentionally mirrors the server's POST /api/projects body so
a Pro user can export-from-pro and import-into-OSS losslessly (and
vice-versa once Pro adds an Export button — out of scope here).
lib/proSaveAction.ts: the default (no-overlay) implementation now
calls triggerDownloadVlx() instead of console.info'ing about the
missing handler. The Pro overlay still wins via installSaveActionImpl()
— Save in Pro keeps opening SaveProjectModal. The Save button in OSS
now actually saves.
components/editor/FileExplorer.tsx: new "Open .vlx" button next to
New + Save. Opens a hidden file input; confirms with the user before
replacing the workspace (loadProjectState is destructive); surfaces
VlxParseError messages via window.alert.
Verified with both builds:
- OSS-only: triggerSaveAction → download .vlx; FileExplorer shows
3 buttons (New, Open, Save).
- OSS + overlay: Pro's installSaveActionImpl overrides — Save opens
SaveProjectModal as before. Open .vlx still works (independent
button, not part of the save flow).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 02:33:00 +07:00
|
|
|
// Hidden <input type="file"> we trigger via ref when the user clicks
|
|
|
|
|
// the Open .vlx button. Kept outside React state so the change event
|
|
|
|
|
// can fire repeatedly even if the user picks the same file twice.
|
|
|
|
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
|
|
|
const handleOpenVlxClick = useCallback(() => {
|
|
|
|
|
fileInputRef.current?.click();
|
|
|
|
|
}, []);
|
|
|
|
|
const handleVlxFilePicked = 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;
|
|
|
|
|
if (
|
|
|
|
|
!window.confirm(
|
|
|
|
|
'Load this .vlx file? Your current workspace will be replaced. This cannot be undone.',
|
|
|
|
|
)
|
|
|
|
|
) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
await importVlxFile(file);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const msg = err instanceof VlxParseError ? err.message : (err as Error).message;
|
|
|
|
|
window.alert(`Could not load .vlx file:\n\n${msg}`);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-05-09 13:08:51 +07:00
|
|
|
const { t } = useTranslation();
|
2026-04-22 02:45:45 +07:00
|
|
|
const {
|
|
|
|
|
fileGroups,
|
|
|
|
|
activeFileId,
|
|
|
|
|
activeGroupId,
|
|
|
|
|
openFile,
|
|
|
|
|
createFile,
|
|
|
|
|
deleteFile,
|
|
|
|
|
renameFile,
|
|
|
|
|
setActiveGroup,
|
|
|
|
|
} = useEditorStore();
|
2026-03-16 00:04:01 +07:00
|
|
|
const boards = useSimulatorStore((s) => s.boards);
|
|
|
|
|
const activeBoardId = useSimulatorStore((s) => s.activeBoardId);
|
|
|
|
|
const setActiveBoardId = useSimulatorStore((s) => s.setActiveBoardId);
|
|
|
|
|
|
2026-03-06 20:14:50 +07:00
|
|
|
const [contextMenu, setContextMenu] = useState<ContextMenu | null>(null);
|
|
|
|
|
const [renamingId, setRenamingId] = useState<string | null>(null);
|
|
|
|
|
const [renameValue, setRenameValue] = useState('');
|
2026-03-16 00:04:01 +07:00
|
|
|
// Track which board group is creating a file: boardGroupId or null
|
|
|
|
|
const [creatingInGroup, setCreatingInGroup] = useState<string | null>(null);
|
2026-03-06 20:14:50 +07:00
|
|
|
const [newFileName, setNewFileName] = useState('');
|
2026-03-16 00:04:01 +07:00
|
|
|
// Collapsed state per board ID
|
|
|
|
|
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
|
|
|
|
|
2026-03-06 20:14:50 +07:00
|
|
|
const renameInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
const newFileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (renamingId && renameInputRef.current) {
|
|
|
|
|
renameInputRef.current.focus();
|
|
|
|
|
renameInputRef.current.select();
|
|
|
|
|
}
|
|
|
|
|
}, [renamingId]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-03-16 00:04:01 +07:00
|
|
|
if (creatingInGroup && newFileInputRef.current) {
|
2026-03-06 20:14:50 +07:00
|
|
|
newFileInputRef.current.focus();
|
|
|
|
|
}
|
2026-03-16 00:04:01 +07:00
|
|
|
}, [creatingInGroup]);
|
2026-03-06 20:14:50 +07:00
|
|
|
|
|
|
|
|
// Close context menu on click outside
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!contextMenu) return;
|
|
|
|
|
const handler = () => setContextMenu(null);
|
|
|
|
|
document.addEventListener('click', handler);
|
|
|
|
|
return () => document.removeEventListener('click', handler);
|
|
|
|
|
}, [contextMenu]);
|
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
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],
|
|
|
|
|
);
|
2026-03-16 00:04:01 +07:00
|
|
|
|
|
|
|
|
const handleContextMenu = (e: React.MouseEvent, fileId: string, boardGroupId: string) => {
|
2026-03-06 20:14:50 +07:00
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
2026-03-16 00:04:01 +07:00
|
|
|
setContextMenu({ fileId, boardGroupId, x: e.clientX, y: e.clientY });
|
2026-03-06 20:14:50 +07:00
|
|
|
};
|
|
|
|
|
|
2026-03-16 00:04:01 +07:00
|
|
|
const startRename = (fileId: string, groupId: string) => {
|
|
|
|
|
const files = fileGroups[groupId] ?? [];
|
2026-03-06 20:14:50 +07:00
|
|
|
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]);
|
|
|
|
|
|
2026-03-16 00:04:01 +07:00
|
|
|
const handleDelete = (fileId: string, groupId: string) => {
|
2026-03-06 20:14:50 +07:00
|
|
|
setContextMenu(null);
|
2026-03-16 00:04:01 +07:00
|
|
|
const files = fileGroups[groupId] ?? [];
|
2026-03-06 20:14:50 +07:00
|
|
|
if (files.length <= 1) return;
|
2026-05-09 13:08:51 +07:00
|
|
|
if (!window.confirm(t('editor.fileExplorer.confirmDelete'))) return;
|
2026-03-06 20:14:50 +07:00
|
|
|
deleteFile(fileId);
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-16 00:04:01 +07:00
|
|
|
const startCreateFile = (boardId: string, groupId: string) => {
|
|
|
|
|
// Switch to this board first so createFile targets the right group
|
|
|
|
|
switchToBoard(boardId, groupId);
|
|
|
|
|
setCreatingInGroup(groupId);
|
2026-03-06 20:14:50 +07:00
|
|
|
setNewFileName('');
|
|
|
|
|
setContextMenu(null);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const commitCreateFile = useCallback(() => {
|
|
|
|
|
const name = newFileName.trim();
|
|
|
|
|
if (name) createFile(name);
|
2026-03-16 00:04:01 +07:00
|
|
|
setCreatingInGroup(null);
|
2026-03-06 20:14:50 +07:00
|
|
|
setNewFileName('');
|
|
|
|
|
}, [newFileName, createFile]);
|
|
|
|
|
|
2026-03-16 00:04:01 +07:00
|
|
|
const toggleCollapse = (boardId: string) => {
|
|
|
|
|
setCollapsed((prev) => ({ ...prev, [boardId]: !prev[boardId] }));
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-06 20:14:50 +07:00
|
|
|
return (
|
|
|
|
|
<div className="file-explorer">
|
|
|
|
|
<div className="file-explorer-header">
|
2026-05-09 13:08:51 +07:00
|
|
|
<span className="file-explorer-title">{t('editor.fileExplorer.workspace')}</span>
|
2026-03-06 20:24:03 +07:00
|
|
|
<div className="file-explorer-header-actions">
|
2026-05-01 01:27:58 +07:00
|
|
|
<button
|
|
|
|
|
className="file-explorer-new-btn"
|
2026-05-09 13:08:51 +07:00
|
|
|
title={t('editor.fileExplorer.newWorkspace')}
|
2026-05-01 01:27:58 +07:00
|
|
|
onClick={onNewClick}
|
|
|
|
|
>
|
|
|
|
|
<IcoNewWorkspace />
|
|
|
|
|
</button>
|
feat(oss): portable .vlx project export/import for self-hosters
Phase 4 of the OSS / pro split. The OSS image has no auth and no
server-side persistence — without this commit, the user's workspace
was ephemeral (lost on tab refresh). `.vlx` is a single-file JSON
snapshot of the entire workspace (boards, file groups, components,
wires, active board id) that the user can save to disk and reload
later.
New: utils/vlxFile.ts
- buildVlxPayload() / buildVlxBlob() — pure snapshot of the current
editor + simulator stores.
- triggerDownloadVlx({ name? }) — anchor-click download with a safe
filename. Returns the filename actually used.
- parseVlxFile(File) — async reader + validator. Checks
format === "velxio-project", version <= 1, and the required
arrays/objects are present. Throws VlxParseError with a human-
readable message on any issue.
- importVlxFile(File) — convenience wrapper that parses AND calls
useSimulatorStore.loadProjectState() with the result.
Format intentionally mirrors the server's POST /api/projects body so
a Pro user can export-from-pro and import-into-OSS losslessly (and
vice-versa once Pro adds an Export button — out of scope here).
lib/proSaveAction.ts: the default (no-overlay) implementation now
calls triggerDownloadVlx() instead of console.info'ing about the
missing handler. The Pro overlay still wins via installSaveActionImpl()
— Save in Pro keeps opening SaveProjectModal. The Save button in OSS
now actually saves.
components/editor/FileExplorer.tsx: new "Open .vlx" button next to
New + Save. Opens a hidden file input; confirms with the user before
replacing the workspace (loadProjectState is destructive); surfaces
VlxParseError messages via window.alert.
Verified with both builds:
- OSS-only: triggerSaveAction → download .vlx; FileExplorer shows
3 buttons (New, Open, Save).
- OSS + overlay: Pro's installSaveActionImpl overrides — Save opens
SaveProjectModal as before. Open .vlx still works (independent
button, not part of the save flow).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 02:33:00 +07:00
|
|
|
<button
|
|
|
|
|
className="file-explorer-save-btn"
|
|
|
|
|
title="Open .vlx file"
|
|
|
|
|
onClick={handleOpenVlxClick}
|
|
|
|
|
>
|
|
|
|
|
<IcoOpen />
|
|
|
|
|
</button>
|
|
|
|
|
<input
|
|
|
|
|
ref={fileInputRef}
|
|
|
|
|
type="file"
|
|
|
|
|
accept=".vlx,application/json"
|
|
|
|
|
onChange={handleVlxFilePicked}
|
|
|
|
|
style={{ display: 'none' }}
|
|
|
|
|
/>
|
2026-03-06 20:24:03 +07:00
|
|
|
<button
|
|
|
|
|
className="file-explorer-save-btn"
|
2026-05-09 13:08:51 +07:00
|
|
|
title={t('editor.fileExplorer.saveProject')}
|
2026-03-06 20:24:03 +07:00
|
|
|
onClick={onSaveClick}
|
|
|
|
|
>
|
|
|
|
|
<IcoSave />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-03-06 20:14:50 +07:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="file-explorer-list">
|
2026-03-16 00:04:01 +07:00
|
|
|
{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
|
2026-04-22 02:45:45 +07:00
|
|
|
? '#f59e0b'
|
|
|
|
|
: '#6b7280';
|
2026-03-16 00:04:01 +07:00
|
|
|
|
|
|
|
|
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);
|
2026-03-06 20:14:50 +07:00
|
|
|
}}
|
2026-05-09 13:08:51 +07:00
|
|
|
title={`${BOARD_KIND_LABELS[board.boardKind]} — ${t('editor.fileExplorer.clickToEdit')}`}
|
2026-03-16 00:04:01 +07:00
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
className="fe-collapse-btn"
|
2026-04-22 02:45:45 +07:00
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
toggleCollapse(board.id);
|
|
|
|
|
}}
|
2026-05-09 13:08:51 +07:00
|
|
|
title={isOpen ? t('editor.fileExplorer.collapse') : t('editor.fileExplorer.expand')}
|
2026-03-16 00:04:01 +07:00
|
|
|
>
|
|
|
|
|
<IcoChevron open={isOpen} />
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<span className="fe-board-icon" style={{ color }}>
|
|
|
|
|
{BOARD_ICON[board.boardKind]}
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
|
|
<span className="fe-board-label">{BOARD_KIND_LABELS[board.boardKind]}</span>
|
|
|
|
|
|
|
|
|
|
<span
|
|
|
|
|
className="fe-status-dot"
|
|
|
|
|
style={{ background: statusColor }}
|
2026-05-09 13:08:51 +07:00
|
|
|
title={
|
|
|
|
|
board.running
|
|
|
|
|
? t('editor.fileExplorer.status.running')
|
|
|
|
|
: board.compiledProgram
|
|
|
|
|
? t('editor.fileExplorer.status.compiled')
|
|
|
|
|
: t('editor.fileExplorer.status.idle')
|
|
|
|
|
}
|
2026-03-16 00:04:01 +07:00
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* New file button — visible on hover */}
|
|
|
|
|
<button
|
|
|
|
|
className="fe-board-new-btn"
|
2026-05-09 13:08:51 +07:00
|
|
|
title={t('editor.fileExplorer.newFileInBoard')}
|
2026-04-22 02:45:45 +07:00
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
startCreateFile(board.id, groupId);
|
|
|
|
|
}}
|
2026-03-16 00:04:01 +07:00
|
|
|
>
|
|
|
|
|
<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)}
|
2026-04-22 02:45:45 +07:00
|
|
|
onDoubleClick={() => {
|
|
|
|
|
switchToBoard(board.id, groupId);
|
|
|
|
|
startRename(file.id, groupId);
|
|
|
|
|
}}
|
2026-05-09 13:08:51 +07:00
|
|
|
title={`${file.name}${file.modified ? ` (${t('editor.fileExplorer.unsavedSuffix')})` : ''}`}
|
2026-03-16 00:04:01 +07:00
|
|
|
>
|
|
|
|
|
<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 && (
|
2026-05-09 13:08:51 +07:00
|
|
|
<span className="file-explorer-dot" title={t('editor.fileExplorer.unsavedChanges')} />
|
2026-03-16 00:04:01 +07:00
|
|
|
)}
|
|
|
|
|
</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>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
|
|
|
|
|
{/* Fallback: no boards yet */}
|
|
|
|
|
{boards.length === 0 && (
|
|
|
|
|
<div style={{ color: '#666', fontSize: 11, padding: '12px 12px', lineHeight: 1.5 }}>
|
2026-05-09 13:08:51 +07:00
|
|
|
{t('editor.fileExplorer.emptyState')}
|
2026-03-06 20:14:50 +07:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{contextMenu && (
|
|
|
|
|
<div
|
|
|
|
|
className="file-explorer-context-menu"
|
|
|
|
|
style={{ top: contextMenu.y, left: contextMenu.x }}
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
>
|
2026-03-16 00:04:01 +07:00
|
|
|
<button onClick={() => startRename(contextMenu.fileId, contextMenu.boardGroupId)}>
|
2026-05-09 13:08:51 +07:00
|
|
|
{t('editor.fileExplorer.contextMenu.rename')}
|
2026-03-06 20:14:50 +07:00
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
className="ctx-delete"
|
2026-03-16 00:04:01 +07:00
|
|
|
onClick={() => handleDelete(contextMenu.fileId, contextMenu.boardGroupId)}
|
|
|
|
|
disabled={(fileGroups[contextMenu.boardGroupId] ?? []).length <= 1}
|
2026-03-06 20:14:50 +07:00
|
|
|
>
|
2026-05-09 13:08:51 +07:00
|
|
|
{t('editor.fileExplorer.contextMenu.delete')}
|
2026-03-06 20:14:50 +07:00
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|