Преглед изворни кода

M8: missing-file badges, close prompt, checkpoint B checklist

Mark pads whose sample file is missing (grid badge, panel path, footer
count; export already refuses). Ask before closing the window with
unsaved changes. Add docs/checkpoint-b.md with the five device
round-trip projects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude пре 1 дан
родитељ
комит
f99f652c05

+ 1 - 1
docs/build-plan.md

@@ -244,7 +244,7 @@ Conclusions:
 
 Not yet tested: sample rates above 96 kHz or below 22.05 kHz; 64-bit float (refused in 1.0). The extensible header that many audio tools write was confirmed in checkpoint A (PCM inside, 16 and 24-bit, mono and stereo); mpckit accepts it when it declares PCM or float inside.
 
-**B (after M8), round trips through the app:**
+**B (after M8), round trips through the app:** step-by-step projects and a results table in `docs/checkpoint-b.md`.
 1. Load `DemoFile` (recorded notes), change nothing, export: sequences still play the same on the device.
 2. Load `DemoFile`, move A1 to A5 after the warning: A5 plays the kick, the notes still point at A1.
 3. Copy a pad across banks; swap two pads.

+ 60 - 0
docs/checkpoint-b.md

@@ -0,0 +1,60 @@
+# Device checkpoint B: round trips through the app
+
+Build plan section 7, after M8. Each project below starts from `testdata\device\DemoFile.xpj`, a project the device saved, and is changed and exported **in the app**. Then it is loaded on the MPC Sample.
+
+DemoFile, for reference:
+- Pads: A01 kick, A02 snare, A03 hat 1, A04 hat 2 (A03 and A04 in mute group 1), B01 pad loop, B02 bass.
+- Sequence 01 "Intro" (110 BPM, 2 bars, notes on A01–A03) and Sequence 02 (110 BPM, 4 bars, notes on A01–A04).
+
+For each project: **Load** DemoFile, make the changes, set the project name, then **Export to folder** (straight onto the card) or **Export zip**. Load DemoFile fresh for each one; don't build them on top of each other.
+
+## Projects
+
+### MpckitB1: no changes (B1)
+
+Only rename to `MpckitB1`.
+
+On the device: both sequences play exactly as DemoFile does.
+
+### MpckitB2: move a pad that has notes (B2)
+
+Drag **A01 → A05**. The app warns that A01's notes stay on A01; confirm.
+
+On the device: A05 plays the kick. In the sequences the kick is gone, because the notes still point at A01, which is now empty.
+
+### MpckitB3: copy across banks, swap pads with notes (B3)
+
+1. **Alt**-drag **B01 → C01** (copy into another bank: drag over the **C** letter first).
+2. Drag **A02 onto A03** (swap; the app warns about both pads' notes; confirm).
+
+On the device:
+- C01 plays the same pad loop as B01, and B01 still plays.
+- A02 plays hat 1 and A03 plays the snare. In the sequences, the snare and hat 1 parts have changed places.
+
+### MpckitB4: remove the current sequence, add one in another bank (B4)
+
+1. Sequences tab: select **A01 "Intro"** → **Remove sequence** → confirm.
+2. Select slot **B01** (sequence 17) → **Add sequence** → name `Bridge`, BPM `100`, length `4`.
+
+On the device:
+- The list shows Sequence 02 and Bridge (slot 17), and no Intro.
+- **Which sequence is selected when the project opens?** Intro was the current sequence; the app made the lowest remaining slot (Sequence 02) current. This answers open question 7 in the format doc (is `currentSequence` a slot or a list position?).
+- Bridge is empty, 4 bars, 100 BPM.
+
+### MpckitB5: shorten a sequence that has notes (B5)
+
+Sequence 02: length **4 → 2** bars (the app asks first; confirm).
+
+On the device:
+- Sequence 02 is 2 bars and plays the notes in bars 1–2.
+- **What happened to the notes from bars 3–4?** Lengthen it back to 4 bars on the device: do they come back, or are they gone?
+
+## Results
+
+| Project | Loads | Result |
+|---|---|---|
+| MpckitB1 | | |
+| MpckitB2 | | |
+| MpckitB3 | | |
+| MpckitB4 | | Sequence selected on open: |
+| MpckitB5 | | Notes past the new end: |

+ 5 - 0
frontend/bindings/events/index.ts

@@ -8,6 +8,11 @@ import type {} from "../github.com/wailsapp/wails/v3/internal/eventdata.js";
 // Make sure event data creation code is registered with the runtime.
 import "../github.com/wailsapp/wails/v3/internal/eventcreate.js";
 
+/**
+ * Name of the custom event "close".
+ */
+export const Close = "close";
+
 /**
  * Name of the custom event "error".
  */

+ 12 - 0
frontend/bindings/git.monoexp.com/bajdev/mpckit/internal/app/models.ts

@@ -84,6 +84,13 @@ export interface Pad {
      */
     "start": number;
     "end": number;
+
+    /**
+     * Source is where the sample is copied from at export; Missing is set
+     * when that file no longer exists.
+     */
+    "source": string;
+    "missing": boolean;
 }
 
 /**
@@ -119,6 +126,11 @@ export interface State {
      */
     "sequences": Sequence[] | null;
     "sampleCount": number;
+
+    /**
+     * pool files whose source is missing
+     */
+    "missingCount": number;
     "sequenceCount": number;
     "limits": Limits;
 }

