diff --git a/crates/loro-internal/Cargo.toml b/crates/loro-internal/Cargo.toml index 6f2229a1b..758f5bf15 100644 --- a/crates/loro-internal/Cargo.toml +++ b/crates/loro-internal/Cargo.toml @@ -128,3 +128,7 @@ harness = false [[bench]] name = "jsonpath" harness = false + +[[bench]] +name = "undo" +harness = false diff --git a/crates/loro-internal/benches/undo.rs b/crates/loro-internal/benches/undo.rs new file mode 100644 index 000000000..3136714f9 --- /dev/null +++ b/crates/loro-internal/benches/undo.rs @@ -0,0 +1,186 @@ +use criterion::{criterion_group, criterion_main, Criterion}; + +#[cfg(feature = "test_utils")] +mod run { + use super::*; + use loro_internal::{ + handler::{HandlerTrait, UpdateOptions}, + LoroDoc, UndoManager, UndoScope, + }; + + /// Number of commits to record per benchmark iteration. Small enough to keep + /// runs fast, large enough to amortize per-iteration timer noise so we can + /// see sub-microsecond per-commit deltas between configurations. + const N_COMMITS: usize = 1_000; + + fn one_text_edit(loro: &LoroDoc, value: &str) { + let text = loro.get_text("text"); + text.update(value, UpdateOptions::default()).unwrap(); + loro.commit_then_renew(); + } + + /// Record-time cost: build a doc, attach an UndoManager (in three different + /// configurations), and measure the time to record `N_COMMITS` local commits. + /// This is the hot path the subscription callback sits on. + pub fn record_local_commits(c: &mut Criterion) { + let mut g = c.benchmark_group("undo/record_local_commits"); + g.sample_size(50); + + // Baseline: no UndoManager attached at all. Establishes the cost of just + // committing N edits, so we can isolate the manager's overhead. + g.bench_function("no_manager", |b| { + b.iter(|| { + let loro = LoroDoc::default(); + for i in 0..N_COMMITS { + one_text_edit(&loro, &format!("v{}", i)); + } + }); + }); + + // Default: UndoManager with UndoScope::Doc (current behavior, our changes + // must not regress this). Compares directly against the same code on main. + g.bench_function("undo_manager_default_scope", |b| { + b.iter(|| { + let loro = LoroDoc::default(); + let _undo = UndoManager::new(&loro); + for i in 0..N_COMMITS { + one_text_edit(&loro, &format!("v{}", i)); + } + }); + }); + + // Scoped: UndoManager with UndoScope::Containers([text_id]). Quantifies + // the cost users opt into when they enable scope. Single-container scope + // is the smallest possible set; larger sets only affect FxHashSet lookup + // (constant-time average). + g.bench_function("undo_manager_scoped_one_container", |b| { + b.iter(|| { + let loro = LoroDoc::default(); + let text = loro.get_text("text"); + let _undo = UndoManager::new(&loro) + .with_scope(UndoScope::containers([text.id()])); + for i in 0..N_COMMITS { + one_text_edit(&loro, &format!("v{}", i)); + } + }); + }); + } + + /// Mixed-scope workload: alternate edits between an in-scope and an + /// out-of-scope container. Out-of-scope commits hit the + /// `compose_remote_event` branch instead of `record_checkpoint`. This + /// stresses the path the scope feature actually exists for. + pub fn record_mixed_scope(c: &mut Criterion) { + let mut g = c.benchmark_group("undo/record_mixed_scope"); + g.sample_size(50); + + // Doc-wide for reference: every commit is recorded. + g.bench_function("doc_scope_all_recorded", |b| { + b.iter(|| { + let loro = LoroDoc::default(); + let text_a = loro.get_text("a"); + let text_b = loro.get_text("b"); + let _undo = UndoManager::new(&loro); + for i in 0..(N_COMMITS / 2) { + text_a + .update(&format!("a{}", i), UpdateOptions::default()) + .unwrap(); + loro.commit_then_renew(); + text_b + .update(&format!("b{}", i), UpdateOptions::default()) + .unwrap(); + loro.commit_then_renew(); + } + }); + }); + + // Container scope = {a}: half the commits are filtered to the + // compose-as-remote branch. + g.bench_function("scoped_a_half_filtered", |b| { + b.iter(|| { + let loro = LoroDoc::default(); + let text_a = loro.get_text("a"); + let text_b = loro.get_text("b"); + let _undo = UndoManager::new(&loro) + .with_scope(UndoScope::containers([text_a.id()])); + for i in 0..(N_COMMITS / 2) { + text_a + .update(&format!("a{}", i), UpdateOptions::default()) + .unwrap(); + loro.commit_then_renew(); + text_b + .update(&format!("b{}", i), UpdateOptions::default()) + .unwrap(); + loro.commit_then_renew(); + } + }); + }); + } + + /// Replay-time cost: build a doc with N recorded commits, then measure the + /// time to undo every commit followed by redoing every commit. Exercises + /// `undo_internal_with_scope` end-to-end including the optional mask block. + pub fn undo_redo_all(c: &mut Criterion) { + let mut g = c.benchmark_group("undo/replay_all"); + g.sample_size(20); + + g.bench_function("doc_scope", |b| { + b.iter_batched( + || { + let loro = LoroDoc::default(); + let undo = UndoManager::new(&loro); + for i in 0..N_COMMITS { + one_text_edit(&loro, &format!("v{}", i)); + } + (loro, undo) + }, + |(_loro, undo)| { + while undo.can_undo() { + undo.undo().unwrap(); + } + while undo.can_redo() { + undo.redo().unwrap(); + } + }, + criterion::BatchSize::SmallInput, + ); + }); + + g.bench_function("scoped_one_container", |b| { + b.iter_batched( + || { + let loro = LoroDoc::default(); + let text = loro.get_text("text"); + let undo = UndoManager::new(&loro) + .with_scope(UndoScope::containers([text.id()])); + for i in 0..N_COMMITS { + one_text_edit(&loro, &format!("v{}", i)); + } + (loro, undo) + }, + |(_loro, undo)| { + while undo.can_undo() { + undo.undo().unwrap(); + } + while undo.can_redo() { + undo.redo().unwrap(); + } + }, + criterion::BatchSize::SmallInput, + ); + }); + } +} + +pub fn dumb(_c: &mut Criterion) {} + +#[cfg(feature = "test_utils")] +criterion_group!( + benches, + run::record_local_commits, + run::record_mixed_scope, + run::undo_redo_all, +); +#[cfg(not(feature = "test_utils"))] +criterion_group!(benches, dumb); +criterion_main!(benches); diff --git a/crates/loro-internal/src/lib.rs b/crates/loro-internal/src/lib.rs index d802af3a1..e20219c6a 100644 --- a/crates/loro-internal/src/lib.rs +++ b/crates/loro-internal/src/lib.rs @@ -40,7 +40,7 @@ pub use state::DocState; pub use state::{TreeNode, TreeNodeWithChildren, TreeParentId}; use subscription::{LocalUpdateCallback, Observer, PeerIdUpdateCallback}; use txn::Transaction; -pub use undo::UndoManager; +pub use undo::{UndoManager, UndoScope}; pub use utils::subscription::SubscriberSetWithQueue; pub use utils::subscription::Subscription; pub mod allocation; diff --git a/crates/loro-internal/src/loro.rs b/crates/loro-internal/src/loro.rs index c00af2e9a..92263c59c 100644 --- a/crates/loro-internal/src/loro.rs +++ b/crates/loro-internal/src/loro.rs @@ -1154,13 +1154,40 @@ impl LoroDoc { /// This implementation is kinda slow, but it's simple and maintainable. We can optimize it /// further when it's needed. The time complexity is O(n + m), n is the ops in the id_span, m is the /// distance from id_span to the current latest version. - #[instrument(level = "info", skip_all)] + #[inline] pub fn undo_internal( &self, id_span: IdSpan, container_remap: &mut FxHashMap, post_transform_base: Option<&DiffBatch>, before_diff: &mut dyn FnMut(&DiffBatch), + ) -> LoroResult> { + // Tracing instrumentation lives on `undo_internal_with_scope`; keeping + // it off this thin wrapper avoids duplicate spans for direct callers. + self.undo_internal_with_scope( + id_span, + container_remap, + post_transform_base, + before_diff, + None, + ) + } + + /// Like [`Self::undo_internal`], but additionally accepts a `scope_filter`. + /// + /// When `scope_filter` is `Some`, the diff computed for the undo is masked + /// so only containers in the set are mutated. This lets callers (notably + /// [`UndoManager`] with [`crate::UndoScope::Containers`]) revert just the + /// in-scope portion of a commit even when the original commit also touched + /// out-of-scope containers. + #[instrument(level = "info", skip_all)] + pub fn undo_internal_with_scope( + &self, + id_span: IdSpan, + container_remap: &mut FxHashMap, + post_transform_base: Option<&DiffBatch>, + before_diff: &mut dyn FnMut(&DiffBatch), + scope_filter: Option<&FxHashSet>, ) -> LoroResult> { if !self.can_edit() { return Err(LoroError::EditWhenDetached); @@ -1208,6 +1235,26 @@ impl LoroDoc { } drop(txn); self.start_auto_commit(); + + // If a scope filter was supplied (UndoManager with UndoScope::Containers), + // mask the diff so only in-scope containers are reverted. This is what + // makes mixed commits — single commits touching both in-scope and + // out-of-scope containers — undo only their in-scope portion. + // The filter walks container_remap so a remapped target counts as in-scope + // when the remap destination is in scope, mirroring _apply_diff's own walk. + let mut diff = diff; + if let Some(scope) = scope_filter { + let in_scope = |cid: &ContainerID| -> bool { + let mut id = cid.clone(); + while let Some(rid) = container_remap.get(&id) { + id = rid.clone(); + } + scope.contains(&id) + }; + diff.cid_to_events.retain(|cid, _| in_scope(cid)); + diff.order.retain(|cid| in_scope(cid)); + } + // Try applying the diff, but ignore the error if it happens. // MovableList's undo behavior is too tricky to handle in a collaborative env // so in edge cases this may be an Error diff --git a/crates/loro-internal/src/undo.rs b/crates/loro-internal/src/undo.rs index e135e7462..bc61834e5 100644 --- a/crates/loro-internal/src/undo.rs +++ b/crates/loro-internal/src/undo.rs @@ -191,6 +191,32 @@ impl UndoOrRedo { } } +/// Restricts which containers an [`UndoManager`] tracks for undo/redo. +/// +/// The default is [`UndoScope::Doc`], which preserves the historical doc-wide +/// behavior. [`UndoScope::Containers`] restricts tracking to a fixed set of +/// containers — local commits that touch only out-of-scope containers are +/// composed into the stacks as if they were remote (so cursor transforms and +/// counter bookkeeping stay correct) but are not pushed as undo items. +#[derive(Debug, Clone, Default)] +pub enum UndoScope { + /// Track every local commit on this peer (default). + #[default] + Doc, + /// Track only commits that touch at least one of the listed containers. + Containers(FxHashSet), +} + +impl UndoScope { + /// Build a [`UndoScope::Containers`] from any iterator of [`ContainerID`]. + pub fn containers(ids: I) -> Self + where + I: IntoIterator, + { + UndoScope::Containers(ids.into_iter().collect()) + } +} + /// 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. pub type OnPush = Box< @@ -207,6 +233,7 @@ struct UndoManagerInner { merge_interval_in_ms: i64, max_stack_size: usize, exclude_origin_prefixes: Vec>, + scope: UndoScope, last_popped_selection: Option>, on_push: Option, on_pop: Option, @@ -224,6 +251,7 @@ impl std::fmt::Debug for UndoManagerInner { .field("merge_interval", &self.merge_interval_in_ms) .field("max_stack_size", &self.max_stack_size) .field("exclude_origin_prefixes", &self.exclude_origin_prefixes) + .field("scope", &self.scope) .field("group", &self.group) .finish() } @@ -506,6 +534,7 @@ impl UndoManagerInner { last_undo_time: 0, max_stack_size: usize::MAX, exclude_origin_prefixes: vec![], + scope: UndoScope::default(), last_popped_selection: None, on_pop: None, on_push: None, @@ -616,15 +645,30 @@ impl UndoManager { .iter() .find(|x| x.peer == peer_clone.load(std::sync::atomic::Ordering::Relaxed)) { - let should_exclude = lock - .borrow() - .exclude_origin_prefixes - .iter() - .any(|x| event.event_meta.origin.starts_with(&**x)); + let should_exclude = { + let inner = lock.borrow(); + let origin_excluded = inner + .exclude_origin_prefixes + .iter() + .any(|x| event.event_meta.origin.starts_with(&**x)); + // Inline the scope check so the optimizer can fold the + // Doc arm to a constant `false` and skip the iteration + // entirely in the default path (zero cost when no scope + // is set). + let out_of_scope = match &inner.scope { + UndoScope::Doc => false, + UndoScope::Containers(set) => { + !event.events.iter().any(|d| set.contains(&d.id)) + } + }; + origin_excluded || out_of_scope + }; 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 the event is from the excluded origin or touches no + // in-scope containers, we don't record it in the undo stack. + // But we need to record its effect like it's a remote event, + // so cursor transforms remain correct and the next in-scope + // commit's CounterSpan starts past this commit. let mut inner = lock.borrow_mut(); inner.undo_stack.compose_remote_event(event.events); inner.redo_stack.compose_remote_event(event.events); @@ -733,6 +777,28 @@ impl UndoManager { .push(prefix.into()); } + /// Restrict this manager to a specific [`UndoScope`]. + /// + /// When set to [`UndoScope::Containers`], the manager combines two filters: + /// + /// - **Record-time:** local commits that touch only out-of-scope containers + /// are not pushed onto the undo stack; instead they are composed into the + /// stacks as if they were remote events, which keeps cursor transforms + /// accurate and advances counter bookkeeping cleanly past them. + /// - **Replay-time:** when an undo or redo is performed, the computed + /// `DiffBatch` is masked so only in-scope containers are mutated. This + /// means a single commit that touched both in-scope and out-of-scope + /// containers undoes only its in-scope portion. + pub fn set_scope(&self, scope: UndoScope) { + self.inner.lock().borrow_mut().scope = scope; + } + + /// Builder-style variant of [`Self::set_scope`]. + pub fn with_scope(self, scope: UndoScope) -> Self { + self.set_scope(scope); + self + } + pub fn record_new_checkpoint(&self) -> LoroResult<()> { // Use implicit-style barrier to preserve next-commit options across // an empty commit before undo/redo processing. @@ -836,7 +902,13 @@ 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( + // Snapshot the scope set so undo_internal can mask out-of-scope + // entries from the computed diff before applying it. + let scope_set = match &self.inner.lock().borrow().scope { + UndoScope::Doc => None, + UndoScope::Containers(set) => Some(set.clone()), + }; + let commit = doc.undo_internal_with_scope( IdSpan { peer: self.peer(), counter: span.span, @@ -850,6 +922,7 @@ impl UndoManager { get_stack(&mut inner.borrow_mut()).transform_based_on_this_delta(diff); }); }, + scope_set.as_ref(), )?; drop(commit); let inner = self.inner.lock(); diff --git a/crates/loro-internal/tests/undo.rs b/crates/loro-internal/tests/undo.rs index c1ad818f2..54ef06c44 100644 --- a/crates/loro-internal/tests/undo.rs +++ b/crates/loro-internal/tests/undo.rs @@ -1,7 +1,10 @@ use std::borrow::Cow; use loro_internal::{ - cursor::PosType, handler::UpdateOptions, loro::ExportMode, LoroDoc, UndoManager, + cursor::PosType, + handler::{HandlerTrait, UpdateOptions}, + loro::ExportMode, + LoroDoc, UndoManager, UndoScope, }; #[test] @@ -229,3 +232,235 @@ fn test_clear_undo() { undo_manager.redo().unwrap(); assert_eq!(text.to_string(), "hello world"); } + +// --------------------------------------------------------------------------- +// UndoScope tests +// --------------------------------------------------------------------------- + +#[test] +fn scope_default_is_doc_wide() { + // UndoScope::Doc is the default and must behave identically to no scope. + let doc = LoroDoc::new(); + let undo = UndoManager::new(&doc).with_scope(UndoScope::Doc); + let text = doc.get_text("text"); + + text.update("hello", UpdateOptions::default()).unwrap(); + doc.commit_then_renew(); + text.update("hello world", UpdateOptions::default()).unwrap(); + doc.commit_then_renew(); + + undo.undo().unwrap(); + assert_eq!(text.to_string(), "hello"); + undo.undo().unwrap(); + assert_eq!(text.to_string(), ""); + undo.redo().unwrap(); + assert_eq!(text.to_string(), "hello"); +} + +#[test] +fn scope_excludes_out_of_scope_local_commits() { + // Two text containers a/b. Scope = {a}. Edits to b must not appear on the + // undo stack, edits to a must. + let doc = LoroDoc::new(); + let text_a = doc.get_text("a"); + let text_b = doc.get_text("b"); + let undo = UndoManager::new(&doc).with_scope(UndoScope::containers([text_a.id()])); + + text_a.update("a1", UpdateOptions::default()).unwrap(); + doc.commit_then_renew(); + text_b.update("b1", UpdateOptions::default()).unwrap(); + doc.commit_then_renew(); + text_a.update("a1a2", UpdateOptions::default()).unwrap(); + doc.commit_then_renew(); + + assert_eq!(undo.undo_count(), 2, "only the two a-edits should be tracked"); + + undo.undo().unwrap(); + assert_eq!(text_a.to_string(), "a1", "first undo reverts the second a-edit"); + assert_eq!(text_b.to_string(), "b1", "b is untouched by undo"); + + undo.undo().unwrap(); + assert_eq!(text_a.to_string(), "", "second undo reverts the first a-edit"); + assert_eq!(text_b.to_string(), "b1", "b is still untouched"); + + assert!(!undo.can_undo(), "no more in-scope undo entries"); +} + +#[test] +fn scope_redo_only_in_scope() { + // After undoing, redo should also affect only in-scope containers. + let doc = LoroDoc::new(); + let text_a = doc.get_text("a"); + let text_b = doc.get_text("b"); + let undo = UndoManager::new(&doc).with_scope(UndoScope::containers([text_a.id()])); + + text_a.update("a1", UpdateOptions::default()).unwrap(); + doc.commit_then_renew(); + text_b.update("b1", UpdateOptions::default()).unwrap(); + doc.commit_then_renew(); + + undo.undo().unwrap(); + assert_eq!(text_a.to_string(), ""); + assert_eq!(text_b.to_string(), "b1", "b unaffected by undo"); + + undo.redo().unwrap(); + assert_eq!(text_a.to_string(), "a1", "redo restores a"); + assert_eq!(text_b.to_string(), "b1", "b unaffected by redo"); +} + +#[test] +fn scope_out_of_scope_edits_do_not_corrupt_in_scope_stack() { + // Out-of-scope local edits between in-scope edits must advance counters + // cleanly; an undo on a later in-scope edit must not drag the out-of-scope + // edit into its CounterSpan. + let doc = LoroDoc::new(); + let text_a = doc.get_text("a"); + let text_b = doc.get_text("b"); + let undo = UndoManager::new(&doc).with_scope(UndoScope::containers([text_a.id()])); + + text_a.update("a1", UpdateOptions::default()).unwrap(); + doc.commit_then_renew(); + // Several out-of-scope commits between the two a-edits + text_b.update("b1", UpdateOptions::default()).unwrap(); + doc.commit_then_renew(); + text_b.update("b1b2", UpdateOptions::default()).unwrap(); + doc.commit_then_renew(); + text_b.update("b1b2b3", UpdateOptions::default()).unwrap(); + doc.commit_then_renew(); + text_a.update("a1a2", UpdateOptions::default()).unwrap(); + doc.commit_then_renew(); + + undo.undo().unwrap(); + assert_eq!(text_a.to_string(), "a1", "second a-edit reverted"); + assert_eq!(text_b.to_string(), "b1b2b3", "b state preserved exactly"); +} + +#[test] +fn scope_mixed_commit_only_undoes_in_scope() { + // A single commit touching both in-scope and out-of-scope containers is + // recorded normally (the record-time filter passes commits with at least + // one in-scope diff). At replay time, undo_internal masks the resulting + // DiffBatch so only in-scope containers are reverted. + let doc = LoroDoc::new(); + let text_a = doc.get_text("a"); + let text_b = doc.get_text("b"); + let undo = UndoManager::new(&doc).with_scope(UndoScope::containers([text_a.id()])); + + // One commit touching BOTH a and b. + text_a.update("a1", UpdateOptions::default()).unwrap(); + text_b.update("b1", UpdateOptions::default()).unwrap(); + doc.commit_then_renew(); + + assert_eq!(undo.undo_count(), 1); + undo.undo().unwrap(); + assert_eq!(text_a.to_string(), "", "in-scope a is reverted"); + assert_eq!( + text_b.to_string(), + "b1", + "out-of-scope b is preserved even though it shared the commit with a" + ); +} + +#[test] +fn scope_mixed_commit_redo_only_restores_in_scope() { + // Symmetric to undo: redoing a previously-undone mixed commit should also + // only re-apply the in-scope portion. After undo+redo of a mixed commit, + // out-of-scope state should be unchanged from its post-original-commit value. + let doc = LoroDoc::new(); + let text_a = doc.get_text("a"); + let text_b = doc.get_text("b"); + let undo = UndoManager::new(&doc).with_scope(UndoScope::containers([text_a.id()])); + + text_a.update("a1", UpdateOptions::default()).unwrap(); + text_b.update("b1", UpdateOptions::default()).unwrap(); + doc.commit_then_renew(); + + undo.undo().unwrap(); + assert_eq!(text_a.to_string(), ""); + assert_eq!(text_b.to_string(), "b1"); + + undo.redo().unwrap(); + assert_eq!(text_a.to_string(), "a1", "in-scope a is restored"); + assert_eq!(text_b.to_string(), "b1", "out-of-scope b stays put across undo+redo"); +} + +#[test] +fn scope_multiple_mixed_commits_chained() { + // Three commits, each mixed across {a} (in-scope) and {b} (out-of-scope). + // Undoing twice should peel back only the in-scope edits; b accumulates + // every commit's contribution untouched. + let doc = LoroDoc::new(); + let text_a = doc.get_text("a"); + let text_b = doc.get_text("b"); + let undo = UndoManager::new(&doc).with_scope(UndoScope::containers([text_a.id()])); + + text_a.update("a1", UpdateOptions::default()).unwrap(); + text_b.update("b1", UpdateOptions::default()).unwrap(); + doc.commit_then_renew(); + text_a.update("a1a2", UpdateOptions::default()).unwrap(); + text_b.update("b1b2", UpdateOptions::default()).unwrap(); + doc.commit_then_renew(); + text_a.update("a1a2a3", UpdateOptions::default()).unwrap(); + text_b.update("b1b2b3", UpdateOptions::default()).unwrap(); + doc.commit_then_renew(); + + assert_eq!(undo.undo_count(), 3); + + undo.undo().unwrap(); + assert_eq!(text_a.to_string(), "a1a2"); + assert_eq!(text_b.to_string(), "b1b2b3", "b unchanged by first undo"); + + undo.undo().unwrap(); + assert_eq!(text_a.to_string(), "a1"); + assert_eq!(text_b.to_string(), "b1b2b3", "b still unchanged by second undo"); + + undo.undo().unwrap(); + assert_eq!(text_a.to_string(), ""); + assert_eq!(text_b.to_string(), "b1b2b3", "b survives all undos"); +} + +#[test] +fn scope_mixed_commit_with_three_containers() { + // Scope = {a, c}, single commit touches a + b + c. Undo reverts a and c + // but not b. + let doc = LoroDoc::new(); + let text_a = doc.get_text("a"); + let text_b = doc.get_text("b"); + let text_c = doc.get_text("c"); + let undo = UndoManager::new(&doc).with_scope(UndoScope::containers([text_a.id(), text_c.id()])); + + text_a.update("a1", UpdateOptions::default()).unwrap(); + text_b.update("b1", UpdateOptions::default()).unwrap(); + text_c.update("c1", UpdateOptions::default()).unwrap(); + doc.commit_then_renew(); + + undo.undo().unwrap(); + assert_eq!(text_a.to_string(), "", "a in scope, reverted"); + assert_eq!(text_b.to_string(), "b1", "b out of scope, preserved"); + assert_eq!(text_c.to_string(), "", "c in scope, reverted"); +} + +#[test] +fn scope_change_after_construction() { + // set_scope mutates an existing manager; commits before/after the change + // are categorized by the scope active at record time. + let doc = LoroDoc::new(); + let text_a = doc.get_text("a"); + let text_b = doc.get_text("b"); + let undo = UndoManager::new(&doc); + + // Doc-wide initially: both edits recorded. + text_a.update("a1", UpdateOptions::default()).unwrap(); + doc.commit_then_renew(); + text_b.update("b1", UpdateOptions::default()).unwrap(); + doc.commit_then_renew(); + assert_eq!(undo.undo_count(), 2); + + // Switch scope to {a}: subsequent b-edits skipped. + undo.set_scope(UndoScope::containers([text_a.id()])); + text_b.update("b1b2", UpdateOptions::default()).unwrap(); + doc.commit_then_renew(); + text_a.update("a1a2", UpdateOptions::default()).unwrap(); + doc.commit_then_renew(); + assert_eq!(undo.undo_count(), 3, "the b-edit after scope-change is filtered"); +} diff --git a/crates/loro/src/lib.rs b/crates/loro/src/lib.rs index 62f8051a0..0d6a809ee 100644 --- a/crates/loro/src/lib.rs +++ b/crates/loro/src/lib.rs @@ -17,7 +17,7 @@ pub use loro_internal::pre_commit::{ PreCommitCallbackPayload, }; pub use loro_internal::sync; -pub use loro_internal::undo::{OnPop, UndoItemMeta, UndoOrRedo}; +pub use loro_internal::undo::{OnPop, UndoItemMeta, UndoOrRedo, UndoScope}; use loro_internal::version::shrink_frontiers; pub use loro_internal::version::ImVersionVector; use loro_internal::DocState; @@ -3904,6 +3904,19 @@ impl UndoManager { self.0.add_exclude_origin_prefix(prefix) } + /// Restrict this manager to a specific [`UndoScope`]. + /// + /// See [`loro_internal::UndoManager::set_scope`] for the full semantics. + pub fn set_scope(&mut self, scope: UndoScope) { + self.0.set_scope(scope) + } + + /// Builder-style variant of [`Self::set_scope`]. + pub fn with_scope(self, scope: UndoScope) -> Self { + self.0.set_scope(scope); + self + } + /// Set the maximum number of undo steps. The default value is 100. pub fn set_max_undo_steps(&mut self, size: usize) { self.0.set_max_undo_steps(size)