Context
I'm building a collaborative application that syncs a multi-file workspace as a single unit: one LoroDoc per workspace, a LoroTree of file nodes, each file's content in a LoroText under its node's meta map. The unified-LoroDoc shape is the right fit for what I'm building — cross-workspace causal ordering matters for me and I don't want to reinvent it across multiple docs.
What I'd like on top of that is per-file undo: Ctrl+Z while focused in file A reverts only file A's local edits.
Current workaround
One UndoManager per file, with cross-exclusion via origin prefixes:
let undo_a = UndoManager::new(&doc);
undo_a.add_exclude_origin_prefix("file:b");
undo_a.add_exclude_origin_prefix("file:c");
undo_a.add_exclude_origin_prefix("undo:file:b");
undo_a.add_exclude_origin_prefix("undo:file:c");
// ...repeat for every file
Plus discipline: set_next_commit_origin("file:X") before every edit, and set_next_commit_origin("undo:file:X") before every undo/redo so the inverse op carries the same origin and other managers exclude it.
It works, but:
- O(N²) registration. Every new file means every existing manager adds two more prefixes.
- Mixed commits aren't handled. A single commit touching multiple files (workspace-wide find-and-replace, save flow that bumps content + metadata, any batched cross-file op) gets fully reverted by any file's undo. I work around this with strict commit-boundary discipline that leaks into application design.
- Undocumented carry-through. The scheme depends on
set_next_commit_origin carrying through to undo()'s internal commit. Not documented anywhere I could find; if it ever changes, cross-manager exclusion silently breaks.
- Boilerplate. Every consumer has to reimplement the same 4-origin scheme.
Proposed API
pub enum UndoScope {
Doc, // default — preserves today's behavior
Containers(FxHashSet<ContainerID>),
}
impl UndoManager {
pub fn set_scope(&self, scope: UndoScope);
pub fn with_scope(self, scope: UndoScope) -> Self;
}
let undo_a = UndoManager::new(&doc).with_scope(UndoScope::containers([text_a.id()]));
Reads to me like a generalization of add_exclude_origin_prefix — same record-time path, just keyed on ContainerID membership instead of origin prefix. Mixed commits (which the origin-prefix workaround can't handle) would be handled at replay time by masking the computed DiffBatch to in-scope containers before _apply_diff.
Prototype
Sketched at edochi/loro@feat/undo-scope (4 commits on top of main at 095ad56f):
- Default
UndoScope::Doc is a true no-op — all existing undo tests pass unchanged.
- Non-breaking signature:
undo_internal is preserved as a thin #[inline] wrapper; new behavior lives on undo_internal_with_scope.
- 9 new integration tests covering basic scope, mixed commits, redo symmetry, edge cases.
- Bench (1000 commits/iter, n=50): default-scope path is +45 ns/commit vs main (~1.3%, near noise floor); replay path is statistically identical with or without the mask.
Rust-only at the moment — happy to discuss whether loro-wasm bindings should be in the same PR or a follow-up. I'd rather agree on the Rust API shape before sending a PR.
Questions
- Is this direction acceptable upstream, or have you considered per-container scope before and decided against it for reasons I'm not seeing?
- Mixed-commit semantics. The replay-time mask breaks cross-container atomicity at undo time — that's what scope means, but I want to confirm you're OK with that as the default rather than asking users to opt in.
- Is there a better shape I'm missing? Different API, different scope primitive, different place to put the filter — open to suggestions.
Thanks for considering!
Context
I'm building a collaborative application that syncs a multi-file workspace as a single unit: one
LoroDocper workspace, aLoroTreeof file nodes, each file's content in aLoroTextunder its node's meta map. The unified-LoroDoc shape is the right fit for what I'm building — cross-workspace causal ordering matters for me and I don't want to reinvent it across multiple docs.What I'd like on top of that is per-file undo: Ctrl+Z while focused in file A reverts only file A's local edits.
Current workaround
One
UndoManagerper file, with cross-exclusion via origin prefixes:Plus discipline:
set_next_commit_origin("file:X")before every edit, andset_next_commit_origin("undo:file:X")before every undo/redo so the inverse op carries the same origin and other managers exclude it.It works, but:
set_next_commit_origincarrying through toundo()'s internal commit. Not documented anywhere I could find; if it ever changes, cross-manager exclusion silently breaks.Proposed API
Reads to me like a generalization of
add_exclude_origin_prefix— same record-time path, just keyed onContainerIDmembership instead of origin prefix. Mixed commits (which the origin-prefix workaround can't handle) would be handled at replay time by masking the computedDiffBatchto in-scope containers before_apply_diff.Prototype
Sketched at edochi/loro@feat/undo-scope (4 commits on top of
mainat095ad56f):UndoScope::Docis a true no-op — all existing undo tests pass unchanged.undo_internalis preserved as a thin#[inline]wrapper; new behavior lives onundo_internal_with_scope.Rust-only at the moment — happy to discuss whether
loro-wasmbindings should be in the same PR or a follow-up. I'd rather agree on the Rust API shape before sending a PR.Questions
Thanks for considering!