Add undo/redo of renames in project panel

HactarCE and Cole Miller created

---------

Co-authored-by: Cole Miller <cole@zed.dev>

Change summary

assets/keymaps/default-linux.json                   |   5 
assets/keymaps/default-macos.json                   |   2 
assets/keymaps/default-windows.json                 |   3 
crates/project_panel/src/project_panel.rs           | 129 +++++++++++++-
crates/project_panel/src/project_panel_operation.rs |  22 ++
crates/project_panel/src/project_panel_tests.rs     |  50 +++++
6 files changed, 199 insertions(+), 12 deletions(-)

Detailed changes

assets/keymaps/default-linux.json 🔗

@@ -869,6 +869,11 @@
       "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",
+      "redo": "project_panel::Redo",
+      "ctrl-y": "project_panel::Redo",
+      "ctrl-shift-z": "project_panel::Redo",
       "enter": "project_panel::Rename",
       "f2": "project_panel::Rename",
       "backspace": ["project_panel::Trash", { "skip_prompt": false }],

assets/keymaps/default-macos.json 🔗

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

assets/keymaps/default-windows.json 🔗

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

crates/project_panel/src/project_panel.rs 🔗

@@ -1,7 +1,8 @@
+mod project_panel_operation;
 mod project_panel_settings;
 mod utils;
 
-use anyhow::{Context as _, Result};
+use anyhow::{Context as _, Result, anyhow};
 use client::{ErrorCode, ErrorExt};
 use collections::{BTreeSet, HashMap, hash_map};
 use command_palette_hooks::CommandPaletteFilter;
@@ -71,6 +72,8 @@ use workspace::{
 use worktree::CreatedEntry;
 use zed_actions::{project_panel::ToggleFocus, workspace::OpenWithSystem};
 
+use crate::project_panel_operation::ProjectPanelOperation;
+
 const PROJECT_PANEL_KEY: &str = "ProjectPanel";
 const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
 
@@ -139,6 +142,8 @@ pub struct ProjectPanel {
     sticky_items_count: usize,
     last_reported_update: Instant,
     update_visible_entries_task: UpdateVisibleEntriesTask,
+    undo_stack: Vec<ProjectPanelOperation>,
+    redo_stack: Vec<ProjectPanelOperation>,
     state: State,
 }
 
@@ -340,6 +345,10 @@ actions!(
         SelectPrevDirectory,
         /// Opens a diff view to compare two marked files.
         CompareMarkedFiles,
+        /// Undoes the last file operation.
+        Undo,
+        /// Redoes the last undone file operation.
+        Redo,
     ]
 );
 
@@ -819,6 +828,8 @@ impl ProjectPanel {
                     unfolded_dir_ids: Default::default(),
                 },
                 update_visible_entries_task: Default::default(),
+                undo_stack: Default::default(),
+                redo_stack: Default::default(),
             };
             this.update_visible_entries(None, false, false, window, cx);
 
@@ -1719,11 +1730,13 @@ impl ProjectPanel {
                 return None;
             }
             edited_entry_id = entry.id;
-            edit_task = self.project.update(cx, |project, cx| {
-                project.rename_entry(entry.id, (worktree_id, new_path).into(), cx)
-            });
+            edit_task =
+                self.confirm_undoable_rename_entry(entry.id, (worktree_id, new_path).into(), cx);
         };
 
+        // Reborrow so lifetime does not overlap `self.confirm_undoable_rename_entry()`
+        let edit_state = self.state.edit_state.as_mut()?;
+
         if refocus {
             window.focus(&self.focus_handle);
         }
@@ -1965,6 +1978,90 @@ impl ProjectPanel {
         }
     }
 
