/** * Native menubar event bridge. * * The Tauri shell (pro/desktop/src-tauri/src/menu.rs in velxio-prod) * builds a Velxio / File / Edit / View / Help menubar. Internal items * (Save .vlx, Open .vlx, Toggle Serial Monitor, Find, …) emit a * `velxio://menu` event with `{ action: '' }`. URL items (Docs, * Examples, Discord, GitHub) are opened directly from Rust and don't * reach this listener. * * Actions handled directly here (no further plumbing needed): * - save-vlx, open-vlx → triggerDownloadVlx / file picker * - toggle-serial-monitor → useSimulatorStore.toggleSerialMonitor() * - check-for-updates → tauri-plugin-updater check() * * Actions forwarded to whoever's listening as a window CustomEvent * `velxio:menu:`: * - new-project, find-in-editor, toggle-file-explorer * * No-op outside Tauri (e.g. running the bundle in a regular browser * for debugging) — listen() returns a no-op when the global event * API isn't present. */ import { listen } from './tauriBridge'; import { dlog } from './log'; import { triggerDownloadVlx, importVlxFile } from '../utils/vlxFile'; import { useSimulatorStore } from '../store/useSimulatorStore'; import { useEditorStore } from '../store/useEditorStore'; import { useProjectStore } from '../store/useProjectStore'; import { useCompileLogsStore } from '../store/useCompileLogsStore'; import { showMessageDialog } from '../store/useMessageDialogStore'; import { switchLocale } from '../i18n/path'; import { LOCALES, type Locale } from '../i18n/config'; type MenuAction = | 'new-project' | 'save-vlx' | 'open-vlx' | 'find-in-editor' | 'toggle-file-explorer' | 'toggle-serial-monitor' | 'check-for-updates' | 'set-locale' | 'navigate-route'; interface MenuEventPayload { action: MenuAction; // Only present when action='set-locale'. Matches an entry in // i18n/config.ts::LOCALES. locale?: string; // Only present when action='navigate-route'. Absolute pathname // (e.g. '/examples', '/docs', '/about') — navigated via React // Router so the current locale prefix gets applied. route?: string; } let installed = false; export async function installDesktopMenuListener(): Promise { if (installed) return; installed = true; await listen('velxio://menu', (event) => { dlog('menu event', event.payload); void handle(event.payload.action, event.payload); }); } async function handle(action: MenuAction, payload?: MenuEventPayload): Promise { switch (action) { case 'save-vlx': triggerDownloadVlx(); return; case 'open-vlx': pickAndImportVlx(); return; case 'toggle-serial-monitor': useSimulatorStore.getState().toggleSerialMonitor(); return; case 'new-project': newProject(); return; case 'find-in-editor': case 'toggle-file-explorer': window.dispatchEvent(new CustomEvent(`velxio:menu:${action}`)); return; case 'check-for-updates': await checkForUpdates(); return; case 'set-locale': if (payload?.locale) setLocale(payload.locale); return; case 'navigate-route': if (payload?.route) navigateTo(payload.route); return; } } function navigateTo(route: string): void { // Prefix with the current locale if we're on a non-default one, // so `/examples` from `/es/editor` lands at `/es/examples` instead // of switching back to English. Mirrors how LanguageSwitcher does it. const cur = window.location.pathname; const localeMatch = cur.match(/^\/([a-z]{2}(?:-[a-z]{2})?)\b/); const prefix = localeMatch && LOCALES.includes(localeMatch[1] as Locale) ? `/${localeMatch[1]}` : ''; const normalised = route.startsWith('/') ? route : `/${route}`; const next = `${prefix}${normalised}`; if (next === cur) return; // history.pushState + popstate keeps React Router happy without // reloading the SPA (Monaco state, simulator state, sidecar // connection all preserved). window.history.pushState(null, '', next); window.dispatchEvent(new PopStateEvent('popstate')); } function setLocale(locale: string): void { // Defensive: ignore unknown locales coming from the menu so a // stale shell doesn't navigate to a broken URL. if (!(LOCALES as readonly string[]).includes(locale)) { dlog('set-locale: ignoring unknown locale', { locale }); return; } const target = locale as Locale; const next = switchLocale(window.location.pathname, target) + window.location.search + window.location.hash; if (next === window.location.pathname + window.location.search + window.location.hash) { return; } // history.pushState + popstate lets React Router pick the change up // without a full reload, preserving the editor state. Reload would // re-spawn the sidecar handshake and lose Monaco/sim state for ~5s. window.history.pushState(null, '', next); window.dispatchEvent(new PopStateEvent('popstate')); } function pickAndImportVlx(): void { const input = document.createElement('input'); input.type = 'file'; input.accept = '.vlx,application/json'; input.style.display = 'none'; document.body.appendChild(input); input.addEventListener('change', async () => { const file = input.files?.[0]; if (file) { try { await importVlxFile(file); } catch (err) { showMessageDialog(`Failed to open .vlx: ${(err as Error).message}`, { kind: 'error', }); } } document.body.removeChild(input); }); input.click(); } /** * Wipe the current workspace and start from the default Blink sketch + * empty canvas. Issue #210: the menu action used to just dispatch a * CustomEvent that nobody listened to. * * Confirms first if the user has unsaved changes (any modified file * or any component on the canvas). The Tauri-side menu can't show a * confirm dialog cheaply, so we use the browser-native `confirm()` * here — fine for the desktop bundle where it renders as a modal. */ function newProject(): void { const sim = useSimulatorStore.getState(); const editor = useEditorStore.getState(); const project = useProjectStore.getState(); const compileLogs = useCompileLogsStore.getState(); const hasWork = sim.boards.length > 0 || sim.components.length > 0 || sim.wires.length > 0 || editor.files.some((f) => f.modified) || project.currentProject !== null; if (hasWork) { const ok = window.confirm( 'Start a new project? Any unsaved changes will be lost.', ); if (!ok) return; } // Stop any running simulation first so workers / bridges shut down // cleanly. Idempotent — no-op if nothing is running. if (sim.running) { sim.stopSimulation(); } // Drop every board (also disconnects its bridges + removes wires // touching it). Iterate over a snapshot copy since removeBoard // mutates the array. for (const board of [...sim.boards]) { sim.removeBoard(board.id); } // Any non-board components + wires that weren't connected to a // board still need to go. sim.setComponents([]); sim.setWires([]); // Reset the editor to the default Blink sketch. loadFiles takes // a {name, content}[] and rebuilds the file list, picking the // first .ino as active. editor.loadFiles([ { name: 'sketch.ino', content: '// Arduino Blink Example\nvoid setup() {\n pinMode(LED_BUILTIN, OUTPUT);\n}\n\nvoid loop() {\n digitalWrite(LED_BUILTIN, HIGH);\n delay(1000);\n digitalWrite(LED_BUILTIN, LOW);\n delay(1000);\n}\n', }, ]); // Drop project metadata so the next Save .vlx doesn't reuse the // previous project's slug / name. project.clearCurrentProject(); // Clear the compile output panel so old build logs don't carry over. compileLogs.clear(); // Leave whatever project URL we were on: staying there would reload the // OLD project over this fresh workspace on refresh. replaceState (not // navigateTo's pushState) so the back button can't pop to the stale // project URL either; the popstate dispatch lets React Router render // the plain editor route. const cur = window.location.pathname; const localeMatch = cur.match(/^\/([a-z]{2}(?:-[a-z]{2})?)\b/); const prefix = localeMatch && LOCALES.includes(localeMatch[1] as Locale) ? `/${localeMatch[1]}` : ''; const editorPath = `${prefix}/editor`; if (cur !== editorPath) { window.history.replaceState(null, '', editorPath); window.dispatchEvent(new PopStateEvent('popstate')); } } async function checkForUpdates(): Promise { try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const updater = (window as any).__TAURI__?.updater; if (!updater?.check) { showMessageDialog('Update plugin not available in this build.'); return; } const update = await updater.check(); if (update) { await update.downloadAndInstall(); } else { showMessageDialog('Velxio Desktop is up to date.', { kind: 'success' }); } } catch (err) { showMessageDialog(`Update check failed: ${(err as Error).message}`, { kind: 'error', }); } }