undo.rs

  1use crate::ProjectPanel;
  2use anyhow::{Result, anyhow};
  3use gpui::{AppContext, SharedString, Task, WeakEntity};
  4use project::ProjectPath;
  5use std::collections::VecDeque;
  6use ui::App;
  7use workspace::{
  8    Workspace,
  9    notifications::{NotificationId, simple_message_notification::MessageNotification},
 10};
 11
 12const MAX_UNDO_OPERATIONS: usize = 10_000;
 13
 14#[derive(Clone, Debug, PartialEq)]
 15pub enum ProjectPanelOperation {
 16    Batch(Vec<ProjectPanelOperation>),
 17    Create { project_path: ProjectPath },
 18    Trash { project_path: ProjectPath },
 19    Rename { from: ProjectPath, to: ProjectPath },
 20}
 21
 22impl ProjectPanelOperation {
 23    fn inverse(&self) -> Self {
 24        match self {
 25            Self::Create { project_path } => Self::Trash {
 26                project_path: project_path.clone(),
 27            },
 28            Self::Trash { project_path } => Self::Create {
 29                project_path: project_path.clone(),
 30            },
 31            Self::Rename { from, to } => Self::Rename {
 32                from: to.clone(),
 33                to: from.clone(),
 34            },
 35            // When inverting a batch of operations, we reverse the order of
 36            // operations to handle dependencies between them. For example, if a
 37            // batch contains the following order of operations:
 38            //
 39            // 1. Create `src/`
 40            // 2. Create `src/main.rs`
 41            //
 42            // If we first tried to revert the directory creation, it would fail
 43            // because there's still files inside the directory.
 44            Self::Batch(operations) => Self::Batch(
 45                operations
 46                    .iter()
 47                    .rev()
 48                    .map(|operation| operation.inverse())
 49                    .collect(),
 50            ),
 51        }
 52    }
 53}
 54
 55pub struct UndoManager {
 56    workspace: WeakEntity<Workspace>,
 57    panel: WeakEntity<ProjectPanel>,
 58    undo_stack: VecDeque<ProjectPanelOperation>,
 59    redo_stack: Vec<ProjectPanelOperation>,
 60    /// Maximum number of operations to keep on the undo history.
 61    limit: usize,
 62}
 63
 64impl UndoManager {
 65    pub fn new(workspace: WeakEntity<Workspace>, panel: WeakEntity<ProjectPanel>) -> Self {
 66        Self::new_with_limit(workspace, panel, MAX_UNDO_OPERATIONS)
 67    }
 68
 69    pub fn new_with_limit(
 70        workspace: WeakEntity<Workspace>,
 71        panel: WeakEntity<ProjectPanel>,
 72        limit: usize,
 73    ) -> Self {
 74        Self {
 75            workspace,
 76            panel,
 77            limit,
 78            undo_stack: VecDeque::new(),
 79            redo_stack: Vec::new(),
 80        }
 81    }
 82
 83    pub fn can_undo(&self) -> bool {
 84        !self.undo_stack.is_empty()
 85    }
 86
 87    pub fn can_redo(&self) -> bool {
 88        !self.redo_stack.is_empty()
 89    }
 90
 91    pub fn undo(&mut self, cx: &mut App) {
 92        if let Some(operation) = self.undo_stack.pop_back() {
 93            let task = self.execute_operation(&operation, cx);
 94            let panel = self.panel.clone();
 95            let workspace = self.workspace.clone();
 96
 97            cx.spawn(async move |cx| match task.await {
 98                Ok(operation) => panel.update(cx, |panel, _cx| {
 99                    panel.undo_manager.redo_stack.push(operation)
100                }),
101                Err(err) => cx.update(|cx| {
102                    Self::show_error(
103                        "Failed to undo Project Panel Operation(s)",
104                        workspace,
105                        err.to_string().into(),
106                        cx,
107                    );
108
109                    Ok(())
110                }),
111            })
112            .detach();
113        }
114    }
115
116    pub fn redo(&mut self, cx: &mut App) {
117        if let Some(operation) = self.redo_stack.pop() {
118            let task = self.execute_operation(&operation, cx);
119            let panel = self.panel.clone();
120            let workspace = self.workspace.clone();
121
122            cx.spawn(async move |cx| match task.await {
123                Ok(operation) => panel.update(cx, |panel, _cx| {
124                    panel.undo_manager.undo_stack.push_back(operation)
125                }),
126                Err(err) => cx.update(|cx| {
127                    Self::show_error(
128                        "Failed to redo Project Panel Operation(s)",
129                        workspace,
130                        err.to_string().into(),
131                        cx,
132                    );
133
134                    Ok(())
135                }),
136            })
137            .detach();
138        }
139    }
140
141    pub fn record(&mut self, operation: ProjectPanelOperation) {
142        // Recording a new operation while there's still operations in the
143        // `redo_stack` should clear all operations from the `redo_stack`, as we
144        // might end up in a situation where the state diverges and the
145        // `redo_stack` operations can no longer be done.
146        if !self.redo_stack.is_empty() {
147            self.redo_stack.clear();
148        }
149
150        if self.undo_stack.len() >= self.limit {
151            self.undo_stack.pop_front();
152        }
153
154        self.undo_stack.push_back(operation.inverse());
155    }
156
157    pub fn record_batch(&mut self, operations: impl IntoIterator<Item = ProjectPanelOperation>) {
158        let mut operations = operations.into_iter().collect::<Vec<_>>();
159        let operation = match operations.len() {
160            0 => return,
161            1 => operations.pop().unwrap(),
162            _ => ProjectPanelOperation::Batch(operations),
163        };
164
165        self.record(operation);
166    }
167
168    /// Attempts to execute the provided operation, returning the inverse of the
169    /// provided `operation` as a result.
170    fn execute_operation(
171        &mut self,
172        operation: &ProjectPanelOperation,
173        cx: &mut App,
174    ) -> Task<Result<ProjectPanelOperation>> {
175        match operation {
176            ProjectPanelOperation::Rename { from, to } => self.rename(from, to, cx),
177            ProjectPanelOperation::Trash { project_path } => self.trash(project_path, cx),
178            ProjectPanelOperation::Create { project_path } => self.create(project_path, cx),
179            ProjectPanelOperation::Batch(operations) => self.batch(operations, cx),
180        }
181    }
182
183    fn rename(
184        &self,
185        from: &ProjectPath,
186        to: &ProjectPath,
187        cx: &mut App,
188    ) -> Task<Result<ProjectPanelOperation>> {
189        let Some(workspace) = self.workspace.upgrade() else {
190            return Task::ready(Err(anyhow!("Failed to obtain workspace.")));
191        };
192
193        let result = workspace.update(cx, |workspace, cx| {
194            workspace.project().update(cx, |project, cx| {
195                let entry_id = project
196                    .entry_for_path(from, cx)
197                    .map(|entry| entry.id)
198                    .ok_or_else(|| anyhow!("No entry for path."))?;
199
200                Ok(project.rename_entry(entry_id, to.clone(), cx))
201            })
202        });
203
204        let task = match result {
205            Ok(task) => task,
206            Err(err) => return Task::ready(Err(err)),
207        };
208
209        let from = from.clone();
210        let to = to.clone();
211        cx.spawn(async move |_| match task.await {
212            Err(err) => Err(err),
213            Ok(_) => Ok(ProjectPanelOperation::Rename {
214                from: to.clone(),
215                to: from.clone(),
216            }),
217        })
218    }
219
220    fn create(
221        &self,
222        project_path: &ProjectPath,
223        cx: &mut App,
224    ) -> Task<Result<ProjectPanelOperation>> {
225        let Some(workspace) = self.workspace.upgrade() else {
226            return Task::ready(Err(anyhow!("Failed to obtain workspace.")));
227        };
228
229        let task = workspace.update(cx, |workspace, cx| {
230            workspace.project().update(cx, |project, cx| {
231                // This should not be hardcoded to `false`, as it can genuinely
232                // be a directory and it misses all the nuances and details from
233                // `ProjectPanel::confirm_edit`. However, we expect this to be a
234                // short-lived solution as we add support for restoring trashed
235                // files, at which point we'll no longer need to `Create` new
236                // files, any redoing of a trash operation should be a restore.
237                let is_directory = false;
238                project.create_entry(project_path.clone(), is_directory, cx)
239            })
240        });
241
242        let project_path = project_path.clone();
243        cx.spawn(async move |_| match task.await {
244            Ok(_) => Ok(ProjectPanelOperation::Trash { project_path }),
245            Err(err) => Err(err),
246        })
247    }
248
249    fn trash(
250        &self,
251        project_path: &ProjectPath,
252        cx: &mut App,
253    ) -> Task<Result<ProjectPanelOperation>> {
254        let Some(workspace) = self.workspace.upgrade() else {
255            return Task::ready(Err(anyhow!("Failed to obtain workspace.")));
256        };
257
258        let result = workspace.update(cx, |workspace, cx| {
259            workspace.project().update(cx, |project, cx| {
260                let entry_id = project
261                    .entry_for_path(&project_path, cx)
262                    .map(|entry| entry.id)
263                    .ok_or_else(|| anyhow!("No entry for path."))?;
264
265                project
266                    .delete_entry(entry_id, true, cx)
267                    .ok_or_else(|| anyhow!("Failed to trash entry."))
268            })
269        });
270
271        let task = match result {
272            Ok(task) => task,
273            Err(err) => return Task::ready(Err(err)),
274        };
275
276        let project_path = project_path.clone();
277        cx.spawn(async move |_| match task.await {
278            // We'll want this to eventually be a `Restore` operation, once
279            // we've added support, in `fs` to track and restore a trashed file.
280            Ok(_) => Ok(ProjectPanelOperation::Create { project_path }),
281            Err(err) => Err(err),
282        })
283    }
284
285    fn batch(
286        &mut self,
287        operations: &[ProjectPanelOperation],
288        cx: &mut App,
289    ) -> Task<Result<ProjectPanelOperation>> {
290        let tasks: Vec<_> = operations
291            .into_iter()
292            .map(|operation| self.execute_operation(operation, cx))
293            .collect();
294
295        cx.spawn(async move |_| {
296            let mut operations = Vec::new();
297
298            for task in tasks {
299                match task.await {
300                    Ok(operation) => operations.push(operation),
301                    Err(err) => return Err(err),
302                }
303            }
304
305            // Return the `ProjectPanelOperation::Batch` that reverses all of
306            // the provided operations. The order of operations should be reversed
307            // so that dependencies are handled correctly.
308            operations.reverse();
309            Ok(ProjectPanelOperation::Batch(operations))
310        })
311    }
312
313    /// Displays a notification with the provided `title` and `error`.
314    fn show_error(
315        title: impl Into<SharedString>,
316        workspace: WeakEntity<Workspace>,
317        error: SharedString,
318        cx: &mut App,
319    ) {
320        workspace
321            .update(cx, move |workspace, cx| {
322                let notification_id =
323                    NotificationId::Named(SharedString::new_static("project_panel_undo"));
324
325                workspace.show_notification(notification_id, cx, move |cx| {
326                    cx.new(|cx| MessageNotification::new(error.to_string(), cx).with_title(title))
327                })
328            })
329            .ok();
330    }
331}
332
333#[cfg(test)]
334pub(crate) mod test {
335    use crate::{
336        ProjectPanel, project_panel_tests,
337        undo::{ProjectPanelOperation, UndoManager},
338    };
339    use gpui::{Entity, TestAppContext, VisualTestContext, WindowHandle};
340    use project::{FakeFs, Project, ProjectPath, WorktreeId};
341    use serde_json::{Value, json};
342    use std::sync::Arc;
343    use util::rel_path::rel_path;
344    use workspace::MultiWorkspace;
345
346    struct TestContext {
347        project: Entity<Project>,
348        panel: Entity<ProjectPanel>,
349        window: WindowHandle<MultiWorkspace>,
350    }
351
352    async fn init_test(cx: &mut TestAppContext, tree: Option<Value>) -> TestContext {
353        project_panel_tests::init_test(cx);
354
355        let fs = FakeFs::new(cx.executor());
356        if let Some(tree) = tree {
357            fs.insert_tree("/root", tree).await;
358        }
359        let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
360        let window =
361            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
362        let workspace = window
363            .read_with(cx, |mw, _| mw.workspace().clone())
364            .unwrap();
365        let cx = &mut VisualTestContext::from_window(window.into(), cx);
366        let panel = workspace.update_in(cx, ProjectPanel::new);
367        cx.run_until_parked();
368
369        TestContext {
370            project,
371            panel,
372            window,
373        }
374    }
375
376    pub(crate) fn build_create_operation(
377        worktree_id: WorktreeId,
378        file_name: &str,
379    ) -> ProjectPanelOperation {
380        ProjectPanelOperation::Create {
381            project_path: ProjectPath {
382                path: Arc::from(rel_path(file_name)),
383                worktree_id,
384            },
385        }
386    }
387
388    pub(crate) fn build_trash_operation(
389        worktree_id: WorktreeId,
390        file_name: &str,
391    ) -> ProjectPanelOperation {
392        ProjectPanelOperation::Trash {
393            project_path: ProjectPath {
394                path: Arc::from(rel_path(file_name)),
395                worktree_id,
396            },
397        }
398    }
399
400    pub(crate) fn build_rename_operation(
401        worktree_id: WorktreeId,
402        from: &str,
403        to: &str,
404    ) -> ProjectPanelOperation {
405        let from_path = Arc::from(rel_path(from));
406        let to_path = Arc::from(rel_path(to));
407
408        ProjectPanelOperation::Rename {
409            from: ProjectPath {
410                worktree_id,
411                path: from_path,
412            },
413            to: ProjectPath {
414                worktree_id,
415                path: to_path,
416            },
417        }
418    }
419
420    async fn rename(
421        panel: &Entity<ProjectPanel>,
422        from: &str,
423        to: &str,
424        cx: &mut VisualTestContext,
425    ) {
426        project_panel_tests::select_path(panel, from, cx);
427        panel.update_in(cx, |panel, window, cx| {
428            panel.rename(&Default::default(), window, cx)
429        });
430        cx.run_until_parked();
431
432        panel
433            .update_in(cx, |panel, window, cx| {
434                panel
435                    .filename_editor
436                    .update(cx, |editor, cx| editor.set_text(to, window, cx));
437                panel.confirm_edit(true, window, cx).unwrap()
438            })
439            .await
440            .unwrap();
441        cx.run_until_parked();
442    }
443
444    #[gpui::test]
445    async fn test_limit(cx: &mut TestAppContext) {
446        let test_context = init_test(cx, None).await;
447        let worktree_id = test_context.project.update(cx, |project, cx| {
448            project.visible_worktrees(cx).next().unwrap().read(cx).id()
449        });
450
451        // Since we're updating the `ProjectPanel`'s undo manager with one whose
452        // limit is 3 operations, we only need to create 4 operations which
453        // we'll record, in order to confirm that the oldest operation is
454        // evicted.
455        let operation_a = build_create_operation(worktree_id, "file_a.txt");
456        let operation_b = build_create_operation(worktree_id, "file_b.txt");
457        let operation_c = build_create_operation(worktree_id, "file_c.txt");
458        let operation_d = build_create_operation(worktree_id, "file_d.txt");
459
460        test_context.panel.update(cx, move |panel, cx| {
461            panel.undo_manager =
462                UndoManager::new_with_limit(panel.workspace.clone(), cx.weak_entity(), 3);
463            panel.undo_manager.record(operation_a);
464            panel.undo_manager.record(operation_b);
465            panel.undo_manager.record(operation_c);
466            panel.undo_manager.record(operation_d);
467
468            assert_eq!(panel.undo_manager.undo_stack.len(), 3);
469        });
470    }
471    #[gpui::test]
472    async fn test_undo_redo_stacks(cx: &mut TestAppContext) {
473        let TestContext {
474            window,
475            panel,
476            project,
477            ..
478        } = init_test(
479            cx,
480            Some(json!({
481                "a.txt": "",
482                "b.txt": ""
483            })),
484        )
485        .await;
486        let worktree_id = project.update(cx, |project, cx| {
487            project.visible_worktrees(cx).next().unwrap().read(cx).id()
488        });
489        let cx = &mut VisualTestContext::from_window(window.into(), cx);
490
491        // Start by renaming `src/file_a.txt` to `src/file_1.txt` and asserting
492        // we get the correct inverse operation in the
493        // `UndoManager::undo_stackand asserting we get the correct inverse
494        // operation in the `UndoManager::undo_stack`.
495        rename(&panel, "root/a.txt", "1.txt", cx).await;
496        panel.update(cx, |panel, _cx| {
497            assert_eq!(
498                panel.undo_manager.undo_stack,
499                vec![build_rename_operation(worktree_id, "1.txt", "a.txt")]
500            );
501            assert!(panel.undo_manager.redo_stack.is_empty());
502        });
503
504        // After undoing, the operation to be executed should be popped from
505        // `UndoManager::undo_stack` and its inverse operation pushed to
506        // `UndoManager::redo_stack`.
507        panel.update_in(cx, |panel, window, cx| {
508            panel.undo(&Default::default(), window, cx);
509        });
510        cx.run_until_parked();
511
512        panel.update(cx, |panel, _cx| {
513            assert!(panel.undo_manager.undo_stack.is_empty());
514            assert_eq!(
515                panel.undo_manager.redo_stack,
516                vec![build_rename_operation(worktree_id, "a.txt", "1.txt")]
517            );
518        });
519
520        // Redoing should have the same effect as undoing, but in reverse.
521        panel.update_in(cx, |panel, window, cx| {
522            panel.redo(&Default::default(), window, cx);
523        });
524        cx.run_until_parked();
525
526        panel.update(cx, |panel, _cx| {
527            assert_eq!(
528                panel.undo_manager.undo_stack,
529                vec![build_rename_operation(worktree_id, "1.txt", "a.txt")]
530            );
531            assert!(panel.undo_manager.redo_stack.is_empty());
532        });
533    }
534
535    #[gpui::test]
536    async fn test_undo_redo_trash(cx: &mut TestAppContext) {
537        let TestContext {
538            window,
539            panel,
540            project,
541            ..
542        } = init_test(
543            cx,
544            Some(json!({
545                "a.txt": "",
546                "b.txt": ""
547            })),
548        )
549        .await;
550        let worktree_id = project.update(cx, |project, cx| {
551            project.visible_worktrees(cx).next().unwrap().read(cx).id()
552        });
553        let cx = &mut VisualTestContext::from_window(window.into(), cx);
554
555        // Start by setting up the `UndoManager::undo_stack` such that, undoing
556        // the last user operation will trash `a.txt`.
557        panel.update(cx, |panel, _cx| {
558            panel
559                .undo_manager
560                .undo_stack
561                .push_back(build_trash_operation(worktree_id, "a.txt"));
562        });
563
564        // Undoing should now delete the file and update the
565        // `UndoManager::redo_stack` state with a new `Create` operation.
566        panel.update_in(cx, |panel, window, cx| {
567            panel.undo(&Default::default(), window, cx);
568        });
569        cx.run_until_parked();
570
571        panel.update(cx, |panel, _cx| {
572            assert!(panel.undo_manager.undo_stack.is_empty());
573            assert_eq!(
574                panel.undo_manager.redo_stack,
575                vec![build_create_operation(worktree_id, "a.txt")]
576            );
577        });
578
579        // Redoing should create the file again and pop the operation from
580        // `UndoManager::redo_stack`.
581        panel.update_in(cx, |panel, window, cx| {
582            panel.redo(&Default::default(), window, cx);
583        });
584        cx.run_until_parked();
585
586        panel.update(cx, |panel, _cx| {
587            assert_eq!(
588                panel.undo_manager.undo_stack,
589                vec![build_trash_operation(worktree_id, "a.txt")]
590            );
591            assert!(panel.undo_manager.redo_stack.is_empty());
592        });
593    }
594
595    #[gpui::test]
596    async fn test_undo_redo_batch(cx: &mut TestAppContext) {
597        let TestContext {
598            window,
599            panel,
600            project,
601            ..
602        } = init_test(
603            cx,
604            Some(json!({
605                "a.txt": "",
606                "b.txt": ""
607            })),
608        )
609        .await;
610        let worktree_id = project.update(cx, |project, cx| {
611            project.visible_worktrees(cx).next().unwrap().read(cx).id()
612        });
613        let cx = &mut VisualTestContext::from_window(window.into(), cx);
614
615        // There's currently no way to trigger two file renames in a single
616        // operation using the `ProjectPanel`. As such, we'll directly record
617        // the batch of operations in `UndoManager`, simulating that `1.txt` and
618        // `2.txt` had been renamed to `a.txt` and `b.txt`, respectively.
619        panel.update(cx, |panel, _cx| {
620            panel.undo_manager.record_batch(vec![
621                build_rename_operation(worktree_id, "1.txt", "a.txt"),
622                build_rename_operation(worktree_id, "2.txt", "b.txt"),
623            ]);
624
625            assert_eq!(
626                panel.undo_manager.undo_stack,
627                vec![ProjectPanelOperation::Batch(vec![
628                    build_rename_operation(worktree_id, "b.txt", "2.txt"),
629                    build_rename_operation(worktree_id, "a.txt", "1.txt"),
630                ])]
631            );
632            assert!(panel.undo_manager.redo_stack.is_empty());
633        });
634
635        panel.update_in(cx, |panel, window, cx| {
636            panel.undo(&Default::default(), window, cx);
637        });
638        cx.run_until_parked();
639
640        // Since the operations in the `Batch` are meant to be done in order,
641        // the inverse should have the operations in the opposite order to avoid
642        // dependencies. For example, creating a `src/` folder come before
643        // creating the `src/file_a.txt` file, but when undoing, the file should
644        // be trashed first.
645        panel.update(cx, |panel, _cx| {
646            assert!(panel.undo_manager.undo_stack.is_empty());
647            assert_eq!(
648                panel.undo_manager.redo_stack,
649                vec![ProjectPanelOperation::Batch(vec![
650                    build_rename_operation(worktree_id, "1.txt", "a.txt"),
651                    build_rename_operation(worktree_id, "2.txt", "b.txt"),
652                ])]
653            );
654        });
655
656        panel.update_in(cx, |panel, window, cx| {
657            panel.redo(&Default::default(), window, cx);
658        });
659        cx.run_until_parked();
660
661        panel.update(cx, |panel, _cx| {
662            assert_eq!(
663                panel.undo_manager.undo_stack,
664                vec![ProjectPanelOperation::Batch(vec![
665                    build_rename_operation(worktree_id, "b.txt", "2.txt"),
666                    build_rename_operation(worktree_id, "a.txt", "1.txt"),
667                ])]
668            );
669            assert!(panel.undo_manager.redo_stack.is_empty());
670        });
671    }
672}