feat(import): unify project import — accept .vlx and .zip in both entry points

Velxio had two parallel import paths that confused users (reported on
Discord by AgUn / dmontero):

  * Toolbar "Import a project from a .zip file" → Wokwi .zip only
  * File-explorer "Open .vlx file"               → Velxio .vlx only

If you exported a Velxio project as .vlx and tried to bring it back via
the toolbar Import button, you bounced off "wrong format" with no hint
that the .vlx loader was hiding behind the file-explorer save-bar.

Fix: introduce `utils/importProject.ts` as the single dispatcher.  It
sniffs the extension and routes:

  *.vlx  → importVlxFile      (writes directly to stores)
  *.zip  → importFromWokwiZip (returns a payload the caller applies,
                               so the toolbar can still trigger the
                               install-libraries modal afterwards)

Both UI entry points now go through the dispatcher with the same
`accept=".vlx,.zip,application/json,application/zip"` filter:

  * Toolbar "Import project (.vlx Velxio or .zip Wokwi)"
  * File-explorer "Open project (.vlx Velxio or .zip Wokwi)"

The toolbar tooltip is i18n-driven — updated EN + 8 other locales
(es, fr, de, it, pt-br, ja, ru, zh-cn) so every user sees the same
clarification.

Wokwi compatibility kept intact — the .zip path still resolves to
`importFromWokwiZip` and the same library-install modal pops if the
imported project lists libraries we don't have locally.
This commit is contained in:
David Montero 2026-05-22 20:55:06 +02:00
parent 1a0877f2af
commit 737ec5c6eb
12 changed files with 142 additions and 26 deletions

View File

