velxio/frontend/src/desktop/menu.ts

265 lines
9.0 KiB
TypeScript

/**
* 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: '<id>' }`. 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:<action>`:
* - 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<void> {
if (installed) return;
installed = true;
await listen<MenuEventPayload>('velxio://menu', (event) => {
dlog('menu event', event.payload);
void handle(event.payload.action, event.payload);
});
}
async function handle(action: MenuAction, payload?: MenuEventPayload): Promise<void> {
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<void> {
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',
});
}
}