From f9af111c7a545ff9d743abcccd69dd0f02fe795b Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 23 Jul 2026 13:27:42 -0400 Subject: [PATCH] Reconcile orphaned Design View snapshots on track delete Add ViewModeModel::reconcile(liveGuids) to prune snapshots of deleted tracks; wire it into applyMode before planning. Keep membership so undo-delete (same GUID) preserves the Design tag. --- src/view.cpp | 10 ++++ src/view_mode_model.cpp | 15 ++++++ src/view_mode_model.h | 20 +++++++ tests/test_view_mode_model.cpp | 96 ++++++++++++++++++++++++++++++++++ 4 files changed, 141 insertions(+) diff --git a/src/view.cpp b/src/view.cpp index bb7d72b..f7c93b5 100644 --- a/src/view.cpp +++ b/src/view.cpp @@ -144,6 +144,16 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject std::vector entries = readFolderEntries(proj, handleByGuid); FolderTree tree = buildFolderTree(entries); + // Reconcile orphaned model state BEFORE planning: prune snapshots whose track was + // deleted from the project (its GUID no longer appears in the live enumeration). + // handleByGuid holds every currently-enumerated track GUID, so its keys are the + // authoritative live set. Membership is intentionally NOT pruned (undo-delete + // restores the same GUID — see ViewModeModel::reconcile). Because reapply-on-load + // routes through applyMode, this also reconciles on project open. + std::set liveGuids; + for (const auto& kv : handleByGuid) liveGuids.insert(kv.first); + model.reconcile(liveGuids); + TogglePlan plan = model.planToggle(tree, targetModeId); Undo_BeginBlock2(proj); diff --git a/src/view_mode_model.cpp b/src/view_mode_model.cpp index bcd5244..faf3c8e 100644 --- a/src/view_mode_model.cpp +++ b/src/view_mode_model.cpp @@ -160,6 +160,21 @@ const TrackSnapshot* ViewModeModel::snapshot(const std::string& guid) const { return it == snapshots_.end() ? nullptr : &it->second; } +std::size_t ViewModeModel::reconcile(const std::set& liveGuids) { + // Prune snapshots for GUIDs the project no longer contains (see header for the + // deliberate snapshot-yes / membership-no asymmetry and the undo-delete rationale). + std::size_t removed = 0; + for (auto it = snapshots_.begin(); it != snapshots_.end();) { + if (liveGuids.count(it->first) == 0) { + it = snapshots_.erase(it); + ++removed; + } else { + ++it; + } + } + return removed; +} + bool ViewModeModel::leafBelongsToMode(const std::string& guid, const std::string& modeId) const { const Membership* m = membership_.query(guid); if (!m) return modeId == kArrangeModeId; // untagged ⇒ Arrange default diff --git a/src/view_mode_model.h b/src/view_mode_model.h index 28056b3..b42ed92 100644 --- a/src/view_mode_model.h +++ b/src/view_mode_model.h @@ -267,6 +267,26 @@ public: const TrackSnapshot* snapshot(const std::string& guid) const; const std::map& snapshots() const { return snapshots_; } + // Prunes orphaned per-track state: drops every snapshot whose GUID is NOT in + // `liveGuids` (the set of GUIDs the shell currently enumerates from the project). + // Returns the number of snapshots removed. The shell calls this before planning a + // toggle; because reapply-on-load also routes through the shell's applyMode, this + // reconciles on project open too. + // + // Why snapshots and NOT membership: a parked track's snapshot is dead weight once + // the track is deleted — it can never be restored, and if REAPER reuses that GUID + // for a different track a stale snapshot would drive an INCORRECT restore. So it + // must be pruned. Membership is deliberately KEPT: REAPER's undo of a track delete + // restores the SAME GUID, so dropping the Design tag on delete would silently lose + // it on undo-delete. Keeping membership means an undone delete brings the track + // back correctly tagged and it re-snapshots + re-parks cleanly on the next toggle. + // A genuinely-deleted-and-never-restored track leaves only a tiny dormant + // membership entry — acceptable, and far better than losing tags on undo. Folder + // RESTRUCTURE (moving tracks without deleting) is already self-healing: the tree is + // rebuilt from I_FOLDERDEPTH every toggle, so a restructure leaves every GUID live + // and reconcile is a no-op over it. This handles DELETION specifically. + std::size_t reconcile(const std::set& liveGuids); + // Does `guid` belong to `modeId`? A leaf belongs if it is tagged into modeId, // is show-both (belongs everywhere), or is untagged and modeId is Arrange (the // default). Parent derivation is NOT applied here — this is the LEAF rule; use diff --git a/tests/test_view_mode_model.cpp b/tests/test_view_mode_model.cpp index f0a9569..e98164f 100644 --- a/tests/test_view_mode_model.cpp +++ b/tests/test_view_mode_model.cpp @@ -579,6 +579,99 @@ static void testTaggedLeafBehaviorUnchangedWithUntagged() { CHECK(!parkTargets(arrange, "{UNT}")); } +// -- 10. reconcile: prune orphaned snapshots on track delete ----------------- +// +// Closes the "reconcile on delete/restructure" hardening item. reconcile prunes a +// snapshot whose GUID is not in the live set (its track was deleted while parked), +// preventing both the slow snapshot leak and an incorrect restore if REAPER reuses +// the GUID. Membership is deliberately KEPT (undo-delete restores the same GUID, so +// dropping the tag would silently lose it). A full live set is a no-op — this is why +// folder RESTRUCTURE, which leaves every GUID live, needs no special handling. + +static void testReconcilePrunesOrphanedSnapshots() { + ViewModeModel vm; + // Two parked tracks (both snapshotted + tagged); {DEL} is about to be deleted. + vm.membership().tag("{LIVE}", kDesignModeId); + vm.membership().tag("{DEL}", kDesignModeId); + TrackSnapshot sLive; sLive.showInTcp = 1; sLive.fxOffline = {0, 1}; + TrackSnapshot sDel; sDel.showInTcp = 1; sDel.fxEnable = 1; + vm.storeSnapshot("{LIVE}", sLive); + vm.storeSnapshot("{DEL}", sDel); + CHECK(vm.snapshots().size() == 2); + + // {DEL} is deleted from the project ⇒ absent from the live GUID set. + std::set liveGuids{"{LIVE}"}; + std::size_t removed = vm.reconcile(liveGuids); + + // The orphaned snapshot is pruned; the live one is retained verbatim. + CHECK(removed == 1); + CHECK(vm.snapshot("{DEL}") == nullptr); + const TrackSnapshot* kept = vm.snapshot("{LIVE}"); + CHECK(kept != nullptr); + if (kept) CHECK(kept->showInTcp == 1 && kept->fxOffline.size() == 2); + + // Membership is NOT pruned — the deleted GUID keeps its Design tag so an + // undo-delete (which restores the same GUID) brings the track back correctly + // tagged. This is the load-bearing design call. + CHECK(vm.membership().query("{DEL}") != nullptr); + CHECK(vm.leafBelongsToMode("{DEL}", kDesignModeId)); + CHECK(vm.membership().query("{LIVE}") != nullptr); +} + +static void testReconcileFullLiveSetIsNoOp() { + // The restructure case: tracks moved between folders but none deleted ⇒ every + // GUID stays live ⇒ reconcile prunes nothing. + ViewModeModel vm; + vm.storeSnapshot("{A}", TrackSnapshot{}); + vm.storeSnapshot("{B}", TrackSnapshot{}); + vm.storeSnapshot("{C}", TrackSnapshot{}); + + std::set liveGuids{"{A}", "{B}", "{C}"}; + std::size_t removed = vm.reconcile(liveGuids); + + CHECK(removed == 0); + CHECK(vm.snapshots().size() == 3); + CHECK(vm.snapshot("{A}") && vm.snapshot("{B}") && vm.snapshot("{C}")); + + // A superset of live GUIDs (tracks exist that were never parked) is also a no-op: + // reconcile only ever removes, never adds. + std::set superset{"{A}", "{B}", "{C}", "{NEVER_PARKED}"}; + CHECK(vm.reconcile(superset) == 0); + CHECK(vm.snapshots().size() == 3); + + // Empty live set (whole project emptied) prunes everything. + CHECK(vm.reconcile(std::set{}) == 3); + CHECK(vm.snapshots().empty()); +} + +static void testReconcileThenReparkLifecycleIntact() { + // No regression to the park/restore lifecycle: after reconcile prunes a deleted + // track's snapshot, a still-live tagged leaf toggled back to its mode still + // restores from its retained snapshot, and a re-park recaptures fresh state. + ViewModeModel vm; + FolderTree tree; + tree.nodes.push_back(FolderNode{"{DES}", "", false}); + vm.membership().tag("{DES}", kDesignModeId); + + TrackSnapshot snap; snap.showInTcp = 1; snap.mainSend = 0; snap.fxEnable = 1; + vm.storeSnapshot("{DES}", snap); // parked while in Arrange + vm.storeSnapshot("{ORPHAN}", TrackSnapshot{}); // a since-deleted parked track + + // Reconcile with {DES} live, {ORPHAN} gone. + CHECK(vm.reconcile(std::set{"{DES}"}) == 1); + CHECK(vm.snapshot("{ORPHAN}") == nullptr); + + // Toggle back to Design: {DES} restores from its retained snapshot verbatim. + auto toDesign = vm.planToggle(tree, kDesignModeId); + const TrackPlan* r = restoreFor(toDesign, "{DES}"); + CHECK(r != nullptr); + if (r) { + CHECK(flagValue(*r, Flag::ShowInTcp) == 1); + CHECK(flagValue(*r, Flag::MainSend) == 0); + CHECK(flagValue(*r, Flag::FxEnable) == 1); + } +} + // -- 8. nextModeId cycle (D4 toggle helper) ---------------------------------- static void testNextModeIdCycles() { @@ -756,6 +849,9 @@ int main() { testUntaggedLeavesManagedByModeSystem(); testTaggedLeafBehaviorUnchangedWithUntagged(); testNestedToggleSnapshotSurvivesRepark(); + testReconcilePrunesOrphanedSnapshots(); + testReconcileFullLiveSetIsNoOp(); + testReconcileThenReparkLifecycleIntact(); testNextModeIdCycles(); if (g_fail == 0) std::printf("All tests passed.\n");