@ -23,7 +23,8 @@ import { LibraryManagerModal } from '../simulator/LibraryManagerModal';
import { InstallLibrariesModal } from '../simulator/InstallLibrariesModal';
import { parseCompileResult } from '../../utils/compilationLogger';
import type { CompilationLog } from '../../utils/compilationLogger';
import { exportToWokwiZip, importFromWokwiZip } from '../../utils/wokwiZip';
import { exportToWokwiZip } from '../../utils/wokwiZip';
import { importProjectFile, PROJECT_FILE_ACCEPT } from '../../utils/importProject';
import { readFirmwareFile } from '../../utils/firmwareLoader';
import {
trackCompileCode,
@ -903,7 +904,14 @@ export const EditorToolbar = ({
importInputRef.current.value = '';
if (!file) return;
try {
const result = await importFromWokwiZip(file);
const result = await importProjectFile(file);
if (result.kind === 'vlx') {
// importVlxFile already wrote into the stores.
setMessage({ type: 'success', text: `Imported ${file.name}` });
return;
}
// .zip path: apply the parsed payload to the stores ourselves, then
// surface any missing libraries via the existing install modal.
const { loadFiles } = useEditorStore.getState();
const { setComponents, setWires, setBoardType, setBoardPosition, stopSimulation } =
useSimulatorStore.getState();
@ -1110,11 +1118,13 @@ export const EditorToolbar = ({
{centerSlot && <div className="toolbar-center-slot">{centerSlot}</div>}
<div className="toolbar-group toolbar-group-right">
{/* Hidden file input for import (always present) */}
{/* Hidden file input for project import. Accepts both .vlx
(Velxio native) and .zip (Wokwi bundle); the dispatcher in
utils/importProject.ts picks the right loader by extension. */}
<input
ref={importInputRef}
type="file"
accept=".zip"
accept={PROJECT_FILE_ACCEPT}
style={{ display: 'none' }}
onChange={handleImportFile}
/>

View File

@ -4,7 +4,7 @@ import { useEditorStore } from '../../store/useEditorStore';
import { useSimulatorStore } from '../../store/useSimulatorStore';
import type { BoardKind } from '../../types/board';
import { BOARD_KIND_LABELS } from '../../types/board';
import { importVlxFile, VlxParseError } from '../../utils/vlxFile';
import { importProjectFile, PROJECT_FILE_ACCEPT } from '../../utils/importProject';
import './FileExplorer.css';
// SVG icons — same style as EditorToolbar (stroke-based, 16x16)
@ -173,29 +173,54 @@ interface FileExplorerProps {
export const FileExplorer: React.FC<FileExplorerProps> = ({ onSaveClick, onNewClick }) => {
// 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.
// 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 handleOpenVlxClick = useCallback(() => {
const handleOpenProjectClick = useCallback(() => {
fileInputRef.current?.click();
}, []);
const handleVlxFilePicked = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
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';
if (
!window.confirm(
'Load this .vlx file? Your current workspace will be replaced. This cannot be undone.',
`Load this ${friendlyName} project? Your current workspace will be replaced. ` +
`This cannot be undone.`,
)
) {
return;
}
try {
await importVlxFile(file);
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) {
const msg = err instanceof VlxParseError ? err.message : (err as Error).message;
window.alert(`Could not load .vlx file:\n\n${msg}`);
window.alert((err as Error).message);
}
}, []);
@ -330,16 +355,16 @@ export const FileExplorer: React.FC<FileExplorerProps> = ({ onSaveClick, onNewCl
</button>
<button
className="file-explorer-save-btn"
title="Open .vlx file"
onClick={handleOpenVlxClick}
title="Open project (.vlx Velxio or .zip Wokwi)"
onClick={handleOpenProjectClick}
>
<IcoOpen />
</button>
<input
ref={fileInputRef}
type="file"
accept=".vlx,application/json"
onChange={handleVlxFilePicked}
accept={PROJECT_FILE_ACCEPT}
onChange={handleProjectFilePicked}
style={{ display: 'none' }}
/>
<button

View File

@ -21,7 +21,7 @@
"title": "Arduino-Bibliotheken suchen und installieren",
"label": "Bibliotheken"
},
"import": "Projekt aus .zip-Datei importieren",
"import": "Projekt importieren (.vlx Velxio oder .zip Wokwi)",
"export": "Aktuelles Projekt als .zip-Datei exportieren",
"uploadFirmware": "Firmware hochladen (.hex, .bin, .elf, .ihex) zur Umgehung der Kompilierung",
"toggleConsole": "Ausgabekonsole umschalten",

View File

@ -21,7 +21,7 @@
"title": "Search and install Arduino libraries",
"label": "Libraries"
},
"import": "Import a project from a .zip file",
"import": "Import project (.vlx Velxio or .zip Wokwi)",
"export": "Export the current project as a .zip file",
"uploadFirmware": "Upload firmware (.hex, .bin, .elf, .ihex) to bypass compilation",
"toggleConsole": "Toggle Output Console",

View File

@ -21,7 +21,7 @@
"title": "Buscar e instalar librerías de Arduino",
"label": "Librerías"
},
"import": "Importar un proyecto desde un archivo .zip",
"import": "Importar proyecto (.vlx de Velxio o .zip de Wokwi)",
"export": "Exportar el proyecto actual como un archivo .zip",
"uploadFirmware": "Subir firmware (.hex, .bin, .elf, .ihex) para omitir la compilación",
"toggleConsole": "Alternar consola de salida",

View File

@ -21,7 +21,7 @@
"title": "Rechercher et installer des bibliothèques Arduino",
"label": "Bibliothèques"
},
"import": "Importer un projet depuis un fichier .zip",
"import": "Importer un projet (.vlx Velxio ou .zip Wokwi)",
"export": "Exporter le projet actuel en fichier .zip",
"uploadFirmware": "Téléverser le firmware (.hex, .bin, .elf, .ihex) pour contourner la compilation",
"toggleConsole": "Afficher/Masquer la console de sortie",

View File

@ -21,7 +21,7 @@
"title": "Cerca e installa librerie Arduino",
"label": "Librerie"
},
"import": "Importa un progetto da file .zip",
"import": "Importa progetto (.vlx Velxio o .zip Wokwi)",
"export": "Esporta il progetto corrente come file .zip",
"uploadFirmware": "Carica firmware (.hex, .bin, .elf, .ihex) per bypassare la compilazione",
"toggleConsole": "Mostra/Nascondi console di output",

View File

@ -21,7 +21,7 @@
"title": "Arduinoライブラリを検索してインストール",
"label": "ライブラリ"
},
"import": ".zipファイルからプロジェクトをインポート",
"import": "プロジェクトをインポート(.vlx Velxio または .zip Wokwi)",
"export": "現在のプロジェクトを.zipファイルとしてエクスポート",
"uploadFirmware": "ファームウェアをアップロード (.hex, .bin, .elf, .ihex) してコンパイルをスキップ",
"toggleConsole": "出力コンソールの表示/非表示",

View File

@ -21,7 +21,7 @@
"title": "Pesquisar e instalar bibliotecas Arduino",
"label": "Bibliotecas"
},
"import": "Importar um projeto de um arquivo .zip",
"import": "Importar projeto (.vlx do Velxio ou .zip do Wokwi)",
"export": "Exportar o projeto atual como um arquivo .zip",
"uploadFirmware": "Carregar firmware (.hex, .bin, .elf, .ihex) para ignorar a compilação",
"toggleConsole": "Alternar Console de Saída",

View File

@ -21,7 +21,7 @@
"title": "Поиск и установка библиотек Arduino",
"label": "Библиотеки"
},
"import": "Импортировать проект из .zip файла",
"import": "Импорт проекта (.vlx Velxio или .zip Wokwi)",
"export": "Экспортировать текущий проект как .zip файл",
"uploadFirmware": "Загрузить прошивку (.hex, .bin, .elf, .ihex) для обхода компиляции",
"toggleConsole": "Переключить консоль вывода",

