One undo-block opener for the whole tree: fourteen sites route through it, and the reclaim span can no longer swallow a user's act

The drain refuses a tick inside a foreign block; applyMode wakes the reclaimer
before it opens the span.
This commit is contained in:
2026-08-07 16:38:11 -04:00
parent b2f6b3f1d0
commit b7b93a7ac3
25 changed files with 561 additions and 313 deletions
+8 -4
View File
@@ -29,9 +29,13 @@ is owned by other directories and only skinned here.
separation is load-bearing here same as everywhere else. Only the arrange-capture
surface writes the `assignment_request` wire; Media-Explorer import and file-drop
onto the bank panel do not, and neither affects a live instance's selection.
- **Every bank index verb wraps its mutation in a batched REAPER undo point**
(`Undo_BeginBlock2`/`EndBlock2`, `UNDO_STATE_MISCCFG`) so one bank operation is
one Ctrl-Z.
- **Every undo block in this directory is opened through `shell/undo`'s
`UndoBlock`**, never `Undo_BeginBlock2` directly — the ingest surfaces, the two
drop shells and the Design View membership actions alike. That is what keeps a
user's act its own correctly-labelled Ctrl-Z while a Design View mode switch's FX
reclaim span is open; the mechanism and its compile-time enforcement are
`src/shell/undo/CLAUDE.md`'s. Bank index verbs additionally stay one batched
`UNDO_STATE_MISCCFG` point apiece, so one bank operation is one Ctrl-Z.
- **The prune action is the ONLY file-deletion action in the system**; it opens no
undo point (file deletion is not REAPER-undoable). It halts on
`blockedByTracking` and prints whichever blockers fired — the malformed ledger,
@@ -39,7 +43,7 @@ is owned by other directories and only skinned here.
## Modules
- `shell/actions` (`action_registry` / `design_view_actions` / `bank_actions` / `prune_action`) — the bindable action families, all routed via the `command_id`/`gaccel`/`hookcommand` contract. `action_registry` owns the shared registration plumbing (interned channel-qualified id strings; register and mirror-unregister present the identical pointer) **and the Q-W6 registration TABLE**: `main.cpp`'s own family (capture scopes, panel toggle, insert, batch, realtime, recapture, version) is one `ActionTableRow` array — suffix, phrase, flat function-pointer handler — that registration, hookcommand dispatch, and the unload mirror-unregister all iterate, so adding an action touches the table only (OCP). Bank mutations flow through the promptless `shell/bank_ops` verbs (`bankOp*` + `persistBankOp`, taking `ReaSamplerSession&`), which the panel menus and `bank_actions` consume as thin UX skins. **Every bank index verb wraps its mutation in a batched REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`, `UNDO_STATE_MISCCFG`) so one bank operation is one Ctrl-Z.** The prune action (`prune_action`, `BANK_PRUNE_FOLDER`) is **the ONLY file-deletion action in the system**; it opens no undo point (file deletion is not REAPER-undoable). `BANK_PRUNE_FOLDER` halts on `blockedByTracking` and prints each blocker that fired, with recovery instructions.
- `shell/actions` (`action_registry` / `design_view_actions` / `bank_actions` / `prune_action`) — the bindable action families, all routed via the `command_id`/`gaccel`/`hookcommand` contract. `action_registry` owns the shared registration plumbing (interned channel-qualified id strings; register and mirror-unregister present the identical pointer) **and the Q-W6 registration TABLE**: `main.cpp`'s own family (capture scopes, panel toggle, insert, batch, realtime, recapture, version) is one `ActionTableRow` array — suffix, phrase, flat function-pointer handler — that registration, hookcommand dispatch, and the unload mirror-unregister all iterate, so adding an action touches the table only (OCP). Bank mutations flow through the promptless `shell/bank_ops` verbs (`bankOp*` + `persistBankOp`, taking `ReaSamplerSession&`), which the panel menus and `bank_actions` consume as thin UX skins; `src/shell/bank_ops/CLAUDE.md` owns the one-op-one-Ctrl-Z rule those verbs enforce. The prune action (`prune_action`, `BANK_PRUNE_FOLDER`) is **the ONLY file-deletion action in the system**; it opens no undo point (file deletion is not REAPER-undoable). `BANK_PRUNE_FOLDER` halts on `blockedByTracking` and prints each blocker that fired, with recovery instructions.
- `package_export_action` — the "export bank as package" skin: survey and report first, confirm what is absent (and, separately, a destination being replaced), pick a destination, write. Every prompt in the flow lives here so `shell/package/export_bank` stays promptless. Read-only against the project — it holds the session by `const&`, so no ext-state write, generation bump or undo point is reachable. Registration rides `main.cpp`'s action table (`EXPORT_BANK_PACKAGE`); the panel's tab menu is the second skin over the same body.
- `drag_out_win` — the host/OS boundary of drag-out: Windows OLE `DoDragDrop`/`CF_HDROP`, copy-only (`DROPEFFECT_MOVE` not offered); macOS/Linux via `SWELL_InitiateDragDropOfFileList`. Also owns `pointerOverHostWindow`, the window-ownership test behind `ui::DropContext::pointerOffHost` (`core/ui/CLAUDE.md` owns the hand-off rule it feeds) — process identity on Windows (`WindowFromPoint` + `GetWindowThreadProcessId`), non-null `WindowFromPoint` under SWELL, whose window list is already this-process-only. Its shipped exception: a plugin bridged into `reaper_host*.exe` reports off-host though the pointer never left REAPER.
- `instrument_drop_win` — instrument-drop shell: `probeDropTarget` resolves a screen point to a track + a `ReaperSurface` (via the pure `wire::classifyReaperSurface`, whose token rules `core/wire/CLAUDE.md` owns) — it answers only what REAPER named, never whether the pointer is still in REAPER — and the drop half adds a ReaSampler 9000 instance and applies the dragged capture's state via a transient `.vstpreset` + `TrackFX_SetPreset` (the former `TrackFX_SetNamedConfigParm` "vst_chunk" write was silently unappliable for VST3). Exposes `loadInstrumentOntoTrack` (inner half, no own undo block) and `performInstrumentDrop` (wraps in its own undo block). **Never captures, never writes the bank, never inserts a timeline item.**
+3 -7
View File
@@ -29,6 +29,7 @@
#include <vector>
#include "core/capture/insert_plan.h" // computeInsertMode — the ONE InsertMedia bitfield owner
#include "shell/undo/undo_block.h" // the ONE undo-block opener
#include "reaper_plugin.h"
@@ -42,8 +43,6 @@
#define REAPERAPI_WANT_SetOnlyTrackSelected
#define REAPERAPI_WANT_SetTrackSelected
#define REAPERAPI_WANT_SnapToGrid
#define REAPERAPI_WANT_Undo_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2
#include "reaper_plugin_functions.h"
namespace reasampler {
@@ -98,7 +97,7 @@ int performArrangeDrop(MediaTrack* track, double time,
// One undo block around the whole drop (every item plus the selection/cursor restore) so a
// single Ctrl-Z returns the project to exactly its pre-drop state.
Undo_BeginBlock2(nullptr);
UndoBlock undo(nullptr);
SetOnlyTrackSelected(track);
SetEditCurPos(SnapToGrid(nullptr, time), /*moveview=*/false, /*seekplay=*/false);
@@ -111,9 +110,6 @@ int performArrangeDrop(MediaTrack* track, double time,
if (InsertMedia(path.c_str(), mode) != 0) ++inserted;
}
// A fixed stack buffer, not std::string concatenation: the prior shape built the label with
// std::to_string + `+` between the restore below and Undo_EndBlock2, so a bad_alloc there
// would leave an unbalanced undo block open. snprintf here removes the allocation outright.
char label[64];
std::snprintf(label, sizeof(label), "ReaSampler: drop %d %s onto arrange", inserted,
inserted == 1 ? "capture" : "captures");
@@ -122,7 +118,7 @@ int performArrangeDrop(MediaTrack* track, double time,
SetEditCurPos(priorCursor, /*moveview=*/false, /*seekplay=*/false);
// extraflags -1 = UNDO_STATE_ALL, matching the insert action's own block.
Undo_EndBlock2(nullptr, label, -1);
undo.recordPoint(label, -1);
return inserted;
}
+22 -44
View File
@@ -25,7 +25,7 @@
#include "shell/capture/item_read.h" // shared MediaItem* -> GUID + fixed-lane-name reads (D2 W3-B)
#include "shell/capture/track_guid.h" // shared MediaTrack* -> canonical GUID key
#include "shell/panel/panel_window.h" // bankPanelInvalidate — footer toggle repaint
#include "shell/view/fx_reclaim.h" // fxReclaimEndUndoSpan — no block opens over the span
#include "shell/undo/undo_block.h" // the ONE undo-block opener
#include "shell/view/view.h" // applyMode (D2 shell)
#include "shell/view/view_lanes.h" // mintManagedLanes
#include "shell/view/view_park.h" // kViewUndoMask
@@ -41,8 +41,6 @@
#define REAPERAPI_WANT_Main_SaveProject
#define REAPERAPI_WANT_Help_Set
#define REAPERAPI_WANT_ShowConsoleMsg
#define REAPERAPI_WANT_Undo_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2
#include "reaper_plugin_functions.h"
namespace reasampler {
@@ -217,37 +215,13 @@ void doActivateMode(const std::string& modeId) {
// of its own — nothing it writes mints a point unaided — so a user-fired tag stays one
// Ctrl-Z only because the ACTION spans it, and the label names the act the user
// actually fired rather than the reapply it happened to trigger. Every one of them
// closes the block before persistViewState, which can pop a modal Save-As. Stack RAII
// so a throw between the two calls cannot leave the block open in the user's project.
// closes the block before persistViewState, which can pop a modal Save-As.
//
// ENDING THE RECLAIM SPAN IS PART OF OPENING, and every action here inherits it from
// this one constructor. A mode switch fired seconds ago has left the span open; a block
// opened over it makes each close pair with the other's open, collapsing the switch
// into this tag — mechanism and full rule in src/shell/view/CLAUDE.md.
//
// `markWrote` decides whether this block mints at all: re-tagging tracks that already
// sit in that mode writes nothing to the project, and the ("", 0) discard form is real
// from a hand-fired action (DAW-measured, same doc). The membership move itself is
// never in the point either way — persistViewState runs after the close.
class ActionUndoBlock {
public:
ActionUndoBlock(std::string label, int mask) : label_(std::move(label)), mask_(mask) {
fxReclaimEndUndoSpan();
Undo_BeginBlock2(nullptr);
}
~ActionUndoBlock() {
if (wrote_) Undo_EndBlock2(nullptr, label_.c_str(), mask_);
else Undo_EndBlock2(nullptr, "", 0);
}
void markWrote() { wrote_ = true; }
ActionUndoBlock(const ActionUndoBlock&) = delete;
ActionUndoBlock& operator=(const ActionUndoBlock&) = delete;
private:
std::string label_;
int mask_;
bool wrote_ = false;
};
// The point is recorded only where the reapply actually wrote: re-tagging tracks that
// already sit in that mode writes nothing to the project, and the ("", 0) discard form
// UndoBlock closes on by default is real from a hand-fired action (DAW-measured,
// src/shell/view/CLAUDE.md). The membership move itself is never in the point either
// way — persistViewState runs after the close.
// tag() replaces any prior single-mode membership (a leaf lives in one mode; the
// cross-mode case is show-both).
@@ -256,9 +230,10 @@ void doTag(const std::string& modeId) {
if (guids.empty()) return; // nothing selected — no act, no undo point (cf. doMoveItems)
{
ActionUndoBlock undo("ReaSampler: tag selected tracks -> " + modeId, kViewUndoMask);
UndoBlock undo(nullptr);
for (const std::string& g : guids) g_session->view().membership().tag(g, modeId);
if (reapplyActiveMode()) undo.markWrote();
if (reapplyActiveMode())
undo.recordPoint("ReaSampler: tag selected tracks -> " + modeId, kViewUndoMask);
}
persistViewState(PersistScope::Full);
}
@@ -270,9 +245,10 @@ void doUntag() {
if (guids.empty()) return;
{
ActionUndoBlock undo("ReaSampler: untag selected tracks", kViewUndoMask);
UndoBlock undo(nullptr);
for (const std::string& g : guids) g_session->view().membership().untag(g);
if (reapplyActiveMode()) undo.markWrote();
if (reapplyActiveMode())
undo.recordPoint("ReaSampler: untag selected tracks", kViewUndoMask);
}
persistViewState(PersistScope::Full);
}
@@ -284,10 +260,11 @@ void doShowBoth() {
if (guids.empty()) return;
{
ActionUndoBlock undo("ReaSampler: toggle show-both on selected tracks", kViewUndoMask);
UndoBlock undo(nullptr);
MembershipIndex& m = g_session->view().membership();
for (const std::string& g : guids) m.setShowBoth(g, !m.isShowBoth(g));
if (reapplyActiveMode()) undo.markWrote();
if (reapplyActiveMode())
undo.recordPoint("ReaSampler: toggle show-both on selected tracks", kViewUndoMask);
}
persistViewState(PersistScope::Full);
}
@@ -307,10 +284,7 @@ void doMoveItems(const std::string& targetMode) {
MembershipIndex& membership = g_session->view().membership();
{
ActionUndoBlock undo(targetMode.empty()
? std::string("ReaSampler: untag selected items")
: "ReaSampler: move selected items -> " + targetMode,
-1);
UndoBlock undo(nullptr);
for (const ItemRetagOp& op : ops) {
if (op.untag) membership.untag(op.guid);
else membership.tag(op.guid, op.modeId);
@@ -321,7 +295,11 @@ void doMoveItems(const std::string& targetMode) {
// no flag leaves nothing for a point to hold.
const bool minted = mintManagedLanes(g_session->view(), nullptr);
const bool reapplied = reapplyActiveMode();
if (minted || reapplied) undo.markWrote();
if (minted || reapplied)
undo.recordPoint(targetMode.empty()
? std::string("ReaSampler: untag selected items")
: "ReaSampler: move selected items -> " + targetMode,
-1);
}
persistViewState(PersistScope::Full);
+30 -29
View File
@@ -22,6 +22,7 @@
#include "core/wire/instrument_drop.h" // pure buildInstrumentDropPreset (.vstpreset image for a sampleId)
#include "shell/actions/instrument_drop_win.h" // shell loadInstrumentOntoTrack (FX add+apply, no own undo block)
#include "shell/persist/session.h" // ReaSamplerSession
#include "shell/undo/undo_block.h" // the ONE undo-block opener
#include "core/capture/wav_codec.h" // parseWavLayout (32f fast-path validator), buildFloat32Wav, hashWavContent
@@ -36,8 +37,6 @@
#define REAPERAPI_WANT_GetMediaSourceNumChannels
#define REAPERAPI_WANT_GetMediaSourceSampleRate
#define REAPERAPI_WANT_GetMediaSourceLength
#define REAPERAPI_WANT_Undo_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2
#define REAPERAPI_WANT_GetSelectedTrack
#include "reaper_plugin_functions.h"
@@ -362,14 +361,14 @@ void doImportFromMediaExplorer() {
// so any open VST3 browser instances refresh to show the new sound.
bool persisted = true; // true when nothing needed persisting (dedup)
if (r.added) {
Undo_BeginBlock2(nullptr);
g_session->bumpBankGeneration();
persisted = g_session->saveToActiveProject();
if (persisted)
Undo_EndBlock2(nullptr, "ReaSampler: import Media Explorer file into bank",
UNDO_STATE_MISCCFG);
else
Undo_EndBlock2(nullptr, "", 0);
{
UndoBlock undo(nullptr);
g_session->bumpBankGeneration();
persisted = g_session->saveToActiveProject();
if (persisted)
undo.recordPoint("ReaSampler: import Media Explorer file into bank",
UNDO_STATE_MISCCFG);
}
bankPanelRefresh();
}
ShowConsoleMsg(("ReaSampler ingest: " + r.message +
@@ -386,20 +385,21 @@ void doImportFromMediaExplorer() {
// Persist happens INSIDE the block and BEFORE the FX add so the new instance's
// setState -> reloadInstrument sees the just-persisted sample.
Undo_BeginBlock2(nullptr);
bool persisted = true; // true when nothing needed persisting (dedup)
if (r.added) {
g_session->bumpBankGeneration();
persisted = g_session->saveToActiveProject();
bool placed = false;
{
UndoBlock undo(nullptr);
if (r.added) {
g_session->bumpBankGeneration();
persisted = g_session->saveToActiveProject();
}
placed = loadInstrumentOntoTrack(target, preset);
// Anything else — the FX add/inject failed (already rolled back, no orphan), or
// the project was unsaved (persist no-op) — records no point at all.
if (placed && persisted)
undo.recordPoint("ReaSampler: import from Media Explorer into selected track",
UNDO_STATE_MISCCFG);
}
const bool placed = loadInstrumentOntoTrack(target, preset);
if (placed && persisted)
Undo_EndBlock2(nullptr, "ReaSampler: import from Media Explorer into selected track",
UNDO_STATE_MISCCFG);
else
// Either the FX add/inject failed (already rolled back, no orphan) or the
// project was unsaved (persist no-op): discard so no empty point is recorded.
Undo_EndBlock2(nullptr, "", 0);
bankPanelRefresh();
if (placed)
@@ -450,13 +450,14 @@ void ingestDroppedFiles(const std::vector<std::string>& absolutePaths) {
// One undo point for the whole drop, opened only if a NEW index entry was created.
if (importedNew > 0) {
Undo_BeginBlock2(nullptr);
g_session->bumpBankGeneration();
const bool persisted = g_session->saveToActiveProject();
if (persisted)
Undo_EndBlock2(nullptr, "ReaSampler: import dropped file(s)", UNDO_STATE_MISCCFG);
else
Undo_EndBlock2(nullptr, "", 0);
bool persisted = false;
{
UndoBlock undo(nullptr);
g_session->bumpBankGeneration();
persisted = g_session->saveToActiveProject();
if (persisted)
undo.recordPoint("ReaSampler: import dropped file(s)", UNDO_STATE_MISCCFG);
}
bankPanelRefresh();
const std::string msg =
"ReaSampler ingest: imported " + std::to_string(importedTotal) +
+3 -4
View File
@@ -13,6 +13,7 @@
#include "core/version/app_version.h" // vstPluginName() — the CHANNEL-correct FX name (stable/beta pairing)
#include "core/wire/instrument_drop.h" // classifyReaperSurface — the PURE, unit-tested classifier
#include "shell/undo/undo_block.h" // the ONE undo-block opener
#include "reaper_plugin.h"
@@ -22,8 +23,6 @@
#define REAPERAPI_WANT_TrackFX_Delete
#define REAPERAPI_WANT_TrackFX_GetCount
#define REAPERAPI_WANT_TrackFX_SetPreset
#define REAPERAPI_WANT_Undo_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2
#include "reaper_plugin_functions.h"
namespace reasampler {
@@ -138,9 +137,9 @@ bool loadInstrumentOntoTrack(MediaTrack* track, const std::vector<std::uint8_t>&
bool performInstrumentDrop(MediaTrack* track, const std::vector<std::uint8_t>& presetBytes) {
if (!track || presetBytes.empty()) return false;
Undo_BeginBlock2(nullptr);
UndoBlock undo(nullptr);
const bool ok = loadInstrumentOntoTrack(track, presetBytes);
Undo_EndBlock2(nullptr, "ReaSampler: drop capture onto FX chain", -1);
undo.recordPoint("ReaSampler: drop capture onto FX chain", -1);
return ok;
}