Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T03:00:28.376Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

undo.rs

573 lines · 21.2 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.