Skip to repository content573 lines · 21.2 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:00:28.376Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
undo.rs
1//! # Undo Manager
2//!
3//! ## Operations and Results
4//!
5//! Undo and Redo actions execute an operation against the filesystem, producing
6//! a result that is recorded back into the history in place of the original
7//! entry. Each result is the semantic inverse of its paired operation, so the
8//! cycle can repeat for continued undo and redo.
9//!
10//! Operations Results
11//! ───────────────────────────────── ──────────────────────────────────────
12//! Create(ProjectPath) → Created(ProjectPath)
13//! Trash(ProjectPath) → Trashed(TrashId)
14//! Rename(ProjectPath, ProjectPath) → Renamed(ProjectPath, ProjectPath)
15//! Restore(TrashId) → Restored(ProjectPath)
16//! Batch(Vec<Operation>) → Batch(Vec<Result>)
17//!
18//!
19//! ## History and Cursor
20//!
21//! The undo manager maintains an operation history with a cursor position (↑).
22//! Recording an operation appends it to the history and advances the cursor to
23//! the end. The cursor separates past entries (left of ↑) from future entries
24//! (right of ↑).
25//!
26//! ─ **Undo**: Takes the history entry just *before* ↑, executes its inverse,
27//! records the result back in its place, and moves ↑ one step to the left.
28//! ─ **Redo**: Takes the history entry just *at* ↑, executes its inverse,
29//! records the result back in its place, and advances ↑ one step to the right.
30//!
31//!
32//! ## Example
33//!
34//! User Operation Create(src/main.rs)
35//! History
36//! 0 Created(src/main.rs)
37//! 1 +++cursor+++
38//!
39//! User Operation Rename(README.md, readme.md)
40//! History
41//! 0 Created(src/main.rs)
42//! 1 Renamed(README.md, readme.md)
43//! 2 +++cursor+++
44//!
45//! User Operation Create(CONTRIBUTING.md)
46//! History
47//! 0 Created(src/main.rs)
48//! 1 Renamed(README.md, readme.md)
49//! 2 Created(CONTRIBUTING.md) ──┐
50//! 3 +++cursor+++ │(before the cursor)
51//! │
52//! ┌──────────────────────────────┴─────────────────────────────────────────────┐
53//! Redoing will take the result at the cursor position, convert that into the
54//! operation that can revert that result, execute that operation and replace
55//! the result in the history with the new result, obtained from running the
56//! inverse operation, advancing the cursor position.
57//! └──────────────────────────────┬─────────────────────────────────────────────┘
58//! │
59//! │
60//! User Operation Undo v
61//! Execute Created(CONTRIBUTING.md) ────────> Trash(CONTRIBUTING.md)
62//! Record Trashed(TrashId(1))
63//! History
64//! 0 Created(src/main.rs)
65//! 1 Renamed(README.md, readme.md) ─┐
66//! 2 +++cursor+++ │(before the cursor)
67//! 2 Trashed(TrashId(1)) │
68//! │
69//! User Operation Undo v
70//! Execute Renamed(README.md, readme.md) ───> Rename(readme.md, README.md)
71//! Record Renamed(readme.md, README.md)
72//! History
73//! 0 Created(src/main.rs)
74//! 1 +++cursor+++
75//! 1 Renamed(readme.md, README.md) ─┐ (at the cursor)
76//! 2 Trashed(TrashId(1)) │
77//! │
78//! ┌──────────────────────────────────┴─────────────────────────────────────────┐
79//! Redoing will take the result at the cursor position, convert that into the
80//! operation that can revert that result, execute that operation and replace
81//! the result in the history with the new result, obtained from running the
82//! inverse operation, advancing the cursor position.
83//! └──────────────────────────────────┬─────────────────────────────────────────┘
84//! │
85//! │
86//! User Operation Redo v
87//! Execute Renamed(readme.md, README.md) ───> Rename(README.md, readme.md)
88//! Record Renamed(README.md, readme.md)
89//! History
90//! 0 Created(src/main.rs)
91//! 1 Renamed(README.md, readme.md)
92//! 2 +++cursor+++
93//! 2 Trashed(TrashId(1))───────┐ (at the cursor)
94//! │
95//! User Operation Redo v
96//! Execute Trashed(TrashId(1)) ────────> Restore(TrashId(1))
97//! Record Restored(ProjectPath)
98//! History
99//! 0 Created(src/main.rs)
100//! 1 Renamed(README.md, readme.md)
101//! 2 Restored(ProjectPath)
102//! 2 +++cursor+++
103
104//!
105//! create A; A
106//! rename A -> B; B
107//! undo (rename B -> A) (takes 10s for some reason) B (still b cause it's hanging for 10s)
108//! remove B _
109//! create B B
110//! put important content in B B
111//! undo manger renames (does not hang) A
112//! remove A _
113//! user sad
114
115//!
116//! create A; A
117//! rename A -> B; B
118//! undo (rename B -> A) (takes 10s for some reason) B (still b cause it's hanging for 10s)
119//! create C B
120//! -- src/c.rs
121//! --
122
123//!
124//! create docs/files/ directory docs/files/
125//! create docs/files/a.txt docs/files/
126//! undo (rename B -> A) (takes 10s for some reason) B (still b cause it's hanging for 10s)
127//! create C B
128//! -- src/c.rs
129//! --
130
131//! List of "tainted files" that the user may not operate on
132
133use crate::ProjectPanel;
134use anyhow::{Context, Result, anyhow};
135use fs::TrashId;
136use futures::channel::mpsc;
137use gpui::{AppContext, AsyncApp, SharedString, Task, WeakEntity};
138use project::{ProjectPath, WorktreeId};
139use std::sync::atomic::{AtomicBool, Ordering};
140use std::{collections::VecDeque, sync::Arc};
141use ui::App;
142use workspace::{
143 Workspace,
144 notifications::{NotificationId, simple_message_notification::MessageNotification},
145};
146use worktree::CreatedEntry;
147
148enum Operation {
149 Trash(ProjectPath),
150 Rename(ProjectPath, ProjectPath),
151 Restore(WorktreeId, TrashId),
152 Batch(Vec<Operation>),
153}
154
155impl Operation {
156 async fn execute(self, undo_manager: &Inner, cx: &mut AsyncApp) -> Result<Change> {
157 Ok(match self {
158 Operation::Trash(project_path) => {
159 let trash_id = undo_manager.trash(&project_path, cx).await?;
160 Change::Trashed(project_path.worktree_id, trash_id)
161 }
162 Operation::Rename(from, to) => {
163 undo_manager.rename(&from, &to, cx).await?;
164 Change::Renamed(from, to)
165 }
166 Operation::Restore(worktree_id, trash_id) => {
167 let project_path = undo_manager.restore(worktree_id, trash_id, cx).await?;
168 Change::Restored(project_path)
169 }
170 Operation::Batch(operations) => {
171 let mut res = Vec::new();
172 for op in operations {
173 res.push(Box::pin(op.execute(undo_manager, cx)).await?);
174 }
175 Change::Batched(res)
176 }
177 })
178 }
179}
180
181#[derive(Clone, Debug)]
182pub(crate) enum Change {
183 Created(ProjectPath),
184 Trashed(WorktreeId, TrashId),
185 Renamed(ProjectPath, ProjectPath),
186 Restored(ProjectPath),
187 Batched(Vec<Change>),
188}
189
190impl Change {
191 fn to_inverse(self) -> Operation {
192 match self {
193 Change::Created(project_path) => Operation::Trash(project_path),
194 Change::Trashed(worktree_id, trash_id) => Operation::Restore(worktree_id, trash_id),
195 Change::Renamed(from, to) => Operation::Rename(to, from),
196 Change::Restored(project_path) => Operation::Trash(project_path),
197 // When inverting a batch of operations, we reverse the order of
198 // operations to handle dependencies between them. For example, if a
199 // batch contains the following order of operations:
200 //
201 // 1. Create `src/`
202 // 2. Create `src/main.rs`
203 //
204 // If we first tried to revert the directory creation, it would fail
205 // because there's still files inside the directory.
206 Change::Batched(changes) => {
207 Operation::Batch(changes.into_iter().rev().map(Change::to_inverse).collect())
208 }
209 }
210 }
211}
212
213// Imagine pressing undo 10000+ times?!
214const MAX_UNDO_OPERATIONS: usize = 10_000;
215
216struct Inner {
217 workspace: WeakEntity<Workspace>,
218 panel: WeakEntity<ProjectPanel>,
219 history: VecDeque<Change>,
220 cursor: usize,
221 /// Maximum number of operations to keep on the undo history.
222 limit: usize,
223 can_undo: Arc<AtomicBool>,
224 can_redo: Arc<AtomicBool>,
225 rx: mpsc::Receiver<UndoMessage>,
226}
227
228/// pls arc this
229#[derive(Clone)]
230pub struct UndoManager {
231 tx: mpsc::Sender<UndoMessage>,
232 is_via_collab: bool,
233 can_undo: Arc<AtomicBool>,
234 can_redo: Arc<AtomicBool>,
235}
236
237impl UndoManager {
238 pub fn new(
239 workspace: WeakEntity<Workspace>,
240 panel: WeakEntity<ProjectPanel>,
241 is_via_collab: bool,
242 cx: &App,
243 ) -> Self {
244 let (tx, rx) = mpsc::channel(1024);
245 let inner = Inner::new(workspace, panel, rx);
246
247 let this = Self {
248 tx,
249 is_via_collab,
250 can_undo: Arc::clone(&inner.can_undo),
251 can_redo: Arc::clone(&inner.can_redo),
252 };
253
254 cx.spawn(async move |cx| inner.manage_undo_and_redo(cx.clone()).await)
255 .detach();
256
257 this
258 }
259
260 pub fn undo(&mut self) -> Result<()> {
261 self.tx
262 .try_send(UndoMessage::Undo)
263 .context("Undo and redo task can not keep up")
264 }
265 pub fn redo(&mut self) -> Result<()> {
266 self.tx
267 .try_send(UndoMessage::Redo)
268 .context("Undo and redo task can not keep up")
269 }
270 pub fn record(&mut self, changes: impl IntoIterator<Item = Change>) -> Result<()> {
271 // In a collab session, undoing or redoing can send `TrashProjectEntry`
272 // or `RestoreProjectEntry`, for example, undoing a create or undoing a
273 // trash.
274 // Since older hosts can't decode those messages, which would
275 // silently never complete, besides disabling the `Undo`/`Redo` actions
276 // for collab, we also avoid recording history here so there's nothing
277 // that could later trigger those messages.
278 if self.is_via_collab {
279 return Ok(());
280 }
281
282 self.tx
283 .try_send(UndoMessage::Changed(changes.into_iter().collect()))
284 .context("Undo and redo task can not keep up")
285 }
286 /// just for the UI, an undo may still fail if there are concurrent file
287 /// operations happening.
288 pub fn can_undo(&self) -> bool {
289 self.can_undo.load(Ordering::Relaxed)
290 }
291 /// just for the UI, an undo may still fail if there are concurrent file
292 /// operations happening.
293 pub fn can_redo(&self) -> bool {
294 self.can_redo.load(Ordering::Relaxed)
295 }
296
297 #[cfg(test)]
298 pub(crate) fn set_is_via_collab(&mut self, is_via_collab: bool) {
299 self.is_via_collab = is_via_collab;
300 }
301}
302
303#[derive(Debug)]
304enum UndoMessage {
305 Changed(Vec<Change>),
306 Undo,
307 Redo,
308}
309
310impl UndoMessage {
311 fn error_title(&self) -> &'static str {
312 match self {
313 UndoMessage::Changed(_) => {
314 "this is a bug in the manage_undo_and_redo task please report"
315 }
316 UndoMessage::Undo => "Undo failed",
317 UndoMessage::Redo => "Redo failed",
318 }
319 }
320}
321
322impl Inner {
323 async fn manage_undo_and_redo(mut self, mut cx: AsyncApp) {
324 loop {
325 let Ok(new) = self.rx.recv().await else {
326 // project panel got closed
327 return;
328 };
329
330 let error_title = new.error_title();
331 let res = match new {
332 UndoMessage::Changed(changes) => {
333 self.record(changes);
334 Ok(())
335 }
336 UndoMessage::Undo => {
337 let res = self.undo(&mut cx).await;
338 let _ = self.panel.update(&mut cx, |_, cx| cx.notify());
339 res
340 }
341 UndoMessage::Redo => {
342 let res = self.redo(&mut cx).await;
343 let _ = self.panel.update(&mut cx, |_, cx| cx.notify());
344 res
345 }
346 };
347
348 if let Err(e) = res {
349 Self::show_error(error_title, self.workspace.clone(), e.to_string(), &mut cx);
350 }
351
352 self.can_undo.store(self.can_undo(), Ordering::Relaxed);
353 self.can_redo.store(self.can_redo(), Ordering::Relaxed);
354 }
355 }
356}
357
358impl Inner {
359 pub fn new(
360 workspace: WeakEntity<Workspace>,
361 panel: WeakEntity<ProjectPanel>,
362 rx: mpsc::Receiver<UndoMessage>,
363 ) -> Self {
364 Self::new_with_limit(workspace, panel, MAX_UNDO_OPERATIONS, rx)
365 }
366
367 pub fn new_with_limit(
368 workspace: WeakEntity<Workspace>,
369 panel: WeakEntity<ProjectPanel>,
370 limit: usize,
371 rx: mpsc::Receiver<UndoMessage>,
372 ) -> Self {
373 Self {
374 workspace,
375 panel,
376 history: VecDeque::new(),
377 cursor: 0usize,
378 limit,
379 can_undo: Arc::new(AtomicBool::new(false)),
380 can_redo: Arc::new(AtomicBool::new(false)),
381 rx,
382 }
383 }
384
385 pub fn can_undo(&self) -> bool {
386 self.cursor > 0
387 }
388
389 pub fn can_redo(&self) -> bool {
390 self.cursor < self.history.len()
391 }
392
393 pub async fn undo(&mut self, cx: &mut AsyncApp) -> Result<()> {
394 if !self.can_undo() {
395 return Ok(());
396 }
397
398 // Undo failure:
399 //
400 // History
401 // 0 Created(src/main.rs)
402 // 1 Renamed(README.md, readme.md) ─┐
403 // 2 +++cursor+++ │(before the cursor)
404 // 2 Trashed(TrashId(1)) │
405 // │
406 // User Operation Undo v
407 // Failed execute Renamed(README.md, readme.md) ───> Rename(readme.md, README.md)
408 // Record nothing
409 // History
410 // 0 Created(src/main.rs)
411 // 1 +++cursor+++
412 // 1 Trashed(TrashId(1)) ---------
413 // |(at the cursor)
414 // User Operation Redo v
415 // Execute Trashed(TrashId(1)) ────────> Restore(TrashId(1))
416 // Record Restored(ProjectPath)
417 // History
418 // 0 Created(src/main.rs)
419 // 1 Restored(ProjectPath)
420 // 1 +++cursor+++
421
422 // We always want to move the cursor back regardless of whether undoing
423 // succeeds or fails, otherwise the cursor could end up pointing to a
424 // position outside of the history, as we remove the change before the
425 // cursor, in case undo fails.
426 let before_cursor = self.cursor - 1; // see docs above
427 self.cursor -= 1; // take a step back into the past
428
429 // If undoing fails, the user would be in a stuck state from which
430 // manual intervention would likely be needed in order to undo. As such,
431 // we remove the change from the `history` even before attempting to
432 // execute its inversion.
433 let undo_change = self
434 .history
435 .remove(before_cursor)
436 .expect("we can undo")
437 .to_inverse()
438 .execute(self, cx)
439 .await?;
440 self.history.insert(before_cursor, undo_change);
441 Ok(())
442 }
443
444 pub async fn redo(&mut self, cx: &mut AsyncApp) -> Result<()> {
445 if !self.can_redo() {
446 return Ok(());
447 }
448
449 // If redoing fails, the user would be in a stuck state from which
450 // manual intervention would likely be needed in order to redo. As such,
451 // we remove the change from the `history` even before attempting to
452 // execute its inversion.
453 let redo_change = self
454 .history
455 .remove(self.cursor)
456 .expect("we can redo")
457 .to_inverse()
458 .execute(self, cx)
459 .await?;
460 self.history.insert(self.cursor, redo_change);
461 self.cursor += 1;
462 Ok(())
463 }
464
465 /// Passed in changes will always be performed as a single step
466 pub fn record(&mut self, mut changes: Vec<Change>) {
467 let change = match changes.len() {
468 0 => return,
469 1 => changes.remove(0),
470 _ => Change::Batched(changes),
471 };
472
473 // When recording a new change, discard any changes that could still be
474 // redone.
475 if self.cursor < self.history.len() {
476 self.history.drain(self.cursor..);
477 }
478
479 // Ensure that the number of recorded changes does not exceed the
480 // maximum amount of tracked changes.
481 if self.history.len() >= self.limit {
482 self.history.pop_front();
483 } else {
484 self.cursor += 1;
485 }
486
487 self.history.push_back(change);
488 }
489
490 async fn rename(
491 &self,
492 from: &ProjectPath,
493 to: &ProjectPath,
494 cx: &mut AsyncApp,
495 ) -> Result<CreatedEntry> {
496 let Some(workspace) = self.workspace.upgrade() else {
497 return Err(anyhow!("Failed to obtain workspace."));
498 };
499
500 let res: Result<Task<Result<CreatedEntry>>> = workspace.update(cx, |workspace, cx| {
501 workspace.project().update(cx, |project, cx| {
502 let entry_id = project
503 .entry_for_path(from, cx)
504 .map(|entry| entry.id)
505 .ok_or_else(|| anyhow!("No entry for path."))?;
506
507 Ok(project.rename_entry(entry_id, to.clone(), cx))
508 })
509 });
510
511 res?.await
512 }
513
514 async fn trash(&self, project_path: &ProjectPath, cx: &mut AsyncApp) -> Result<TrashId> {
515 let Some(workspace) = self.workspace.upgrade() else {
516 return Err(anyhow!("Failed to obtain workspace."));
517 };
518
519 workspace
520 .update(cx, |workspace, cx| {
521 workspace.project().update(cx, |project, cx| {
522 let entry_id = project
523 .entry_for_path(&project_path, cx)
524 .map(|entry| entry.id)
525 .ok_or_else(|| anyhow!("No entry for path."))?;
526
527 project
528 .trash_entry(entry_id, cx)
529 .ok_or_else(|| anyhow!("Worktree entry should exist"))
530 })
531 })?
532 .await
533 }
534
535 async fn restore(
536 &self,
537 worktree_id: WorktreeId,
538 trash_id: TrashId,
539 cx: &mut AsyncApp,
540 ) -> Result<ProjectPath> {
541 let Some(workspace) = self.workspace.upgrade() else {
542 return Err(anyhow!("Failed to obtain workspace."));
543 };
544
545 workspace
546 .update(cx, |workspace, cx| {
547 workspace.project().update(cx, |project, cx| {
548 project.restore_entry(worktree_id, trash_id, cx)
549 })
550 })
551 .await
552 }
553
554 /// Displays a notification with the provided `title` and `error`.
555 fn show_error(
556 title: impl Into<SharedString>,
557 workspace: WeakEntity<Workspace>,
558 error: String,
559 cx: &mut AsyncApp,
560 ) {
561 workspace
562 .update(cx, move |workspace, cx| {
563 let notification_id =
564 NotificationId::Named(SharedString::new_static("project_panel_undo"));
565
566 workspace.show_notification(notification_id, cx, move |cx| {
567 cx.new(|cx| MessageNotification::new(error, cx).with_title(title))
568 })
569 })
570 .ok();
571 }
572}
573