store.svelte.ts 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. // Go is the single source of truth: every service call returns a fresh State
  2. // and the store replaces its copy. UI-only state (tab, bank, selection,
  3. // dialogs) lives here too and never goes to Go (build plan 4.6).
  4. import { Events } from "@wailsio/runtime";
  5. import { EventNames, Service, type ExportResult, type State } from "./api";
  6. import { stopAll } from "./audio";
  7. export type Tab = "pads" | "sequences";
  8. export const app = $state({
  9. state: null as State | null,
  10. error: "", // shown until dismissed or the next successful call
  11. notice: "", // short success message, e.g. where an export went
  12. });
  13. export type Confirm = {
  14. title: string;
  15. message: string;
  16. items: string[];
  17. action: string; // label of the confirming button
  18. resolve: (ok: boolean) => void;
  19. };
  20. /** Open "Move to pad" / "Copy to pad" picker. */
  21. export type Picker = { kind: "move" | "copy"; from: number };
  22. export const ui = $state({
  23. tab: "pads" as Tab,
  24. padBank: 0,
  25. padSlot: 0,
  26. seqBank: 0,
  27. seqSlot: 0,
  28. aboutOpen: false,
  29. confirm: null as Confirm | null,
  30. picker: null as Picker | null,
  31. });
  32. export function message(e: unknown): string {
  33. return e instanceof Error ? e.message : String(e);
  34. }
  35. /**
  36. * Runs a service call that returns State. Go returns the state even when
  37. * the edit fails, but a rejected call loses it, so fetch it again then.
  38. */
  39. export async function run(call: () => Promise<State>): Promise<boolean> {
  40. try {
  41. app.state = await call();
  42. app.error = "";
  43. return true;
  44. } catch (e) {
  45. app.error = message(e);
  46. try {
  47. app.state = await Service.State();
  48. } catch {
  49. // keep the old state; the error is already shown
  50. }
  51. return false;
  52. }
  53. }
  54. /** Fetches the current project from Go. */
  55. export function refresh(): Promise<boolean> {
  56. return run(() => Service.State());
  57. }
  58. /** Asks before throwing away unsaved changes. True means go ahead. */
  59. async function discardOK(): Promise<boolean> {
  60. if (!app.state?.dirty) return true;
  61. return confirm({
  62. title: "Discard unsaved changes?",
  63. message: "This project has changes that have not been exported. They will be lost.",
  64. items: [],
  65. action: "Discard",
  66. });
  67. }
  68. function resetSelection() {
  69. ui.padBank = ui.padSlot = ui.seqBank = ui.seqSlot = 0;
  70. }
  71. /** Starts a new, empty project. */
  72. export async function newProject(): Promise<void> {
  73. if (!(await discardOK())) return;
  74. stopAll();
  75. app.notice = "";
  76. if (await run(() => Service.New())) resetSelection();
  77. }
  78. /** Asks for a project file and opens it. */
  79. export async function loadProject(): Promise<void> {
  80. if (!(await discardOK())) return;
  81. try {
  82. const res = await Service.Load();
  83. if (!res.path) return; // dialog cancelled
  84. stopAll();
  85. app.state = res.state;
  86. resetSelection();
  87. app.error = res.warnings?.length ? "Loaded with warnings:\n" + res.warnings.join("\n") : "";
  88. app.notice = "Loaded " + res.path;
  89. } catch (e) {
  90. app.error = message(e);
  91. }
  92. }
  93. /** Listens for state and errors Go sends on its own (e.g. after a file drop). */
  94. export function listen(): () => void {
  95. const offState = Events.On(EventNames.State, (e) => {
  96. app.state = e.data;
  97. app.error = "";
  98. });
  99. const offError = Events.On(EventNames.Error, (e) => {
  100. app.error = e.data;
  101. });
  102. return () => {
  103. offState();
  104. offError();
  105. };
  106. }
  107. /** Shows the confirm dialog and resolves with the user's answer. */
  108. export function confirm(c: Omit<Confirm, "resolve">): Promise<boolean> {
  109. return new Promise((resolve) => {
  110. ui.confirm = {
  111. ...c,
  112. resolve: (ok) => {
  113. ui.confirm = null;
  114. resolve(ok);
  115. },
  116. };
  117. });
  118. }
  119. async function exportWith(
  120. choose: () => Promise<string>,
  121. write: (target: string, overwrite: boolean) => Promise<ExportResult>,
  122. ): Promise<void> {
  123. try {
  124. const target = await choose();
  125. if (!target) return; // dialog cancelled
  126. let res = await write(target, false);
  127. if (res.exists?.length) {
  128. const ok = await confirm({
  129. title: "Replace existing files?",
  130. message: "These already exist and will be replaced:",
  131. items: res.exists,
  132. action: "Replace",
  133. });
  134. if (!ok) return;
  135. res = await write(target, true);
  136. }
  137. app.error = "";
  138. app.notice = "Exported to " + res.path;
  139. await refresh();
  140. } catch (e) {
  141. app.error = message(e);
  142. }
  143. }
  144. export function exportFolder(): Promise<void> {
  145. return exportWith(() => Service.ChooseExportFolder(), (dir, overwrite) => Service.ExportFolder(dir, overwrite));
  146. }
  147. export function exportZip(): Promise<void> {
  148. return exportWith(() => Service.ChooseZipPath(), (path, overwrite) => Service.ExportZip(path, overwrite));
  149. }