Make Design View membership edits undoable: the view_state write moves inside the block

All four actions share one body; the point turns on a model diff, so a no-op
leaves history and the Undo button alone. The Save-As prompt stays outside.
This commit is contained in:
2026-08-07 19:51:37 -04:00
parent 20fef2a3bf
commit d5ff476a19
7 changed files with 177 additions and 132 deletions
+12
View File
@@ -36,6 +36,18 @@ is owned by other directories and only skinned here.
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.
- **A Design View membership edit's undo point holds its `view_state` write — so the
persist runs INSIDE the block, and the point turns on a MODEL DIFF, not on "REAPER
was written to".** All four membership actions (tag, untag / tag→Arrange, show-both,
the item moves) share one body, `design_view_actions`' `runMembershipEdit`.
Everything the reapply they drive writes is the four driven track flags, which mint
no undo state at all (`src/shell/view/CLAUDE.md`), so a point closed BEFORE the
ext-state write covered no change: the edit was not undoable and rode whatever point
the user's next act minted. Two consequences bind. The persist's modal Save-As half
is split off and runs before the block opens — a modal may not hold a block open.
And a no-op edit records no label, because a recorded label re-titles the Undo button
whether or not REAPER appends an entry. Same shape as `shell/bank_ops`'
`persistBankOp`, for the same reason.
- **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,
+117 -88
View File
@@ -104,16 +104,13 @@ std::vector<std::string> selectedTrackGuids() {
}
// Reapplies the CURRENT active mode so a membership mutation takes visible effect
// immediately (park/unpark/re-derive parents), answering whether it wrote any project
// state — the caller's block mints only if something did. `applied` is deliberately
// discarded: a refused reapply leaves the membership edit standing and unapplied, and
// the user's next edit or mode switch reapplies it (a refusal writes nothing, so it
// reports false here and the block discards). What must NOT be dropped with it is the
// immediately (park/unpark/re-derive parents). `applied` is deliberately discarded: a
// refused reapply leaves the membership edit standing and unapplied, and the user's
// next edit or mode switch reapplies it. What must NOT be dropped with it is the
// reclaimer's queue, which is why applyMode invalidates on its refusal path too
// (view.cpp).
bool reapplyActiveMode() {
return applyMode(g_session->view(), g_session->view().activeModeId(), nullptr)
.wroteProjectState;
void reapplyActiveMode() {
applyMode(g_session->view(), g_session->view().activeModeId(), nullptr);
}
constexpr int kFreeModeFixedLanes = 2; // I_FREEMODE value; mirrors the shell's constant
@@ -147,33 +144,45 @@ std::vector<RetagItem> selectedRetagItems() {
// serialization. Every other action keeps the full save.
enum class PersistScope { Full, ViewOnly };
// Persists the bank + Design-View model after every state-changing action so the
// view model is not lost across save/close/reopen. If membership is non-empty and
// the project is unsaved, prompts Save-As first (mirrors the flow capture uses) —
// DAW-ONLY: Main_SaveProject(proj, true) blocks until the dialog is dismissed.
void persistViewState(PersistScope scope) {
if (!g_session->view().membership().empty()) {
ReaProject* proj = EnumProjects(-1, nullptr, 0);
if (proj) {
auto readRppPath = [&]() -> std::string {
std::vector<char> buf(4096, '\0');
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
return std::string(buf.data());
};
// The persist's MODAL half, split from the write below so a caller can run the two on
// opposite sides of an undo block. If membership is non-empty and the project is
// unsaved, prompts Save-As (mirrors the flow capture uses) — DAW-ONLY:
// Main_SaveProject(proj, true) blocks until the dialog is dismissed. False means the
// user declined and nothing may be written.
bool ensurePersistTarget() {
if (g_session->view().membership().empty()) return true;
if (readRppPath().empty()) {
Main_SaveProject(proj, true);
if (readRppPath().empty()) { // still empty -> user cancelled
ShowConsoleMsg(
"ReaSampler: Design View state will not persist until "
"the project is saved.\n");
return;
}
}
}
}
if (scope == PersistScope::ViewOnly) g_session->saveViewStateOnly();
else g_session->saveToActiveProject();
ReaProject* proj = EnumProjects(-1, nullptr, 0);
if (!proj) return true; // no active project — the write below no-ops on its own
auto readRppPath = [&]() -> std::string {
std::vector<char> buf(4096, '\0');
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
return std::string(buf.data());
};
if (!readRppPath().empty()) return true;
Main_SaveProject(proj, true);
if (!readRppPath().empty()) return true; // still empty -> user cancelled
ShowConsoleMsg("ReaSampler: Design View state will not persist until "
"the project is saved.\n");
return false;
}
// The persist's WRITE half: the bank + Design-View model, so neither is lost across
// save/close/reopen. Returns whether the write was ISSUED — false means no active
// project or an unsaved one, with NOTHING written (session.h owns that contract), which
// is the only reading a caller may turn into a discarded undo point.
bool writePersistedState(PersistScope scope) {
return scope == PersistScope::ViewOnly ? g_session->saveViewStateOnly()
: g_session->saveToActiveProject();
}
// The two halves as a caller holding no undo block runs them.
void persistViewState(PersistScope scope) {
if (ensurePersistTarget()) writePersistedState(scope);
}
// The footer segment already reads disabled while the transport runs, but an action can
@@ -211,17 +220,53 @@ void doActivateMode(const std::string& modeId) {
bankPanelInvalidate(); // repaint the footer [Arrange|Design] toggle immediately
}
// The block each membership-edit action below owns. The reapply they drive opens none
// 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.
// The drive half of a membership edit: what runs inside the block, answering whether
// it made a project change of its own that a point must hold even with nothing
// persisted. The reapply alone never does — everything it writes is the four driven
// flags, which mint no undo state (src/shell/view/CLAUDE.md).
bool driveReapply() {
reapplyActiveMode();
return false;
}
// The item moves' drive: re-drive the same minting/apply path auto-tag uses — mint or
// split lanes for any track whose items now span modes, then reassert active-mode lane
// visibility. A mint moves items between lanes, which is a real project change whether
// or not the persist below lands.
bool driveMintLanesAndReapply() {
const bool minted = mintManagedLanes(g_session->view(), nullptr);
reapplyActiveMode();
return minted;
}
// The ONE body behind all four membership edits (this directory's CLAUDE.md owns the
// rule; what follows is why the ORDER is what it is). `mutate` is a pure model edit —
// nothing in it reaches REAPER.
//
// 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.
// The prompt sits between the edit and the block because it is pinned on both sides:
// its condition reads the POST-edit membership (a first-ever tag on an unsaved project
// must still prompt), and a modal may not stand inside an open block. The persist then
// runs INSIDE, being the only thing here a point can hold; a re-tag of tracks already
// in that mode re-writes byte-identical ext state, which is why the label turns on the
// model diff and not on the write.
template <class Mutate>
void runMembershipEdit(Mutate&& mutate, bool (*drive)(), const std::string& label,
int mask) {
const MembershipIndex before = g_session->view().membership();
mutate();
const bool changed = !(g_session->view().membership() == before);
const bool persistable = ensurePersistTarget();
UndoBlock undo(nullptr);
const bool structural = drive();
const bool persisted = persistable && writePersistedState(PersistScope::Full);
if (structural || (changed && persisted))
undo.recordPoint(label, mask);
// Otherwise the block closes on its ("", 0) discard form — real from a hand-fired
// action (DAW-measured, src/shell/view/CLAUDE.md) — leaving history and the Undo
// button both untouched.
}
// tag() replaces any prior single-mode membership (a leaf lives in one mode; the
// cross-mode case is show-both).
@@ -229,13 +274,11 @@ void doTag(const std::string& modeId) {
const std::vector<std::string> guids = selectedTrackGuids();
if (guids.empty()) return; // nothing selected — no act, no undo point (cf. doMoveItems)
{
UndoBlock undo(nullptr);
for (const std::string& g : guids) g_session->view().membership().tag(g, modeId);
if (reapplyActiveMode())
undo.recordPoint("ReaSampler: tag selected tracks -> " + modeId, kViewUndoMask);
}
persistViewState(PersistScope::Full);
runMembershipEdit(
[&] {
for (const std::string& g : guids) g_session->view().membership().tag(g, modeId);
},
&driveReapply, "ReaSampler: tag selected tracks -> " + modeId, kViewUndoMask);
}
// Shared body behind "Untag selected" and "Tag -> Arrange" — Arrange is the absence
@@ -244,13 +287,11 @@ void doUntag() {
const std::vector<std::string> guids = selectedTrackGuids();
if (guids.empty()) return;
{
UndoBlock undo(nullptr);
for (const std::string& g : guids) g_session->view().membership().untag(g);
if (reapplyActiveMode())
undo.recordPoint("ReaSampler: untag selected tracks", kViewUndoMask);
}
persistViewState(PersistScope::Full);
runMembershipEdit(
[&] {
for (const std::string& g : guids) g_session->view().membership().untag(g);
},
&driveReapply, "ReaSampler: untag selected tracks", kViewUndoMask);
}
// Flips each track's pin independently — the honest semantics of a toggle on a
@@ -259,14 +300,12 @@ void doShowBoth() {
const std::vector<std::string> guids = selectedTrackGuids();
if (guids.empty()) return;
{
UndoBlock undo(nullptr);
MembershipIndex& m = g_session->view().membership();
for (const std::string& g : guids) m.setShowBoth(g, !m.isShowBoth(g));
if (reapplyActiveMode())
undo.recordPoint("ReaSampler: toggle show-both on selected tracks", kViewUndoMask);
}
persistViewState(PersistScope::Full);
runMembershipEdit(
[&] {
MembershipIndex& m = g_session->view().membership();
for (const std::string& g : guids) m.setShowBoth(g, !m.isShowBoth(g));
},
&driveReapply, "ReaSampler: toggle show-both on selected tracks", kViewUndoMask);
}
// Retag the current ITEM selection to `targetMode` (empty => untag -> Arrange
@@ -281,28 +320,18 @@ void doMoveItems(const std::string& targetMode) {
const std::vector<ItemRetagOp> ops = planItemRetag(selected, targetMode);
if (ops.empty()) return; // nothing selected, or every selected item was exempt/empty
MembershipIndex& membership = g_session->view().membership();
{
UndoBlock undo(nullptr);
for (const ItemRetagOp& op : ops) {
if (op.untag) membership.untag(op.guid);
else membership.tag(op.guid, op.modeId);
}
// Re-drive the same minting/apply path auto-tag uses: mint/split lanes for any
// track whose items now span modes, then reassert active-mode lane visibility.
// Either half writing is enough to mint; a retag that split no lanes and drove
// 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.recordPoint(targetMode.empty()
? std::string("ReaSampler: untag selected items")
: "ReaSampler: move selected items -> " + targetMode,
-1);
}
persistViewState(PersistScope::Full);
runMembershipEdit(
[&] {
MembershipIndex& membership = g_session->view().membership();
for (const ItemRetagOp& op : ops) {
if (op.untag) membership.untag(op.guid);
else membership.tag(op.guid, op.modeId);
}
},
&driveMintLanesAndReapply,
targetMode.empty() ? std::string("ReaSampler: untag selected items")
: "ReaSampler: move selected items -> " + targetMode,
-1);
}
} // namespace