+ 8 - 0
frontend/bindings/git.monoexp.com/bajdev/mpckit/internal/app/service.ts

@@ -66,6 +66,14 @@ export function ClearPad(slot: number): $CancellablePromise<$models.State> {
     return $Call.ByID(3159325479, slot);
 }
 
+/**
+ * CloseWindow closes the app without asking about unsaved changes. The
+ * frontend calls it after the user chose to discard them.
+ */
+export function CloseWindow(): $CancellablePromise<void> {
+    return $Call.ByID(2314271135);
+}
+
 /**
  * CopyPad copies the pad in from to slot to, replacing what was there.
  */

+ 1 - 0
frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts

@@ -12,6 +12,7 @@ import type * as app$0 from "../../../../../git.monoexp.com/bajdev/mpckit/intern
 declare module "@wailsio/runtime" {
     namespace Events {
         interface CustomEvents {
+            "close": void;
             "error": string;
             "state": app$0.State;
         }

+ 9 - 2
frontend/src/App.svelte

@@ -40,13 +40,16 @@
     st
       ? gridSlots(ui.padBank).map((slot) => {
           const p = pads[slot];
-          const badges = [tuneText(p.coarse, p.fine), p.muteGroup ? "M" + p.muteGroup : "", p.hasNotes ? "notes" : ""].filter(Boolean);
+          const badges = p.missing
+            ? ["missing"]
+            : [tuneText(p.coarse, p.fine), p.muteGroup ? "M" + p.muteGroup : "", p.hasNotes ? "notes" : ""].filter(Boolean);
           return {
             slot,
             label: p.label,
             name: p.name,
             meta: p.file ? badges.join(" · ") : "",
-            aria: `Pad ${p.label}, ${p.file || "empty"}${p.hasNotes ? ", has recorded notes" : ""}`,
+            aria: `Pad ${p.label}, ${p.file || "empty"}${p.missing ? ", sample file missing" : ""}${p.hasNotes ? ", has recorded notes" : ""}`,
+            warn: p.missing,
           };
         })
       : [],
@@ -185,6 +188,7 @@
       <span>{st.sampleCount} of {st.limits.maxSamples} samples</span>
       <span>{st.sequenceCount} of {st.limits.maxSequences} sequences</span>
       <span>WAV files only</span>
+      {#if st.missingCount}<span class="missing">{st.missingCount} sample {st.missingCount === 1 ? "file" : "files"} missing</span>{/if}
       {#if st.dirty}<span class="dirty">Unsaved changes: export to keep them</span>{/if}
       {#if app.notice}<span class="notice" title={app.notice}>{app.notice}</span>{/if}
     </footer>
@@ -314,6 +318,9 @@
   .dirty {
     color: var(--accent);
   }
+  .missing {
+    color: #ff8a8a;
+  }
   .notice {
     margin-left: auto;
     min-width: 0;

+ 21 - 1
frontend/src/lib/PadPanel.svelte

@@ -47,8 +47,15 @@
     </div>
   {:else}
     <div class="stack">
+      {#if pad.missing}
+        <div class="missing" role="status">
+          <strong>Sample file not found.</strong>
+          <span class="mono">{pad.source}</span>
+          <span>Replace it, or put the file back and load the project again. Export is blocked until then.</span>
+        </div>
+      {/if}
       <div class="row">
-        <button type="button" class="btn primary" onclick={() => preview(pad)}>
+        <button type="button" class="btn primary" disabled={pad.missing} onclick={() => preview(pad)}>
           <svg aria-hidden="true" width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M3 2 L12 7 L3 12 Z" /></svg>
           Play
         </button>
@@ -143,6 +150,19 @@
   .notes {
     color: var(--accent);
   }
+  .missing {
+    display: flex;
+    flex-direction: column;
+    gap: 4px;
+    padding: 10px 12px;
+    border: 1px solid #e5484d;
+    border-radius: 6px;
+    color: #ffb4b4;
+    font-size: 12px;
+  }
+  .missing .mono {
+    overflow-wrap: anywhere;
+  }
   .drop:global(.file-drop-target-active) {
     border-color: var(--accent);
     background: rgba(245, 165, 36, 0.08);

+ 5 - 0
frontend/src/lib/SlotGrid.svelte

@@ -5,6 +5,7 @@
     name: string; // sample or sequence name; empty for an empty slot
     meta: string; // small line under the name
     aria: string;
+    warn?: boolean; // something is wrong with this slot, e.g. its sample is missing
   };
 </script>
 
@@ -52,6 +53,7 @@
     <button
       type="button"
       class:filled={c.name !== ""}
+      class:warn={c.warn}
       class:on={c.slot === selected}
       class:over={c.slot === over}
       class:copying={c.slot === over && copying}
@@ -113,6 +115,9 @@
   button.on {
     border-color: var(--accent);
   }
+  button.warn .meta {
+    color: #ff8a8a;
+  }
   /* A pad dragged over this cell: dashed for move or swap, solid for copy. */
   button.over {
     border-style: dashed;

+ 12 - 0
frontend/src/lib/store.svelte.ts

@@ -112,9 +112,21 @@ export function listen(): () => void {
   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();
   };
 }
 

+ 45 - 1
internal/app/app.go

@@ -8,6 +8,7 @@ import (
 	"encoding/hex"
 	"errors"
 	"fmt"
+	"os"
 	"path/filepath"
 	"strings"
 	"sync"
@@ -40,6 +41,10 @@ type Pad struct {
 	// plays only this region, as the device does.
 	Start int64 `json:"start"`
 	End   int64 `json:"end"`
+	// Source is where the sample is copied from at export; Missing is set
+	// when that file no longer exists.
+	Source  string `json:"source"`
+	Missing bool   `json:"missing"`
 }
 
 // Sequence is one existing sequence.
@@ -74,6 +79,7 @@ type State struct {
 	Pads          []Pad      `json:"pads"`      // all 128, in slot order
 	Sequences     []Sequence `json:"sequences"` // existing ones, in slot order
 	SampleCount   int        `json:"sampleCount"`
+	MissingCount  int        `json:"missingCount"` // pool files whose source is missing
 	SequenceCount int        `json:"sequenceCount"`
 	Limits        Limits     `json:"limits"`
 }
@@ -116,6 +122,29 @@ type Service struct {
 	mu      sync.Mutex
 	p       *project.Project
 	dialogs Dialogs
+	closer  func() // closes the window without asking; set by main.go
+}
+
+// SetCloser sets what CloseWindow calls.
+//
+//wails:ignore
+func (s *Service) SetCloser(f func()) { s.closer = f }
+
+// Dirty reports whether the project has changes not yet exported.
+//
+//wails:ignore
+func (s *Service) Dirty() bool {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	return s.p.Dirty
+}
+
+// CloseWindow closes the app without asking about unsaved changes. The
+// frontend calls it after the user chose to discard them.
+func (s *Service) CloseWindow() {
+	if s.closer != nil {
+		s.closer()
+	}
 }
 
 // NewService returns a service holding a new, empty project.
@@ -188,6 +217,12 @@ func (s *Service) state() State {
 			MaxBars:      project.MaxBars,
 		},
 	}
+	missing := map[string]bool{} // pool file -> source missing
+	for _, f := range p.PoolFiles() {
+		src := p.Sources[f]
+		info, err := os.Stat(src)
+		missing[f] = src == "" || err != nil || !info.Mode().IsRegular()
+	}
 	for slot := 0; slot < project.Pads; slot++ {
 		v, err := p.Pad(slot)
 		if err != nil {
@@ -199,7 +234,11 @@ func (s *Service) state() State {
 			Coarse: v.Coarse, Fine: v.Fine, MuteGroup: v.MuteGroup, Start: v.Start, End: v.End}
 		if !v.Empty() {
 			pad.HasNotes = p.PadHasNotes(slot)
-			pad.Audio = audioURL(slot, p.Sources[v.File])
+			pad.Source = p.Sources[v.File]
+			pad.Missing = missing[v.File]
+			if !pad.Missing {
+				pad.Audio = audioURL(slot, pad.Source)
+			}
 		}
 		st.Pads = append(st.Pads, pad)
 	}
@@ -208,6 +247,11 @@ func (s *Service) state() State {
 			Name: q.Name, BPM: q.BPM, Bars: q.Bars, HasNotes: q.HasNotes})
 	}
 	st.SequenceCount = len(st.Sequences)
+	for _, m := range missing {
+		if m {
+			st.MissingCount++
+		}
+	}
 	return st
 }
 

+ 76 - 0
internal/app/app_test.go

@@ -434,3 +434,79 @@ func TestGoldenT1ThroughService(t *testing.T) {
 		t.Error("differs from testdata/accepted/MpckitT1.xpj")
 	}
 }
+
+// copyProject copies DemoFile and its data folder into dir.
+func copyProject(t *testing.T, dir string) string {
+	t.Helper()
+	src := filepath.Join(testdata, "device")
+	must(t, os.Mkdir(filepath.Join(dir, "DemoFile"+project.DataSuffix), 0o755))
+	b, err := os.ReadFile(filepath.Join(src, "DemoFile.xpj"))
+	must(t, err)
+	must(t, os.WriteFile(filepath.Join(dir, "DemoFile.xpj"), b, 0o644))
+	ents, err := os.ReadDir(filepath.Join(src, "DemoFile"+project.DataSuffix))
+	must(t, err)
+	for _, e := range ents {
+		b, err := os.ReadFile(filepath.Join(src, "DemoFile"+project.DataSuffix, e.Name()))
+		must(t, err)
+		must(t, os.WriteFile(filepath.Join(dir, "DemoFile"+project.DataSuffix, e.Name()), b, 0o644))
+	}
+	return filepath.Join(dir, "DemoFile.xpj")
+}
+
+func TestMissingFiles(t *testing.T) {
+	dir := t.TempDir()
+	path := copyProject(t, dir)
+	must(t, os.Remove(filepath.Join(dir, "DemoFile"+project.DataSuffix, "80s-Snr-Dreamer SA.wav")))
+
+	s, _ := newService()
+	res, err := s.LoadPath(path)
+	must(t, err)
+	st := res.State
+	if st.MissingCount != 1 {
+		t.Fatalf("missing count %d, want 1", st.MissingCount)
+	}
+	a2 := st.Pads[1]
+	if !a2.Missing || a2.Audio != "" || !strings.HasSuffix(a2.Source, "80s-Snr-Dreamer SA.wav") {
+		t.Errorf("A02 %+v", a2)
+	}
+	if st.Pads[0].Missing || st.Pads[0].Audio == "" {
+		t.Error("A01 marked missing")
+	}
+
+	// Export names the missing file and writes nothing.
+	out := t.TempDir()
+	if _, err := s.ExportFolder(out, false); err == nil || !strings.Contains(err.Error(), "80s-Snr-Dreamer SA.wav") {
+		t.Errorf("export with a missing file: %v", err)
+	}
+	if ents, _ := os.ReadDir(out); len(ents) != 0 {
+		t.Error("export wrote files")
+	}
+
+	// Replacing the pad's sample fixes it.
+	st, err = s.AssignSamplePath(1, filepath.Join(kickDir, "Linn 9000 kick.wav"))
+	must(t, err)
+	if st.MissingCount != 0 || st.Pads[1].Missing {
+		t.Error("still missing after replace")
+	}
+	if _, err := s.ExportFolder(out, false); err != nil {
+		t.Error(err)
+	}
+}
+
+func TestCloseWindow(t *testing.T) {
+	s, _ := newService()
+	s.CloseWindow() // no closer set: does nothing
+	closed := false
+	s.SetCloser(func() { closed = true })
+	if s.Dirty() {
+		t.Error("new project dirty")
+	}
+	_, _ = s.SetName("Other")
+	if !s.Dirty() {
+		t.Error("not dirty after rename")
+	}
+	s.CloseWindow()
+	if !closed {
+		t.Error("closer not called")
+	}
+}

+ 19 - 0
main.go

@@ -7,6 +7,7 @@ import (
 	"path/filepath"
 	"strconv"
 	"strings"
+	"sync/atomic"
 
 	"github.com/wailsapp/wails/v3/pkg/application"
 	"github.com/wailsapp/wails/v3/pkg/events"
@@ -30,11 +31,13 @@ const (
 const (
 	eventState = "state" // a new app.State after a change the frontend did not ask for
 	eventError = "error" // a message to show
+	eventClose = "close" // the window was asked to close with unsaved changes
 )
 
 func init() {
 	application.RegisterEvent[app.State](eventState)
 	application.RegisterEvent[string](eventError)
+	application.RegisterEvent[application.Void](eventClose)
 }
 
 func main() {
@@ -80,6 +83,22 @@ func main() {
 		w.Show()
 	})
 
+	// Closing with unsaved changes: stop, and let the frontend ask. If the
+	// user chooses to discard them, the frontend calls CloseWindow, which
+	// closes for real.
+	var closeConfirmed atomic.Bool
+	svc.SetCloser(func() {
+		closeConfirmed.Store(true)
+		w.Close()
+	})
+	w.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
+		if closeConfirmed.Load() || !svc.Dirty() {
+			return
+		}
+		e.Cancel()
+		a.Event.Emit(eventClose)
+	})
+
 	// A WAV dropped on a pad (an element with data-file-drop-target and
 	// data-slot) is assigned to that pad.
 	w.OnWindowEvent(events.Common.WindowFilesDropped, func(e *application.WindowEvent) {

+ 30 - 0
pull-requests/m8-load-guard-rails/m8-load-and-guard-rails.md

@@ -0,0 +1,30 @@
+# M8: load and guard rails
+
+Branch: `m8-load-guard-rails` → `main`
+
+Load itself, the unsaved-changes prompt on New and Load, the overwrite confirmation and Go's error messages for bad WAVs and limits came in M5–M7. This PR adds the rest of M8 and the checklist for device checkpoint B.
+
+## What changed
+
+- **Missing sample files.** A loaded project's samples are expected in its `_[ProjectData]` folder, and a sample assigned from elsewhere can be moved or deleted later. The state now says, per pad, where the sample comes from and whether that file still exists.
+  - The grid shows `missing` (in red) on such pads.
+  - The pad panel shows the expected path and says to replace the sample or put the file back and load again. Play is disabled.
+  - The footer counts missing files.
+  - Export refuses and lists every missing file, as since M3, and writes nothing.
+  - Replacing the sample clears the warning.
+- **Closing the window with unsaved changes.** A Go hook stops the close and sends a `close` event. The app asks "Close without exporting?"; Close discards the changes and quits, Cancel stays. Without unsaved changes the window closes straight away.
+- **Device checkpoint B checklist** (`docs/checkpoint-b.md`, linked from the build plan). Five projects (MpckitB1–B5), each made from DemoFile in the app: no changes, moving a pad with notes, copying across banks and swapping pads with notes, removing the current sequence and adding one in bank B, and shortening a sequence with notes. Each lists what to listen for, plus an empty results table. It also covers open question 7 (`currentSequence`) and B5 (notes past a new end).
+- Go service: `CloseWindow` for the frontend. `Dirty` and `SetCloser` are for `main.go` only and kept out of the bindings.
+
+## Testing
+
+On the server: `go vet` and `go test ./...` pass. New tests:
+- **Missing files:** load a copy of DemoFile with one sample deleted. The count is 1 and the right pad is marked (no preview URL, source path set). Export fails naming the file and writes nothing. After replacing the sample, the count is 0 and export works.
+- **Close:** `CloseWindow` calls the closer, and `Dirty` follows edits.
+
+The frontend is untested here (no Node). **On the laptop:**
+
+1. `npm run check` in `frontend`: 0 errors, 0 warnings.
+2. **Missing file:** copy `DemoFile.xpj` and its `DemoFile_[ProjectData]` folder somewhere, delete `80s-Snr-Dreamer SA.wav` from the copy, and Load it. A02 shows `missing`, the footer says 1 sample file missing, A02's panel shows the path, and Play is disabled. Export reports the missing file. Replace A02 with any WAV: the warning goes and export works.
+3. **Close:** make any change and close the window. The app asks; Cancel stays open. Close again and choose Close: the app quits. With no changes (or right after exporting) the window closes without asking.
+4. **Checkpoint B** (M8 done-when): build the five projects in `docs/checkpoint-b.md`, try them on the device, and send me the results. I'll record them in the format doc and build plan.