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>
This commit is contained in:
parent
7a3996776b
commit
b4ab742456
|
|
@ -4,6 +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 './FileExplorer.css';
|
||||
|
||||
// SVG icons — same style as EditorToolbar (stroke-based, 16x16)
|
||||
|
|
@ -93,6 +94,25 @@ const IcoSave = () => (
|
|||
</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"
|
||||
|
|
@ -152,6 +172,33 @@ 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.
|
||||
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}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
fileGroups,
|
||||
|
|
@ -281,6 +328,20 @@ export const FileExplorer: React.FC<FileExplorerProps> = ({ onSaveClick, onNewCl
|
|||
>
|
||||
<IcoNewWorkspace />
|
||||
</button>
|
||||
<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' }}
|
||||
/>
|
||||
<button
|
||||
className="file-explorer-save-btn"
|
||||
title={t('editor.fileExplorer.saveProject')}
|
||||
|
|
|
|||
|
|
@ -7,43 +7,54 @@
|
|||
* in the pro overlay. The OSS editor exposes a stable `triggerSaveAction()`
|
||||
* that:
|
||||
*
|
||||
* - In OSS without an overlay → no-op (in Phase 4 this becomes the
|
||||
* entry point for the .vlx Export dialog so anonymous users still
|
||||
* have a way to persist work).
|
||||
* - With the pro overlay loaded → dispatches to the registered impl,
|
||||
* which inspects the auth store and opens SaveProjectModal (logged
|
||||
* in) or LoginPromptModal (anonymous).
|
||||
* - In OSS without an overlay → downloads a portable `.vlx` snapshot
|
||||
* of the current workspace. The user picks where it goes (browser
|
||||
* save dialog) and can re-load it later via the "Open .vlx" button.
|
||||
* This is Phase 4 of the OSS split — gives self-hosters durable
|
||||
* project persistence without requiring a DB or auth.
|
||||
* - With the pro overlay loaded → installSaveActionImpl() overrides
|
||||
* the default. The overlay's impl inspects the auth store and opens
|
||||
* SaveProjectModal (logged in) or LoginPromptModal (anonymous).
|
||||
*
|
||||
* Impl receives no arguments and returns nothing. State lives in the
|
||||
* overlay's React tree (modal open/close, project data) — this registry
|
||||
* caller's React tree (modal open/close, project data) — this registry
|
||||
* is just the doorbell.
|
||||
*/
|
||||
|
||||
import { triggerDownloadVlx } from '../utils/vlxFile';
|
||||
import { useProjectStore } from '../store/useProjectStore';
|
||||
|
||||
let _impl: (() => void) | null = null;
|
||||
|
||||
export function installSaveActionImpl(impl: (() => void) | null): void {
|
||||
_impl = impl;
|
||||
}
|
||||
|
||||
function defaultSaveAction(): void {
|
||||
// OSS fallback: dump the workspace to a `.vlx` file the user downloads.
|
||||
// Use the loaded project's name if there is one (slug/identity tracking
|
||||
// doesn't require auth — useProjectStore stays in OSS).
|
||||
const proj = useProjectStore.getState().currentProject;
|
||||
const name = proj?.slug ?? proj?.id ?? undefined;
|
||||
const filename = triggerDownloadVlx({ name });
|
||||
// eslint-disable-next-line no-console
|
||||
console.info(`[oss] downloaded workspace as ${filename}`);
|
||||
}
|
||||
|
||||
export function triggerSaveAction(): void {
|
||||
if (_impl) {
|
||||
try {
|
||||
_impl();
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[oss] save-action impl threw:', err);
|
||||
}
|
||||
} else {
|
||||
const impl = _impl ?? defaultSaveAction;
|
||||
try {
|
||||
impl();
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.info(
|
||||
'[oss] No save handler is installed. Builds without the pro overlay ' +
|
||||
'will gain a local .vlx export in Phase 4 of the OSS split.',
|
||||
);
|
||||
console.warn('[oss] save-action impl threw:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether an implementation has been installed. Lets UI conditionally
|
||||
* show the Save button — without an impl, clicking it does nothing useful. */
|
||||
/** Whether a custom (overlay) implementation has been installed. The OSS
|
||||
* default always works, so callers don't usually need this. Kept as a
|
||||
* hook for UIs that want to label the button differently in OSS vs Pro
|
||||
* (e.g. "Download .vlx" vs "Save project"). */
|
||||
export function hasSaveActionImpl(): boolean {
|
||||
return _impl !== null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,231 @@
|
|||
/**
|
||||
* .vlx file format — portable project export/import for OSS Velxio.
|
||||
*
|
||||
* Phase 4 of the OSS / pro split. The OSS image has no auth, no DB, no
|
||||
* server-side project persistence. The user's work is otherwise ephemeral
|
||||
* (lost on tab refresh). `.vlx` is a single-file JSON snapshot that
|
||||
* round-trips everything the server-side Save flow captures:
|
||||
*
|
||||
* {
|
||||
* "format": "velxio-project",
|
||||
* "version": 1,
|
||||
* "exportedAt": "ISO timestamp",
|
||||
* "name": "project name (optional)",
|
||||
* "boards": [...],
|
||||
* "fileGroups": { "<groupId>": [{ name, content }, ...] },
|
||||
* "components": [...],
|
||||
* "wires": [...],
|
||||
* "activeBoardId": "..." | null
|
||||
* }
|
||||
*
|
||||
* Reading: `parseVlxFile(File) → payload` validates and returns a shape
|
||||
* directly consumable by `useSimulatorStore.loadProjectState(...)`.
|
||||
*
|
||||
* Writing: `buildVlxBlob()` snapshots the current store state into a
|
||||
* `Blob`, ready to feed an `<a download>` link.
|
||||
*
|
||||
* The format is INTENTIONALLY identical to the server's POST/PUT body
|
||||
* for `/api/projects/`, so a pro user can export-from-Pro / import-into-
|
||||
* OSS (and vice-versa) without surprises.
|
||||
*/
|
||||
|
||||
import type { BoardInstance } from '../types/board';
|
||||
import type { Component } from '../types/component';
|
||||
import type { Wire } from '../types/wire';
|
||||
import { useEditorStore } from '../store/useEditorStore';
|
||||
import { useSimulatorStore } from '../store/useSimulatorStore';
|
||||
|
||||
const VLX_FORMAT = 'velxio-project';
|
||||
const VLX_VERSION = 1;
|
||||
|
||||
export interface VlxPayload {
|
||||
format: typeof VLX_FORMAT;
|
||||
version: number;
|
||||
exportedAt: string;
|
||||
name?: string;
|
||||
boards: Array<{
|
||||
id: string;
|
||||
boardKind: string;
|
||||
x: number;
|
||||
y: number;
|
||||
activeFileGroupId: string;
|
||||
languageMode?: string;
|
||||
serialBaudRate?: number;
|
||||
}>;
|
||||
fileGroups: Record<string, Array<{ name: string; content: string }>>;
|
||||
components: Component[];
|
||||
wires: Wire[];
|
||||
activeBoardId: string | null;
|
||||
}
|
||||
|
||||
function serialisableBoard(b: BoardInstance) {
|
||||
return {
|
||||
id: b.id,
|
||||
boardKind: b.boardKind,
|
||||
x: b.x,
|
||||
y: b.y,
|
||||
activeFileGroupId: b.activeFileGroupId,
|
||||
languageMode: b.languageMode,
|
||||
serialBaudRate: b.serialBaudRate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot the current editor + simulator state into a VlxPayload object.
|
||||
* Pure function: no side effects.
|
||||
*/
|
||||
export function buildVlxPayload(opts: { name?: string } = {}): VlxPayload {
|
||||
const sim = useSimulatorStore.getState();
|
||||
const editor = useEditorStore.getState();
|
||||
|
||||
// Only persist file groups that are actually referenced by a board.
|
||||
// Stray groups left over from deleted boards don't need to round-trip.
|
||||
const referencedGroupIds = new Set(sim.boards.map((b) => b.activeFileGroupId));
|
||||
const fileGroups: VlxPayload['fileGroups'] = {};
|
||||
for (const gid of referencedGroupIds) {
|
||||
fileGroups[gid] = (editor.fileGroups[gid] ?? []).map((f) => ({
|
||||
name: f.name,
|
||||
content: f.content,
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
format: VLX_FORMAT,
|
||||
version: VLX_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
name: opts.name,
|
||||
boards: sim.boards.map(serialisableBoard),
|
||||
fileGroups,
|
||||
components: sim.components,
|
||||
wires: sim.wires,
|
||||
activeBoardId: sim.activeBoardId,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a Blob (MIME: application/json) carrying the current state. */
|
||||
export function buildVlxBlob(opts: { name?: string } = {}): Blob {
|
||||
const payload = buildVlxPayload(opts);
|
||||
const json = JSON.stringify(payload, null, 2);
|
||||
return new Blob([json], { type: 'application/json' });
|
||||
}
|
||||
|
||||
/** Sanitise a filename: keep letters, digits, dashes, dots, underscores. */
|
||||
function safeFilename(name?: string): string {
|
||||
const base = (name ?? 'velxio-project').trim() || 'velxio-project';
|
||||
const cleaned = base
|
||||
.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 80);
|
||||
return `${cleaned || 'velxio-project'}.vlx`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a browser download of the current state as `<name>.vlx`.
|
||||
* Returns the filename actually used (for UI feedback).
|
||||
*/
|
||||
export function triggerDownloadVlx(opts: { name?: string } = {}): string {
|
||||
const blob = buildVlxBlob(opts);
|
||||
const filename = safeFilename(opts.name);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
// The browser starts the download immediately. Revoke the URL on the
|
||||
// next tick so the click handler has time to settle before GC.
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, 0);
|
||||
return filename;
|
||||
}
|
||||
|
||||
/** Thrown by parseVlxFile when the file isn't a valid .vlx payload. */
|
||||
export class VlxParseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'VlxParseError';
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that `data` is shaped like a VlxPayload. Throws VlxParseError
|
||||
* on mismatch with a human-readable reason. Keep the checks defensive —
|
||||
* users may edit .vlx files by hand or feed us a wrong file by accident.
|
||||
*/
|
||||
function validatePayload(data: unknown): VlxPayload {
|
||||
if (!isPlainObject(data)) {
|
||||
throw new VlxParseError('File is not a JSON object.');
|
||||
}
|
||||
if (data.format !== VLX_FORMAT) {
|
||||
throw new VlxParseError(
|
||||
`Not a Velxio project file (expected format="${VLX_FORMAT}", got ${JSON.stringify(
|
||||
data.format,
|
||||
)}).`,
|
||||
);
|
||||
}
|
||||
if (typeof data.version !== 'number') {
|
||||
throw new VlxParseError('Missing or invalid "version" field.');
|
||||
}
|
||||
if (data.version > VLX_VERSION) {
|
||||
throw new VlxParseError(
|
||||
`This file uses .vlx format version ${data.version}, but this Velxio supports up to v${VLX_VERSION}. Update Velxio to open it.`,
|
||||
);
|
||||
}
|
||||
if (!Array.isArray(data.boards)) {
|
||||
throw new VlxParseError('Missing or invalid "boards" array.');
|
||||
}
|
||||
if (!isPlainObject(data.fileGroups)) {
|
||||
throw new VlxParseError('Missing or invalid "fileGroups" object.');
|
||||
}
|
||||
if (!Array.isArray(data.components)) {
|
||||
throw new VlxParseError('Missing or invalid "components" array.');
|
||||
}
|
||||
if (!Array.isArray(data.wires)) {
|
||||
throw new VlxParseError('Missing or invalid "wires" array.');
|
||||
}
|
||||
return data as unknown as VlxPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a File object (from a `<input type="file">` change event or a
|
||||
* drop), parse it as JSON, validate, and return the payload. Throws
|
||||
* VlxParseError on any failure.
|
||||
*/
|
||||
export async function parseVlxFile(file: File): Promise<VlxPayload> {
|
||||
let text: string;
|
||||
try {
|
||||
text = await file.text();
|
||||
} catch (err) {
|
||||
throw new VlxParseError(`Could not read file: ${(err as Error).message}`);
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch (err) {
|
||||
throw new VlxParseError(`Invalid JSON: ${(err as Error).message}`);
|
||||
}
|
||||
return validatePayload(parsed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper: parse the file AND load its contents into the
|
||||
* simulator stores via `loadProjectState`. Returns the parsed payload
|
||||
* so the caller can show a confirmation toast or similar.
|
||||
*/
|
||||
export async function importVlxFile(file: File): Promise<VlxPayload> {
|
||||
const payload = await parseVlxFile(file);
|
||||
useSimulatorStore.getState().loadProjectState({
|
||||
boards: payload.boards as unknown as BoardInstance[],
|
||||
fileGroups: payload.fileGroups,
|
||||
components: payload.components,
|
||||
wires: payload.wires,
|
||||
activeBoardId: payload.activeBoardId,
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
Loading…
Reference in New Issue