View File

@ -21,7 +21,7 @@
"title": "搜索并安装 Arduino 库",
"label": "库"
},
"import": "从 .zip 文件导入项目",
"import": "导入项目(.vlx Velxio 或 .zip Wokwi)",
"export": "将当前项目导出为 .zip 文件",
"uploadFirmware": "上传固件 (.hex, .bin, .elf, .ihex) 以跳过编译",
"toggleConsole": "切换输出控制台",

View File

@ -0,0 +1,81 @@
/**
* Unified project import dispatcher.
*
* Velxio accepts two on-disk project formats:
*
* - `.vlx` — Velxio's native single-file JSON (boards + components + wires +
* file groups, optionally multi-board). Handled by importVlxFile,
* which writes directly into the simulator + editor stores.
*
* - `.zip` — Wokwi-compatible bundle (diagram.json + sketch.ino + headers +
* libraries.txt). Handled by importFromWokwiZip, which returns a
* structured payload that callers apply to the stores themselves
* (so the toolbar can also pop the "install missing libraries"
* modal afterwards).
*
* Both UI entry points (the toolbar "Import" button and the file-explorer
* "Open project" button) route through this dispatcher so the user sees the
* same behaviour regardless of which button they click — and so we have ONE
* place to extend if a third format ever shows up.
*/
import type { Wire } from '../types/wire';
import { importVlxFile, VlxParseError } from './vlxFile';
import { importFromWokwiZip, type VelxioComponent } from './wokwiZip';
/**
* Common result shape: `kind` tells callers which path ran, so they can
* react appropriately (e.g. only the zip path needs the library-install
* modal — the .vlx format inlines library state).
*/
export type ProjectImportResult =
| { kind: 'vlx' }
| {
kind: 'zip';
boardType: 'arduino-uno' | 'arduino-nano' | 'arduino-mega' | 'raspberry-pi-pico';
boardPosition: { x: number; y: number };
components: VelxioComponent[];
wires: Wire[];
files: Array<{ name: string; content: string }>;
libraries: string[];
};
/**
* Detect format from the filename + MIME type and dispatch to the right
* loader. Returns the loader's result so callers can chain UI behaviour
* (e.g. show the install-libraries modal for zips). Throws when the
* format is unrecognised or the loader fails — callers should catch and
* surface to the user.
*
* Note: `.vlx` loads itself directly into the stores; `.zip` returns a
* payload that the caller must apply (we keep that asymmetry so the
* toolbar can still trigger the libraries modal at the end).
*/
export async function importProjectFile(file: File): Promise<ProjectImportResult> {
const lower = file.name.toLowerCase();
if (lower.endsWith('.vlx') || (file.type === 'application/json' && !lower.endsWith('.zip'))) {
try {
await importVlxFile(file);
return { kind: 'vlx' };
} catch (err) {
// Re-raise with a friendlier prefix. VlxParseError already has a
// good message; native errors get a clear context label.
const msg = err instanceof VlxParseError ? err.message : (err as Error).message;
throw new Error(`Could not load .vlx file:\n\n${msg}`);
}
}
if (lower.endsWith('.zip')) {
const result = await importFromWokwiZip(file);
return { kind: 'zip', ...result };
}
throw new Error(
`Unsupported project file: ${file.name}.\n` +
`Velxio accepts .vlx (Velxio projects) and .zip (Wokwi bundles).`,
);
}
/** File-input `accept` attribute that pairs with importProjectFile. */
export const PROJECT_FILE_ACCEPT = '.vlx,.zip,application/json,application/zip';