diff --git a/Cargo.lock b/Cargo.lock index 6570398f5b22f2..7976548c402e83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13166,6 +13166,7 @@ name = "project_panel" version = "0.1.0" dependencies = [ "anyhow", + "circular-buffer", "client", "collections", "command_palette_hooks", @@ -13173,6 +13174,7 @@ dependencies = [ "db", "editor", "file_icons", + "futures 0.3.31", "git", "git_ui", "gpui", diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index cb5cef24c50f9f..98240c6b7a1032 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -895,6 +895,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 08fb63868be875..2773edf5512c28 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -956,6 +956,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 600025e2069978..c5aad87dc7fcc0 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -893,6 +893,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/project_panel/Cargo.toml b/crates/project_panel/Cargo.toml index 88d85c75f9e645..ac1caa53e824f1 100644 --- a/crates/project_panel/Cargo.toml +++ b/crates/project_panel/Cargo.toml @@ -18,11 +18,13 @@ doctest = false [dependencies] anyhow.workspace = true +circular-buffer.workspace = true collections.workspace = true command_palette_hooks.workspace = true db.workspace = true editor.workspace = true file_icons.workspace = true +futures.workspace = true git_ui.workspace = true git.workspace = true gpui.workspace = true diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 068fb8d71fa883..fbaa48204e6c18 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}; @@ -81,6 +82,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 +160,7 @@ pub struct ProjectPanel { sticky_items_count: usize, last_reported_update: Instant, update_visible_entries_task: UpdateVisibleEntriesTask, + undo_manager: UndoManager, state: State, } @@ -394,6 +398,8 @@ actions!( SelectPrevDirectory, /// Opens a diff view to compare two marked files. CompareMarkedFiles, + /// Undoes the last file operation. + Undo, ] ); @@ -893,6 +899,7 @@ impl ProjectPanel { unfolded_dir_ids: Default::default(), }, update_visible_entries_task: Default::default(), + undo_manager: UndoManager::new(project.clone()), }; this.update_visible_entries(None, false, false, window, cx); @@ -1881,6 +1888,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, @@ -1891,12 +1900,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() @@ -1908,9 +1919,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 { @@ -1923,6 +1936,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(Some(operation)); + } + cx.notify(); })?; @@ -2173,6 +2202,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>, @@ -2360,6 +2394,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(), @@ -3066,8 +3101,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(); @@ -3077,16 +3119,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); @@ -3097,26 +3145,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(operations); + }) + .ok(); + // update selection if let Some(entry) = last_succeed { project_panel @@ -4352,9 +4418,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 @@ -4366,6 +4436,8 @@ impl ProjectPanel { entry_id, }); + project_panel.undo_manager.record(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); @@ -4415,6 +4487,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(); @@ -4430,22 +4519,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(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)); } } @@ -4454,6 +4569,12 @@ impl ProjectPanel { return; } + project_panel + .update(cx, |this, _| { + this.undo_manager.record(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. @@ -6465,6 +6586,7 @@ 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)) .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 720ac04fdd2a65..8118ef76ed3182 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -1956,6 +1956,482 @@ 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_remove_opened_file(cx: &mut gpui::TestAppContext) { init_test_with_editor(cx); diff --git a/crates/project_panel/src/undo.rs b/crates/project_panel/src/undo.rs new file mode 100644 index 00000000000000..983dcfe81639b2 --- /dev/null +++ b/crates/project_panel/src/undo.rs @@ -0,0 +1,108 @@ +use anyhow::{Result, anyhow}; +use circular_buffer::CircularBuffer; +use gpui::{Entity, Task}; +use project::{Project, ProjectPath}; +use ui::App; + +const MAX_UNDO_OPERATIONS: usize = 10_000; + +pub enum ProjectPanelOperation { + Batch(Vec), + Create { + project_path: ProjectPath, + }, + Rename { + old_path: ProjectPath, + new_path: ProjectPath, + }, +} + +impl ProjectPanelOperation { + pub fn batch(operations: impl IntoIterator) -> Option { + let mut operations: Vec<_> = operations.into_iter().collect(); + match operations.len() { + 0 => None, + 1 => operations.pop(), + _ => Some(Self::Batch(operations)), + } + } +} + +pub struct UndoManager { + project: Entity, + stack: Box>, +} + +impl UndoManager { + pub fn new(project: Entity) -> Self { + Self { + project, + stack: CircularBuffer::boxed(), + } + } + + pub fn undo(&mut self, cx: &mut App) { + if let Some(operation) = self.stack.pop_back() { + self.revert_operation(operation, cx).detach_and_log_err(cx); + } + } + + pub fn record(&mut self, operations: impl IntoIterator) { + if let Some(operation) = ProjectPanelOperation::batch(operations) { + self.stack.push_back(operation); + } + } + + fn revert_operation(&self, operation: ProjectPanelOperation, cx: &mut App) -> Task> { + match operation { + ProjectPanelOperation::Create { project_path } => { + let Some(entry_id) = self + .project + .read(cx) + .entry_for_path(&project_path, cx) + .map(|e| e.id) + else { + return Task::ready(Err(anyhow!("no entry for path"))); + }; + let Some(task) = self + .project + .update(cx, |project, cx| project.delete_entry(entry_id, false, cx)) + else { + return Task::ready(Err(anyhow!("failed to trash entry"))); + }; + cx.spawn(async move |_cx| task.await.map(|_| ())) + } + ProjectPanelOperation::Rename { old_path, new_path } => { + let Some(entry_id) = self + .project + .read(cx) + .entry_for_path(&new_path, cx) + .map(|e| e.id) + else { + return Task::ready(Err(anyhow!("no entry for path"))); + }; + let task = self.project.update(cx, |project, cx| { + project.rename_entry(entry_id, old_path.clone(), cx) + }); + cx.spawn(async move |_| task.await.map(|_| ())) + } + ProjectPanelOperation::Batch(operations) => { + let tasks: Vec<_> = operations + .into_iter() + .map(|op| self.revert_operation(op, cx)) + .collect(); + + cx.spawn(async move |_| { + let results = futures::future::join_all(tasks).await; + let errors: Vec<_> = results.into_iter().filter_map(|r| r.err()).collect(); + // TODO: better understand what to do with these errors + if errors.is_empty() { + Ok(()) + } else { + Err(anyhow!("Some operations failed")) + } + }) + } + } + } +}