From 9809204acaf6231f2a5a2c777d5ffd80b0bda1e0 Mon Sep 17 00:00:00 2001 From: HactarCE <6060305+HactarCE@users.noreply.github.com> Date: Tue, 16 Dec 2025 11:53:04 -0500 Subject: [PATCH] Add undo/redo of renames in project panel --------- Co-authored-by: Cole Miller --- assets/keymaps/default-linux.json | 5 + assets/keymaps/default-macos.json | 2 + assets/keymaps/default-windows.json | 3 + crates/project_panel/src/project_panel.rs | 129 ++++++++++++++++-- .../src/project_panel_operation.rs | 22 +++ .../project_panel/src/project_panel_tests.rs | 50 +++++++ 6 files changed, 199 insertions(+), 12 deletions(-) create mode 100644 crates/project_panel/src/project_panel_operation.rs diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 54a4f331c0b0c59eca79065fe42c1a8ecbf646b7..2dffa90e32bad55617d1cad541e706b3293fcd2b 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -869,6 +869,11 @@ "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", + "redo": "project_panel::Redo", + "ctrl-y": "project_panel::Redo", + "ctrl-shift-z": "project_panel::Redo", "enter": "project_panel::Rename", "f2": "project_panel::Rename", "backspace": ["project_panel::Trash", { "skip_prompt": false }], diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 060151c647e42370f5aa0be5d2fa186774c2574d..1194713e50e4ff3257060880d4e9cd6f5d83c675 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -930,6 +930,8 @@ "cmd-v": "project_panel::Paste", "cmd-alt-c": "workspace::CopyPath", "alt-cmd-shift-c": "workspace::CopyRelativePath", + "cmd-z": "project_panel::Undo", + "cmd-shift-z": "project_panel::Redo", "enter": "project_panel::Rename", "f2": "project_panel::Rename", "backspace": ["project_panel::Trash", { "skip_prompt": false }], diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index d749ac56886860b0e80de27f942082639df0447b..ad418931dbbc412ea582b76d3f5b8f2fc66d878a 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -868,6 +868,9 @@ "ctrl-v": "project_panel::Paste", "shift-alt-c": "project_panel::CopyPath", "ctrl-k ctrl-shift-c": "workspace::CopyRelativePath", + "ctrl-z": "project_panel::Undo", + "ctrl-y": "project_panel::Redo", + "ctrl-shift-z": "project_panel::Redo", "enter": "project_panel::Rename", "f2": "project_panel::Rename", "backspace": ["project_panel::Trash", { "skip_prompt": false }], diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index f8f8ab7d744078b7eeddb2b7ab7a97547d600265..9fde01f64169279f1683979cfd77ce5594cb54a3 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -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, + redo_stack: Vec, 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) { + 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) -> () { + 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> { + 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, + ) -> Task> { + 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, + ) -> Task> { + 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>, @@ -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)) diff --git a/crates/project_panel/src/project_panel_operation.rs b/crates/project_panel/src/project_panel_operation.rs new file mode 100644 index 0000000000000000000000000000000000000000..8f365b809e3d5023c042396b871b8018791b12ae --- /dev/null +++ b/crates/project_panel/src/project_panel_operation.rs @@ -0,0 +1,22 @@ +use project::ProjectPath; +// use trash::FileInTrash; + +/// Operation done in the project panel that can be undone. +/// +/// There is no variant for creating a file or copying a file because their +/// reverse is `Trash`. +/// +/// - `Trash` and `Restore` are the reverse of each other. +/// - `Rename` is its own reverse. +pub enum ProjectPanelOperation { + // Trash(RelPath), + // Restore(FileInTrashId), + Rename { + old_path: ProjectPath, + new_path: ProjectPath, + }, +} + +// pub struct FileInTrashId(u32); + +// proto::Trash -> opaque integer diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index 6cf487bf9849a9252abc21504171b8c6bdf7e298..37e2a588e5a0ffd937689c0084d0950d8159cc92 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -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);