feat(project_panel): add redo support for rename operations

dino created

* Update `project_panel::undo::UndoManager::redo` to support redoing
  rename operations. Support for other opeations will come in future
  commits
* Extract rename logic into `project_panel::undo::UndoManager::rename`
  as it can be used both when undoing and redoing rename operations
* Refactor `project_panel::undo::UndoManager::show_errors` to expect the
  title as an argument, so that it can be used for both undo and redo
  errors
* Update `project_panel::project_panel_tests::test_undo_rename` to
  `project_panel::project_panel_tests::test_undo_redo_rename` as it now
  also tests redoing rename operations

Change summary

crates/project_panel/src/project_panel_tests.rs |  17 ++
crates/project_panel/src/undo.rs                | 116 +++++++++++++-----
2 files changed, 100 insertions(+), 33 deletions(-)

Detailed changes

crates/project_panel/src/project_panel_tests.rs 🔗

@@ -1995,7 +1995,7 @@ async fn test_copy_paste_nested_and_root_entries(cx: &mut gpui::TestAppContext)
 }
 
 #[gpui::test]
-async fn test_undo_rename(cx: &mut gpui::TestAppContext) {
+async fn test_undo_redo_rename(cx: &mut gpui::TestAppContext) {
     init_test(cx);
 
     let fs = FakeFs::new(cx.executor());
@@ -2054,6 +2054,21 @@ async fn test_undo_rename(cx: &mut gpui::TestAppContext) {
         None,
         "Renamed file should no longer exist after undo"
     );
+
+    panel.update_in(cx, |panel, window, cx| {
+        panel.redo(&Redo, window, cx);
+    });
+    cx.run_until_parked();
+
+    assert!(
+        find_project_entry(&panel, "root/renamed.txt", cx).is_some(),
+        "File should be renamed to renamed.txt after redo"
+    );
+    assert_eq!(
+        find_project_entry(&panel, "root/a.txt", cx),
+        None,
+        "Original file should no longer exist after redo"
+    );
 }
 
 #[gpui::test]

crates/project_panel/src/undo.rs 🔗

@@ -80,7 +80,12 @@ impl UndoManager {
                             .map(|err| SharedString::from(err.to_string()))
                             .collect();
 
-                        Self::show_errors(workspace, messages, cx)
+                        Self::show_errors(
+                            "Failed to undo Project Panel Operation(s)",
+                            workspace,
+                            messages,
+                            cx,
+                        )
                     })
                 }
             })
@@ -88,18 +93,52 @@ impl UndoManager {
         }
     }
 
-    pub fn redo(&mut self, _cx: &mut App) {
+    pub fn redo(&mut self, cx: &mut App) {
         if self.cursor >= self.history.len() {
             return;
         }
 
-        if let Some(_operation) = self.history.get(self.cursor) {
-            // TODO!: Implement actual operation redo.
+        if let Some(operation) = self.history.get(self.cursor) {
+            let task = self.redo_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(
+                            "Failed to redo Project Panel Operation(s)",
+                            workspace,
+                            messages,
+                            cx,
+                        )
+                    })
+                }
+            })
+            .detach();
         }
 
         self.cursor += 1;
     }
 
+    fn redo_operation(
+        &self,
+        operation: &ProjectPanelOperation,
+        cx: &mut App,
+    ) -> Task<Vec<anyhow::Error>> {
+        match operation {
+            ProjectPanelOperation::Rename { old_path, new_path } => {
+                self.rename(old_path, new_path, cx)
+            }
+            _ => Task::ready(vec![anyhow!("Not implemented.")]),
+        }
+    }
+
     pub fn record(&mut self, operation: ProjectPanelOperation) {
         // Recording a new operation while the cursor is not at the end of the
         // undo history should remove all operations from the cursor position to
@@ -172,30 +211,7 @@ impl UndoManager {
                 })
             }
             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],
-                })
+                self.rename(new_path, old_path, cx)
             }
             ProjectPanelOperation::Batch(operations) => {
                 // When reverting operations in a batch, we reverse the order of
@@ -226,11 +242,48 @@ impl UndoManager {
         }
     }
 
+    fn rename(
+        &self,
+        from: &ProjectPath,
+        to: &ProjectPath,
+        cx: &mut App,
+    ) -> Task<Vec<anyhow::Error>> {
+        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(from, cx)
+                    .map(|entry| entry.id)
+                    .ok_or_else(|| anyhow!("No entry for path."))?;
+
+                Ok(project.rename_entry(entry_id, to.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],
+        })
+    }
+
     /// 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) {
+    fn show_errors(
+        title: impl Into<SharedString>,
+        workspace: WeakEntity<Workspace>,
+        messages: Vec<SharedString>,
+        cx: &mut App,
+    ) {
         workspace
             .update(cx, move |workspace, cx| {
                 let notification_id =
@@ -239,8 +292,7 @@ impl UndoManager {
                 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")
+                            MessageNotification::new(err.to_string(), cx).with_title(title)
                         } else {
                             MessageNotification::new_from_builder(cx, move |_, _| {
                                 v_flex()
@@ -252,7 +304,7 @@ impl UndoManager {
                                     )
                                     .into_any_element()
                             })
-                            .with_title("Failed to undo Project Panel Operations")
+                            .with_title(title)
                         }
                     })
                 })