velxio/frontend/src/pages/EditorPage.tsx

687 lines
26 KiB
TypeScript

/**
* Editor Page — main editor + simulator with resizable panels
*/
import React, { useRef, useState, useCallback, useEffect, lazy, Suspense } from 'react';
import { useTranslation } from 'react-i18next';
import { startSimulation } from '../simulation/spice/start';
import { useSEO } from '../utils/useSEO';
import { CodeEditor } from '../components/editor/CodeEditor';
import { EditorToolbar } from '../components/editor/EditorToolbar';
import { FileTabs } from '../components/editor/FileTabs';
import { FileExplorer } from '../components/editor/FileExplorer';
// Lazy-load Pi workspace so xterm.js isn't in the main bundle
const RaspberryPiWorkspace = lazy(() =>
import('../components/raspberry-pi/RaspberryPiWorkspace').then((m) => ({
default: m.RaspberryPiWorkspace,
})),
);
import { CompilationConsole } from '../components/editor/CompilationConsole';
import { SimulatorCanvas } from '../components/simulator/SimulatorCanvas';
import { SerialMonitor } from '../components/simulator/SerialMonitor';
import { Oscilloscope } from '../components/simulator/Oscilloscope';
import { AppHeader } from '../components/layout/AppHeader';
import { triggerSaveAction } from '../lib/proSaveAction';
import { GitHubStarBanner } from '../components/layout/GitHubStarBanner';
import { useSimulatorStore, DEFAULT_BOARD_POSITION } from '../store/useSimulatorStore';
import { useEditorStore } from '../store/useEditorStore';
import { useCompileLogsStore } from '../store/useCompileLogsStore';
import { useOscilloscopeStore } from '../store/useOscilloscopeStore';
import { useProjectStore } from '../store/useProjectStore';
import { useAutoSaveProject } from '../hooks/useAutoSaveProject';
import type { CompilationLog } from '../utils/compilationLogger';
import { isPiBoardKind } from '../types/board';
import '../App.css';
const MOBILE_BREAKPOINT = 768;
const BOTTOM_PANEL_MIN = 80;
const BOTTOM_PANEL_MAX = 600;
const BOTTOM_PANEL_DEFAULT = 200;
const EXPLORER_MIN = 110;
const EXPLORER_MAX = 500;
const EXPLORER_DEFAULT = 165;
const resizeHandleStyle: React.CSSProperties = {
height: 5,
flexShrink: 0,
cursor: 'row-resize',
background: '#2a2d2e',
borderTop: '1px solid #3c3c3c',
borderBottom: '1px solid #3c3c3c',
};
export const EditorPage: React.FC = () => {
const { t } = useTranslation();
useSEO({
title: 'Multi-Board Simulator Editor — Arduino, ESP32, RP2040, RISC-V | Velxio',
description:
'Write, compile and simulate Arduino, ESP32, Raspberry Pi Pico, ESP32-C3, and Raspberry Pi 3 code in your browser. 19 boards, 5 CPU architectures, 48+ components. Free and open-source.',
url: 'https://velxio.dev/editor',
});
// Silent auto-save for the loaded project (only fires when authed AND
// currentProject has a UUID — see useAutoSaveProject for the gating rules).
const autoSave = useAutoSaveProject();
const [editorWidthPct, setEditorWidthPct] = useState(45);
// Desktop-only 3-way layout switch (code-only / circuit-only / both).
// Lets users hide a pane to give the right-docked chat more room.
const viewMode = useEditorStore((s) => s.viewMode);
const setViewMode = useEditorStore((s) => s.setViewMode);
const containerRef = useRef<HTMLDivElement>(null);
const resizingRef = useRef(false);
const serialMonitorOpen = useSimulatorStore((s) => s.serialMonitorOpen);
const activeBoardId = useSimulatorStore((s) => s.activeBoardId);
const activeBoardKind = useSimulatorStore(
(s) => s.boards.find((b) => b.id === s.activeBoardId)?.boardKind,
);
// Pi 3/4/5 and Zero/1/2 all run the QEMU Linux workspace (terminal + Python),
// not the Arduino/Monaco editor. Pico (RP2040) is browser-emulated, not a Pi here.
const isLinuxPi = isPiBoardKind(activeBoardKind ?? '');
const oscilloscopeOpen = useOscilloscopeStore((s) => s.open);
const [consoleOpen, setConsoleOpen] = useState(false);
// compileLogs live in a Zustand store so the velxio-pro agent overlay
// (mounted in a separate React tree via slotMounter) can subscribe and
// build a "diagnose this failure" prompt without prop-drilling.
const compileLogs = useCompileLogsStore((s) => s.logs);
const setCompileLogs = useCompileLogsStore((s) => s.setLogs);
const [bottomPanelHeight, setBottomPanelHeight] = useState(BOTTOM_PANEL_DEFAULT);
const [showStarBanner, setShowStarBanner] = useState(false);
const [starRound, setStarRound] = useState<1 | 2>(1);
// ── Electrical simulation (one-time mount) ────────────────────────────────
// `startSimulation()` is the single entry point: it constructs the
// CircuitSimulationService, mounts the ADC bridge, and subscribes
// PinManager → service.handleMcuEdge. No more legacy paths — the
// WASM ngspice (via NgSpiceWorkerAdapter) is the only solver.
useEffect(() => {
return startSimulation();
}, []);
// ── GitHub star prompt (show twice at most: 2nd visit OR after 3 min) ──────
// Three localStorage flags drive this:
// velxio_star_prompted → dismissed the first ask
// velxio_star_prompted_v2 → dismissed the follow-up ask (stop forever)
// velxio_star_clicked → clicked through to the repo (stop forever)
// Anyone who dismissed the first ask WITHOUT clicking through gets one
// follow-up (round 2) with a stronger message; clicking the repo link at
// any time opts them out permanently.
useEffect(() => {
const STAR_KEY = 'velxio_star_prompted';
const STAR_KEY_V2 = 'velxio_star_prompted_v2';
const STAR_CLICKED_KEY = 'velxio_star_clicked';
const VISITS_KEY = 'velxio_editor_visits';
const FIRST_VISIT_KEY = 'velxio_editor_first_visit';
const THREE_MIN = 3 * 60 * 1000;
// Never bother people who already starred or already saw the follow-up.
if (localStorage.getItem(STAR_CLICKED_KEY)) return;
if (localStorage.getItem(STAR_KEY_V2)) return;
// Round 2 = they dismissed the first ask (without clicking through).
const round = localStorage.getItem(STAR_KEY) ? 2 : 1;
setStarRound(round);
// Increment visit counter
const visits = parseInt(localStorage.getItem(VISITS_KEY) ?? '0', 10) + 1;
localStorage.setItem(VISITS_KEY, String(visits));
// Record timestamp of first visit
if (!localStorage.getItem(FIRST_VISIT_KEY)) {
localStorage.setItem(FIRST_VISIT_KEY, String(Date.now()));
}
const firstVisit = parseInt(localStorage.getItem(FIRST_VISIT_KEY)!, 10);
// Show immediately on second+ visit
if (visits >= 2) {
setShowStarBanner(true);
return;
}
// Otherwise schedule after the 3-minute mark
const elapsed = Date.now() - firstVisit;
const delay = Math.max(0, THREE_MIN - elapsed);
const timer = setTimeout(() => {
if (!localStorage.getItem(STAR_CLICKED_KEY) && !localStorage.getItem(STAR_KEY_V2)) {
setShowStarBanner(true);
}
}, delay);
return () => clearTimeout(timer);
}, []);
const handleDismissStarBanner = () => {
// First dismiss → mark round 1; second dismiss → mark round 2 (stop forever).
if (localStorage.getItem('velxio_star_prompted')) {
localStorage.setItem('velxio_star_prompted_v2', '1');
} else {
localStorage.setItem('velxio_star_prompted', '1');
}
setShowStarBanner(false);
};
const handleStarClick = () => {
// They went to the repo — opt them out of any further prompts.
localStorage.setItem('velxio_star_clicked', '1');
setShowStarBanner(false);
};
const [explorerOpen, setExplorerOpen] = useState(true);
const [explorerWidth, setExplorerWidth] = useState(EXPLORER_DEFAULT);
const [isMobile, setIsMobile] = useState(
() => window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`).matches,
);
// Slot element for SimulatorCanvas to portal its header into. When set, the
// canvas board selector / Serial / Scope / zoom / Add buttons render here
// instead of above the canvas — keeping the top bar a single full-width row
// that doesn't reflow when the editor/canvas splitter is dragged.
const [canvasHeaderSlot, setCanvasHeaderSlot] = useState<HTMLDivElement | null>(null);
// Default to 'code' on mobile — show the editor so users can write/view code
const [mobileView, setMobileView] = useState<'code' | 'circuit'>('code');
// Save is dispatched to the pro overlay, which inspects auth state and
// shows the right modal (Save vs Login prompt). In OSS without the
// overlay this is a no-op today and becomes the .vlx Export entry
// point in Phase 4 of the OSS split.
const handleSaveClick = useCallback(() => {
triggerSaveAction();
}, []);
const handleNewClick = useCallback(() => {
if (
!window.confirm(
'Start a new workspace? This clears every board, component, wire and file. This cannot be undone.',
)
) {
return;
}
const sim = useSimulatorStore.getState();
sim.boards.forEach((b) => sim.stopBoard(b.id));
const ids = sim.boards.map((b) => b.id);
ids.forEach((id) => sim.removeBoard(id));
sim.setComponents([]);
sim.setWires([]);
useProjectStore.getState().clearCurrentProject();
const newId = useSimulatorStore
.getState()
.addBoard('arduino-uno', DEFAULT_BOARD_POSITION.x, DEFAULT_BOARD_POSITION.y);
useSimulatorStore.getState().setActiveBoardId(newId);
}, []);
// Track mobile breakpoint
useEffect(() => {
const mq = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`);
const update = (e: MediaQueryListEvent | MediaQueryList) => {
const mobile = e.matches;
setIsMobile(mobile);
if (mobile) setExplorerOpen(false);
};
update(mq);
mq.addEventListener('change', update);
return () => mq.removeEventListener('change', update);
}, []);
// Ctrl+S shortcut
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
handleSaveClick();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [handleSaveClick]);
// Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z — canvas undo/redo. Skipped when the
// user is typing in any input/textarea/contenteditable so the Monaco
// editor's per-file history (and the AI chat composer, etc.) keep
// working untouched.
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const t = e.target as HTMLElement | null;
if (t) {
const tag = t.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) {
return;
}
}
if (!(e.ctrlKey || e.metaKey)) return;
const k = e.key.toLowerCase();
const sim = useSimulatorStore.getState();
if (k === 'z' && !e.shiftKey) {
e.preventDefault();
sim.undo();
} else if (k === 'y' || (k === 'z' && e.shiftKey)) {
e.preventDefault();
sim.redo();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
// Prevent body scroll on the editor page
useEffect(() => {
const html = document.documentElement;
const body = document.body;
html.style.overflow = 'hidden';
body.style.overflow = 'hidden';
window.scrollTo(0, 0);
return () => {
html.style.overflow = '';
body.style.overflow = '';
};
}, []);
const handleResizeMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
resizingRef.current = true;
const handleMouseMove = (ev: MouseEvent) => {
if (!resizingRef.current || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const pct = ((ev.clientX - rect.left) / rect.width) * 100;
setEditorWidthPct(Math.max(20, Math.min(80, pct)));
};
const handleMouseUp = () => {
resizingRef.current = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}, []);
const handleBottomPanelResizeMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
const startY = e.clientY;
const startHeight = bottomPanelHeight;
const onMove = (ev: MouseEvent) => {
const delta = startY - ev.clientY;
setBottomPanelHeight(
Math.max(BOTTOM_PANEL_MIN, Math.min(BOTTOM_PANEL_MAX, startHeight + delta)),
);
};
const onUp = () => {
document.body.style.cursor = '';
document.body.style.userSelect = '';
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.body.style.cursor = 'row-resize';
document.body.style.userSelect = 'none';
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
},
[bottomPanelHeight],
);
const handleExplorerResizeMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
const startX = e.clientX;
const startWidth = explorerWidth;
const onMove = (ev: MouseEvent) => {
const delta = ev.clientX - startX;
setExplorerWidth(Math.max(EXPLORER_MIN, Math.min(EXPLORER_MAX, startWidth + delta)));
};
const onUp = () => {
document.body.style.cursor = '';
document.body.style.userSelect = '';
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
},
[explorerWidth],
);
return (
<div className="app">
<AppHeader autoSave={autoSave} />
{/* ── Mobile tab bar (top, above panels) ── */}
{isMobile && (
<nav className="mobile-tab-bar">
<button
className={`mobile-tab-btn${mobileView === 'code' ? ' mobile-tab-btn--active' : ''}`}
onClick={() => setMobileView('code')}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
<span>&lt;/&gt; {t('editor.shell.code')}</span>
</button>
<button
className={`mobile-tab-btn${mobileView === 'circuit' ? ' mobile-tab-btn--active' : ''}`}
onClick={() => setMobileView('circuit')}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="2" y="7" width="20" height="14" rx="2" />
<path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2" />
<line x1="12" y1="12" x2="12" y2="16" />
<line x1="10" y1="14" x2="14" y2="14" />
</svg>
<span>{t('editor.shell.circuit')}</span>
</button>
</nav>
)}
{/* ── Unified top toolbar (desktop only) ──
Editor controls + canvas controls share a single full-width row so
the bar doesn't reflow when the editor/canvas splitter is dragged.
The canvas controls (board selector, Serial, Scope, zoom, Add) are
portaled into `canvasHeaderSlot` from inside SimulatorCanvas. */}
{!isMobile && (
<div className="unified-toolbar">
<button
className="explorer-toggle-btn unified-toolbar-explorer-toggle"
onClick={() => setExplorerOpen((v) => !v)}
title={explorerOpen ? 'Hide file explorer' : 'Show file explorer'}
>
<svg
width="16"
height="16"
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" />
</svg>
</button>
{/* View-mode toggle: Code / Both / Circuit. Lets users hide a
pane to give the right-docked AI chat more breathing room.
Hidden on mobile — there's already a code/circuit toggle in
the mobile bottom-nav. */}
<div
role="group"
aria-label={t('editor.shell.viewMode')}
className="view-mode-toggle"
style={{
display: 'flex',
gap: 1,
background: '#252526',
border: '1px solid #3c3c3c',
borderRadius: 4,
overflow: 'hidden',
alignSelf: 'center',
margin: '0 6px',
}}
>
{(
[
{ key: 'code', label: t('editor.shell.code'), path: 'M16 18l6-6-6-6M8 6l-6 6 6 6' },
{ key: 'both', label: t('editor.shell.both'), path: 'M3 3h7v18H3zM14 3h7v18h-7z' },
{ key: 'circuit', label: t('editor.shell.circuit'), path: 'M5 12h14M12 5v14' },
] as const
).map((m) => (
<button
key={m.key}
onClick={() => setViewMode(m.key)}
aria-pressed={viewMode === m.key}
style={{
background: viewMode === m.key ? '#0e639c' : 'transparent',
color: viewMode === m.key ? 'white' : '#aaa',
border: 'none',
height: 28,
padding: '0 10px',
display: 'flex',
alignItems: 'center',
gap: 4,
cursor: 'pointer',
fontSize: 12,
fontFamily: 'inherit',
}}
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d={m.path} />
</svg>
<span>{m.label}</span>
</button>
))}
</div>
<div className="unified-toolbar-editor">
<EditorToolbar
consoleOpen={consoleOpen}
setConsoleOpen={setConsoleOpen}
compileLogs={compileLogs}
setCompileLogs={setCompileLogs}
centerSlot={!isLinuxPi ? <FileTabs /> : null}
/>
</div>
<div className="unified-toolbar-canvas" ref={setCanvasHeaderSlot} />
</div>
)}
<div className="app-container" ref={containerRef}>
{/* ── Editor side ── */}
<div
className="editor-panel"
style={{
width: isMobile
? '100%'
: viewMode === 'code'
? '100%'
: viewMode === 'circuit'
? '0%'
: `${editorWidthPct}%`,
display:
(isMobile && mobileView !== 'code') || (!isMobile && viewMode === 'circuit')
? 'none'
: 'flex',
flexDirection: 'row',
}}
>
{/* File explorer sidebar + resize handle */}
{explorerOpen && (
<>
<div
style={{ width: explorerWidth, flexShrink: 0, display: 'flex', overflow: 'hidden' }}
>
<FileExplorer onSaveClick={handleSaveClick} onNewClick={handleNewClick} />
</div>
{!isMobile && (
<div
className="explorer-resize-handle"
onMouseDown={handleExplorerResizeMouseDown}
/>
)}
</>
)}
{/* Editor main area */}
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
minWidth: 0,
}}
>
{/* Mobile-only: explorer toggle + editor toolbar inside the panel.
On desktop these are hoisted into the unified top toolbar. */}
{isMobile && (
<div style={{ display: 'flex', alignItems: 'stretch', flexShrink: 0 }}>
<button
className="explorer-toggle-btn"
onClick={() => setExplorerOpen((v) => !v)}
title={explorerOpen ? 'Hide file explorer' : 'Show file explorer'}
>
<svg
width="16"
height="16"
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" />
</svg>
</button>
<div style={{ flex: 1, minWidth: 0 }}>
<EditorToolbar
consoleOpen={consoleOpen}
setConsoleOpen={setConsoleOpen}
compileLogs={compileLogs}
setCompileLogs={setCompileLogs}
centerSlot={!isLinuxPi ? <FileTabs /> : null}
/>
</div>
</div>
)}
{/* Editor area: Pi workspace or Monaco editor */}
<div className="editor-wrapper" style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
{isLinuxPi && activeBoardId ? (
<Suspense
fallback={
<div style={{ color: '#666', padding: 16, fontSize: 12 }}>
Loading Pi workspace
</div>
}
>
<RaspberryPiWorkspace boardId={activeBoardId} />
</Suspense>
) : (
<CodeEditor />
)}
</div>
{/* Console */}
{consoleOpen && (
<>
<div
onMouseDown={handleBottomPanelResizeMouseDown}
style={resizeHandleStyle}
title={t('editor.shell.dragResize')}
/>
<div style={{ height: bottomPanelHeight, flexShrink: 0 }}>
<CompilationConsole
isOpen={consoleOpen}
onClose={() => setConsoleOpen(false)}
logs={compileLogs}
onClear={() => setCompileLogs([])}
/>
</div>
</>
)}
</div>
</div>
{/* Resize handle (desktop only, and only when both panes are visible) */}
{!isMobile && viewMode === 'both' && (
<div className="resize-handle" onMouseDown={handleResizeMouseDown}>
<div className="resize-handle-grip" />
</div>
)}
{/* ── Simulator side ── */}
<div
className="simulator-panel"
style={{
width: isMobile
? '100%'
: viewMode === 'circuit'
? '100%'
: viewMode === 'code'
? '0%'
: `${100 - editorWidthPct}%`,
display:
(isMobile && mobileView !== 'circuit') || (!isMobile && viewMode === 'code')
? 'none'
: 'flex',
flexDirection: 'column',
}}
>
<div style={{ flex: 1, overflow: 'hidden', position: 'relative', minHeight: 0 }}>
<SimulatorCanvas headerSlot={!isMobile ? canvasHeaderSlot : null} />
</div>
{serialMonitorOpen && (
<>
<div
onMouseDown={handleBottomPanelResizeMouseDown}
style={resizeHandleStyle}
title={t('editor.shell.dragResize')}
/>
<div style={{ height: bottomPanelHeight, flexShrink: 0 }}>
<SerialMonitor />
</div>
</>
)}
{oscilloscopeOpen && (
<>
<div
onMouseDown={handleBottomPanelResizeMouseDown}
style={resizeHandleStyle}
title={t('editor.shell.dragResize')}
/>
<div style={{ height: bottomPanelHeight, flexShrink: 0 }}>
<Oscilloscope />
</div>
</>
)}
</div>
</div>
{showStarBanner && (
<GitHubStarBanner
onClose={handleDismissStarBanner}
onStarClick={handleStarClick}
round={starRound}
/>
)}
{/* Slot reserved for the private pro overlay (e.g. agent chat panel).
Self-hosted builds without an overlay see nothing here. */}
<div data-velxio-slot="agent-chat" />
</div>
);
};