Add basic undo in project panel (#47091)

Marco Mihai Condrache , Cole Miller , and dino created

- Add `project_panel::undo::UndoManager` with a bounded operation stack
  to track and revert project panel operations
- Support undoing file and directory creation, renaming, moving, pasting
  and drag-and-drop operations
- Revert batch operations sequentially in reverse order to handle
  dependencies between them
- Show an error notification when one or more undo operations fail
- Add "Undo" entry to the project panel context menu, disabled when
  there is nothing to undo
- Gate the feature behind the `project-panel-undo-redo` feature flag

Ref: #5039

Release Notes:

- N/A

---------

Signed-off-by: Marco Mihai Condrache <52580954+marcocondrache@users.noreply.github.com>
Co-authored-by: Cole Miller <cole@zed.dev>
Co-authored-by: dino <dinojoaocosta@gmail.com>

Change summary

Cargo.lock                                      |   2 
assets/keymaps/default-linux.json               |   2 
assets/keymaps/default-macos.json               |   1 
assets/keymaps/default-windows.json             |   1 
crates/feature_flags/src/flags.rs               |  10 
crates/project_panel/Cargo.toml                 |   2 
crates/project_panel/src/project_panel.rs       | 172 +++++
crates/project_panel/src/project_panel_tests.rs | 553 ++++++++++++++++++
crates/project_panel/src/undo.rs                | 286 +++++++++
9 files changed, 1,007 insertions(+), 22 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -13250,6 +13250,7 @@ dependencies = [
  "criterion",
  "db",
  "editor",
+ "feature_flags",
  "file_icons",
  "git",
  "git_ui",
@@ -13261,6 +13262,7 @@ dependencies = [
  "pretty_assertions",
  "project",
  "rayon",
+ "remote_connection",
  "schemars",
  "search",
  "serde",

assets/keymaps/default-linux.json 🔗

@@ -890,6 +890,8 @@
       "ctrl-alt-c": "project_panel::CopyPath",
       "alt-shift-copy": "workspace::CopyRelativePath",
       "alt-ctrl-shift-c": "workspace::CopyRelativePath",
+      "undo": "project_panel::Undo",
+      "ctrl-z": "project_panel::Undo",
       "enter": "project_panel::Rename",
       "f2": "project_panel::Rename",
       "backspace": ["project_panel::Trash", { "skip_prompt": false }],

assets/keymaps/default-macos.json 🔗

@@ -951,6 +951,7 @@
       "cmd-v": "project_panel::Paste",
       "cmd-alt-c": "workspace::CopyPath",
       "alt-cmd-shift-c": "workspace::CopyRelativePath",
+      "cmd-z": "project_panel::Undo",
       "enter": "project_panel::Rename",
       "f2": "project_panel::Rename",
       "backspace": ["project_panel::Trash", { "skip_prompt": false }],

assets/keymaps/default-windows.json 🔗

@@ -887,6 +887,7 @@
       "ctrl-v": "project_panel::Paste",
       "shift-alt-c": "project_panel::CopyPath",
       "ctrl-k ctrl-shift-c": "workspace::CopyRelativePath",
+      "ctrl-z": "project_panel::Undo",
       "enter": "project_panel::Rename",
       "f2": "project_panel::Rename",
       "backspace": ["project_panel::Trash", { "skip_prompt": false }],

crates/feature_flags/src/flags.rs 🔗

@@ -62,3 +62,13 @@ impl FeatureFlag for StreamingEditFileToolFeatureFlag {
         true
     }
 }
+
+pub struct ProjectPanelUndoRedoFeatureFlag;
+
+impl FeatureFlag for ProjectPanelUndoRedoFeatureFlag {
+    const NAME: &'static str = "project-panel-undo-redo";
+
+    fn enabled_for_staff() -> bool {
+        false
+    }
+}

crates/project_panel/Cargo.toml 🔗

@@ -47,6 +47,7 @@ language.workspace = true
 zed_actions.workspace = true
 telemetry.workspace = true
 notifications.workspace = true
+feature_flags.workspace = true
 
 [dev-dependencies]
 client = { workspace = true, features = ["test-support"] }
@@ -54,6 +55,7 @@ criterion.workspace = true
 editor = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }
 language = { workspace = true, features = ["test-support"] }
+remote_connection = { workspace = true, features = ["test-support"] }
 serde_json.workspace = true
 tempfile.workspace = true
 workspace = { workspace = true, features = ["test-support"] }

crates/project_panel/src/project_panel.rs 🔗

@@ -1,4 +1,5 @@
 pub mod project_panel_settings;
+mod undo;
 mod utils;
 
 use anyhow::{Context as _, Result};
@@ -13,6 +14,7 @@ use editor::{
         entry_diagnostic_aware_icon_name_and_color, entry_git_aware_label_color,
     },
 };
+use feature_flags::{FeatureFlagAppExt, ProjectPanelUndoRedoFeatureFlag};
 use file_icons::FileIcons;
 use git;
 use git::status::GitSummary;
@@ -81,6 +83,8 @@ use zed_actions::{
     workspace::OpenWithSystem,
 };
 
+use crate::undo::{ProjectPanelOperation, UndoManager};
+
 const PROJECT_PANEL_KEY: &str = "ProjectPanel";
 const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
 
@@ -157,6 +161,7 @@ pub struct ProjectPanel {
     sticky_items_count: usize,
     last_reported_update: Instant,
     update_visible_entries_task: UpdateVisibleEntriesTask,
+    undo_manager: UndoManager,
     state: State,
 }
 
