Skip to content

feat(editor): make drum edits undoable + harden undo history - #89

Merged
byrongamatos merged 2 commits into
mainfrom
feat/editor-undo-hardening
Jul 6, 2026
Merged

feat(editor): make drum edits undoable + harden undo history#89
byrongamatos merged 2 commits into
mainfrom
feat/editor-undo-hardening

Conversation

@ChrisBeWithYou

@ChrisBeWithYou ChrisBeWithYou commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Drum edits (click-add, drag-move, Delete, and the G/F/K ghost/flam/choke toggles) previously bypassed EditHistory entirely — a mis-drag in the drum grid was unrecoverable, and Ctrl+Z while drum-edit mode was active silently undid the last note edit in the hidden guitar/keys arrangement instead. This PR routes all four drum mutations through the shared undo history and hardens that history for multi-arrangement editing.

Drum undo

  • Four new command classes in a @pure:drum-cmds block: AddDrumHitCmd, DeleteDrumHitsCmd, MoveDrumHitsCmd, ToggleDrumArticulationCmd.
  • Commands hold hit object references, never indices — the hits array is re-sorted after adds/moves and replaced by delete's filter(), so captured indices go stale but refs survive both. Selection (S.drumSel is index-based) is rebuilt from refs after every reorder.
  • Drag-move finalizes with the same revert-then-exec pattern the note-drag MoveNoteCmd path uses, so redo replays the exact drag result; a snapped no-op drag no longer pushes a command (or spuriously dirties the tab).
  • The choke cymbal-only gate is applied at command construction, keeping exec/rollback symmetric.
  • Every exec()/rollback() marks S.drumTabDirty so a save issued after an undo persists the reverted state.

