diff --git a/src/shell/actions/design_view_actions.cpp b/src/shell/actions/design_view_actions.cpp index a6f45ca..7bf8ab9 100644 --- a/src/shell/actions/design_view_actions.cpp +++ b/src/shell/actions/design_view_actions.cpp @@ -133,11 +133,17 @@ std::vector selectedRetagItems() { return items; } +// Which keys the following persist has to write. A mode switch changes `view_state` +// and nothing else, and it is the one action here a user fires repeatedly — so it +// alone narrows, and pays neither the bank-book nor the tracking-ledger +// serialization. Every other action keeps the full save. +enum class PersistScope { Full, ViewOnly }; + // Persists the bank + Design-View model after every state-changing action so the // view model is not lost across save/close/reopen. If membership is non-empty and // the project is unsaved, prompts Save-As first (mirrors the flow capture uses) — // DAW-ONLY: Main_SaveProject(proj, true) blocks until the dialog is dismissed. -void persistViewState() { +void persistViewState(PersistScope scope) { if (!g_session->view().membership().empty()) { ReaProject* proj = EnumProjects(-1, nullptr, 0); if (proj) { @@ -158,7 +164,8 @@ void persistViewState() { } } } - g_session->saveToActiveProject(); + if (scope == PersistScope::ViewOnly) g_session->saveViewStateOnly(); + else g_session->saveToActiveProject(); } // The footer segment already reads disabled while the transport runs, but an action can @@ -178,7 +185,7 @@ void doToggleMode() { nextModeId(g_session->view().modes(), g_session->view().activeModeId()); if (target.empty()) return; // no modes to cycle to (degenerate) if (!applyMode(g_session->view(), target, nullptr)) { reportModeSwitchRefused(); return; } - persistViewState(); + persistViewState(PersistScope::ViewOnly); bankPanelInvalidate(); // repaint the footer [Arrange|Design] toggle immediately } @@ -186,7 +193,7 @@ void doToggleMode() { // the id is unregistered or the transport is running, so both fail safe. void doActivateMode(const std::string& modeId) { if (!applyMode(g_session->view(), modeId, nullptr)) { reportModeSwitchRefused(); return; } - persistViewState(); + persistViewState(PersistScope::ViewOnly); bankPanelInvalidate(); // repaint the footer [Arrange|Design] toggle immediately } @@ -196,7 +203,7 @@ void doTag(const std::string& modeId) { for (const std::string& g : selectedTrackGuids()) g_session->view().membership().tag(g, modeId); reapplyActiveMode(); - persistViewState(); + persistViewState(PersistScope::Full); } // Shared body behind "Untag selected" and "Tag -> Arrange" — Arrange is the absence @@ -205,7 +212,7 @@ void doUntag() { for (const std::string& g : selectedTrackGuids()) g_session->view().membership().untag(g); reapplyActiveMode(); - persistViewState(); + persistViewState(PersistScope::Full); } // Flips each track's pin independently — the honest semantics of a toggle on a @@ -215,7 +222,7 @@ void doShowBoth() { for (const std::string& g : selectedTrackGuids()) m.setShowBoth(g, !m.isShowBoth(g)); reapplyActiveMode(); - persistViewState(); + persistViewState(PersistScope::Full); } // Retag the current ITEM selection to `targetMode` (empty => untag -> Arrange @@ -247,7 +254,7 @@ void doMoveItems(const std::string& targetMode) { : std::string("ReaSampler: move selected items -> ") + targetMode; Undo_EndBlock2(nullptr, label.c_str(), -1); - persistViewState(); + persistViewState(PersistScope::Full); } } // namespace diff --git a/src/shell/persist/CLAUDE.md b/src/shell/persist/CLAUDE.md index 9447f06..b172052 100644 --- a/src/shell/persist/CLAUDE.md +++ b/src/shell/persist/CLAUDE.md @@ -53,7 +53,7 @@ REAPER/filesystem-facing half only, and it gathers rather than decides. ## Modules -- `shell/persist` (`session` / `ext_state_io` / `prune_fs`) — the persist seam, split by responsibility (Q-W5; the former `persist.cpp` god-TU and its `persist.h` compatibility umbrella are both retired — callers include `shell/persist/session.h` / `ext_state_io.h` directly). `session` owns the `ReaSamplerSession` lifecycle: the poll identity-transition detection (load / Save-As / forked sibling / recycled pointer) and the `projectconfig`-driven deferred undo/redo reload. `ext_state_io` owns project ext state (`SetProjExtState`/`GetProjExtState`, namespace `"reasampler"`) ↔ `BankBook` JSON, `ViewModeModel` JSON, `TailSetting` JSON, the tracking ledger JSON, the writing-version stamp, GUID minting, and bank-folder relocation. `saveToActiveProject` returns whether the writes were ISSUED — false means no active/saved project and NOTHING was written, which is the only reading its callers' discard-the-undo-point branch is safe under; it must never grow an observational third failure mode (the contract lives at its declaration in `session.h`). `session` additionally owns `recordCreated` — **the one writer of a birth record**, called at the same point the `Sample` is added, deriving lineage from that `Sample`'s own provenance. `prune_fs` hosts the prune dry-run / full-set orphan queries (gathering `referencedPaths()` plus `tracking::pruneProtection`'s two inputs for the `prune_reconcile` pure core) — and, beside them, `tiedUsageFor`, the resample's replace-vs-add input, deliberately co-located so "both answers come out of one `TrackingState`" is structural rather than a rule two files must remember. It is also **the single file-deletion authority over user files in the bank folder** (`deleteOrphanFile` via `SHFileOperationW`); nothing else in the system deletes bank-folder bytes. Dry-run / orphan-set / reclaim each independently abort (delete nothing) when the authority reports a block. +- `shell/persist` (`session` / `ext_state_io` / `prune_fs`) — the persist seam, split by responsibility (Q-W5; the former `persist.cpp` god-TU and its `persist.h` compatibility umbrella are both retired — callers include `shell/persist/session.h` / `ext_state_io.h` directly). `session` owns the `ReaSamplerSession` lifecycle: the poll identity-transition detection (load / Save-As / forked sibling / recycled pointer) and the `projectconfig`-driven deferred undo/redo reload. `ext_state_io` owns project ext state (`SetProjExtState`/`GetProjExtState`, namespace `"reasampler"`) ↔ `BankBook` JSON, `ViewModeModel` JSON, `TailSetting` JSON, the tracking ledger JSON, the writing-version stamp, GUID minting, and bank-folder relocation. `saveToActiveProject` returns whether the writes were ISSUED — false means no active/saved project and NOTHING was written, which is the only reading its callers' discard-the-undo-point branch is safe under; it must never grow an observational third failure mode (the contract lives at its declaration in `session.h`). `saveViewStateOnly` is its narrowed sibling for a caller that changed only the Design-View model — `view_state` plus the dirty mark, no other key — and returns on that same rule; the full save stays the default and narrowing is opt-in per call site. `session` additionally owns `recordCreated` — **the one writer of a birth record**, called at the same point the `Sample` is added, deriving lineage from that `Sample`'s own provenance. `prune_fs` hosts the prune dry-run / full-set orphan queries (gathering `referencedPaths()` plus `tracking::pruneProtection`'s two inputs for the `prune_reconcile` pure core) — and, beside them, `tiedUsageFor`, the resample's replace-vs-add input, deliberately co-located so "both answers come out of one `TrackingState`" is structural rather than a rule two files must remember. It is also **the single file-deletion authority over user files in the bank folder** (`deleteOrphanFile` via `SHFileOperationW`); nothing else in the system deletes bank-folder bytes. Dry-run / orphan-set / reclaim each independently abort (delete nothing) when the authority reports a block. - `usage_scan` — extension-side prune-scan shell: enumerates every `rsusage_*` ext-state key, decodes each `sample_usage` wire record, enumerates every ReaSampler 9000 FX instance across all tracks + master / normal + record chains / containers (recursive) / take FX, and returns the pure `sample_usage::foldUsageRecords` result verbatim. One of the two inputs `tracking::pruneProtection` reads; it decides nothing itself. Read-only: writes no ext-state. - `persist_internal.h` — internal-only shared helpers for the persist TU family (`session` / `ext_state_io` / `prune_fs`); included only by those three TUs, never a public seam (mirror of the panel's `panel_state.h` / the editor's `editor_internal.h` precedent). Holds the former anonymous-namespace helpers more than one split TU needs (active-project + `.rpp` path lookup, project-dir derivation, growing `GetProjExtState` read, project-GUID minting, bank-folder relocation) — all definitions live in `ext_state_io.cpp`. REAPER-free header: the project handle crosses this seam as the same opaque `void*` the public `session` header already uses. diff --git a/src/shell/persist/ext_state_io.cpp b/src/shell/persist/ext_state_io.cpp index 59502d5..02bdaf3 100644 --- a/src/shell/persist/ext_state_io.cpp +++ b/src/shell/persist/ext_state_io.cpp @@ -137,51 +137,61 @@ namespace reasampler { using persist_detail::getProjExtStateString; using persist_detail::readActiveProject; -bool ReaSamplerSession::saveToActiveProject() { +namespace { + +// The ONE guard behind every ext-state write entry point here, so the two conditions +// that produce a false stay a single rule rather than a copy per entry point. +// session.h owns what that false has to mean to callers. +ReaProject* activeSavedProject() { std::string rppPath; void* proj = readActiveProject(rppPath); - if (!proj) return false; // no active project — nothing to persist - if (rppPath.empty()) return false; // unsaved project — no .rpp to store into + if (!proj || rppPath.empty()) return nullptr; + return static_cast(proj); +} + +void writeViewState(ReaProject* proj, const ViewModeModel& view) { + const std::string viewJson = view.serialize(); + SetProjExtState(proj, projExtNamespace(), kProjExtViewKey, viewJson.c_str()); +} + +} // namespace + +bool ReaSamplerSession::saveToActiveProject() { + ReaProject* proj = activeSavedProject(); + if (!proj) return false; const std::string banksJson = book_.serialize(); - SetProjExtState(static_cast(proj), projExtNamespace(), - kProjExtBanksKey, banksJson.c_str()); + SetProjExtState(proj, projExtNamespace(), kProjExtBanksKey, banksJson.c_str()); // Retire the legacy single-bank key: SetProjExtState with an empty value // deletes it. Idempotent when already absent. - SetProjExtState(static_cast(proj), projExtNamespace(), - kProjExtIndexKey, ""); + SetProjExtState(proj, projExtNamespace(), kProjExtIndexKey, ""); // Each of the following rides in its own key, independent of `banks`. - const std::string viewJson = view_.serialize(); - SetProjExtState(static_cast(proj), projExtNamespace(), - kProjExtViewKey, viewJson.c_str()); + writeViewState(proj, view_); const std::string tailJson = capture::serializeTailSetting(tail_); - SetProjExtState(static_cast(proj), projExtNamespace(), - kProjExtTailKey, tailJson.c_str()); + SetProjExtState(proj, projExtNamespace(), kProjExtTailKey, tailJson.c_str()); // Never write over a blob this build could not read (see this directory's // CLAUDE.md for why the suppression, not a rewrite, is the safe direction). if (!tracking::ledgerDegraded(trackingStatus_)) { const std::string ledgerJson = tracking_.serialize(); - SetProjExtState(static_cast(proj), projExtNamespace(), - kProjExtOwnedKey, ledgerJson.c_str()); + SetProjExtState(proj, projExtNamespace(), kProjExtOwnedKey, ledgerJson.c_str()); } // stampVersion() (not appVersion()) is the numeric triple only, no "-beta" // suffix, so the stamp is byte-identical to stable regardless of channel // — the channel is already carried by the isolated namespace. - SetProjExtState(static_cast(proj), projExtNamespace(), - kProjExtVersionKey, version::stampVersion().c_str()); + SetProjExtState(proj, projExtNamespace(), kProjExtVersionKey, + version::stampVersion().c_str()); // Whatever bumpBankGeneration() advanced the counter to since the last // save (0 if never bumped). Shared encoder so writer/reader agree byte-for-byte. - SetProjExtState(static_cast(proj), projExtNamespace(), - kProjExtBankGenKey, + SetProjExtState(proj, projExtNamespace(), kProjExtBankGenKey, instrument::map::formatBankGeneration(bankGeneration_).c_str()); - MarkProjectDirty(static_cast(proj)); + MarkProjectDirty(proj); // The writes were ISSUED into a saved active project — all this call can observe, and // deliberately all it claims. Do not "prove" them with a read-back; session.h states @@ -189,17 +199,26 @@ bool ReaSamplerSession::saveToActiveProject() { return true; } +// The version stamp and the degraded-ledger suppression deliberately do NOT appear +// here: neither the bank book nor the ledger is rewritten, so there is nothing for +// this build to claim authorship of and nothing to suppress. +bool ReaSamplerSession::saveViewStateOnly() { + ReaProject* proj = activeSavedProject(); + if (!proj) return false; + + writeViewState(proj, view_); + MarkProjectDirty(proj); + return true; +} + bool ReaSamplerSession::writeAssignmentRequest(const std::string& wire) { - std::string rppPath; - void* proj = readActiveProject(rppPath); - if (!proj) return false; // no active project — nothing to signal - if (rppPath.empty()) return false; // unsaved project — no .rpp to store into + ReaProject* proj = activeSavedProject(); + if (!proj) return false; // One-shot write under its own key: a transient signal to the instrument, // not session state that rides every save. - SetProjExtState(static_cast(proj), projExtNamespace(), - kProjExtAssignKey, wire.c_str()); - MarkProjectDirty(static_cast(proj)); + SetProjExtState(proj, projExtNamespace(), kProjExtAssignKey, wire.c_str()); + MarkProjectDirty(proj); return true; } diff --git a/src/shell/persist/session.h b/src/shell/persist/session.h index 0bc1967..e8c4706 100644 --- a/src/shell/persist/session.h +++ b/src/shell/persist/session.h @@ -131,6 +131,19 @@ public: // remove the Ctrl-Z for a bank mutation that landed. bool saveToActiveProject(); + // The narrowed sibling, for a caller that changed ONLY the Design-View model: + // writes `view_state`, marks the project dirty, and touches no other key. The + // full save stays the default — this is opt-in per call site. + // + // Opt-in rather than a per-key dirty flag on the session: a call site that + // forgets to opt in merely pays the old cost, whereas a mutation site that + // forgets to mark its key dirty would silently stop persisting it, and the + // dirty flags would have to be threaded through every writer of book_/tail_/ + // tracking_ across the capture, bank-op and ingest layers. + // + // Returns on the SAME rule as saveToActiveProject above, prohibition included. + bool saveViewStateOnly(); + // Report-only prune dry-run: feeds the pure core with (present, referenced, // owned) — `present` from the folder enumeration, the other two from the // tracking authority. FAIL-SAFE: tracking state the authority cannot read