diff --git a/Cargo.lock b/Cargo.lock index 207ceaa2a68a2f665df888e1e8554ae865700ea4..28fd637c212ef6d50b327eada4fafde4cfae556d 100644 --- a/Cargo.lock +++ b/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", diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 2fc50fe8f8d01dc0a1dd2086d8b57ba8dbe0ff01..8671bb7be912b9ed89851f00aaa68ecb8af8ca56 100644 --- a/assets/keymaps/default-linux.json +++ b/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 }], diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 4f400565825d8c1cb05bd4874a4bfe95bbc0c4d5..26848eeed695e00b91e1f52015e7a11a1b8a03ed 100644 --- a/assets/keymaps/default-macos.json +++ b/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 }], diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 1254febb1cf2afbaa8773e6b9d5f852451d6d62b..dcfee8ec86bcbe7cf54f4ccbf627de1d1f66cbe9 100644 --- a/assets/keymaps/default-windows.json +++ b/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 }], diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index 8cbacfd823400f2988738af03a05dfbfc0ed72d4..da91382819ec51d35b7e659b961438a70a96c3c0 100644 --- a/crates/feature_flags/src/flags.rs +++ b/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 + } +} diff --git a/crates/project_panel/Cargo.toml b/crates/project_panel/Cargo.toml index 88d85c75f9e6452a72eb4181a94a8bf6395ba754..4306a25132ba460e1b3e48437226bf56020b6834 100644 --- a/crates/project_panel/Cargo.toml +++ b/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"] } diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index eafaf208d98b1c37727b6f3df2224be37470a37d..9e7ac1259bee16e8521fc0b0ccb1dd6cb56435d9 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/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::(), |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.undo_manager.undo(cx); + cx.notify(); + } + fn rename_impl( &mut self, selection: Option>, @@ -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>), - Copy(Task>>), + Rename { + task: Task>, + old_path: ProjectPath, + new_path: ProjectPath, + }, + Copy { + task: Task>>, + 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 = { + 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>)> = 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::(), |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)) diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index 1ee00c05e372df719ce5f33fd92e29269e01d0bd..8bb5fbc17f68970f1d721ac2cca4f9f7ae64138e 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/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); diff --git a/crates/project_panel/src/undo.rs b/crates/project_panel/src/undo.rs new file mode 100644 index 0000000000000000000000000000000000000000..3a8baa23c55db8f3572174ee667196936e633281 --- /dev/null +++ b/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), + Create { + project_path: ProjectPath, + }, + Rename { + old_path: ProjectPath, + new_path: ProjectPath, + }, +} + +pub struct UndoManager { + workspace: WeakEntity, + stack: VecDeque, + /// Maximum number of operations to keep on the undo stack. + limit: usize, +} + +impl UndoManager { + pub fn new(workspace: WeakEntity) -> Self { + Self::new_with_limit(workspace, MAX_UNDO_OPERATIONS) + } + + pub fn new_with_limit(workspace: WeakEntity, 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) { + let mut operations = operations.into_iter().collect::>(); + 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> { + 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, messages: Vec, 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, + panel: Entity, + } + + 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); + }); + } +}