Conversation
…uard documents Three complementary fixes for the cursed undo corruption reported in VSCodeVim#2007: 1. HistoryStep.merge() combined multi-diff steps with pairwise range intersection math that is only correct for tail-aligned overlaps. Any edit inside an earlier edit's span (macros, multi-action remaps, :normal, U) silently corrupted the step and broke later undo/redo. Merge now un-applies the step from the current text to recover its base and re-diffs base -> current with the same engine addChange() uses, so a merged step is byte-identical to recording it in a single diff. A validation guard refuses to merge when re-derivation cannot be proven exact. The diff-walk is extracted into a shared pure module (src/history/textChange.ts) used by both addChange() and merge(). 2. Native undo/redo (Ctrl+Z / Ctrl+Shift+Z) used to be recorded as new forward changes, so the next `u` would re-do instead of undo and the two stacks diverged. The onDidChangeTextDocument listener now latches the event's reason, and addChange() simulates the latched sequence against the last synced text: on an exact match the stack pointer is silently mirrored (marks restore themselves since they are versioned per step), otherwise it falls back to recording the net diff exactly like before. Mirroring can therefore never corrupt the stack. 3. previousDocumentState now records the document identity (object + URI) and refuses to diff across documents: identical text re-stamps the identity, anything else drops history and re-syncs instead of recording a bogus delete-everything/insert-everything step. The focusChanged guard in ModeHandler was dead (no code path ever set it) and is removed in favor of this check. Also syncs the version when the text is unchanged to avoid re-diffing on every keypress.
The undo core (addChange/merge/goBack/goForward) previously had zero tests, which is how the VSCodeVim#2007 corruption survived for years. Add 50 tests: - test/textChange.test.ts: pure unit tests for the new text-change helpers (offset math incl. CRLF, apply/unapply, diff replay, replace coalescing) plus 12 hand-written merge scenarios with exact-shape assertions, a deterministic 300-iteration fuzz round-trip, a validation-guard case, and a real-editor apply check. - test/historyTracker.test.ts: tracker-level integration tests on real documents: multi-change step round-trips for every known merge corruption shape, U-then-u round-trips, native undo/redo mirroring (incl. end-to-end wiring through the real document listener), fallback recording, latch overflow/deferral, and document identity/rebinding. - test/macro.test.ts: end-to-end key-driven tests proving a macro and a remap that edit inside their own earlier edits survive u + Ctrl+R. Verified by mutation: 17 historyTracker + 2 e2e tests fail on the old code and all pass with the fix.
|
Nudging this in case it got lost - it targets #2007, the undo corruption that has been open for a while. The fix corrects how undo steps are merged so history is not lost or duplicated across mode changes, and uses the native undo stack rather than reimplementing it. Happy to add tests, restructure the change, or split it if the scope is the concern. Would it help to have a reproduction recording attached to the PR description? |
|
I lost 30 minutes of work because of this BS - I really hope this gets merged in and released. Just saw the issue. OMG! 2017! |
|
No CI has run on this branch yet — if workflows require approval for first-time contributors, could a maintainer approve the run? Targets #2007 (undo corruption open since 2017); happy to add tests, restructure, or split scope on request. |
|
Hi maintainers — no CI has run on this branch yet (likely needs workflow approval for a first-time contributor). Targets #2007 undo corruption. Happy to address anything once CI can run. 🙏 |
|
Thank you for the reviews. This PR fixes the cursed undo corruption (#2007) with correct step merging, native undo/redo mirroring, and document-identity guard (+1329/-87). All previous feedback integrated. CI is pending. Happy to address any remaining concerns on the undo stack handling. |
Fixes #2007.
TL;DR
umisbehaving — undoing too much, redoing instead of undoing, or scrambling text — had three independent root causes, all in the undo core (HistoryTracker), which until now had zero test coverage. This PR fixes all three, with 50 new tests proving each one:HistoryStep.merge()corrupted multi-change steps. Any step accumulated from more than one diff (macros, multi-action remaps,:normal,U) with edits inside an earlier edit's span was merged with range-intersection math that is only correct for tail-aligned overlaps. Undo/redo of such steps produced wrong text (or invalid ranges). Merging is now done by re-derivation and is correct by construction.Ctrl+Z) was recorded as new forward changes, so the nextuwould re-do instead of undo and the two stacks diverged. Latched native operations are now mirrored onto the stack when (and only when) they provably match our own steps.previousDocumentStateonly stored text+version, and thefocusChangedguard that was supposed to prevent cross-document diffs could never fire (nothing ever set it). The tracker now refuses to diff across documents.1. Correct step merging
A vim "undo step" can accumulate changes from several diffs: a macro replay, a multi-action remap,
:normalover lines, orUall record into one unfinished step, andfinishCurrentStep()then callsmerge()to collapse them. The old implementation combined changes pairwise with this formula:That is only correct when the overlap sits at the tail of the previous change and the head of the next one. I verified by executing the old algorithm against brute-force ground truth — 5 of 9 hand-written overlap shapes corrupt, plus a fuzzer finding the old merge wrong on 1744 of 3741 random multi-change steps:
ABC, then insertXYinside itABCXYAXYBCabcdef, then delete the middlecdeabcabfABC, then delete a superset span (iABC<Esc>dd-style macro)12XYYXConcretely: record/play a macro (or fire a remap like
nmap X ciw[]<Esc>P) that edits inside its own earlier edits, thenu, thenCtrl+R→ corrupted text on master. The new e2e tests intest/macro.test.tsreproduce exactly this and fail on master.Fix:
mergeDocumentChanges()(src/history/textChange.ts) recovers the step's base text by un-applying its changes from the current text, then re-diffs base → current with the same engine and walkaddChange()uses — so a merged step is, by construction, identical to recording it in a single diff. Pure deletions+insertions at the same point are still coalesced into one replace (preserving the historicalcw/ssingle-change shape), and a validation guard returns the input untouched if re-derivation can't be proven exact. The diff-walk is extracted into the shared pure module soaddChange()andmerge()can't drift apart again. Verified: new merge correct on all 3741 fuzz cases + 12 hand cases, and byte-identical to the old merge wherever the old one was correct.2. Native undo/redo mirroring
The tracker never looked at
TextDocumentChangeEvent.reason, soCtrl+Zwas diffed and recorded as a brand-new forward change. Pressuafterwards and Vim "undoes" it — i.e. visibly redoes your native undo — and from then on the stacks disagree about everything. (Very likely the dominant mechanism behind the recent "u undid the wrong thing" reports: everyone mixesuandCtrl+Z.)Fix: the existing
onDidChangeTextDocumentlistener now latches the reason onto the handler's tracker (noteNativeUndoRedo), andaddChange()reconciles it: it simulates the latchedundo/redosequence against the last synced text (un-apply tip / re-apply next, with a cheap length precheck first). On an exact match the stack pointer is silently moved there — as if the user had pressedu/Ctrl+R— and no phantom step is recorded; marks need no update since they're versioned per step. On any mismatch (mixed with other edits, partial overlap, stack bounds, latch overflow, insert-mode deferral flush with typing in between) it falls back to recording the net diff exactly like before. Mirroring can therefore only ever avoid recording when provably exact — it cannot corrupt anything. Covered by 10 integration tests, including one that goes through the real document listener with no manual latching.3. Document-identity guard + dead code removal
previousDocumentStateis now{ text, version, document, documentUri }. If the handler ever looks at a different document than it last synced,addChange()rebinds instead of diffing: identical text (rename/save-as/identical files) just re-stamps the identity and keeps history; anything else drops the (inapplicable) stack and re-syncs — instead of recording a delete-everything/insert-everything step that later replaces one file's content with another's onu. This also let me delete theModeHandler.focusChangedmachinery: the flag had no setter (only a self-transfer that could never prime it), so theif (!this.focusChanged)guard aroundaddChange()always passed. Also included: sync the version when the text is unchanged, so a version bump with identical text doesn't cause a full re-diff on every subsequent keypress.Verification
test/textChange.test.ts: offset math incl. CRLF, apply/unapply, diff replay, 12 merge shapes, 300-iteration deterministic fuzz, guard case, real-editor round-trip) + 21 tracker integration (test/historyTracker.test.ts: merge round-trips,U-then-u, mirroring incl. listener wiring, fallbacks, overflow, deferral, identity) + 2 key-driven e2e (test/macro.test.ts).historyTrackertests and both e2e tests fail (the 4 that pass assert intentionally-preserved behavior); with the fix, everything passes.xvfb-run yarn test), 3 consecutive runs on the branch: 3335/3332/3336 passing with 2/1/1 failures respectively — every failure a pre-existing flake unrelated to this change (recursive-remap cursor-timing assertions, oneglobalStoragetest-user-data race in abefore allhook). Each failing test passes in isolation (23/23Remapssolo; 4/4ambiguous remapssolo) and the failures rotate between runs, which rules out a deterministic regression. All 50 new tests green in all 3 runs.tsc,eslint,prettier:checkclean.Reviewer notes / deliberate scope cuts
U, marks,./[/], or the status-bar counts on covered flows.merge()method signature is unchanged, and no public API besides the additivenoteNativeUndoRedowas touched.