History hardening

  • exec() tags each command with the arrangement it ran against (_arrIdx); undo/redo now switch back to that arrangement first — updating the arrangement selector — instead of rolling index-based commands back into whichever arrangement happens to be active (silent corruption of the wrong arrangement's notes). Song-level commands (drum tab) opt out via songScope.
  • Undo stack capped at MAX_UNDO = 500, oldest dropped first.
  • remove-arrangement now resets history: the splice renumbers every arrangement after the removed index, so older commands would target the wrong one (same rationale as the save-time reconstructChords() reset, Undo history not invalidated on save — index-based commands can corrupt after reconstructChords() #18).

Verification

  • node --check screen.js clean
  • New tests/drum_undo.test.js — 9 cases (add/delete/move/articulation exec+undo+redo round-trips, selection-survives-resort, malformed-drum_tab guard, 500-cap eviction, arrangement tagging)
  • All 26 JS test files pass; existing tests/edit_history_reset.test.js unchanged and green

🤖 Generated with Claude Code

https://claude.ai/code/session_01JEoFeTPSnz4NpwwCG52hnu

Summary by CodeRabbit

  • Bug Fixes
    • Drum grid edits now undo and redo correctly, including adding, deleting, moving hits, and articulation changes.
    • Selection stays consistent after drum hits are reordered, and changes are properly saved.
    • Undo/redo now switches to the correct arrangement before applying changes, reducing the risk of edits affecting the wrong chart.
    • Undo history is cleared when an arrangement is removed, and the history size is limited to keep behavior stable.

Drum edits (click-add, drag-move, Delete, G/F/K articulation toggles)
previously bypassed EditHistory entirely: a mis-drag was unrecoverable and
Ctrl+Z in drum-edit mode silently undid the last NOTE edit in the hidden
guitar/keys arrangement. Adds four ref-based drum command classes
(AddDrumHitCmd / DeleteDrumHitsCmd / MoveDrumHitsCmd /
ToggleDrumArticulationCmd) that preserve selection across resorts and mark
the drum tab dirty on exec AND rollback.

Hardens the shared history for multi-arrangement editing:
- exec() tags each command with the arrangement it ran against; undo/redo
  switch back to that arrangement (or refuse if it was removed) instead of
  rolling index-based commands into whichever arrangement is active.
- Undo stack capped at 500 (oldest dropped first).
- remove-arrangement resets history (the splice renumbers arrangements).

Tests: tests/drum_undo.test.js (9 cases); node --check clean; all 26 JS
test files pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JEoFeTPSnz4NpwwCG52hnu
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@byrongamatos, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 8 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 53b60b93-de9f-4e95-b412-ef1e6d34e70a

📥 Commits

Reviewing files that changed from the base of the PR and between 3617ba6 and 2a9e594.

📒 Files selected for processing (2)
  • screen.js
  • tests/cross_arr_undo.test.js
📝 Walkthrough

Walkthrough

This PR makes undo/redo arrangement-aware in EditHistory (tagging commands with a target arrangement, capping the stack at 500, clearing history on arrangement removal) and converts drum-grid edits (add, delete, move, toggle) into undoable command classes wired into the drum editor UI, with accompanying tests and changelog updates.

Changes

Undo History and Drum Commands

Layer / File(s) Summary
EditHistory arrangement tagging and capping
screen.js
EditHistory tags commands with _arrIdx, caps the undo stack at 500, adds _historyEnsureArr() to switch arrangements before undo/redo, and clears S.history when removing an arrangement.
Drum command classes
screen.js
New AddDrumHitCmd, DeleteDrumHitsCmd, MoveDrumHitsCmd, ToggleDrumArticulationCmd implement exec/rollback with hit-reference selection tracking, re-sorting, and S.drumTabDirty updates.
Drum editor UI wiring
screen.js
Add/click, drag-end finalize, Delete key, and articulation toggle handlers now dispatch the new drum commands via S.history.exec() instead of mutating hits directly.
Tests and CHANGELOG
tests/drum_undo.test.js, CHANGELOG.md
New test suite validates drum command and EditHistory behavior by extracting pure code blocks from screen.js; CHANGELOG documents both undo-history improvements.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant DrumEditor
  participant DrumCmd as DrumHitCmd
  participant SHistory as S.history
  participant DrumTab as S.drumTab

  User->>DrumEditor: perform edit (add/move/delete/toggle)
  DrumEditor->>DrumCmd: construct command with hit references
  DrumEditor->>SHistory: exec(DrumCmd)
  SHistory->>SHistory: tag command with _arrIdx
  SHistory->>DrumCmd: exec()
  DrumCmd->>DrumTab: update hits, resort, remap S.drumSel
  DrumCmd-->>SHistory: set S.drumTabDirty

  User->>SHistory: undo()
  SHistory->>SHistory: _historyEnsureArr(cmd)
  SHistory->>DrumCmd: rollback()
  DrumCmd->>DrumTab: restore prior state, resort, remap selection
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main editor change: undoable drum edits and hardened undo history.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/editor-undo-hardening

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@screen.js`:
- Around line 13197-13220: The ToggleDrumArticulationCmd undo path is
re-toggling instead of restoring the original hit state, so pre-existing values
are not preserved. Update ToggleDrumArticulationCmd to capture each hit’s
original articulation value/absence at construction or exec time, then have
rollback restore that saved state instead of calling _toggle; keep exec as the
toggle operation and make rollback reconstruct the exact prior k/g field values
for every hit.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e81a7368-2e6b-4d08-8407-848e85fa3576

📥 Commits

Reviewing files that changed from the base of the PR and between e067f25 and 3617ba6.

📒 Files selected for processing (3)
  • CHANGELOG.md
  • screen.js
  • tests/drum_undo.test.js

Comment thread screen.js
Comment on lines +13197 to +13220
class ToggleDrumArticulationCmd {
// g/f/k toggles are involutive per hit, so rollback = exec and mixed
// initial states round-trip exactly. The choke cymbal-only gate is applied
// at CONSTRUCTION (callers pass only the refs the toggle really touches),
// keeping exec/rollback perfectly symmetric.
constructor(hitRefs, field) {
this.hits = [...hitRefs];
this.field = field;
this.songScope = true;
}
_toggle() {
for (const h of this.hits) {
if (this.field === 'k') {
if (h.k) delete h.k; else h.k = 0.08;
} else if (h[this.field]) {
delete h[this.field];
} else {
h[this.field] = true;
}
}
S.drumTabDirty = true;
}
exec() { this._toggle(); }
rollback() { this._toggle(); }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve original articulation values on undo.

rollback() re-toggles instead of restoring the captured pre-edit value, so existing values are not round-tripped exactly. For example, an imported k: 0.05 hit is undone as k: 0.08, and g: false is undone as missing.

Suggested fix
 class ToggleDrumArticulationCmd {
-    // g/f/k toggles are involutive per hit, so rollback = exec and mixed
-    // initial states round-trip exactly. The choke cymbal-only gate is applied
+    // Capture original values so undo round-trips imported/noncanonical drum tabs
+    // exactly. The choke cymbal-only gate is applied
     // at CONSTRUCTION (callers pass only the refs the toggle really touches),
-    // keeping exec/rollback perfectly symmetric.
+    // keeping exec/rollback scoped to the same hits.
     constructor(hitRefs, field) {
         this.hits = [...hitRefs];
         this.field = field;
+        this.before = this.hits.map(h => ({
+            has: Object.prototype.hasOwnProperty.call(h, field),
+            value: h[field],
+        }));
         this.songScope = true;
     }
@@
     }
     exec() { this._toggle(); }
-    rollback() { this._toggle(); }
+    rollback() {
+        for (let i = 0; i < this.hits.length; i++) {
+            if (this.before[i].has) this.hits[i][this.field] = this.before[i].value;
+            else delete this.hits[i][this.field];
+        }
+        S.drumTabDirty = true;
+    }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
class ToggleDrumArticulationCmd {
// g/f/k toggles are involutive per hit, so rollback = exec and mixed
// initial states round-trip exactly. The choke cymbal-only gate is applied
// at CONSTRUCTION (callers pass only the refs the toggle really touches),
// keeping exec/rollback perfectly symmetric.
constructor(hitRefs, field) {
this.hits = [...hitRefs];
this.field = field;
this.songScope = true;
}
_toggle() {
for (const h of this.hits) {
if (this.field === 'k') {
if (h.k) delete h.k; else h.k = 0.08;
} else if (h[this.field]) {
delete h[this.field];
} else {
h[this.field] = true;
}
}
S.drumTabDirty = true;
}
exec() { this._toggle(); }
rollback() { this._toggle(); }
class ToggleDrumArticulationCmd {
// Capture original values so undo round-trips imported/noncanonical drum tabs
// exactly. The choke cymbal-only gate is applied
// at CONSTRUCTION (callers pass only the refs the toggle really touches),
// keeping exec/rollback scoped to the same hits.
constructor(hitRefs, field) {
this.hits = [...hitRefs];
this.field = field;
this.before = this.hits.map(h => ({
has: Object.prototype.hasOwnProperty.call(h, field),
value: h[field],
}));
this.songScope = true;
}
_toggle() {
for (const h of this.hits) {
if (this.field === 'k') {
if (h.k) delete h.k; else h.k = 0.08;
} else if (h[this.field]) {
delete h[this.field];
} else {
h[this.field] = true;
}
}
S.drumTabDirty = true;
}
exec() { this._toggle(); }
rollback() {
for (let i = 0; i < this.hits.length; i++) {
if (this.before[i].has) this.hits[i][this.field] = this.before[i].value;
else delete this.hits[i][this.field];
}
S.drumTabDirty = true;
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@screen.js` around lines 13197 - 13220, The ToggleDrumArticulationCmd undo
path is re-toggling instead of restoring the original hit state, so pre-existing
values are not preserved. Update ToggleDrumArticulationCmd to capture each hit’s
original articulation value/absence at construction or exec time, then have
rollback restore that saved state instead of calling _toggle; keep exec as the
toggle operation and make rollback reconstruct the exact prior k/g field values
for every hit.

_historyEnsureArr routed undo/redo through editorSelectArrangement, whose
flattenChords() -> _flattenArrChords() re-sorts arr.notes. Index-based note
commands (MoveNoteCmd/DeleteNotesCmd/ResizeSustainCmd) rely on notes() staying
in exec-time order at rollback, so a drag-past-neighbour + arrangement switch +
undo rolled back the WRONG index and silently corrupted a different note —
reintroducing the exact bug this PR set out to fix.

A MANUAL switch also re-sorts the revisited arrangement, so merely making the
undo-driven switch non-flattening still leaves a hole (edit arr0, switch away,
edit arr1, switch back to arr0 -> re-sort invalidates arr0's pending index
commands). The provably-correct fix is to reset the history on every USER
arrangement switch, so no index-based command ever survives an arr.notes
re-sort and cross-arrangement undo can never occur. The undo-driven switch opts
out via _undoDrivenArrSwitch so it never discards the stack it is replaying.
This mirrors the existing save/build reset() (another renumbering event).

Also cap the undo stack in doRedo (a redo pushes back onto it), mirroring
exec()/doUndo, so a redo-heavy session can't grow past MAX_UNDO.

Adds tests/cross_arr_undo.test.js, which eval's the REAL routing
(_historyEnsureArr + editorSelectArrangement + MoveNoteCmd, with a flatten stub
that mimics the real re-sort) — it fails on the pre-fix code and passes here.
The existing drum_undo.test.js only eval's the @pure blocks and so stubbed
_historyEnsureArr out entirely, leaving this path uncovered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@byrongamatos
byrongamatos merged commit e273687 into main Jul 6, 2026
1 check passed
@byrongamatos
byrongamatos deleted the feat/editor-undo-hardening branch July 6, 2026 19:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants