diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 8671bb7be912b9..6d2744a6cae856 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -892,6 +892,8 @@ "alt-ctrl-shift-c": "workspace::CopyRelativePath", "undo": "project_panel::Undo", "ctrl-z": "project_panel::Undo", + "redo": "project_panel::Redo", + "ctrl-shift-z": "project_panel::Redo", "enter": "project_panel::Rename", "f2": "project_panel::Rename", "backspace": ["project_panel::Trash", { "skip_prompt": false }], diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 26848eeed695e0..3cd9f1eea542ef 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -952,6 +952,7 @@ "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 }], diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index dcfee8ec86bcbe..0ee9bcfbb45a13 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -888,6 +888,7 @@ "shift-alt-c": "project_panel::CopyPath", "ctrl-k ctrl-shift-c": "workspace::CopyRelativePath", "ctrl-z": "project_panel::Undo", + "ctrl-shift-z": "project_panel::Redo", "enter": "project_panel::Rename", "f2": "project_panel::Rename", "backspace": ["project_panel::Trash", { "skip_prompt": false }], diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 41acd58c3cd2fb..f45d637e52d5c0 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -401,6 +401,8 @@ actions!( CompareMarkedFiles, /// Undoes the last file operation. Undo, + /// Redoes the last undone file operation. + Redo, ] ); @@ -900,7 +902,7 @@ impl ProjectPanel { unfolded_dir_ids: Default::default(), }, update_visible_entries_task: Default::default(), - undo_manager: UndoManager::new(workspace.weak_handle()), + undo_manager: UndoManager::new(workspace.weak_handle(), cx.weak_entity()), }; this.update_visible_entries(None, false, false, window, cx); @@ -1243,6 +1245,11 @@ impl ProjectPanel { "Undo", Box::new(Undo), ) + .action_disabled_when( + !self.undo_manager.can_redo(), + "Redo", + Box::new(Redo), + ) }) .when(is_remote, |menu| { menu.separator() @@ -1942,8 +1949,8 @@ impl ProjectPanel { 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, + from: (worktree_id, old_entry.path).into(), + to: new_project_path, } } else { ProjectPanelOperation::Create { @@ -2208,6 +2215,11 @@ impl ProjectPanel { cx.notify(); } + pub fn redo(&mut self, _: &Redo, _window: &mut Window, cx: &mut Context) { + self.undo_manager.redo(cx); + cx.notify(); + } + fn rename_impl( &mut self, selection: Option>, @@ -3137,8 +3149,8 @@ impl ProjectPanel { enum PasteTask { Rename { task: Task>, - old_path: ProjectPath, - new_path: ProjectPath, + from: ProjectPath, + to: ProjectPath, }, Copy { task: Task>>, @@ -3155,14 +3167,14 @@ impl ProjectPanel { 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 original_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, destination.clone(), cx) }); PasteTask::Rename { task, - old_path, - new_path: destination, + from: original_path, + to: destination, } } else { let task = self.project.update(cx, |project, cx| { @@ -3183,17 +3195,12 @@ impl ProjectPanel { for task in paste_tasks { match task { - PasteTask::Rename { - task, - old_path, - new_path, - } => { + PasteTask::Rename { task, from, to } => { if let Some(CreatedEntry::Included(entry)) = task .await .notify_workspace_async_err(workspace.clone(), &mut cx) { - operations - .push(ProjectPanelOperation::Rename { old_path, new_path }); + operations.push(ProjectPanelOperation::Rename { from, to }); last_succeed = Some(entry); } } @@ -4625,8 +4632,8 @@ impl ProjectPanel { (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(), + from: old_path.clone(), + to: (worktree_id, new_entry.path).into(), }); } } @@ -4652,8 +4659,8 @@ impl ProjectPanel { (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(), + from: old_path.clone(), + to: (worktree_id, new_entry.path.clone()).into(), }); } move_results.push((entry_id, new_entry)); @@ -6684,6 +6691,7 @@ impl Render for ProjectPanel { .on_action(cx.listener(Self::compare_marked_files)) .when(cx.has_flag::(), |el| { el.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)) diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index afcc6db8d1600e..ae155d8bf13242 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::undo::test::{build_create_operation, build_rename_operation}; use collections::HashSet; use editor::MultiBufferOffset; use gpui::{Empty, Entity, TestAppContext, VisualTestContext}; @@ -1995,7 +1996,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 +2055,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] @@ -2504,18 +2520,8 @@ async fn test_undo_batch(cx: &mut gpui::TestAppContext) { // 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/")), - }, - }, + build_create_operation(worktree_id, "src/main.rs"), + build_create_operation(worktree_id, "src/"), ]); }); @@ -2543,6 +2549,81 @@ async fn test_undo_batch(cx: &mut gpui::TestAppContext) { ); } +#[gpui::test] +async fn test_redo_batch(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "file_c.txt": "", + "file_d.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); + let worktree_id = project.update(cx, |project, cx| { + project.visible_worktrees(cx).next().unwrap().read(cx).id() + }); + cx.run_until_parked(); + + // At the time of writing, only the `ProjectPanelOperation::Rename` + // operation supports redoing. As such, that's what we'll use in the batch + // operation, to ensure that undoing and redoing a batch operation works as + // expected. + panel.update(cx, |panel, _cx| { + panel.undo_manager.record_batch(vec![ + build_rename_operation(worktree_id, "file_a.txt", "file_c.txt"), + build_rename_operation(worktree_id, "file_b.txt", "file_d.txt"), + ]); + }); + + // Before proceeding, ensure that both `file_c.txt` as well as `file_d.txt` + // exist in the filesystem, so we can later ensure that undoing renames + // these files and redoing renames again to how the test started. + assert_eq!( + fs.files(), + vec![ + PathBuf::from(path!("/root/file_c.txt")), + PathBuf::from(path!("/root/file_d.txt")) + ] + ); + + panel.update_in(cx, |panel, window, cx| { + panel.undo(&Undo, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + fs.files(), + vec![ + PathBuf::from(path!("/root/file_a.txt")), + PathBuf::from(path!("/root/file_b.txt")) + ] + ); + + panel.update_in(cx, |panel, window, cx| { + panel.redo(&Redo, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + fs.files(), + vec![ + PathBuf::from(path!("/root/file_c.txt")), + PathBuf::from(path!("/root/file_d.txt")) + ] + ); +} + #[gpui::test] async fn test_paste_external_paths(cx: &mut gpui::TestAppContext) { init_test(cx); @@ -9556,7 +9637,7 @@ async fn test_hide_hidden_entries(cx: &mut gpui::TestAppContext) { ); } -fn select_path(panel: &Entity, path: &str, cx: &mut VisualTestContext) { +pub(crate) fn select_path(panel: &Entity, path: &str, cx: &mut VisualTestContext) { let path = rel_path(path); panel.update_in(cx, |panel, window, cx| { for worktree in panel.project.read(cx).worktrees(cx).collect::>() { diff --git a/crates/project_panel/src/undo.rs b/crates/project_panel/src/undo.rs index 3a8baa23c55db8..32ad8d8ed06576 100644 --- a/crates/project_panel/src/undo.rs +++ b/crates/project_panel/src/undo.rs @@ -1,8 +1,9 @@ -use anyhow::anyhow; +use crate::ProjectPanel; +use anyhow::{Result, anyhow}; use gpui::{AppContext, SharedString, Task, WeakEntity}; use project::ProjectPath; use std::collections::VecDeque; -use ui::{App, IntoElement, Label, ParentElement, Styled, v_flex}; +use ui::App; use workspace::{ Workspace, notifications::{NotificationId, simple_message_notification::MessageNotification}, @@ -10,70 +11,147 @@ use workspace::{ const MAX_UNDO_OPERATIONS: usize = 10_000; -#[derive(Clone)] +#[derive(Clone, Debug, PartialEq)] pub enum ProjectPanelOperation { Batch(Vec), - Create { - project_path: ProjectPath, - }, - Rename { - old_path: ProjectPath, - new_path: ProjectPath, - }, + Create { project_path: ProjectPath }, + Trash { project_path: ProjectPath }, + Rename { from: ProjectPath, to: ProjectPath }, +} + +impl ProjectPanelOperation { + fn inverse(&self) -> Self { + match self { + Self::Create { project_path } => Self::Trash { + project_path: project_path.clone(), + }, + Self::Trash { project_path } => Self::Create { + project_path: project_path.clone(), + }, + Self::Rename { from, to } => Self::Rename { + from: to.clone(), + to: from.clone(), + }, + // When inverting a batch of operations, 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 tried to revert the directory creation, it would fail + // because there's still files inside the directory. + Self::Batch(operations) => Self::Batch( + operations + .iter() + .rev() + .map(|operation| operation.inverse()) + .collect(), + ), + } + } } pub struct UndoManager { workspace: WeakEntity, - stack: VecDeque, - /// Maximum number of operations to keep on the undo stack. + panel: WeakEntity, + undo_stack: VecDeque, + redo_stack: Vec, + /// Maximum number of operations to keep on the undo history. limit: usize, } impl UndoManager { - pub fn new(workspace: WeakEntity) -> Self { - Self::new_with_limit(workspace, MAX_UNDO_OPERATIONS) + pub fn new(workspace: WeakEntity, panel: WeakEntity) -> Self { + Self::new_with_limit(workspace, panel, MAX_UNDO_OPERATIONS) } - pub fn new_with_limit(workspace: WeakEntity, limit: usize) -> Self { + pub fn new_with_limit( + workspace: WeakEntity, + panel: WeakEntity, + limit: usize, + ) -> Self { Self { workspace, + panel, limit, - stack: VecDeque::new(), + undo_stack: VecDeque::new(), + redo_stack: Vec::new(), } } pub fn can_undo(&self) -> bool { - !self.stack.is_empty() + !self.undo_stack.is_empty() + } + + pub fn can_redo(&self) -> bool { + !self.redo_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); + if let Some(operation) = self.undo_stack.pop_back() { + let task = self.execute_operation(&operation, cx); + let panel = self.panel.clone(); 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) - }) - } + cx.spawn(async move |cx| match task.await { + Ok(operation) => panel.update(cx, |panel, _cx| { + panel.undo_manager.redo_stack.push(operation) + }), + Err(err) => cx.update(|cx| { + Self::show_error( + "Failed to undo Project Panel Operation(s)", + workspace, + err.to_string().into(), + cx, + ); + + Ok(()) + }), + }) + .detach(); + } + } + + pub fn redo(&mut self, cx: &mut App) { + if let Some(operation) = self.redo_stack.pop() { + let task = self.execute_operation(&operation, cx); + let panel = self.panel.clone(); + let workspace = self.workspace.clone(); + + cx.spawn(async move |cx| match task.await { + Ok(operation) => panel.update(cx, |panel, _cx| { + panel.undo_manager.undo_stack.push_back(operation) + }), + Err(err) => cx.update(|cx| { + Self::show_error( + "Failed to redo Project Panel Operation(s)", + workspace, + err.to_string().into(), + cx, + ); + + Ok(()) + }), }) .detach(); } } pub fn record(&mut self, operation: ProjectPanelOperation) { - if self.stack.len() >= self.limit { - self.stack.pop_front(); + // Recording a new operation while there's still operations in the + // `redo_stack` should clear all operations from the `redo_stack`, as we + // might end up in a situation where the state diverges and the + // `redo_stack` operations can no longer be done. + if !self.redo_stack.is_empty() { + self.redo_stack.clear(); + } + + if self.undo_stack.len() >= self.limit { + self.undo_stack.pop_front(); } - self.stack.push_back(operation); + self.undo_stack.push_back(operation.inverse()); } pub fn record_batch(&mut self, operations: impl IntoIterator) { @@ -87,129 +165,165 @@ impl UndoManager { 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, + /// Attempts to execute the provided operation, returning the inverse of the + /// provided `operation` as a result. + fn execute_operation( + &mut self, + operation: &ProjectPanelOperation, cx: &mut App, - ) -> Task> { + ) -> Task> { 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 - }) - } + ProjectPanelOperation::Rename { from, to } => self.rename(from, to, cx), + ProjectPanelOperation::Trash { project_path } => self.trash(project_path, cx), + ProjectPanelOperation::Create { project_path } => self.create(project_path, cx), + ProjectPanelOperation::Batch(operations) => self.batch(operations, cx), } } - /// 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, messages: Vec, cx: &mut App) { + fn rename( + &self, + from: &ProjectPath, + to: &ProjectPath, + cx: &mut App, + ) -> Task> { + let Some(workspace) = self.workspace.upgrade() else { + return Task::ready(Err(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(Err(err)), + }; + + let from = from.clone(); + let to = to.clone(); + cx.spawn(async move |_| match task.await { + Err(err) => Err(err), + Ok(_) => Ok(ProjectPanelOperation::Rename { + from: to.clone(), + to: from.clone(), + }), + }) + } + + fn create( + &self, + project_path: &ProjectPath, + cx: &mut App, + ) -> Task> { + let Some(workspace) = self.workspace.upgrade() else { + return Task::ready(Err(anyhow!("Failed to obtain workspace."))); + }; + + let task = workspace.update(cx, |workspace, cx| { + workspace.project().update(cx, |project, cx| { + // This should not be hardcoded to `false`, as it can genuinely + // be a directory and it misses all the nuances and details from + // `ProjectPanel::confirm_edit`. However, we expect this to be a + // short-lived solution as we add support for restoring trashed + // files, at which point we'll no longer need to `Create` new + // files, any redoing of a trash operation should be a restore. + let is_directory = false; + project.create_entry(project_path.clone(), is_directory, cx) + }) + }); + + let project_path = project_path.clone(); + cx.spawn(async move |_| match task.await { + Ok(_) => Ok(ProjectPanelOperation::Trash { project_path }), + Err(err) => Err(err), + }) + } + + fn trash( + &self, + project_path: &ProjectPath, + cx: &mut App, + ) -> Task> { + let Some(workspace) = self.workspace.upgrade() else { + return Task::ready(Err(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(Err(err)), + }; + + let project_path = project_path.clone(); + cx.spawn(async move |_| match task.await { + // We'll want this to eventually be a `Restore` operation, once + // we've added support, in `fs` to track and restore a trashed file. + Ok(_) => Ok(ProjectPanelOperation::Create { project_path }), + Err(err) => Err(err), + }) + } + + fn batch( + &mut self, + operations: &[ProjectPanelOperation], + cx: &mut App, + ) -> Task> { + let tasks: Vec<_> = operations + .into_iter() + .map(|operation| self.execute_operation(operation, cx)) + .collect(); + + cx.spawn(async move |_| { + let mut operations = Vec::new(); + + for task in tasks { + match task.await { + Ok(operation) => operations.push(operation), + Err(err) => return Err(err), + } + } + + // Return the `ProjectPanelOperation::Batch` that reverses all of + // the provided operations. The order of operations should be reversed + // so that dependencies are handled correctly. + operations.reverse(); + Ok(ProjectPanelOperation::Batch(operations)) + }) + } + + /// Displays a notification with the provided `title` and `error`. + fn show_error( + title: impl Into, + workspace: WeakEntity, + error: 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") - } - }) + cx.new(|cx| MessageNotification::new(error.to_string(), cx).with_title(title)) }) }) .ok(); @@ -217,13 +331,14 @@ impl UndoManager { } #[cfg(test)] -mod test { +pub(crate) mod test { use crate::{ ProjectPanel, project_panel_tests, undo::{ProjectPanelOperation, UndoManager}, }; - use gpui::{Entity, TestAppContext, VisualTestContext}; - use project::{FakeFs, Project, ProjectPath}; + use gpui::{Entity, TestAppContext, VisualTestContext, WindowHandle}; + use project::{FakeFs, Project, ProjectPath, WorktreeId}; + use serde_json::{Value, json}; use std::sync::Arc; use util::rel_path::rel_path; use workspace::MultiWorkspace; @@ -231,12 +346,16 @@ mod test { struct TestContext { project: Entity, panel: Entity, + window: WindowHandle, } - async fn init_test(cx: &mut TestAppContext) -> TestContext { + async fn init_test(cx: &mut TestAppContext, tree: Option) -> TestContext { project_panel_tests::init_test(cx); let fs = FakeFs::new(cx.executor()); + if let Some(tree) = tree { + fs.insert_tree("/root", tree).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)); @@ -247,40 +366,307 @@ mod test { let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); - TestContext { project, panel } + TestContext { + project, + panel, + window, + } } - #[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() - }); + pub(crate) fn build_create_operation( + worktree_id: WorktreeId, + file_name: &str, + ) -> ProjectPanelOperation { + ProjectPanelOperation::Create { + project_path: ProjectPath { + path: Arc::from(rel_path(file_name)), + worktree_id, + }, + } + } - let build_create_operation = |file_name: &str| ProjectPanelOperation::Create { + pub(crate) fn build_trash_operation( + worktree_id: WorktreeId, + file_name: &str, + ) -> ProjectPanelOperation { + ProjectPanelOperation::Trash { project_path: ProjectPath { path: Arc::from(rel_path(file_name)), worktree_id, }, - }; + } + } + + pub(crate) fn build_rename_operation( + worktree_id: WorktreeId, + from: &str, + to: &str, + ) -> ProjectPanelOperation { + let from_path = Arc::from(rel_path(from)); + let to_path = Arc::from(rel_path(to)); + + ProjectPanelOperation::Rename { + from: ProjectPath { + worktree_id, + path: from_path, + }, + to: ProjectPath { + worktree_id, + path: to_path, + }, + } + } + + async fn rename( + panel: &Entity, + from: &str, + to: &str, + cx: &mut VisualTestContext, + ) { + project_panel_tests::select_path(panel, from, cx); + panel.update_in(cx, |panel, window, cx| { + panel.rename(&Default::default(), window, cx) + }); + cx.run_until_parked(); + + panel + .update_in(cx, |panel, window, cx| { + panel + .filename_editor + .update(cx, |editor, cx| editor.set_text(to, window, cx)); + panel.confirm_edit(true, window, cx).unwrap() + }) + .await + .unwrap(); + cx.run_until_parked(); + } + + #[gpui::test] + async fn test_limit(cx: &mut TestAppContext) { + let test_context = init_test(cx, None).await; + let worktree_id = test_context.project.update(cx, |project, cx| { + project.visible_worktrees(cx).next().unwrap().read(cx).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); + let operation_a = build_create_operation(worktree_id, "file_a.txt"); + let operation_b = build_create_operation(worktree_id, "file_b.txt"); + let operation_c = build_create_operation(worktree_id, "file_c.txt"); + let operation_d = build_create_operation(worktree_id, "file_d.txt"); + + test_context.panel.update(cx, move |panel, cx| { + panel.undo_manager = + UndoManager::new_with_limit(panel.workspace.clone(), cx.weak_entity(), 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); + assert_eq!(panel.undo_manager.undo_stack.len(), 3); + }); + } + #[gpui::test] + async fn test_undo_redo_stacks(cx: &mut TestAppContext) { + let TestContext { + window, + panel, + project, + .. + } = init_test( + cx, + Some(json!({ + "a.txt": "", + "b.txt": "" + })), + ) + .await; + let worktree_id = project.update(cx, |project, cx| { + project.visible_worktrees(cx).next().unwrap().read(cx).id() + }); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + + // Start by renaming `src/file_a.txt` to `src/file_1.txt` and asserting + // we get the correct inverse operation in the + // `UndoManager::undo_stackand asserting we get the correct inverse + // operation in the `UndoManager::undo_stack`. + rename(&panel, "root/a.txt", "1.txt", cx).await; + panel.update(cx, |panel, _cx| { + assert_eq!( + panel.undo_manager.undo_stack, + vec![build_rename_operation(worktree_id, "1.txt", "a.txt")] + ); + assert!(panel.undo_manager.redo_stack.is_empty()); + }); + + // After undoing, the operation to be executed should be popped from + // `UndoManager::undo_stack` and its inverse operation pushed to + // `UndoManager::redo_stack`. + panel.update_in(cx, |panel, window, cx| { + panel.undo(&Default::default(), window, cx); + }); + cx.run_until_parked(); + + panel.update(cx, |panel, _cx| { + assert!(panel.undo_manager.undo_stack.is_empty()); + assert_eq!( + panel.undo_manager.redo_stack, + vec![build_rename_operation(worktree_id, "a.txt", "1.txt")] + ); + }); + + // Redoing should have the same effect as undoing, but in reverse. + panel.update_in(cx, |panel, window, cx| { + panel.redo(&Default::default(), window, cx); + }); + cx.run_until_parked(); + + panel.update(cx, |panel, _cx| { + assert_eq!( + panel.undo_manager.undo_stack, + vec![build_rename_operation(worktree_id, "1.txt", "a.txt")] + ); + assert!(panel.undo_manager.redo_stack.is_empty()); + }); + } + + #[gpui::test] + async fn test_undo_redo_trash(cx: &mut TestAppContext) { + let TestContext { + window, + panel, + project, + .. + } = init_test( + cx, + Some(json!({ + "a.txt": "", + "b.txt": "" + })), + ) + .await; + let worktree_id = project.update(cx, |project, cx| { + project.visible_worktrees(cx).next().unwrap().read(cx).id() + }); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + + // Start by setting up the `UndoManager::undo_stack` such that, undoing + // the last user operation will trash `a.txt`. + panel.update(cx, |panel, _cx| { + panel + .undo_manager + .undo_stack + .push_back(build_trash_operation(worktree_id, "a.txt")); + }); + + // Undoing should now delete the file and update the + // `UndoManager::redo_stack` state with a new `Create` operation. + panel.update_in(cx, |panel, window, cx| { + panel.undo(&Default::default(), window, cx); + }); + cx.run_until_parked(); + + panel.update(cx, |panel, _cx| { + assert!(panel.undo_manager.undo_stack.is_empty()); + assert_eq!( + panel.undo_manager.redo_stack, + vec![build_create_operation(worktree_id, "a.txt")] + ); + }); + + // Redoing should create the file again and pop the operation from + // `UndoManager::redo_stack`. + panel.update_in(cx, |panel, window, cx| { + panel.redo(&Default::default(), window, cx); + }); + cx.run_until_parked(); + + panel.update(cx, |panel, _cx| { + assert_eq!( + panel.undo_manager.undo_stack, + vec![build_trash_operation(worktree_id, "a.txt")] + ); + assert!(panel.undo_manager.redo_stack.is_empty()); + }); + } + + #[gpui::test] + async fn test_undo_redo_batch(cx: &mut TestAppContext) { + let TestContext { + window, + panel, + project, + .. + } = init_test( + cx, + Some(json!({ + "a.txt": "", + "b.txt": "" + })), + ) + .await; + let worktree_id = project.update(cx, |project, cx| { + project.visible_worktrees(cx).next().unwrap().read(cx).id() + }); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + + // There's currently no way to trigger two file renames in a single + // operation using the `ProjectPanel`. As such, we'll directly record + // the batch of operations in `UndoManager`, simulating that `1.txt` and + // `2.txt` had been renamed to `a.txt` and `b.txt`, respectively. + panel.update(cx, |panel, _cx| { + panel.undo_manager.record_batch(vec![ + build_rename_operation(worktree_id, "1.txt", "a.txt"), + build_rename_operation(worktree_id, "2.txt", "b.txt"), + ]); + + assert_eq!( + panel.undo_manager.undo_stack, + vec![ProjectPanelOperation::Batch(vec![ + build_rename_operation(worktree_id, "b.txt", "2.txt"), + build_rename_operation(worktree_id, "a.txt", "1.txt"), + ])] + ); + assert!(panel.undo_manager.redo_stack.is_empty()); + }); + + panel.update_in(cx, |panel, window, cx| { + panel.undo(&Default::default(), window, cx); + }); + cx.run_until_parked(); + + // Since the operations in the `Batch` are meant to be done in order, + // the inverse should have the operations in the opposite order to avoid + // dependencies. For example, creating a `src/` folder come before + // creating the `src/file_a.txt` file, but when undoing, the file should + // be trashed first. + panel.update(cx, |panel, _cx| { + assert!(panel.undo_manager.undo_stack.is_empty()); + assert_eq!( + panel.undo_manager.redo_stack, + vec![ProjectPanelOperation::Batch(vec![ + build_rename_operation(worktree_id, "1.txt", "a.txt"), + build_rename_operation(worktree_id, "2.txt", "b.txt"), + ])] + ); + }); + + panel.update_in(cx, |panel, window, cx| { + panel.redo(&Default::default(), window, cx); + }); + cx.run_until_parked(); + + panel.update(cx, |panel, _cx| { + assert_eq!( + panel.undo_manager.undo_stack, + vec![ProjectPanelOperation::Batch(vec![ + build_rename_operation(worktree_id, "b.txt", "2.txt"), + build_rename_operation(worktree_id, "a.txt", "1.txt"), + ])] + ); + assert!(panel.undo_manager.redo_stack.is_empty()); }); } }