@@ -1,7 +1,8 @@
+mod project_panel_operation;
mod project_panel_settings;
mod utils;
-use anyhow::{Context as _, Result};
+use anyhow::{Context as _, Result, anyhow};
use client::{ErrorCode, ErrorExt};
use collections::{BTreeSet, HashMap, hash_map};
use command_palette_hooks::CommandPaletteFilter;
@@ -71,6 +72,8 @@ use workspace::{
use worktree::CreatedEntry;
use zed_actions::{project_panel::ToggleFocus, workspace::OpenWithSystem};
+use crate::project_panel_operation::ProjectPanelOperation;
+
const PROJECT_PANEL_KEY: &str = "ProjectPanel";
const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
@@ -139,6 +142,8 @@ pub struct ProjectPanel {
sticky_items_count: usize,
last_reported_update: Instant,
update_visible_entries_task: UpdateVisibleEntriesTask,
+ undo_stack: Vec<ProjectPanelOperation>,
+ redo_stack: Vec<ProjectPanelOperation>,
state: State,
}
@@ -340,6 +345,10 @@ actions!(
SelectPrevDirectory,
/// Opens a diff view to compare two marked files.
CompareMarkedFiles,
+ /// Undoes the last file operation.
+ Undo,
+ /// Redoes the last undone file operation.
+ Redo,
]
);
@@ -819,6 +828,8 @@ impl ProjectPanel {
unfolded_dir_ids: Default::default(),
},
update_visible_entries_task: Default::default(),
+ undo_stack: Default::default(),
+ redo_stack: Default::default(),
};
this.update_visible_entries(None, false, false, window, cx);
@@ -1719,11 +1730,13 @@ impl ProjectPanel {
return None;
}
edited_entry_id = entry.id;
- edit_task = self.project.update(cx, |project, cx| {
- project.rename_entry(entry.id, (worktree_id, new_path).into(), cx)
- });
+ edit_task =
+ self.confirm_undoable_rename_entry(entry.id, (worktree_id, new_path).into(), cx);
};
+ // Reborrow so lifetime does not overlap `self.confirm_undoable_rename_entry()`
+ let edit_state = self.state.edit_state.as_mut()?;
+
if refocus {
window.focus(&self.focus_handle);
}
@@ -1965,6 +1978,90 @@ impl ProjectPanel {
}
}
+ fn record_undoable(&mut self, operation: ProjectPanelOperation) {
+ self.redo_stack.clear();
+ self.undo_stack.push(operation);
+ }
+
+ pub fn undo(&mut self, _: &Undo, _window: &mut Window, cx: &mut Context<Self>) {
+ if let Some(operation) = self.undo_stack.pop() {
+ let task = self.do_operation(operation, cx);
+ cx.spawn(async move |this, cx| {
+ let reverse_operation = task.await?;
+ this.update(cx, |this, _| this.redo_stack.push(reverse_operation))
+ })
+ .detach();
+ }
+ }
+
+ fn redo(&mut self, _: &Redo, _window: &mut Window, cx: &mut Context<Self>) -> () {
+ if let Some(operation) = self.redo_stack.pop() {
+ let task = self.do_operation(operation, cx);
+ cx.spawn(async |this, cx| {
+ let reverse_operation = task.await?;
+ this.update(cx, |this, cx| this.undo_stack.push(reverse_operation))
+ })
+ .detach();
+ }
+ }
+
+ /// Does an undoable operation and returns the reverse operation.
+ fn do_operation(
+ &self,
+ operation: ProjectPanelOperation,
+ cx: &mut Context<'_, Self>,
+ ) -> Task<Result<ProjectPanelOperation>> {
+ match operation {
+ ProjectPanelOperation::Rename { old_path, new_path } => {
+ let Some(entry) = self.project.read(cx).entry_for_path(&old_path, cx) else {
+ return Task::ready(Err(anyhow!("no entry for path")));
+ };
+ let task = self.confirm_rename_entry(entry.id, new_path, cx);
+ cx.spawn(async move |_, _| {
+ let (_created_entry, reverse_operation) = task.await?;
+ Ok(reverse_operation)
+ })
+ }
+ }
+ }
+
+ fn confirm_undoable_rename_entry(
+ &self,
+ entry_id: ProjectEntryId,
+ new_path: ProjectPath,
+ cx: &mut Context<Self>,
+ ) -> Task<Result<CreatedEntry>> {
+ let rename_task = self.confirm_rename_entry(entry_id, new_path, cx);
+ cx.spawn(async move |this, cx| {
+ let (new_entry, operation) = rename_task.await?;
+ this.update(cx, |this, _cx| this.record_undoable(operation))
+ .ok();
+ Ok(new_entry)
+ })
+ }
+
+ fn confirm_rename_entry(
+ &self,
+ entry_id: ProjectEntryId,
+ new_path: ProjectPath,
+ cx: &mut Context<Self>,
+ ) -> Task<Result<(CreatedEntry, ProjectPanelOperation)>> {
+ let Some(old_path) = self.project.read(cx).path_for_entry(entry_id, cx) else {
+ return Task::ready(Err(anyhow!("no path for entry")));
+ };
+ let rename_task = self.project.update(cx, |project, cx| {
+ project.rename_entry(entry_id, new_path.clone(), cx)
+ });
+ cx.spawn(async move |_, _| {
+ let created_entry = rename_task.await?;
+ let reverse_operation = ProjectPanelOperation::Rename {
+ old_path: new_path,
+ new_path: old_path,
+ };
+ Ok((created_entry, reverse_operation))
+ })
+ }
+
fn rename_impl(
&mut self,
selection: Option<Range<usize>>,
@@ -2796,9 +2893,11 @@ impl ProjectPanel {
self.create_paste_path(clipboard_entry, self.selected_sub_entry(cx)?, cx)?;
let clip_entry_id = clipboard_entry.entry_id;
let task = if clipboard_entries.is_cut() {
- let task = self.project.update(cx, |project, cx| {
- project.rename_entry(clip_entry_id, (worktree_id, new_path).into(), cx)
- });
+ let task = self.confirm_undoable_rename_entry(
+ clip_entry_id,
+ (worktree_id, new_path).into(),
+ cx,
+ );
PasteTask::Rename(task)
} else {
let task = self.project.update(cx, |project, cx| {
@@ -3131,6 +3230,8 @@ impl ProjectPanel {
return;
}
+ let this = cx.entity();
+
let destination_worktree = self.project.update(cx, |project, cx| {
let source_path = project.path_for_entry(entry_to_move, cx)?;
let destination_path = project.path_for_entry(destination_entry, cx)?;
@@ -3144,11 +3245,13 @@ impl ProjectPanel {
let mut new_path = destination_path.to_rel_path_buf();
new_path.push(RelPath::unix(source_path.path.file_name()?).unwrap());
if new_path.as_rel_path() != source_path.path.as_ref() {
- let task = project.rename_entry(
- entry_to_move,
- (destination_worktree_id, new_path).into(),
- cx,
- );
+ let task = this.update(cx, |this, cx| {
+ this.confirm_undoable_rename_entry(
+ entry_to_move,
+ (destination_worktree_id, new_path).into(),
+ cx,
+ )
+ });
cx.foreground_executor().spawn(task).detach_and_log_err(cx);
}
@@ -5622,6 +5725,8 @@ 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))
+ .on_action(cx.listener(Self::redo))
.when(!project.is_read_only(cx), |el| {
el.on_action(cx.listener(Self::new_file))
.on_action(cx.listener(Self::new_directory))
@@ -1917,6 +1917,56 @@ async fn test_copy_paste_nested_and_root_entries(cx: &mut gpui::TestAppContext)
);
}
+#[gpui::test]
+async fn test_undo_redo(cx: &mut gpui::TestAppContext) {
+ init_test(cx);
+
+ // - paste (?)
+ // -
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ "/test",
+ json!({
+ "dir1": {
+ "a.txt": "",
+ "b.txt": "",
+ },
+ "dir2": {},
+ "c.txt": "",
+ "d.txt": "",
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
+ let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let cx = &mut VisualTestContext::from_window(*workspace, cx);
+ let panel = workspace.update(cx, ProjectPanel::new).unwrap();
+ cx.run_until_parked();
+
+ toggle_expand_dir(&panel, "test/dir1", cx);
+
+ cx.simulate_modifiers_change(gpui::Modifiers {
+ control: true,
+ ..Default::default()
+ });
+
+ // todo!(andrew) test cut/paste conflict->rename, copy/paste conflict->rename, drag rename, and rename with 'enter' key
+
+ select_path(&panel, path, cx);
+ // select_path_with_mark(&panel, "test/dir1/a.txt", cx);
+ // select_path_with_mark(&panel, "test/dir1", cx);
+ // select_path_with_mark(&panel, "test/c.txt", cx);
+ drag_selection_to(&panel, target_path, is_file, cx);
+ panel.update_in(cx, |this, window, cx| {
+ this.undo(&Undo, window, cx);
+ });
+ panel.update_in(cx, |this, window, cx| {
+ this.rename(&Undo, window, cx);
+ });
+}
+
#[gpui::test]
async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx);