feat(persist): reload book+view on undo/redo via projectconfig hook (R-B)

BeginLoadProjectState(isUndo) requests a deferred reload the timer drains
next tick, once REAPER has restored ext-state. Hook owns undo/redo; identity
poll still owns open/tab-switch/save-as. Fixes copy-collapse over-count
(verb-aware guard) and dangling undo point on unsaved-project bank ops.
This commit is contained in:
2026-07-26 06:59:26 -04:00
parent 6d372794f5
commit a07b28fd65
4 changed files with 158 additions and 18 deletions
+51 -8
View File
@@ -51,11 +51,23 @@
// (after relocating, or on the forked-sibling Load branch) so identities diverge.
//
// Rationale for the timer: the brief mandates ext-state storage (rules out the
// projectconfig .rpp-line hook), and the timer composes cleanly with ext-state
// while covering both load and Save-As detection in one place. The
// `projectconfig` BeginLoadProjectState hook is a deterministic alternative for
// pure load detection but would still need the timer (or Main_SaveProject
// post-hook) for Save-As path-change detection — surfaced in the handoff.
// projectconfig .rpp-line hook for STORAGE), and the timer composes cleanly with
// ext-state while covering identity-transition load + Save-As detection in one
// place.
//
// DIVISION OF LABOUR (R-B undo):
// * Identity-transition poll (this file, classifyProjectTransition) owns
// open / tab-switch / new / forked-sibling / Save-As-relocation — every case
// where the project OF RECORD changes.
// * The `projectconfig` hook (main.cpp registers project_config_extension_t;
// BeginLoadProjectState with isUndo) owns UNDO/REDO — where the project
// identity is unchanged but its ext state rolled back/forward on disk. The
// identity poll sees NoOp there and would never re-read ext state, so the hook
// requests a reload (requestReload) that poll() drains on the next tick, once
// REAPER has restored the <EXTSTATE> block. See requestReload / the poll drain.
// The hook fires on undo AND redo (isUndo true for both), and on normal open
// (isUndo false) — but we set the reload flag ONLY for isUndo, so a normal open
// flows solely through the identity-transition Load path and never double-loads.
//
// NON-DESTRUCTIVE: this module writes ONLY our own ext-state key and moves ONLY
// our own reasampler_bank/ folder. It never touches the user's media, items, or
@@ -172,11 +184,11 @@ bool relocateBankFolder(const std::string& oldBankDir,
} // namespace
void ReaSamplerSession::saveToActiveProject() {
bool ReaSamplerSession::saveToActiveProject() {
std::string rppPath;
void* proj = readActiveProject(rppPath);
if (!proj) return; // no active project — nothing to persist
if (rppPath.empty()) return; // unsaved project — no .rpp to store into
if (!proj) return false; // no active project — nothing to persist
if (rppPath.empty()) return false; // unsaved project — no .rpp to store into
// Phase B: the whole book (pool as bank-zero + named banks) is authoritative and
// rides in the `banks` key.
@@ -206,6 +218,7 @@ void ReaSamplerSession::saveToActiveProject() {
kProjExtTailKey, tailJson.c_str());
MarkProjectDirty(static_cast<ReaProject*>(proj));
return true;
}
namespace {
@@ -339,6 +352,13 @@ bool ReaSamplerSession::consumeLoadSignal() {
return pending;
}
void ReaSamplerSession::requestReload() {
// Set-only; poll() drains it on the next tick (see the poll() drain block for why
// the read is deferred past the projectconfig callback). Cheap and idempotent —
// multiple undo/redo callbacks before the next tick collapse to one reload.
reloadRequested_ = true;
}
void ReaSamplerSession::poll() {
std::string rppPath;
void* proj = readActiveProject(rppPath);
@@ -355,6 +375,29 @@ void ReaSamplerSession::poll() {
lastProject_ = proj;
lastGuid_ = ensureProjectGuid(proj, rppPath, currentGuid);
lastRppPath_ = rppPath;
reloadRequested_ = false; // priming already loaded — a co-tick request is moot
return;
}
// Undo/redo reload (owner: the projectconfig hook, NOT the identity classifier
// below). An undo/redo keeps the SAME project identity — same ReaProject*, GUID,
// and .rpp path — so classifyProjectTransition would return NoOp and never re-read
// ext state, leaving book_/view_ stale after the on-disk ext state rolled back.
// The projectconfig BeginLoadProjectState callback (isUndo) raised reloadRequested_
// one or more ticks ago; by NOW REAPER has finished restoring the project's
// <EXTSTATE> block, so GetProjExtState returns the POST-undo value. Reload from the
// current active project and identity-adopt it (no relocation — the path is
// unchanged), then return. loadFromProject raises loadPending_, so the existing
// consumeLoadSignal() glue re-baselines the panel detector and reapplies the active
// mode; bankPanelRefresh's fingerprint pass then repaints the restored book. This is
// the ONLY undo/redo reload path — the timer never polls ext-state CONTENT to detect
// an undo (Daniel's directive: the hook drives it, not a poll heuristic).
if (reloadRequested_) {
reloadRequested_ = false;
loadFromProject(proj, projectDirOf(rppPath));
lastProject_ = proj;
lastGuid_ = ensureProjectGuid(proj, rppPath, currentGuid);
lastRppPath_ = rppPath;
return;
}