@@ -394,6 +399,8 @@ actions!(
         SelectPrevDirectory,
         /// Opens a diff view to compare two marked files.
         CompareMarkedFiles,
+        /// Undoes the last file operation.
+        Undo,
     ]
 );
 
@@ -893,6 +900,7 @@ impl ProjectPanel {
                     unfolded_dir_ids: Default::default(),
                 },
                 update_visible_entries_task: Default::default(),
+                undo_manager: UndoManager::new(workspace.weak_handle()),
             };
             this.update_visible_entries(None, false, false, window, cx);
 
@@ -1189,7 +1197,7 @@ impl ProjectPanel {
 
             let has_pasteable_content = self.has_pasteable_content(cx);
             let entity = cx.entity();
-            let context_menu = ContextMenu::build(window, cx, |menu, _, _| {
+            let context_menu = ContextMenu::build(window, cx, |menu, _, cx| {
                 menu.context(self.focus_handle.clone()).map(|menu| {
                     if is_read_only {
                         menu.when(is_dir, |menu| {
@@ -1229,6 +1237,13 @@ impl ProjectPanel {
                             .action("Duplicate", Box::new(Duplicate))
                             // TODO: Paste should always be visible, cbut disabled when clipboard is empty
                             .action_disabled_when(!has_pasteable_content, "Paste", Box::new(Paste))
+                            .when(cx.has_flag::<ProjectPanelUndoRedoFeatureFlag>(), |menu| {
+                                menu.action_disabled_when(
+                                    !self.undo_manager.can_undo(),
+                                    "Undo",
+                                    Box::new(Undo),
+                                )
+                            })
                             .when(is_remote, |menu| {
                                 menu.separator()
                                     .action("Download...", Box::new(DownloadFromRemote))
@@ -1874,6 +1889,8 @@ impl ProjectPanel {
 
         let edit_task;
         let edited_entry_id;
+        let edited_entry;
+        let new_project_path: ProjectPath;
         if is_new_entry {
             self.selection = Some(SelectedEntry {
                 worktree_id,
@@ -1884,12 +1901,14 @@ impl ProjectPanel {
                 return None;
             }
 
+            edited_entry = None;
             edited_entry_id = NEW_ENTRY_ID;
+            new_project_path = (worktree_id, new_path).into();
             edit_task = self.project.update(cx, |project, cx| {
-                project.create_entry((worktree_id, new_path), is_dir, cx)
+                project.create_entry(new_project_path.clone(), is_dir, cx)
             });
         } else {
-            let new_path = if let Some(parent) = entry.path.clone().parent() {
+            let new_path = if let Some(parent) = entry.path.parent() {
                 parent.join(&filename)
             } else {
                 filename.clone()
@@ -1901,9 +1920,11 @@ impl ProjectPanel {
                 return None;
             }
             edited_entry_id = entry.id;
+            edited_entry = Some(entry);
+            new_project_path = (worktree_id, new_path).into();
             edit_task = self.project.update(cx, |project, cx| {
-                project.rename_entry(entry.id, (worktree_id, new_path).into(), cx)
-            });
+                project.rename_entry(edited_entry_id, new_project_path.clone(), cx)
+            })
         };
 
         if refocus {
@@ -1916,6 +1937,22 @@ impl ProjectPanel {
             let new_entry = edit_task.await;
             project_panel.update(cx, |project_panel, cx| {
                 project_panel.state.edit_state = None;
+
+                // Record the operation if the edit was applied
+                if new_entry.is_ok() {
+                    let operation = if let Some(old_entry) = edited_entry {
+                        ProjectPanelOperation::Rename {
+                            old_path: (worktree_id, old_entry.path).into(),
+                            new_path: new_project_path,
+                        }
+                    } else {
+                        ProjectPanelOperation::Create {
+                            project_path: new_project_path,
+                        }
+                    };
+                    project_panel.undo_manager.record(operation);
+                }
+
                 cx.notify();
             })?;
 
@@ -2166,6 +2203,11 @@ impl ProjectPanel {
         }
     }
 
+    pub fn undo(&mut self, _: &Undo, _window: &mut Window, cx: &mut Context<Self>) {
+        self.undo_manager.undo(cx);
+        cx.notify();
+    }
+
     fn rename_impl(
         &mut self,
         selection: Option<Range<usize>>,
@@ -2353,6 +2395,7 @@ impl ProjectPanel {
                     let project_path = project.path_for_entry(selection.entry_id, cx)?;
                     dirty_buffers +=
                         project.dirty_buffers(cx).any(|path| path == project_path) as usize;
+
                     Some((
                         selection.entry_id,
                         project_path.path.file_name()?.to_string(),
@@ -3083,8 +3126,15 @@ impl ProjectPanel {
                 .filter(|clipboard| !clipboard.items().is_empty())?;
 
             enum PasteTask {
-                Rename(Task<Result<CreatedEntry>>),
-                Copy(Task<Result<Option<Entry>>>),
+                Rename {
+                    task: Task<Result<CreatedEntry>>,
+                    old_path: ProjectPath,
+                    new_path: ProjectPath,
+                },
+                Copy {
+                    task: Task<Result<Option<Entry>>>,
+                    destination: ProjectPath,
+                },
             }
 
             let mut paste_tasks = Vec::new();
@@ -3094,16 +3144,22 @@ impl ProjectPanel {
                 let (new_path, new_disambiguation_range) =
                     self.create_paste_path(clipboard_entry, self.selected_sub_entry(cx)?, cx)?;
                 let clip_entry_id = clipboard_entry.entry_id;
+                let destination: ProjectPath = (worktree_id, new_path).into();
                 let task = if clipboard_entries.is_cut() {
+                    let old_path = self.project.read(cx).path_for_entry(clip_entry_id, cx)?;
                     let task = self.project.update(cx, |project, cx| {
-                        project.rename_entry(clip_entry_id, (worktree_id, new_path).into(), cx)
+                        project.rename_entry(clip_entry_id, destination.clone(), cx)
                     });
-                    PasteTask::Rename(task)
+                    PasteTask::Rename {
+                        task,
+                        old_path,
+                        new_path: destination,
+                    }
                 } else {
                     let task = self.project.update(cx, |project, cx| {
-                        project.copy_entry(clip_entry_id, (worktree_id, new_path).into(), cx)
+                        project.copy_entry(clip_entry_id, destination.clone(), cx)
                     });
-                    PasteTask::Copy(task)
+                    PasteTask::Copy { task, destination }
                 };
                 paste_tasks.push(task);
                 disambiguation_range = new_disambiguation_range.or(disambiguation_range);
@@ -3114,26 +3170,44 @@ impl ProjectPanel {
 
             cx.spawn_in(window, async move |project_panel, mut cx| {
                 let mut last_succeed = None;
+                let mut operations = Vec::new();
+
                 for task in paste_tasks {
                     match task {
-                        PasteTask::Rename(task) => {
+                        PasteTask::Rename {
+                            task,
+                            old_path,
+                            new_path,
+                        } => {
                             if let Some(CreatedEntry::Included(entry)) = task
                                 .await
                                 .notify_workspace_async_err(workspace.clone(), &mut cx)
                             {
+                                operations
+                                    .push(ProjectPanelOperation::Rename { old_path, new_path });
                                 last_succeed = Some(entry);
                             }
                         }
-                        PasteTask::Copy(task) => {
+                        PasteTask::Copy { task, destination } => {
                             if let Some(Some(entry)) = task
                                 .await
                                 .notify_workspace_async_err(workspace.clone(), &mut cx)
                             {
+                                operations.push(ProjectPanelOperation::Create {
+                                    project_path: destination,
+                                });
                                 last_succeed = Some(entry);
                             }
                         }
                     }
                 }
+
+                project_panel
+                    .update(cx, |this, _| {
+                        this.undo_manager.record_batch(operations);
+                    })
+                    .ok();
+
                 // update selection
                 if let Some(entry) = last_succeed {
                     project_panel
@@ -4430,9 +4504,13 @@ impl ProjectPanel {
 
                 cx.spawn_in(window, async move |project_panel, cx| {
                     let mut last_succeed = None;
+                    let mut operations = Vec::new();
                     for task in copy_tasks.into_iter() {
                         if let Some(Some(entry)) = task.await.log_err() {
                             last_succeed = Some(entry.id);
+                            operations.push(ProjectPanelOperation::Create {
+                                project_path: (worktree_id, entry.path).into(),
+                            });
                         }
                     }
                     // update selection
@@ -4444,6 +4522,8 @@ impl ProjectPanel {
                                     entry_id,
                                 });
 
+                                project_panel.undo_manager.record_batch(operations);
+
                                 // if only one entry was dragged and it was disambiguated, open the rename editor
                                 if item_count == 1 && disambiguation_range.is_some() {
                                     project_panel.rename_impl(disambiguation_range, window, cx);
@@ -4493,6 +4573,23 @@ impl ProjectPanel {
                 (info, folded_entries)
             };
 
+            // Capture old paths before moving so we can record undo operations.
+            let old_paths: HashMap<ProjectEntryId, ProjectPath> = {
+                let project = self.project.read(cx);
+                entries
+                    .iter()
+                    .filter_map(|entry| {
+                        let path = project.path_for_entry(entry.entry_id, cx)?;
+                        Some((entry.entry_id, path))
+                    })
+                    .collect()
+            };
+            let destination_worktree_id = self
+                .project
+                .read(cx)
+                .worktree_for_entry(target_entry_id, cx)
+                .map(|wt| wt.read(cx).id());
+
             // Collect move tasks paired with their source entry ID so we can correlate
             // results with folded selections that need refreshing.
             let mut move_tasks: Vec<(ProjectEntryId, Task<Result<CreatedEntry>>)> = Vec::new();
@@ -4508,22 +4605,48 @@ impl ProjectPanel {
 
             let workspace = self.workspace.clone();
             if folded_selection_info.is_empty() {
-                for (_, task) in move_tasks {
-                    let workspace = workspace.clone();
-                    cx.spawn_in(window, async move |_, mut cx| {
-                        task.await.notify_workspace_async_err(workspace, &mut cx);
-                    })
-                    .detach();
-                }
+                cx.spawn_in(window, async move |project_panel, mut cx| {
+                    let mut operations = Vec::new();
+                    for (entry_id, task) in move_tasks {
+                        if let Some(CreatedEntry::Included(new_entry)) = task
+                            .await
+                            .notify_workspace_async_err(workspace.clone(), &mut cx)
+                        {
+                            if let (Some(old_path), Some(worktree_id)) =
+                                (old_paths.get(&entry_id), destination_worktree_id)
+                            {
+                                operations.push(ProjectPanelOperation::Rename {
+                                    old_path: old_path.clone(),
+                                    new_path: (worktree_id, new_entry.path).into(),
+                                });
+                            }
+                        }
+                    }
+                    project_panel
+                        .update(cx, |this, _| {
+                            this.undo_manager.record_batch(operations);
+                        })
+                        .ok();
+                })
+                .detach();
             } else {
                 cx.spawn_in(window, async move |project_panel, mut cx| {
                     // Await all move tasks and collect successful results
                     let mut move_results: Vec<(ProjectEntryId, Entry)> = Vec::new();
+                    let mut operations = Vec::new();
                     for (entry_id, task) in move_tasks {
                         if let Some(CreatedEntry::Included(new_entry)) = task
                             .await
                             .notify_workspace_async_err(workspace.clone(), &mut cx)
                         {
+                            if let (Some(old_path), Some(worktree_id)) =
+                                (old_paths.get(&entry_id), destination_worktree_id)
+                            {
+                                operations.push(ProjectPanelOperation::Rename {
+                                    old_path: old_path.clone(),
+                                    new_path: (worktree_id, new_entry.path.clone()).into(),
+                                });
+                            }
                             move_results.push((entry_id, new_entry));
                         }
                     }
@@ -4532,6 +4655,12 @@ impl ProjectPanel {
                         return;
                     }
 
+                    project_panel
+                        .update(cx, |this, _| {
+                            this.undo_manager.record_batch(operations);
+                        })
+                        .ok();
+
                     // For folded selections, we need to refresh the leaf paths (with suffixes)
                     // because they may not be indexed yet after the parent directory was moved.
                     // First collect the paths to refresh, then refresh them.
@@ -6544,6 +6673,9 @@ impl Render for ProjectPanel {
                 .on_action(cx.listener(Self::fold_directory))
                 .on_action(cx.listener(Self::remove_from_project))
                 .on_action(cx.listener(Self::compare_marked_files))
+                .when(cx.has_flag::<ProjectPanelUndoRedoFeatureFlag>(), |el| {
+                    el.on_action(cx.listener(Self::undo))
+                })
                 .when(!project.is_read_only(cx), |el| {
                     el.on_action(cx.listener(Self::new_file))
                         .on_action(cx.listener(Self::new_directory))

crates/project_panel/src/project_panel_tests.rs 🔗

@@ -4,7 +4,7 @@ use editor::MultiBufferOffset;
 use gpui::{Empty, Entity, TestAppContext, VisualTestContext};
 use menu::Cancel;
 use pretty_assertions::assert_eq;
-use project::FakeFs;
+use project::{FakeFs, ProjectPath};
 use serde_json::json;
 use settings::{ProjectPanelAutoOpenSettings, SettingsStore};
 use std::path::{Path, PathBuf};
@@ -1956,6 +1956,555 @@ async fn test_copy_paste_nested_and_root_entries(cx: &mut gpui::TestAppContext)
     );
 }
 
+#[gpui::test]
+async fn test_undo_rename(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        "/root",
+        json!({
+            "a.txt": "",
+            "b.txt": "",
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
+    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+    let workspace = window
+        .read_with(cx, |mw, _| mw.workspace().clone())
+        .unwrap();
+    let cx = &mut VisualTestContext::from_window(window.into(), cx);
+    let panel = workspace.update_in(cx, ProjectPanel::new);
+    cx.run_until_parked();
+
+    select_path(&panel, "root/a.txt", cx);
+    panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
+    cx.run_until_parked();
+
+    let confirm = panel.update_in(cx, |panel, window, cx| {
+        panel
+            .filename_editor
+            .update(cx, |editor, cx| editor.set_text("renamed.txt", window, cx));
+        panel.confirm_edit(true, window, cx).unwrap()
+    });
+    confirm.await.unwrap();
+    cx.run_until_parked();
+
+    assert!(
+        find_project_entry(&panel, "root/renamed.txt", cx).is_some(),
+        "File should be renamed to renamed.txt"
+    );
+    assert_eq!(
+        find_project_entry(&panel, "root/a.txt", cx),
+        None,
+        "Original file should no longer exist"
+    );
+
+    panel.update_in(cx, |panel, window, cx| {
+        panel.undo(&Undo, window, cx);
+    });
+    cx.run_until_parked();
+
+    assert!(
+        find_project_entry(&panel, "root/a.txt", cx).is_some(),
+        "File should be restored to original name after undo"
+    );
+    assert_eq!(
+        find_project_entry(&panel, "root/renamed.txt", cx),
+        None,
+        "Renamed file should no longer exist after undo"
+    );
+}
+
+#[gpui::test]
+async fn test_undo_create_file(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        "/root",
+        json!({
+            "existing.txt": "",
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
+    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+    let workspace = window
+        .read_with(cx, |mw, _| mw.workspace().clone())
+        .unwrap();
+    let cx = &mut VisualTestContext::from_window(window.into(), cx);
+    let panel = workspace.update_in(cx, ProjectPanel::new);
+    cx.run_until_parked();
+
+    select_path(&panel, "root", cx);
+    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
+    cx.run_until_parked();
+
+    let confirm = panel.update_in(cx, |panel, window, cx| {
+        panel
+            .filename_editor
+            .update(cx, |editor, cx| editor.set_text("new.txt", window, cx));
+        panel.confirm_edit(true, window, cx).unwrap()
+    });
+    confirm.await.unwrap();
+    cx.run_until_parked();
+
+    assert!(
+        find_project_entry(&panel, "root/new.txt", cx).is_some(),
+        "New file should exist"
+    );
+
+    panel.update_in(cx, |panel, window, cx| {
+        panel.undo(&Undo, window, cx);
+    });
+    cx.run_until_parked();
+
+    assert_eq!(
+        find_project_entry(&panel, "root/new.txt", cx),
+        None,
+        "New file should be removed after undo"
+    );
+    assert!(
+        find_project_entry(&panel, "root/existing.txt", cx).is_some(),
+        "Existing file should still be present"
+    );
+}
+
+#[gpui::test]
+async fn test_undo_create_directory(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        "/root",
+        json!({
+            "existing.txt": "",
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
+    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+    let workspace = window
+        .read_with(cx, |mw, _| mw.workspace().clone())
+        .unwrap();
+    let cx = &mut VisualTestContext::from_window(window.into(), cx);
+    let panel = workspace.update_in(cx, ProjectPanel::new);
+    cx.run_until_parked();
+
+    select_path(&panel, "root", cx);
+    panel.update_in(cx, |panel, window, cx| {
+        panel.new_directory(&NewDirectory, window, cx)
+    });
+    cx.run_until_parked();
+
+    let confirm = panel.update_in(cx, |panel, window, cx| {
+        panel
+            .filename_editor
+            .update(cx, |editor, cx| editor.set_text("new_dir", window, cx));
+        panel.confirm_edit(true, window, cx).unwrap()
+    });
+    confirm.await.unwrap();
+    cx.run_until_parked();
+
+    assert!(
+        find_project_entry(&panel, "root/new_dir", cx).is_some(),
+        "New directory should exist"
+    );
+
+    panel.update_in(cx, |panel, window, cx| {
+        panel.undo(&Undo, window, cx);
+    });
+    cx.run_until_parked();
+
+    assert_eq!(
+        find_project_entry(&panel, "root/new_dir", cx),
+        None,
+        "New directory should be removed after undo"
+    );
+}
+
+#[gpui::test]
+async fn test_undo_cut_paste(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        "/root",
+        json!({
+            "src": {
+                "file.txt": "content",
+            },
+            "dst": {},
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
+    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+    let workspace = window
+        .read_with(cx, |mw, _| mw.workspace().clone())
+        .unwrap();
+    let cx = &mut VisualTestContext::from_window(window.into(), cx);
+    let panel = workspace.update_in(cx, ProjectPanel::new);
+    cx.run_until_parked();
+
+    toggle_expand_dir(&panel, "root/src", cx);
+
+    select_path_with_mark(&panel, "root/src/file.txt", cx);
+    panel.update_in(cx, |panel, window, cx| {
+        panel.cut(&Default::default(), window, cx);
+    });
+
+    select_path(&panel, "root/dst", cx);
+    panel.update_in(cx, |panel, window, cx| {
+        panel.paste(&Default::default(), window, cx);
+    });
+    cx.run_until_parked();
+
+    assert!(
+        find_project_entry(&panel, "root/dst/file.txt", cx).is_some(),
+        "File should be moved to dst"
+    );
+    assert_eq!(
+        find_project_entry(&panel, "root/src/file.txt", cx),
+        None,
+        "File should no longer be in src"
+    );
+
+    panel.update_in(cx, |panel, window, cx| {
+        panel.undo(&Undo, window, cx);
+    });
+    cx.run_until_parked();
+
+    assert!(
+        find_project_entry(&panel, "root/src/file.txt", cx).is_some(),
+        "File should be back in src after undo"
+    );
+    assert_eq!(
+        find_project_entry(&panel, "root/dst/file.txt", cx),
+        None,
+        "File should no longer be in dst after undo"
+    );
+}
+
+#[gpui::test]
+async fn test_undo_drag_single_entry(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        "/root",
+        json!({
+            "src": {
+                "main.rs": "",
+            },
+            "dst": {},
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
+    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+    let workspace = window
+        .read_with(cx, |mw, _| mw.workspace().clone())
+        .unwrap();
+    let cx = &mut VisualTestContext::from_window(window.into(), cx);
+    let panel = workspace.update_in(cx, ProjectPanel::new);
+    cx.run_until_parked();
+
+    toggle_expand_dir(&panel, "root/src", cx);
+
+    panel.update(cx, |panel, _| panel.marked_entries.clear());
+    select_path_with_mark(&panel, "root/src/main.rs", cx);
+    drag_selection_to(&panel, "root/dst", false, cx);
+
+    assert!(
+        find_project_entry(&panel, "root/dst/main.rs", cx).is_some(),
+        "File should be in dst after drag"
+    );
+    assert_eq!(
+        find_project_entry(&panel, "root/src/main.rs", cx),
+        None,
+        "File should no longer be in src after drag"
+    );
+
+    panel.update_in(cx, |panel, window, cx| {
+        panel.undo(&Undo, window, cx);
+    });
+    cx.run_until_parked();
+
+    assert!(
+        find_project_entry(&panel, "root/src/main.rs", cx).is_some(),
+        "File should be back in src after undo"
+    );
+    assert_eq!(
+        find_project_entry(&panel, "root/dst/main.rs", cx),
+        None,
+        "File should no longer be in dst after undo"
+    );
+}
+
+#[gpui::test]
+async fn test_undo_drag_multiple_entries(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        "/root",
+        json!({
+            "src": {
+                "alpha.txt": "",
+                "beta.txt": "",
+            },
+            "dst": {},
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
+    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+    let workspace = window
+        .read_with(cx, |mw, _| mw.workspace().clone())
+        .unwrap();
+    let cx = &mut VisualTestContext::from_window(window.into(), cx);
+    let panel = workspace.update_in(cx, ProjectPanel::new);
+    cx.run_until_parked();
+
+    toggle_expand_dir(&panel, "root/src", cx);
+
+    panel.update(cx, |panel, _| panel.marked_entries.clear());
+    select_path_with_mark(&panel, "root/src/alpha.txt", cx);
+    select_path_with_mark(&panel, "root/src/beta.txt", cx);
+    drag_selection_to(&panel, "root/dst", false, cx);
+
+    assert!(
+        find_project_entry(&panel, "root/dst/alpha.txt", cx).is_some(),
+        "alpha.txt should be in dst after drag"
+    );
+    assert!(
+        find_project_entry(&panel, "root/dst/beta.txt", cx).is_some(),
+        "beta.txt should be in dst after drag"
+    );
+
+    // A single undo should revert the entire batch
+    panel.update_in(cx, |panel, window, cx| {
+        panel.undo(&Undo, window, cx);
+    });
+    cx.run_until_parked();
+
+    assert!(
+        find_project_entry(&panel, "root/src/alpha.txt", cx).is_some(),
+        "alpha.txt should be back in src after undo"
+    );
+    assert!(
+        find_project_entry(&panel, "root/src/beta.txt", cx).is_some(),
+        "beta.txt should be back in src after undo"
+    );
+    assert_eq!(
+        find_project_entry(&panel, "root/dst/alpha.txt", cx),
+        None,
+        "alpha.txt should no longer be in dst after undo"
+    );
+    assert_eq!(
+        find_project_entry(&panel, "root/dst/beta.txt", cx),
+        None,
+        "beta.txt should no longer be in dst after undo"
+    );
+}
+
+#[gpui::test]
+async fn test_multiple_sequential_undos(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        "/root",
+        json!({
+            "a.txt": "",
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
+    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+    let workspace = window
+        .read_with(cx, |mw, _| mw.workspace().clone())
+        .unwrap();
+    let cx = &mut VisualTestContext::from_window(window.into(), cx);
+    let panel = workspace.update_in(cx, ProjectPanel::new);
+    cx.run_until_parked();
+
+    select_path(&panel, "root/a.txt", cx);
+    panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
+    cx.run_until_parked();
+    let confirm = panel.update_in(cx, |panel, window, cx| {
+        panel
+            .filename_editor
+            .update(cx, |editor, cx| editor.set_text("b.txt", window, cx));
+        panel.confirm_edit(true, window, cx).unwrap()
+    });
+    confirm.await.unwrap();
+    cx.run_until_parked();
+
+    assert!(find_project_entry(&panel, "root/b.txt", cx).is_some());
+
+    select_path(&panel, "root", cx);
+    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
+    cx.run_until_parked();
+    let confirm = panel.update_in(cx, |panel, window, cx| {
+        panel
+            .filename_editor
+            .update(cx, |editor, cx| editor.set_text("c.txt", window, cx));
+        panel.confirm_edit(true, window, cx).unwrap()
+    });
+    confirm.await.unwrap();
+    cx.run_until_parked();
+
+    assert!(find_project_entry(&panel, "root/b.txt", cx).is_some());
+    assert!(find_project_entry(&panel, "root/c.txt", cx).is_some());
+
+    panel.update_in(cx, |panel, window, cx| {
+        panel.undo(&Undo, window, cx);
+    });
+    cx.run_until_parked();
+
+    assert_eq!(
+        find_project_entry(&panel, "root/c.txt", cx),
+        None,
+        "c.txt should be removed after first undo"
+    );
+    assert!(
+        find_project_entry(&panel, "root/b.txt", cx).is_some(),
+        "b.txt should still exist after first undo"
+    );
+
+    panel.update_in(cx, |panel, window, cx| {
+        panel.undo(&Undo, window, cx);
+    });
+    cx.run_until_parked();
+
+    assert!(
+        find_project_entry(&panel, "root/a.txt", cx).is_some(),
+        "a.txt should be restored after second undo"
+    );
+    assert_eq!(
+        find_project_entry(&panel, "root/b.txt", cx),
+        None,
+        "b.txt should no longer exist after second undo"
+    );
+}
+
+#[gpui::test]
+async fn test_undo_with_empty_stack(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        "/root",
+        json!({
+            "a.txt": "",
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
+    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+    let workspace = window
+        .read_with(cx, |mw, _| mw.workspace().clone())
+        .unwrap();
+    let cx = &mut VisualTestContext::from_window(window.into(), cx);
+    let panel = workspace.update_in(cx, ProjectPanel::new);
+    cx.run_until_parked();
+
+    panel.update_in(cx, |panel, window, cx| {
+        panel.undo(&Undo, window, cx);
+    });
+    cx.run_until_parked();
+
+    assert!(
+        find_project_entry(&panel, "root/a.txt", cx).is_some(),
+        "File tree should be unchanged after undo on empty stack"
+    );
+}
+
+#[gpui::test]
+async fn test_undo_batch(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        "/root",
+        json!({
+            "src": {
+                "main.rs": "// Code!"
+            }
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
+    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+    let workspace = window
+        .read_with(cx, |mw, _| mw.workspace().clone())
+        .unwrap();
+    let cx = &mut VisualTestContext::from_window(window.into(), cx);
+    let panel = workspace.update_in(cx, ProjectPanel::new);
+    let worktree_id = project.update(cx, |project, cx| {
+        project.visible_worktrees(cx).next().unwrap().read(cx).id()
+    });
+    cx.run_until_parked();
+
+    // Since there currently isn't a way to both create a folder and the file
+    // within it as two separate operations batched under the same
+    // `ProjectPanelOperation::Batch` operation, we'll simply record those
+    // ourselves, knowing that the filesystem already has the folder and file
+    // being provided in the operations.
+    panel.update(cx, |panel, _cx| {
+        panel.undo_manager.record_batch(vec![
+            ProjectPanelOperation::Create {
+                project_path: ProjectPath {
+                    worktree_id,
+                    path: Arc::from(rel_path("src/main.rs")),
+                },
+            },
+            ProjectPanelOperation::Create {
+                project_path: ProjectPath {
+                    worktree_id,
+                    path: Arc::from(rel_path("src/")),
+                },
+            },
+        ]);
+    });
+
+    // Ensure that `src/main.rs` is present in the filesystem before proceeding,
+    // otherwise this test is irrelevant.
+    assert_eq!(fs.files(), vec![PathBuf::from(path!("/root/src/main.rs"))]);
+    assert_eq!(
+        fs.directories(false),
+        vec![
+            PathBuf::from(path!("/")),
+            PathBuf::from(path!("/root/")),
+            PathBuf::from(path!("/root/src/"))
+        ]
+    );
+
+    panel.update_in(cx, |panel, window, cx| {
+        panel.undo(&Undo, window, cx);
+    });
+    cx.run_until_parked();
+
+    assert_eq!(fs.files().len(), 0);
+    assert_eq!(
+        fs.directories(false),
+        vec![PathBuf::from(path!("/")), PathBuf::from(path!("/root/"))]
+    );
+}
+
 #[gpui::test]
 async fn test_paste_external_paths(cx: &mut gpui::TestAppContext) {
     init_test(cx);
@@ -9837,7 +10386,7 @@ async fn run_create_file_in_folded_path_case(
     }
 }
 
-fn init_test(cx: &mut TestAppContext) {
+pub(crate) fn init_test(cx: &mut TestAppContext) {
     cx.update(|cx| {
         let settings_store = SettingsStore::test(cx);
         cx.set_global(settings_store);

crates/project_panel/src/undo.rs 🔗

@@ -0,0 +1,286 @@
+use anyhow::anyhow;
+use gpui::{AppContext, SharedString, Task, WeakEntity};
+use project::ProjectPath;
+use std::collections::VecDeque;
+use ui::{App, IntoElement, Label, ParentElement, Styled, v_flex};
+use workspace::{
+    Workspace,
+    notifications::{NotificationId, simple_message_notification::MessageNotification},
+};
+
+const MAX_UNDO_OPERATIONS: usize = 10_000;
+
+#[derive(Clone)]
+pub enum ProjectPanelOperation {
+    Batch(Vec<ProjectPanelOperation>),
+    Create {
+        project_path: ProjectPath,
+    },
+    Rename {
+        old_path: ProjectPath,
+        new_path: ProjectPath,
+    },
+}
+
+pub struct UndoManager {
+    workspace: WeakEntity<Workspace>,
+    stack: VecDeque<ProjectPanelOperation>,
+    /// Maximum number of operations to keep on the undo stack.
+    limit: usize,
+}
+
+impl UndoManager {
+    pub fn new(workspace: WeakEntity<Workspace>) -> Self {
+        Self::new_with_limit(workspace, MAX_UNDO_OPERATIONS)
+    }
+
+    pub fn new_with_limit(workspace: WeakEntity<Workspace>, limit: usize) -> Self {
+        Self {
+            workspace,
+            limit,
+            stack: VecDeque::new(),
+        }
+    }
+
+    pub fn can_undo(&self) -> bool {
+        !self.stack.is_empty()
+    }
+
+    pub fn undo(&mut self, cx: &mut App) {
+        if let Some(operation) = self.stack.pop_back() {
+            let task = self.revert_operation(operation, cx);
+            let workspace = self.workspace.clone();
+
+            cx.spawn(async move |cx| {
+                let errors = task.await;
+                if !errors.is_empty() {
+                    cx.update(|cx| {
+                        let messages = errors
+                            .iter()
+                            .map(|err| SharedString::from(err.to_string()))
+                            .collect();
+
+                        Self::show_errors(workspace, messages, cx)
+                    })
+                }
+            })
+            .detach();
+        }
+    }
+
+    pub fn record(&mut self, operation: ProjectPanelOperation) {
+        if self.stack.len() >= self.limit {
+            self.stack.pop_front();
+        }
+
+        self.stack.push_back(operation);
+    }
+
+    pub fn record_batch(&mut self, operations: impl IntoIterator<Item = ProjectPanelOperation>) {
+        let mut operations = operations.into_iter().collect::<Vec<_>>();
+        let operation = match operations.len() {
+            0 => return,
+            1 => operations.pop().unwrap(),
+            _ => ProjectPanelOperation::Batch(operations),
+        };
+
+        self.record(operation);
+    }
+
+    /// Attempts to revert the provided `operation`, returning a vector of errors
+    /// in case there was any failure while reverting the operation.
+    ///
+    /// For all operations other than [`crate::undo::ProjectPanelOperation::Batch`], a maximum
+    /// of one error is returned.
+    fn revert_operation(
+        &self,
+        operation: ProjectPanelOperation,
+        cx: &mut App,
+    ) -> Task<Vec<anyhow::Error>> {
+        match operation {
+            ProjectPanelOperation::Create { project_path } => {
+                let Some(workspace) = self.workspace.upgrade() else {
+                    return Task::ready(vec![anyhow!("Failed to obtain workspace.")]);
+                };
+
+                let result = workspace.update(cx, |workspace, cx| {
+                    workspace.project().update(cx, |project, cx| {
+                        let entry_id = project
+                            .entry_for_path(&project_path, cx)
+                            .map(|entry| entry.id)
+                            .ok_or_else(|| anyhow!("No entry for path."))?;
+
+                        project
+                            .delete_entry(entry_id, true, cx)
+                            .ok_or_else(|| anyhow!("Failed to trash entry."))
+                    })
+                });
+
+                let task = match result {
+                    Ok(task) => task,
+                    Err(err) => return Task::ready(vec![err]),
+                };
+
+                cx.spawn(async move |_| match task.await {
+                    Ok(_) => vec![],
+                    Err(err) => vec![err],
+                })
+            }
+            ProjectPanelOperation::Rename { old_path, new_path } => {
+                let Some(workspace) = self.workspace.upgrade() else {
+                    return Task::ready(vec![anyhow!("Failed to obtain workspace.")]);
+                };
+
+                let result = workspace.update(cx, |workspace, cx| {
+                    workspace.project().update(cx, |project, cx| {
+                        let entry_id = project
+                            .entry_for_path(&new_path, cx)
+                            .map(|entry| entry.id)
+                            .ok_or_else(|| anyhow!("No entry for path."))?;
+
+                        Ok(project.rename_entry(entry_id, old_path.clone(), cx))
+                    })
+                });
+
+                let task = match result {
+                    Ok(task) => task,
+                    Err(err) => return Task::ready(vec![err]),
+                };
+
+                cx.spawn(async move |_| match task.await {
+                    Ok(_) => vec![],
+                    Err(err) => vec![err],
+                })
+            }
+            ProjectPanelOperation::Batch(operations) => {
+                // When reverting operations in a batch, we reverse the order of
+                // operations to handle dependencies between them. For example,
+                // if a batch contains the following order of operations:
+                //
+                // 1. Create `src/`
+                // 2. Create `src/main.rs`
+                //
+                // If we first try to revert the directory creation, it would
+                // fail because there's still files inside the directory.
+                // Operations are also reverted sequentially in order to avoid
+                // this same problem.
+                let tasks: Vec<_> = operations
+                    .into_iter()
+                    .rev()
+                    .map(|operation| self.revert_operation(operation, cx))
+                    .collect();
+
+                cx.spawn(async move |_| {
+                    let mut errors = Vec::new();
+                    for task in tasks {
+                        errors.extend(task.await);
+                    }
+                    errors
+                })
+            }
+        }
+    }
+
+    /// Displays a notification with the list of provided errors ensuring that,
+    /// when more than one error is provided, which can be the case when dealing
+    /// with undoing a [`crate::undo::ProjectPanelOperation::Batch`], a list is
+    /// displayed with each of the errors, instead of a single message.
+    fn show_errors(workspace: WeakEntity<Workspace>, messages: Vec<SharedString>, cx: &mut App) {
+        workspace
+            .update(cx, move |workspace, cx| {
+                let notification_id =
+                    NotificationId::Named(SharedString::new_static("project_panel_undo"));
+
+                workspace.show_notification(notification_id, cx, move |cx| {
+                    cx.new(|cx| {
+                        if let [err] = messages.as_slice() {
+                            MessageNotification::new(err.to_string(), cx)
+                                .with_title("Failed to undo Project Panel Operation")
+                        } else {
+                            MessageNotification::new_from_builder(cx, move |_, _| {
+                                v_flex()
+                                    .gap_1()
+                                    .children(
+                                        messages
+                                            .iter()
+                                            .map(|message| Label::new(format!("- {message}"))),
+                                    )
+                                    .into_any_element()
+                            })
+                            .with_title("Failed to undo Project Panel Operations")
+                        }
+                    })
+                })
+            })
+            .ok();
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use crate::{
+        ProjectPanel, project_panel_tests,
+        undo::{ProjectPanelOperation, UndoManager},
+    };
+    use gpui::{Entity, TestAppContext, VisualTestContext};
+    use project::{FakeFs, Project, ProjectPath};
+    use std::sync::Arc;
+    use util::rel_path::rel_path;
+    use workspace::MultiWorkspace;
+
+    struct TestContext {
+        project: Entity<Project>,
+        panel: Entity<ProjectPanel>,
+    }
+
+    async fn init_test(cx: &mut TestAppContext) -> TestContext {
+        project_panel_tests::init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
+        let window =
+            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+        let workspace = window
+            .read_with(cx, |mw, _| mw.workspace().clone())
+            .unwrap();
+        let cx = &mut VisualTestContext::from_window(window.into(), cx);
+        let panel = workspace.update_in(cx, ProjectPanel::new);
+        cx.run_until_parked();
+
+        TestContext { project, panel }
+    }
+
+    #[gpui::test]
+    async fn test_limit(cx: &mut TestAppContext) {
+        let test_context = init_test(cx).await;
+        let worktree_id = test_context.project.update(cx, |project, cx| {
+            project.visible_worktrees(cx).next().unwrap().read(cx).id()
+        });
+
+        let build_create_operation = |file_name: &str| ProjectPanelOperation::Create {
+            project_path: ProjectPath {
+                path: Arc::from(rel_path(file_name)),
+                worktree_id,
+            },
+        };
+
+        // Since we're updating the `ProjectPanel`'s undo manager with one whose
+        // limit is 3 operations, we only need to create 4 operations which
+        // we'll record, in order to confirm that the oldest operation is
+        // evicted.
+        let operation_a = build_create_operation("file_a.txt");
+        let operation_b = build_create_operation("file_b.txt");
+        let operation_c = build_create_operation("file_c.txt");
+        let operation_d = build_create_operation("file_d.txt");
+
+        test_context.panel.update(cx, move |panel, _cx| {
+            panel.undo_manager = UndoManager::new_with_limit(panel.workspace.clone(), 3);
+            panel.undo_manager.record(operation_a);
+            panel.undo_manager.record(operation_b);
+            panel.undo_manager.record(operation_c);
+            panel.undo_manager.record(operation_d);
+
+            assert_eq!(panel.undo_manager.stack.len(), 3);
+        });
+    }
+}