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.
This commit is contained in:
@@ -144,6 +144,16 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
|
|||||||
std::vector<TrackFolderEntry> entries = readFolderEntries(proj, handleByGuid);
|
std::vector<TrackFolderEntry> entries = readFolderEntries(proj, handleByGuid);
|
||||||
FolderTree tree = buildFolderTree(entries);
|
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<std::string> liveGuids;
|
||||||
|
for (const auto& kv : handleByGuid) liveGuids.insert(kv.first);
|
||||||
|
model.reconcile(liveGuids);
|
||||||
|
|
||||||
TogglePlan plan = model.planToggle(tree, targetModeId);
|
TogglePlan plan = model.planToggle(tree, targetModeId);
|
||||||
|
|
||||||
Undo_BeginBlock2(proj);
|
Undo_BeginBlock2(proj);
|
||||||
|
|||||||
@@ -160,6 +160,21 @@ const TrackSnapshot* ViewModeModel::snapshot(const std::string& guid) const {
|
|||||||
return it == snapshots_.end() ? nullptr : &it->second;
|
return it == snapshots_.end() ? nullptr : &it->second;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::size_t ViewModeModel::reconcile(const std::set<std::string>& 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 {
|
bool ViewModeModel::leafBelongsToMode(const std::string& guid, const std::string& modeId) const {
|
||||||
const Membership* m = membership_.query(guid);
|
const Membership* m = membership_.query(guid);
|
||||||
if (!m) return modeId == kArrangeModeId; // untagged ⇒ Arrange default
|
if (!m) return modeId == kArrangeModeId; // untagged ⇒ Arrange default
|
||||||
|
|||||||
@@ -267,6 +267,26 @@ public:
|
|||||||
const TrackSnapshot* snapshot(const std::string& guid) const;
|
const TrackSnapshot* snapshot(const std::string& guid) const;
|
||||||
const std::map<std::string, TrackSnapshot>& snapshots() const { return snapshots_; }
|
const std::map<std::string, TrackSnapshot>& 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<std::string>& liveGuids);
|
||||||
|
|
||||||
// Does `guid` belong to `modeId`? A leaf belongs if it is tagged into modeId,
|
// 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
|
// 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
|
// default). Parent derivation is NOT applied here — this is the LEAF rule; use
|
||||||
|
|||||||
@@ -579,6 +579,99 @@ static void testTaggedLeafBehaviorUnchangedWithUntagged() {
|
|||||||
CHECK(!parkTargets(arrange, "{UNT}"));
|
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<std::string> 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<std::string> 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<std::string> 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<std::string>{}) == 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<std::string>{"{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) ----------------------------------
|
// -- 8. nextModeId cycle (D4 toggle helper) ----------------------------------
|
||||||
|
|
||||||
static void testNextModeIdCycles() {
|
static void testNextModeIdCycles() {
|
||||||
@@ -756,6 +849,9 @@ int main() {
|
|||||||
testUntaggedLeavesManagedByModeSystem();
|
testUntaggedLeavesManagedByModeSystem();
|
||||||
testTaggedLeafBehaviorUnchangedWithUntagged();
|
testTaggedLeafBehaviorUnchangedWithUntagged();
|
||||||
testNestedToggleSnapshotSurvivesRepark();
|
testNestedToggleSnapshotSurvivesRepark();
|
||||||
|
testReconcilePrunesOrphanedSnapshots();
|
||||||
|
testReconcileFullLiveSetIsNoOp();
|
||||||
|
testReconcileThenReparkLifecycleIntact();
|
||||||
testNextModeIdCycles();
|
testNextModeIdCycles();
|
||||||
|
|
||||||
if (g_fail == 0) std::printf("All tests passed.\n");
|
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||||
|
|||||||
Reference in New Issue
Block a user