Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
e3626c0
Add scarier warning messages on destructive file ops
marcocondrache Jan 18, 2026
ec82d14
Add undo/redo of renames in project panel
marcocondrache Jan 18, 2026
1cf99e0
Add trash & restore operations to `Fs`
marcocondrache Jan 18, 2026
245c593
avoid intermediate layer
marcocondrache Jan 18, 2026
99aee34
leftovers
marcocondrache Jan 18, 2026
ca80b74
leftovers pt2
marcocondrache Jan 18, 2026
a718770
leftovers pt3
marcocondrache Jan 18, 2026
a62c39d
let's store trash inside fs
marcocondrache Jan 18, 2026
ab5ca4c
register the undo operation
marcocondrache Jan 18, 2026
0a00c81
simplify trash item
marcocondrache Jan 18, 2026
7eb5a84
that's too much
marcocondrache Jan 18, 2026
efaa1b7
just a wrapper
marcocondrache Jan 18, 2026
6c359d0
fix double update panic
marcocondrache Jan 18, 2026
867f406
better naming
marcocondrache Jan 19, 2026
d8e6ab4
same
marcocondrache Jan 19, 2026
ef80504
add remote restore
marcocondrache Jan 19, 2026
705f0d1
use a registry to track items
marcocondrache Jan 19, 2026
a418e10
Revert "use a registry to track items"
marcocondrache Jan 19, 2026
4a94a2c
bring trash-rs in
marcocondrache Jan 19, 2026
a37ebae
let's do things the correct way
marcocondrache Jan 19, 2026
b27a45a
use centralized trash
marcocondrache Jan 19, 2026
031cb25
fix usage
marcocondrache Jan 19, 2026
3fc3867
windows impl
marcocondrache Jan 19, 2026
3fa15cb
undo paste
marcocondrache Jan 20, 2026
bd37103
we don't need a separate method
marcocondrache Jan 20, 2026
3aed9e1
remove helper
marcocondrache Jan 20, 2026
21ac945
copy during drag
marcocondrache Jan 20, 2026
d59baa9
we have to use TrashItem
marcocondrache Jan 20, 2026
980f7b2
return trashed entry
marcocondrache Jan 20, 2026
eb7b9d0
Merge branch 'main' into project-panel-undo-redo
marcocondrache Jan 22, 2026
c4a9f26
remove tests
marcocondrache Jan 22, 2026
4f13b14
Merge branch 'main' into project-panel-undo-redo
marcocondrache Jan 22, 2026
682e910
split delete_entry
marcocondrache Jan 22, 2026
0971f2d
Merge remote-tracking branch 'origin/main' into project-panel-undo-redo
marcocondrache Jan 26, 2026
668e120
tests
marcocondrache Jan 26, 2026
6915d44
WIP
marcocondrache Feb 6, 2026
347880a
WIP
marcocondrache Feb 10, 2026
ed6fec2
Merge branch 'main' into project-panel-undo-redo
marcocondrache Feb 10, 2026
0ffa878
WIP
marcocondrache Feb 10, 2026
7f8ae67
WIP
marcocondrache Feb 10, 2026
ab2997f
Merge branch 'main' into project-panel-undo-redo
marcocondrache Feb 16, 2026
1160a72
Merge branch 'main' into project-panel-undo-redo
marcocondrache Mar 2, 2026
64442f4
restore original proto
marcocondrache Mar 2, 2026
71b202c
remove some changes
marcocondrache Mar 3, 2026
13445da
Merge branch 'main' into project-panel-undo-redo
marcocondrache Mar 3, 2026
f36ea5f
restore even more
marcocondrache Mar 3, 2026
7c18484
keep only undo
marcocondrache Mar 3, 2026
b9a0378
revert even more unrelated stuff
marcocondrache Mar 3, 2026
bbbc731
add tests
marcocondrache Mar 3, 2026
02ab2f7
flatten
marcocondrache Mar 4, 2026
4ff094e
dont trash when reverting create
marcocondrache Mar 4, 2026
3e4ee7c
extract undo logic
marcocondrache Mar 5, 2026
a9f2464
simplify
marcocondrache Mar 5, 2026
4a31d70
revert
marcocondrache Mar 5, 2026
0ae235e
Merge branch 'main' into project-panel-undo-redo
marcocondrache Mar 5, 2026
ac3faf9
Merge branch 'main' into project-panel-undo-redo
marcocondrache Mar 12, 2026
227fa46
clippy
marcocondrache Mar 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions assets/keymaps/default-linux.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 }],
Expand Down
1 change: 1 addition & 0 deletions assets/keymaps/default-macos.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 }],
Expand Down
1 change: 1 addition & 0 deletions assets/keymaps/default-windows.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 }],
Expand Down
2 changes: 2 additions & 0 deletions crates/project_panel/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
160 changes: 141 additions & 19 deletions crates/project_panel/src/project_panel.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod project_panel_settings;
mod undo;
mod utils;

use anyhow::{Context as _, Result};
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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,
}

Expand Down Expand Up @@ -394,6 +398,8 @@ actions!(
SelectPrevDirectory,
/// Opens a diff view to compare two marked files.
CompareMarkedFiles,
/// Undoes the last file operation.
Undo,
]
);

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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,
Expand All @@ -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()
Expand All @@ -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 {
Expand All @@ -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();
})?;

Expand Down Expand Up @@ -2173,6 +2202,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>>,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -3066,8 +3101,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();
Expand All @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -4415,6 +4487,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();
Expand All @@ -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));
}
}
Expand All @@ -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.
Expand Down Expand Up @@ -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))
Expand Down
Loading