Skip to content

Commit 3a0db5b

Browse files
authored
feat(undo): add API to fetch top undo/redo metadata and values (#797)
- internal: add Stack.peek_top_meta(); expose UndoManager::{top_undo_meta, top_redo_meta, top_undo_value, top_redo_value} - rust (crates/loro): surface the new getters on public UndoManager - wasm (crates/loro-wasm): add JS APIs UndoManager.topUndoValue()/topRedoValue() returning undefined when empty - tests: add “top undo/redo values” coverage in loro-wasm basic.test.ts
1 parent 537c486 commit 3a0db5b

6 files changed

Lines changed: 132 additions & 1 deletion

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"loro-crdt": minor
3+
---
4+
5+
feat: add APIs to fetch values associated with the top Undo and Redo stack entries (#790)
6+
7+
- JS/WASM: `undo.topUndoValue()` and `undo.topRedoValue()` return the `value` from the top undo/redo item (or `undefined` when empty).
8+
- Rust: `UndoManager::{top_undo_meta, top_redo_meta, top_undo_value, top_redo_value}` to inspect top-of-stack metadata and values.
9+
- Internal: stack now supports peeking the top item metadata without mutation.
10+
11+
This enables attaching human-readable labels via `onPush`/`onPop` and retrieving them to keep Undo/Redo menu items up to date.

‎crates/loro-internal/src/undo.rs‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,23 @@ impl Stack {
305305
Stack { stack, size: 0 }
306306
}
307307

308+
/// Peek the top-most StackItem's metadata without modifying the stack.
309+
///
310+
/// Returns None if the stack is empty.
311+
fn peek_top_meta(&self) -> Option<UndoItemMeta> {
312+
if self.is_empty() {
313+
return None;
314+
}
315+
316+
for (items, _) in self.stack.iter().rev() {
317+
if let Some(item) = items.back() {
318+
return Some(item.meta.clone());
319+
}
320+
}
321+
322+
None
323+
}
324+
308325
pub fn pop(&mut self) -> Option<(StackItem, Arc<Mutex<DiffBatch>>)> {
309326
while self.stack.back().unwrap().0.is_empty() && self.stack.len() > 1 {
310327
let (_, diff) = self.stack.pop_back().unwrap();
@@ -876,6 +893,26 @@ impl UndoManager {
876893
self.inner.lock().unwrap().redo_stack.len()
877894
}
878895

896+
/// Get the metadata of the top undo stack item, if any.
897+
pub fn top_undo_meta(&self) -> Option<UndoItemMeta> {
898+
self.inner.lock().unwrap().undo_stack.peek_top_meta()
899+
}
900+
901+
/// Get the metadata of the top redo stack item, if any.
902+
pub fn top_redo_meta(&self) -> Option<UndoItemMeta> {
903+
self.inner.lock().unwrap().redo_stack.peek_top_meta()
904+
}
905+
906+
/// Get the value associated with the top undo stack item, if any.
907+
pub fn top_undo_value(&self) -> Option<LoroValue> {
908+
self.top_undo_meta().map(|m| m.value)
909+
}
910+
911+
/// Get the value associated with the top redo stack item, if any.
912+
pub fn top_redo_value(&self) -> Option<LoroValue> {
913+
self.top_redo_meta().map(|m| m.value)
914+
}
915+
879916
pub fn set_on_push(&self, on_push: Option<OnPush>) {
880917
self.inner.lock().unwrap().on_push = on_push;
881918
}

‎crates/loro-wasm/VERSION‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.5.12
1+
1.6.0

‎crates/loro-wasm/src/lib.rs‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4858,6 +4858,28 @@ impl UndoManager {
48584858
self.undo.can_redo()
48594859
}
48604860

4861+
/// Get the value associated with the top undo stack item, if any.
4862+
/// Returns `undefined` if there is no undo item.
4863+
pub fn topUndoValue(&self) -> Option<JsLoroValue> {
4864+
self.undo
4865+
.top_undo_value()
4866+
.map(|v| {
4867+
let js: JsValue = v.into();
4868+
js.into()
4869+
})
4870+
}
4871+
4872+
/// Get the value associated with the top redo stack item, if any.
4873+
/// Returns `undefined` if there is no redo item.
4874+
pub fn topRedoValue(&self) -> Option<JsLoroValue> {
4875+
self.undo
4876+
.top_redo_value()
4877+
.map(|v| {
4878+
let js: JsValue = v.into();
4879+
js.into()
4880+
})
4881+
}
4882+
48614883
/// The number of max undo steps.
48624884
/// If the number of undo steps exceeds this number, the oldest undo step will be removed.
48634885
pub fn setMaxUndoSteps(&mut self, steps: usize) {

‎crates/loro-wasm/tests/basic.test.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
LoroCounter,
2121
JsonDiff,
2222
redactJsonUpdates,
23+
UndoManager,
2324
} from "../bundler/index";
2425

2526
it("basic example", () => {
@@ -145,6 +146,46 @@ describe("map", () => {
145146
});
146147
});
147148

149+
it("top undo/redo values", () => {
150+
const doc = new LoroDoc();
151+
doc.setPeerId(1);
152+
153+
let lastCommitLabel: string | null = null;
154+
let lastPoppedLabel: string | null = null;
155+
156+
const undo = new UndoManager(doc, {
157+
mergeInterval: 0,
158+
onPush: (isUndo: boolean) => {
159+
// For normal commits (Undo stack), use the label set before commit.
160+
// For opposite pushes (Redo stack), reuse the last popped label.
161+
const value = isUndo ? lastCommitLabel : lastPoppedLabel;
162+
return { value, cursors: [] };
163+
},
164+
onPop: (_isUndo: boolean, meta: { value: unknown }) => {
165+
// Remember last popped label so we can assign it to the opposite stack item.
166+
lastPoppedLabel = meta.value as string | null;
167+
},
168+
});
169+
170+
// 1st commit
171+
doc.getText("text").insert(0, "A");
172+
lastCommitLabel = 'Insert "A"';
173+
doc.commit();
174+
expect(undo.topUndoValue()).toBe('Insert "A"');
175+
expect(undo.topRedoValue()).toBe(undefined);
176+
177+
// 2nd commit
178+
doc.getText("text").insert(1, "B");
179+
lastCommitLabel = 'Insert "B"';
180+
doc.commit();
181+
expect(undo.topUndoValue()).toBe('Insert "B"');
182+
183+
// Undo once: the popped undo label should appear as the redo top value
184+
undo.undo();
185+
expect(undo.topRedoValue()).toBe('Insert "B"');
186+
expect(undo.topUndoValue()).toBe('Insert "A"');
187+
});
188+
148189
describe("import", () => {
149190
it("pending and import status", () => {
150191
const a = new LoroDoc();

‎crates/loro/src/lib.rs‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3602,6 +3602,26 @@ impl UndoManager {
36023602
pub fn peer(&self) -> PeerID {
36033603
self.0.peer()
36043604
}
3605+
3606+
/// Get the metadata of the top undo stack item, if any.
3607+
pub fn top_undo_meta(&self) -> Option<UndoItemMeta> {
3608+
self.0.top_undo_meta()
3609+
}
3610+
3611+
/// Get the metadata of the top redo stack item, if any.
3612+
pub fn top_redo_meta(&self) -> Option<UndoItemMeta> {
3613+
self.0.top_redo_meta()
3614+
}
3615+
3616+
/// Get the value associated with the top undo stack item, if any.
3617+
pub fn top_undo_value(&self) -> Option<LoroValue> {
3618+
self.0.top_undo_value()
3619+
}
3620+
3621+
/// Get the value associated with the top redo stack item, if any.
3622+
pub fn top_redo_value(&self) -> Option<LoroValue> {
3623+
self.0.top_redo_value()
3624+
}
36053625
}
36063626
/// 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.
36073627
/// The returned cursors will be recorded for a new pushed undo item.

0 commit comments

Comments
 (0)