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
+57
View File
@@ -241,6 +241,56 @@ static void OnTimer()
reasampler::bankPanelRefresh();
}
// --- projectconfig hook: reload the session on undo/redo (R-B) ---------------
// A Ctrl-Z / Ctrl-Shift-Z rolls back / forward the "reasampler" project ext state on
// disk but keeps the SAME project identity (ReaProject*/GUID/.rpp path), so the timer's
// identity poll reads it as NoOp and never re-reads ext state — the in-memory book/view
// would stay stale until close+reopen. REAPER's projectconfig extension fires
// BeginLoadProjectState on every project-state (re)load, INCLUDING an undo/redo restore
// (isUndo == true for both). We hook it to drive a session reload.
//
// TIMING (the crux): BeginLoadProjectState is documented (reaper_plugin.h ~1203) as
// firing BEFORE any state restore. Reading GetProjExtState synchronously here would
// return the PRE-undo value. So we do NOT read here — we raise a one-shot reload request
// (g_session.requestReload()) that OnTimer's poll() drains on the NEXT tick, by which
// point REAPER has finished restoring the <EXTSTATE> block and GetProjExtState returns
// the POST-undo value. Deterministic, event-driven — NOT ext-state content polling.
//
// GATED ON isUndo: a normal project open also fires BeginLoadProjectState (isUndo=false);
// we ignore that here so a normal open flows solely through the timer's identity-transition
// Load path (no double load). Only undo/redo (isUndo=true) requests the reload.
static void OnBeginLoadProjectState(bool isUndo, project_config_extension_t* /*reg*/)
{
if (isUndo)
g_session.requestReload();
}
// ProcessExtensionLine / SaveExtensionConfig are intentional no-ops: ReaSampler stores
// its state via project EXT STATE (SetProjExtState/GetProjExtState under "reasampler"),
// which REAPER persists in its own <EXTSTATE> RPP block — NOT via this extension's own
// project lines. We register the struct ONLY for the BeginLoadProjectState undo/redo
// notification. Returning false from ProcessExtensionLine means "not our line" so REAPER
// keeps dispatching (we claim none). SaveExtensionConfig writes nothing.
static bool OnProcessExtensionLine(const char* /*line*/, ProjectStateContext* /*ctx*/,
bool /*isUndo*/, project_config_extension_t* /*reg*/)
{
return false; // we own no project lines — ext state carries our data
}
static void OnSaveExtensionConfig(ProjectStateContext* /*ctx*/, bool /*isUndo*/,
project_config_extension_t* /*reg*/)
{
// Nothing to write: our data rides in ext state, not project lines.
}
// Storage must outlive registration — REAPER holds this pointer until we unregister it.
static project_config_extension_t g_projectConfig{
&OnProcessExtensionLine,
&OnSaveExtensionConfig,
&OnBeginLoadProjectState,
nullptr, // userData
};
// --- Scope-action source resolution -----------------------------------------
// The three scope actions (item / track / master) each resolve to (1) an exact
// render range in project seconds — razor-else-time, inferred here — and (2) the
@@ -786,6 +836,7 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
}
g_rec->Register("-timer", (void*)&OnTimer);
g_rec->Register("-projectconfig", (void*)&g_projectConfig);
g_rec->Register("-toggleaction", (void*)&OnToggleAction);
g_rec->Register("-hookcommand", (void*)&OnHookCommand);
// Tear down the Design View action family (D4) — mirror-unregisters each
@@ -957,6 +1008,12 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
// state, on a Save-As it relocates the bank folder under the new .rpp.
rec->Register("timer", (void*)&OnTimer);
// Register the projectconfig hook so an UNDO/REDO state restore reloads the
// session's book + view from the restored ext state (R-B). The timer's identity
// poll cannot see an undo (same project identity), so this hook owns undo/redo; it
// requests a deferred reload that the next timer tick drains (see the hook comment).
rec->Register("projectconfig", (void*)&g_projectConfig);
ShowConsoleMsg("ReaSampler loaded.\n");
return 1; // success — REAPER keeps us loaded