diff --git a/.changeset/undo-pause-resume.md b/.changeset/undo-pause-resume.md new file mode 100644 index 000000000..0c5e52592 --- /dev/null +++ b/.changeset/undo-pause-resume.md @@ -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. diff --git a/crates/loro-internal/src/undo.rs b/crates/loro-internal/src/undo.rs index e135e7462..f60fa1d32 100644 --- a/crates/loro-internal/src/undo.rs +++ b/crates/loro-internal/src/undo.rs @@ -203,6 +203,7 @@ struct UndoManagerInner { undo_stack: Stack, redo_stack: Stack, processing_undo: bool, + paused: bool, last_undo_time: i64, merge_interval_in_ms: i64, max_stack_size: usize, @@ -244,6 +245,16 @@ impl UndoGroup { } } +struct ProcessingUndoGuard { + inner: Arc>>, +} + +impl Drop for ProcessingUndoGuard { + fn drop(&mut self) { + self.inner.lock().borrow_mut().processing_undo = false; + } +} + #[derive(Debug)] struct Stack { stack: VecDeque<(VecDeque, Arc>)>, @@ -502,6 +513,7 @@ impl UndoManagerInner { undo_stack: Default::default(), redo_stack: Default::default(), processing_undo: false, + paused: false, merge_interval_in_ms: 0, last_undo_time: 0, max_stack_size: usize::MAX, @@ -616,15 +628,18 @@ impl UndoManager { .iter() .find(|x| x.peer == peer_clone.load(std::sync::atomic::Ordering::Relaxed)) { + let is_paused = lock.borrow().paused; let should_exclude = lock .borrow() .exclude_origin_prefixes .iter() .any(|x| event.event_meta.origin.starts_with(&**x)); - if should_exclude { - // If the event is from the excluded origin, we don't record it - // in the undo stack. But we need to record its effect like it's - // a remote event. + if is_paused || should_exclude { + // Paused and excluded-origin edits are not recorded as + // undo steps, but their effects must be composed into + // both stacks so transforms stay correct, and + // next_counter must advance so the next recorded item + // does not fold these edits into its span. let mut inner = lock.borrow_mut(); inner.undo_stack.compose_remote_event(event.events); inner.redo_stack.compose_remote_event(event.events); @@ -666,6 +681,9 @@ impl UndoManager { } EventTriggerKind::Checkout => { let lock = inner_clone.lock(); + if lock.borrow().paused { + return; + } let mut inner = lock.borrow_mut(); inner.undo_stack.clear(); inner.redo_stack.clear(); @@ -766,6 +784,9 @@ impl UndoManager { get_opposite: impl Fn(&mut UndoManagerInner) -> &mut Stack, kind: UndoOrRedo, ) -> LoroResult { + if self.inner.lock().borrow().paused { + return Ok(false); + } let doc = &self.doc.clone(); // When in the undo/redo loop, the new undo/redo stack item should restore the selection // to the state it was in before the item that was popped two steps ago from the stack. @@ -828,6 +849,9 @@ impl UndoManager { inner.processing_undo = true; get_stack(&mut inner).pop() }; + let _guard = ProcessingUndoGuard { + inner: self.inner.clone(), + }; let mut executed = false; while let Some((mut span, remote_diff)) = top { @@ -836,7 +860,7 @@ impl UndoManager { let inner = self.inner.clone(); // We need to clone this because otherwise will be applied to the same remote diff let remote_change_clone = remote_diff.lock().clone(); - let commit = doc.undo_internal( + let commit = match doc.undo_internal( IdSpan { peer: self.peer(), counter: span.span, @@ -850,7 +874,14 @@ impl UndoManager { get_stack(&mut inner.borrow_mut()).transform_based_on_this_delta(diff); }); }, - )?; + ) { + Ok(c) => c, + Err(e) => { + get_stack(&mut self.inner.lock().borrow_mut()) + .push(span.span, span.meta); + return Err(e); + } + }; drop(commit); let inner = self.inner.lock(); let mut is_some = false; @@ -914,7 +945,6 @@ impl UndoManager { } } - self.inner.lock().borrow_mut().processing_undo = false; Ok(executed) } @@ -977,6 +1007,32 @@ impl UndoManager { self.inner.lock().borrow_mut().undo_stack.clear(); } + /// Pause the UndoManager so that local edits and checkout events are ignored. + /// + /// 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 before temporary checkouts (e.g. read-only history preview) that + /// should not disturb the undo/redo history. Close any open group (via + /// [`Self::group_end`]) before pausing. + /// + /// Call [`Self::resume`] after returning the document to its original state. + pub fn pause(&self) { + self.inner.lock().borrow_mut().paused = true; + } + + /// Resume the UndoManager after a [`Self::pause`]. + pub fn resume(&self) { + self.inner.lock().borrow_mut().paused = false; + } + + /// Returns whether the UndoManager is currently paused. + pub fn is_paused(&self) -> bool { + self.inner.lock().borrow().paused + } + pub fn set_top_undo_meta(&self, meta: UndoItemMeta) { self.inner.lock().borrow_mut().undo_stack.set_top_meta(meta); } diff --git a/crates/loro-wasm/src/lib.rs b/crates/loro-wasm/src/lib.rs index d9b8a4dfd..023925e03 100644 --- a/crates/loro-wasm/src/lib.rs +++ b/crates/loro-wasm/src/lib.rs @@ -5452,6 +5452,31 @@ impl UndoManager { pub fn clearUndo(&self) { self.undo.lock().clear_undo(); } + + /// Pause the UndoManager so that local edits and checkout events are ignored. + /// + /// 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 before temporary checkouts (e.g. read-only history preview) that + /// should not disturb undo/redo history. Close any open group before pausing. + /// + /// Call `resume()` after returning the document to its original state. + pub fn pause(&self) { + self.undo.lock().pause(); + } + + /// Resume the UndoManager after a `pause()`. + pub fn resume(&self) { + self.undo.lock().resume(); + } + + /// Returns whether the UndoManager is currently paused. + pub fn isPaused(&self) -> bool { + self.undo.lock().is_paused() + } } /// Use this function to throw an error after the micro task. diff --git a/crates/loro/src/lib.rs b/crates/loro/src/lib.rs index 62f8051a0..2b4933097 100644 --- a/crates/loro/src/lib.rs +++ b/crates/loro/src/lib.rs @@ -3976,6 +3976,32 @@ impl UndoManager { pub fn top_redo_value(&self) -> Option { self.0.top_redo_value() } + + /// Pause the UndoManager so that local edits and checkout events are ignored. + /// + /// 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 before temporary checkouts (e.g. read-only history preview) that + /// should not disturb undo/redo history. Close any open group (via + /// [`Self::group_end`]) before pausing. + /// + /// Call [`Self::resume`] after returning the document to its original state. + pub fn pause(&self) { + self.0.pause(); + } + + /// Resume the UndoManager after a [`Self::pause`]. + pub fn resume(&self) { + self.0.resume(); + } + + /// Returns whether the UndoManager is currently paused. + pub fn is_paused(&self) -> bool { + self.0.is_paused() + } } /// When a undo/redo item is pushed, the undo manager will call the on_push callback to get the meta data of the undo item. /// The returned cursors will be recorded for a new pushed undo item. diff --git a/crates/loro/tests/integration_test/undo_test.rs b/crates/loro/tests/integration_test/undo_test.rs index 0375a4515..b235871b8 100644 --- a/crates/loro/tests/integration_test/undo_test.rs +++ b/crates/loro/tests/integration_test/undo_test.rs @@ -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<()> { + 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(()) +} diff --git a/loro-js/src/runtime/undo.ts b/loro-js/src/runtime/undo.ts index b7cdccd4f..53f979964 100644 --- a/loro-js/src/runtime/undo.ts +++ b/loro-js/src/runtime/undo.ts @@ -71,6 +71,7 @@ export class UndoManager { #onPush: UndoConfig["onPush"]; #onPop: UndoConfig["onPop"]; #applying = false; + #paused = false; #groupDepth = 0; #unsubscribe: () => void; @@ -92,6 +93,7 @@ export class UndoManager { } undo(): boolean { + if (this.#paused) return false; const item = this.#undo.pop(); if (item === undefined) return false; try { @@ -105,6 +107,7 @@ export class UndoManager { } redo(): boolean { + if (this.#paused) return false; const item = this.#redo.pop(); if (item === undefined) return false; try { @@ -186,6 +189,18 @@ export class UndoManager { this.#redo.length = 0; } + pause(): void { + this.#paused = true; + } + + resume(): void { + this.#paused = false; + } + + isPaused(): boolean { + return this.#paused; + } + destroy(): void { this.#unsubscribe(); this.clear(); @@ -195,13 +210,17 @@ export class UndoManager { if (this.#applying) return; const targets = new Set(event.events.map(({ target }) => target)); if (event.by === "checkout") { - this.clear(); + if (!this.#paused) this.clear(); return; } if (event.by === "import") { for (const target of targets) this.#remoteTargets.add(target); return; } + if (this.#paused) { + for (const target of targets) this.#remoteTargets.add(target); + return; + } if ( event.origin !== undefined && [...this.#excludeOriginPrefixes].some((prefix) => event.origin!.startsWith(prefix)) diff --git a/loro-js/tests/runtime.test.ts b/loro-js/tests/runtime.test.ts index 66afd83f6..47550b1a5 100644 --- a/loro-js/tests/runtime.test.ts +++ b/loro-js/tests/runtime.test.ts @@ -684,6 +684,93 @@ describe("loro-wasm-compatible runtime", () => { expect(text.toString()).toBe("Hello"); }); + test("pause preserves undo stacks across checkout", () => { + const doc = new LoroDoc(); + doc.setPeerId(1); + const undo = new UndoManager(doc, { mergeInterval: 0 }); + const text = doc.getText("text"); + + text.insert(0, "Hello"); + doc.commit(); + text.insert(5, " World"); + doc.commit(); + expect(text.toString()).toBe("Hello World"); + expect(undo.canUndo()).toBe(true); + + undo.pause(); + expect(undo.isPaused()).toBe(true); + doc.checkout([]); + expect(text.toString()).toBe(""); + doc.attach(); + expect(text.toString()).toBe("Hello World"); + undo.resume(); + expect(undo.isPaused()).toBe(false); + + expect(undo.canUndo()).toBe(true); + expect(undo.undo()).toBe(true); + expect(text.toString()).toBe("Hello"); + expect(undo.undo()).toBe(true); + expect(text.toString()).toBe(""); + + expect(undo.canRedo()).toBe(true); + expect(undo.redo()).toBe(true); + expect(text.toString()).toBe("Hello"); + }); + + test("paused local edits are not folded into next undo", () => { + const doc = new LoroDoc(); + doc.setPeerId(1); + const undo = new UndoManager(doc, { mergeInterval: 0 }); + const text = doc.getText("text"); + + text.insert(0, "A"); + doc.commit(); + + undo.pause(); + text.insert(1, "B"); + doc.commit(); + + undo.resume(); + text.insert(2, "C"); + doc.commit(); + expect(text.toString()).toBe("ABC"); + + expect(undo.undo()).toBe(true); + expect(text.toString()).toBe("AB"); + + expect(undo.undo()).toBe(true); + expect(text.toString()).toBe("B"); + + expect(undo.canUndo()).toBe(false); + }); + + test("import during pause transforms undo stack correctly", () => { + const doc = new LoroDoc(); + doc.setPeerId(1); + const undo = new UndoManager(doc, { mergeInterval: 0 }); + const text = doc.getText("text"); + + text.insert(0, "Hello"); + doc.commit(); + + undo.pause(); + + const remote = new LoroDoc(); + remote.setPeerId(2); + remote.import(doc.export({ mode: "snapshot" })); + remote.getText("text").insert(0, "XY"); + remote.commit(); + doc.import(remote.export({ mode: "update", from: doc.version() })); + expect(text.toString()).toBe("XYHello"); + + undo.resume(); + expect(undo.undo()).toBe(true); + expect(text.toString()).toBe("XY"); + + expect(undo.redo()).toBe(true); + expect(text.toString()).toBe("XYHello"); + }); + test("round-trips compressed and uncompressed JSON updates", () => { const source = new LoroDoc(); source.setPeerId(19);