store.svelte.ts 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  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. // The window's close button, with unsaved changes: Go waits for us.
  103. const offClose = Events.On(EventNames.Close, async () => {
  104. if (ui.confirm) return; // already asking something
  105. const ok = await confirm({
  106. title: "Close without exporting?",
  107. message: "This project has changes that have not been exported. They will be lost.",
  108. items: [],
  109. action: "Close",
  110. });
  111. if (ok) await Service.CloseWindow();
  112. });
  113. return () => {
  114. offState();
  115. offError();
  116. offClose();
  117. };
  118. }
  119. /** Shows the confirm dialog and resolves with the user's answer. */
  120. export function confirm(c: Omit<Confirm, "resolve">): Promise<boolean> {
  121. return new Promise((resolve) => {
  122. ui.confirm = {
  123. ...c,
  124. resolve: (ok) => {
  125. ui.confirm = null;
  126. resolve(ok);
  127. },
  128. };
  129. });
  130. }
  131. async function exportWith(
  132. choose: () => Promise<string>,
  133. write: (target: string, overwrite: boolean) => Promise<ExportResult>,
  134. ): Promise<void> {
  135. try {
  136. const target = await choose();
  137. if (!target) return; // dialog cancelled
  138. let res = await write(target, false);
  139. if (res.exists?.length) {
  140. const ok = await confirm({
  141. title: "Replace existing files?",
  142. message: "These already exist and will be replaced:",
  143. items: res.exists,
  144. action: "Replace",
  145. });
  146. if (!ok) return;
  147. res = await write(target, true);
  148. }
  149. app.error = "";
  150. app.notice = "Exported to " + res.path;
  151. await refresh();
  152. } catch (e) {
  153. app.error = message(e);
  154. }
  155. }
  156. export function exportFolder(): Promise<void> {
  157. return exportWith(() => Service.ChooseExportFolder(), (dir, overwrite) => Service.ExportFolder(dir, overwrite));
  158. }
  159. export function exportZip(): Promise<void> {
  160. return exportWith(() => Service.ChooseZipPath(), (path, overwrite) => Service.ExportZip(path, overwrite));
  161. }