@@ -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::<ProjectPanelUndoRedoFeatureFlag>(), |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>) {
+ self.undo_manager.undo(cx);
+ cx.notify();
+ }
+
fn rename_impl(
&mut self,
selection: Option<Range<usize>>,
@@ -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<Result<CreatedEntry>>),
- Copy(Task<Result<Option<Entry>>>),
+ Rename {
+ task: Task<Result<CreatedEntry>>,
+ old_path: ProjectPath,
+ new_path: ProjectPath,
+ },
+ Copy {
+ task: Task<Result<Option<Entry>>>,
+ 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<ProjectEntryId, ProjectPath> = {
+ 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<Result<CreatedEntry>>)> = 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::<ProjectPanelUndoRedoFeatureFlag>(), |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))
@@ -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);
@@ -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<ProjectPanelOperation>),
+ Create {
+ project_path: ProjectPath,
+ },
+ Rename {
+ old_path: ProjectPath,
+ new_path: ProjectPath,
+ },
+}
+
+pub struct UndoManager {
+ workspace: WeakEntity<Workspace>,
+ stack: VecDeque<ProjectPanelOperation>,
+ /// Maximum number of operations to keep on the undo stack.
+ limit: usize,
+}
+
+impl UndoManager {
+ pub fn new(workspace: WeakEntity<Workspace>) -> Self {
+ Self::new_with_limit(workspace, MAX_UNDO_OPERATIONS)
+ }
+
+ pub fn new_with_limit(workspace: WeakEntity<Workspace>, 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<Item = ProjectPanelOperation>) {
+ let mut operations = operations.into_iter().collect::<Vec<_>>();
+ 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<Vec<anyhow::Error>> {
+ 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<Workspace>, messages: Vec<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")
+ }
+ })
+ })
+ })
+ .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<Project>,
+ panel: Entity<ProjectPanel>,
+ }
+
+ 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);
+ });
+ }
+}