// Go is the single source of truth: every service call returns a fresh State // and the store replaces its copy. UI-only state (tab, bank, selection, // dialogs) lives here too and never goes to Go (build plan 4.6). import { Events } from "@wailsio/runtime"; import { EventNames, Service, type ExportResult, type State } from "./api"; import { stopAll } from "./audio"; export type Tab = "pads" | "sequences"; export const app = $state({ state: null as State | null, error: "", // shown until dismissed or the next successful call notice: "", // short success message, e.g. where an export went }); export type Confirm = { title: string; message: string; items: string[]; action: string; // label of the confirming button resolve: (ok: boolean) => void; }; /** Open "Move to pad" / "Copy to pad" picker. */ export type Picker = { kind: "move" | "copy"; from: number }; export const ui = $state({ tab: "pads" as Tab, padBank: 0, padSlot: 0, seqBank: 0, seqSlot: 0, aboutOpen: false, confirm: null as Confirm | null, picker: null as Picker | null, }); export function message(e: unknown): string { return e instanceof Error ? e.message : String(e); } /** * Runs a service call that returns State. Go returns the state even when * the edit fails, but a rejected call loses it, so fetch it again then. */ export async function run(call: () => Promise): Promise { try { app.state = await call(); app.error = ""; return true; } catch (e) { app.error = message(e); try { app.state = await Service.State(); } catch { // keep the old state; the error is already shown } return false; } } /** Fetches the current project from Go. */ export function refresh(): Promise { return run(() => Service.State()); } /** Asks before throwing away unsaved changes. True means go ahead. */ async function discardOK(): Promise { if (!app.state?.dirty) return true; return confirm({ title: "Discard unsaved changes?", message: "This project has changes that have not been exported. They will be lost.", items: [], action: "Discard", }); } function resetSelection() { ui.padBank = ui.padSlot = ui.seqBank = ui.seqSlot = 0; } /** Starts a new, empty project. */ export async function newProject(): Promise { if (!(await discardOK())) return; stopAll(); app.notice = ""; if (await run(() => Service.New())) resetSelection(); } /** Asks for a project file and opens it. */ export async function loadProject(): Promise { if (!(await discardOK())) return; try { const res = await Service.Load(); if (!res.path) return; // dialog cancelled stopAll(); app.state = res.state; resetSelection(); app.error = res.warnings?.length ? "Loaded with warnings:\n" + res.warnings.join("\n") : ""; app.notice = "Loaded " + res.path; } catch (e) { app.error = message(e); } } /** Listens for state and errors Go sends on its own (e.g. after a file drop). */ export function listen(): () => void { const offState = Events.On(EventNames.State, (e) => { app.state = e.data; app.error = ""; }); const offError = Events.On(EventNames.Error, (e) => { app.error = e.data; }); // The window's close button, with unsaved changes: Go waits for us. const offClose = Events.On(EventNames.Close, async () => { if (ui.confirm) return; // already asking something const ok = await confirm({ title: "Close without exporting?", message: "This project has changes that have not been exported. They will be lost.", items: [], action: "Close", }); if (ok) await Service.CloseWindow(); }); return () => { offState(); offError(); offClose(); }; } /** Shows the confirm dialog and resolves with the user's answer. */ export function confirm(c: Omit): Promise { return new Promise((resolve) => { ui.confirm = { ...c, resolve: (ok) => { ui.confirm = null; resolve(ok); }, }; }); } async function exportWith( choose: () => Promise, write: (target: string, overwrite: boolean) => Promise, ): Promise { try { const target = await choose(); if (!target) return; // dialog cancelled let res = await write(target, false); if (res.exists?.length) { const ok = await confirm({ title: "Replace existing files?", message: "These already exist and will be replaced:", items: res.exists, action: "Replace", }); if (!ok) return; res = await write(target, true); } app.error = ""; app.notice = "Exported to " + res.path; await refresh(); } catch (e) { app.error = message(e); } } export function exportFolder(): Promise { return exportWith(() => Service.ChooseExportFolder(), (dir, overwrite) => Service.ExportFolder(dir, overwrite)); } export function exportZip(): Promise { return exportWith(() => Service.ChooseZipPath(), (path, overwrite) => Service.ExportZip(path, overwrite)); }