+    fn record_undoable(&mut self, operation: ProjectPanelOperation) {
+        self.redo_stack.clear();
+        self.undo_stack.push(operation);
+    }
+
+    pub fn undo(&mut self, _: &Undo, _window: &mut Window, cx: &mut Context<Self>) {
+        if let Some(operation) = self.undo_stack.pop() {
+            let task = self.do_operation(operation, cx);
+            cx.spawn(async move |this, cx| {
+                let reverse_operation = task.await?;
+                this.update(cx, |this, _| this.redo_stack.push(reverse_operation))
+            })
+            .detach();
+        }
+    }
+
+    fn redo(&mut self, _: &Redo, _window: &mut Window, cx: &mut Context<Self>) -> () {
+        if let Some(operation) = self.redo_stack.pop() {
+            let task = self.do_operation(operation, cx);
+            cx.spawn(async |this, cx| {
+                let reverse_operation = task.await?;
+                this.update(cx, |this, cx| this.undo_stack.push(reverse_operation))
+            })
+            .detach();
+        }
+    }
+
+    /// Does an undoable operation and returns the reverse operation.
+    fn do_operation(
+        &self,
+        operation: ProjectPanelOperation,
+        cx: &mut Context<'_, Self>,
+    ) -> Task<Result<ProjectPanelOperation>> {
+        match operation {
+            ProjectPanelOperation::Rename { old_path, new_path } => {
+                let Some(entry) = self.project.read(cx).entry_for_path(&old_path, cx) else {
+                    return Task::ready(Err(anyhow!("no entry for path")));
+                };
+                let task = self.confirm_rename_entry(entry.id, new_path, cx);
+                cx.spawn(async move |_, _| {
+                    let (_created_entry, reverse_operation) = task.await?;
+                    Ok(reverse_operation)
+                })
+            }
+        }
+    }
+
+    fn confirm_undoable_rename_entry(
+        &self,
+        entry_id: ProjectEntryId,
+        new_path: ProjectPath,
+        cx: &mut Context<Self>,
+    ) -> Task<Result<CreatedEntry>> {
+        let rename_task = self.confirm_rename_entry(entry_id, new_path, cx);
+        cx.spawn(async move |this, cx| {
+            let (new_entry, operation) = rename_task.await?;
+            this.update(cx, |this, _cx| this.record_undoable(operation))
+                .ok();
+            Ok(new_entry)
+        })
+    }
+
+    fn confirm_rename_entry(
+        &self,
+        entry_id: ProjectEntryId,
+        new_path: ProjectPath,
+        cx: &mut Context<Self>,
+    ) -> Task<Result<(CreatedEntry, ProjectPanelOperation)>> {
+        let Some(old_path) = self.project.read(cx).path_for_entry(entry_id, cx) else {
+            return Task::ready(Err(anyhow!("no path for entry")));
+        };
+        let rename_task = self.project.update(cx, |project, cx| {
+            project.rename_entry(entry_id, new_path.clone(), cx)
+        });
+        cx.spawn(async move |_, _| {
+            let created_entry = rename_task.await?;
+            let reverse_operation = ProjectPanelOperation::Rename {
+                old_path: new_path,
+                new_path: old_path,
+            };
+            Ok((created_entry, reverse_operation))
+        })
+    }
+
     fn rename_impl(
         &mut self,
         selection: Option<Range<usize>>,
@@ -2796,9 +2893,11 @@ impl ProjectPanel {
                     self.create_paste_path(clipboard_entry, self.selected_sub_entry(cx)?, cx)?;
                 let clip_entry_id = clipboard_entry.entry_id;
                 let task = if clipboard_entries.is_cut() {
-                    let task = self.project.update(cx, |project, cx| {
-                        project.rename_entry(clip_entry_id, (worktree_id, new_path).into(), cx)
-                    });
+                    let task = self.confirm_undoable_rename_entry(
+                        clip_entry_id,
+                        (worktree_id, new_path).into(),
+                        cx,
+                    );
                     PasteTask::Rename(task)
                 } else {
                     let task = self.project.update(cx, |project, cx| {
@@ -3131,6 +3230,8 @@ impl ProjectPanel {
             return;
         }
 
+        let this = cx.entity();
+
         let destination_worktree = self.project.update(cx, |project, cx| {
             let source_path = project.path_for_entry(entry_to_move, cx)?;
             let destination_path = project.path_for_entry(destination_entry, cx)?;
@@ -3144,11 +3245,13 @@ impl ProjectPanel {
             let mut new_path = destination_path.to_rel_path_buf();
             new_path.push(RelPath::unix(source_path.path.file_name()?).unwrap());
             if new_path.as_rel_path() != source_path.path.as_ref() {
-                let task = project.rename_entry(
-                    entry_to_move,
-                    (destination_worktree_id, new_path).into(),
-                    cx,
-                );
+                let task = this.update(cx, |this, cx| {
+                    this.confirm_undoable_rename_entry(
+                        entry_to_move,
+                        (destination_worktree_id, new_path).into(),
+                        cx,
+                    )
+                });
                 cx.foreground_executor().spawn(task).detach_and_log_err(cx);
             }
 
@@ -5622,6 +5725,8 @@ 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))
+                .on_action(cx.listener(Self::undo))
+                .on_action(cx.listener(Self::redo))
                 .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_operation.rs 🔗

@@ -0,0 +1,22 @@
+use project::ProjectPath;
+// use trash::FileInTrash;
+
+/// Operation done in the project panel that can be undone.
+///
+/// There is no variant for creating a file or copying a file because their
+/// reverse is `Trash`.
+///
+/// - `Trash` and `Restore` are the reverse of each other.
+/// - `Rename` is its own reverse.
+pub enum ProjectPanelOperation {
+    // Trash(RelPath),
+    // Restore(FileInTrashId),
+    Rename {
+        old_path: ProjectPath,
+        new_path: ProjectPath,
+    },
+}
+
+// pub struct FileInTrashId(u32);
+
+// proto::Trash -> opaque integer

crates/project_panel/src/project_panel_tests.rs 🔗

@@ -1917,6 +1917,56 @@ async fn test_copy_paste_nested_and_root_entries(cx: &mut gpui::TestAppContext)
     );
 }
 
+#[gpui::test]
+async fn test_undo_redo(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    // - paste (?)
+    // -
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        "/test",
+        json!({
+            "dir1": {
+                "a.txt": "",
+                "b.txt": "",
+            },
+            "dir2": {},
+            "c.txt": "",
+            "d.txt": "",
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
+    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+    let cx = &mut VisualTestContext::from_window(*workspace, cx);
+    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
+    cx.run_until_parked();
+
+    toggle_expand_dir(&panel, "test/dir1", cx);
+
+    cx.simulate_modifiers_change(gpui::Modifiers {
+        control: true,
+        ..Default::default()
+    });
+
+    // todo!(andrew) test cut/paste conflict->rename, copy/paste conflict->rename, drag rename, and rename with 'enter' key
+
+    select_path(&panel, path, cx);
+    // select_path_with_mark(&panel, "test/dir1/a.txt", cx);
+    // select_path_with_mark(&panel, "test/dir1", cx);
+    // select_path_with_mark(&panel, "test/c.txt", cx);
+    drag_selection_to(&panel, target_path, is_file, cx);
+    panel.update_in(cx, |this, window, cx| {
+        this.undo(&Undo, window, cx);
+    });
+    panel.update_in(cx, |this, window, cx| {
+        this.rename(&Undo, window, cx);
+    });
+}
+
 #[gpui::test]
 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
     init_test_with_editor(cx);