-
-
Notifications
You must be signed in to change notification settings - Fork 159
feat: add pause()/resume() to UndoManager #1053
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
acd36bb
a306b5c
183a397
6b30bea
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| --- | ||
| "loro-crdt": minor | ||
| "loro-js": minor | ||
| --- | ||
|
|
||
| Add `pause()`, `resume()`, and `isPaused()` to `UndoManager`. While paused, | ||
| local edits are not recorded as undo steps and checkout events do not clear the | ||
| stacks. Import events (remote changes) are still processed so that the stacks | ||
| remain correctly transformed against concurrent edits. Use this to preserve | ||
| undo/redo history across temporary checkouts such as read-only history previews. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1761,3 +1761,154 @@ fn undo_with_custom_commit_options() -> anyhow::Result<()> { | |
| assert_eq!(trigger_times.load(atomic::Ordering::SeqCst), 2); | ||
| Ok(()) | ||
| } | ||
|
|
||
| #[test] | ||
| fn pause_preserves_undo_stacks_across_checkout() -> LoroResult<()> { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Both new tests are single-peer checkout round-trips — they pass with or without the Import-while-paused distinction, so the central claim of the second commit is currently untested. Suggest adding (here and mirrored in #[test]
fn import_while_paused_keeps_stacks_transformed() -> LoroResult<()> {
let doc_a = LoroDoc::new();
doc_a.set_peer_id(1)?;
let doc_b = LoroDoc::new();
doc_b.set_peer_id(2)?;
let mut undo = UndoManager::new(&doc_a);
let text_a = doc_a.get_text("text");
text_a.insert(0, "local")?;
doc_a.commit();
undo.pause();
// concurrent remote edit arrives while paused
doc_b.import(&doc_a.export(loro::ExportMode::all_updates())?)?;
doc_b.get_text("text").insert(0, "remote-")?;
doc_b.commit();
doc_a.import(&doc_b.export(loro::ExportMode::all_updates())?)?;
undo.resume();
// undo must remove only the local edit, at the transformed position
undo.undo()?;
assert_eq!(text_a.to_string(), "remote-");
Ok(())
}This is the regression test that would catch a future refactor back to a blanket early-return guard. |
||
| let doc = LoroDoc::new(); | ||
| doc.set_peer_id(1)?; | ||
| let mut undo = UndoManager::new(&doc); | ||
| let text = doc.get_text("text"); | ||
|
|
||
| text.insert(0, "Hello")?; | ||
| doc.commit(); | ||
| text.insert(5, " World")?; | ||
| doc.commit(); | ||
| assert_eq!(text.to_string(), "Hello World"); | ||
| assert!(undo.can_undo()); | ||
|
|
||
| // Pause before checkout so undo stacks survive | ||
| undo.pause(); | ||
| assert!(undo.is_paused()); | ||
| doc.checkout(&Frontiers::default())?; | ||
| assert_eq!(text.to_string(), ""); | ||
| doc.attach(); | ||
| assert_eq!(text.to_string(), "Hello World"); | ||
| undo.resume(); | ||
| assert!(!undo.is_paused()); | ||
|
|
||
| // Undo stacks should still be intact | ||
| assert!(undo.can_undo()); | ||
| undo.undo()?; | ||
| assert_eq!(text.to_string(), "Hello"); | ||
| undo.undo()?; | ||
| assert_eq!(text.to_string(), ""); | ||
|
|
||
| // Redo should also work | ||
| assert!(undo.can_redo()); | ||
| undo.redo()?; | ||
| assert_eq!(text.to_string(), "Hello"); | ||
| undo.redo()?; | ||
| assert_eq!(text.to_string(), "Hello World"); | ||
|
|
||
| Ok(()) | ||
| } | ||
|
|
||
| #[test] | ||
| fn checkout_without_pause_clears_undo_stacks() -> LoroResult<()> { | ||
| let doc = LoroDoc::new(); | ||
| doc.set_peer_id(1)?; | ||
| let undo = UndoManager::new(&doc); | ||
| let text = doc.get_text("text"); | ||
|
|
||
| text.insert(0, "Hello")?; | ||
| doc.commit(); | ||
| assert!(undo.can_undo()); | ||
|
|
||
| // Checkout without pausing should still clear stacks (existing behavior) | ||
| doc.checkout(&Frontiers::default())?; | ||
| assert!(!undo.can_undo()); | ||
| assert!(!undo.can_redo()); | ||
|
|
||
| Ok(()) | ||
| } | ||
|
|
||
| #[test] | ||
| fn undo_while_paused_does_not_leak_processing_flag() -> LoroResult<()> { | ||
| let doc = LoroDoc::new(); | ||
| doc.set_peer_id(1)?; | ||
| let mut undo = UndoManager::new(&doc); | ||
| let text = doc.get_text("text"); | ||
|
|
||
| text.insert(0, "Hello")?; | ||
| doc.commit(); | ||
| assert!(undo.can_undo()); | ||
|
|
||
| undo.pause(); | ||
| assert_eq!(undo.undo()?, false); | ||
| undo.resume(); | ||
|
|
||
| assert!(undo.can_undo()); | ||
| undo.undo()?; | ||
| assert_eq!(text.to_string(), ""); | ||
|
|
||
| text.insert(0, "World")?; | ||
| doc.commit(); | ||
| assert!(undo.can_undo()); | ||
| undo.undo()?; | ||
| assert_eq!(text.to_string(), ""); | ||
|
|
||
| Ok(()) | ||
| } | ||
|
|
||
| #[test] | ||
| fn paused_local_edits_are_not_folded_into_next_undo() -> LoroResult<()> { | ||
| let doc = LoroDoc::new(); | ||
| doc.set_peer_id(1)?; | ||
| let mut undo = UndoManager::new(&doc); | ||
| let text = doc.get_text("text"); | ||
|
|
||
| text.insert(0, "A")?; | ||
| doc.commit(); | ||
|
|
||
| undo.pause(); | ||
| text.insert(1, "B")?; | ||
| doc.commit(); | ||
|
|
||
| undo.resume(); | ||
| text.insert(2, "C")?; | ||
| doc.commit(); | ||
| assert_eq!(text.to_string(), "ABC"); | ||
|
|
||
| undo.undo()?; | ||
| assert_eq!(text.to_string(), "AB"); | ||
|
|
||
| undo.undo()?; | ||
| assert_eq!(text.to_string(), "B"); | ||
|
|
||
| assert!(!undo.can_undo()); | ||
|
|
||
| Ok(()) | ||
| } | ||
|
|
||
| #[test] | ||
| fn import_during_pause_transforms_undo_stack_correctly() -> LoroResult<()> { | ||
| let doc_a = LoroDoc::new(); | ||
| doc_a.set_peer_id(1)?; | ||
| let mut undo = UndoManager::new(&doc_a); | ||
| let text_a = doc_a.get_text("text"); | ||
|
|
||
| text_a.insert(0, "Hello")?; | ||
| doc_a.commit(); | ||
|
|
||
| undo.pause(); | ||
|
|
||
| let doc_b = LoroDoc::new(); | ||
| doc_b.set_peer_id(2)?; | ||
| let updates_a = doc_a.export(ExportMode::all_updates())?; | ||
| doc_b.import(&updates_a)?; | ||
| doc_b.get_text("text").insert(0, "XY")?; | ||
| doc_b.commit(); | ||
| let updates_b = doc_b.export(ExportMode::updates(&doc_a.oplog_vv()))?; | ||
|
|
||
| doc_a.import(&updates_b)?; | ||
| assert_eq!(text_a.to_string(), "XYHello"); | ||
|
|
||
| undo.resume(); | ||
| undo.undo()?; | ||
| assert_eq!(text_a.to_string(), "XY"); | ||
|
|
||
| undo.redo()?; | ||
| assert_eq!(text_a.to_string(), "XYHello"); | ||
|
|
||
| Ok(()) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
undo()/redo()are not guarded while paused, and the primary use case makes that fatal. With the slider open the doc is detached and — thanks to this PR — the stacks are non-empty for the first time in that state. A stray Ctrl+Z then runsperform(), which pops the top item and getsErr(EditWhenDetached)fromundo_internal. The?exits beforeprocessing_undo = falseis restored, so:processing_undostaystrueforever → every future Local event is skipped → undo recording silently dead.Repro in the review body (
can_undo()isfalseafter resume + edit).Suggested fix: early-return
Ok(false)fromperform()when paused, and make the error path safe regardless (resetprocessing_undovia a drop guard, push the popped item back on error) — the TS runtime's#invertalready does both viacatch/finally, so this also restores parity.