mirror of
https://github.com/zed-industries/zed.git
synced 2026-08-24 08:24:58 +00:00
project_panel: Confirm before trashing unsaved file on undo (#62294) (cherry-pick to stable) (#62481)
Some checks failed
run_tests / orchestrate (push) Has been cancelled
run_tests / check_style (push) Has been cancelled
run_tests / clippy_windows (push) Has been cancelled
run_tests / clippy_linux (push) Has been cancelled
run_tests / clippy_mac (push) Has been cancelled
run_tests / clippy_mac_x86_64 (push) Has been cancelled
run_tests / run_tests_windows (push) Has been cancelled
run_tests / run_tests_linux (push) Has been cancelled
run_tests / run_tests_mac (push) Has been cancelled
run_tests / miri_scheduler (push) Has been cancelled
run_tests / doctests (push) Has been cancelled
run_tests / check_workspace_binaries (push) Has been cancelled
run_tests / build_visual_tests_binary (push) Has been cancelled
run_tests / check_wasm (push) Has been cancelled
run_tests / check_dependencies (push) Has been cancelled
run_tests / check_docs (push) Has been cancelled
run_tests / check_licenses (push) Has been cancelled
run_tests / check_scripts (push) Has been cancelled
run_tests / check_postgres_and_protobuf_migrations (push) Has been cancelled
run_tests / extension_tests (push) Has been cancelled
run_tests / tests_pass (push) Has been cancelled
Some checks failed
run_tests / orchestrate (push) Has been cancelled
run_tests / check_style (push) Has been cancelled
run_tests / clippy_windows (push) Has been cancelled
run_tests / clippy_linux (push) Has been cancelled
run_tests / clippy_mac (push) Has been cancelled
run_tests / clippy_mac_x86_64 (push) Has been cancelled
run_tests / run_tests_windows (push) Has been cancelled
run_tests / run_tests_linux (push) Has been cancelled
run_tests / run_tests_mac (push) Has been cancelled
run_tests / miri_scheduler (push) Has been cancelled
run_tests / doctests (push) Has been cancelled
run_tests / check_workspace_binaries (push) Has been cancelled
run_tests / build_visual_tests_binary (push) Has been cancelled
run_tests / check_wasm (push) Has been cancelled
run_tests / check_dependencies (push) Has been cancelled
run_tests / check_docs (push) Has been cancelled
run_tests / check_licenses (push) Has been cancelled
run_tests / check_scripts (push) Has been cancelled
run_tests / check_postgres_and_protobuf_migrations (push) Has been cancelled
run_tests / extension_tests (push) Has been cancelled
run_tests / tests_pass (push) Has been cancelled
Cherry-pick of #62294 to stable ---- # Objective Ensure that, when users undo project panel operations, we don't trash files with unsaved edits as that could lead to data loss, as outlined [here](https://github.com/zed-industries/zed/issues/62243#issuecomment-5203625087). Closes #62243 ## Solution Update `UndoManager::trash` to require confirmation before moving files to the trash. For files with unsaved edits, users can save, discard, or cancel the operation while clean files receive a standard trash confirmation, same as shown when trashing a file through the Project Panel. This helps avoid the issue where, if an user undoes a file creation for a file that has unsaved edits and then quits Zed, the edits that were saved in memory, as well as the Project Panel history, will now be gone and there's no way to recover the data. Batch operations have also been updated to now show a single confirmation before making filesystem changes, preventing partial execution when cancelled and warning about unsaved edits. Lastly, the trash/delete prompt building has been refactored in order for the Project Panel and Undo Manager to share the same wording, file-list truncation, and unsaved-change warnings. ## Testing Tested both manually as well as added the following tests: * `project_panel::tests::undo::undo_create_cancel_trash` * `project_panel::tests::undo::undo_create_dirty_file` * `project_panel::tests::undo::cancel_partial_trash_batch` * `project_panel::tests::undo::batch_trash_warns_about_unsaved_changes` ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable ## Showcase The screen recording below shows the trash confirmation dialog on both a clean and dirty files, on both undo and redo flows. https://github.com/user-attachments/assets/0f9b96f5-0357-4e3f-8ec2-141486942c89 --- Release Notes: - Fixed issue with undoing or redoing project panel operations that could lead to a file with unsaved edits being trashed without confirmation. Co-authored-by: Dino <dinojoaocosta@gmail.com>
This commit is contained in:
parent
3d340cc45a
commit
084ce16b1f
3 changed files with 476 additions and 90 deletions
|
|
@ -662,6 +662,17 @@ enum RemoveEntryTask {
|
|||
Delete(Task<Result<()>>),
|
||||
}
|
||||
|
||||
struct RemovalPrompt {
|
||||
message: String,
|
||||
detail: Option<&'static str>,
|
||||
confirmation_label: &'static str,
|
||||
}
|
||||
|
||||
enum RemovalKind {
|
||||
Trash,
|
||||
Delete,
|
||||
}
|
||||
|
||||
impl ProjectPanel {
|
||||
fn new(
|
||||
workspace: &mut Workspace,
|
||||
|
|
@ -2564,6 +2575,74 @@ impl ProjectPanel {
|
|||
});
|
||||
}
|
||||
|
||||
/// Builds the confirmation prompt shared by direct removal and undo/redo.
|
||||
///
|
||||
/// The `names` slice must contain every path being removed, and this
|
||||
/// function will apply markdown formatting and display truncation. The
|
||||
/// `dirty_buffers` parameter should be the number of those paths that have
|
||||
/// unsaved changes.
|
||||
fn build_removal_prompt<S>(
|
||||
kind: RemovalKind,
|
||||
names: &[S],
|
||||
dirty_buffers: usize,
|
||||
) -> RemovalPrompt
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let (message_start, confirmation_label, detail) = match kind {
|
||||
RemovalKind::Trash => ("Do you want to trash", "Trash", None),
|
||||
RemovalKind::Delete => (
|
||||
"Are you sure you want to permanently delete",
|
||||
"Delete",
|
||||
Some("This cannot be undone."),
|
||||
),
|
||||
};
|
||||
|
||||
let mut message = match names {
|
||||
[name] => format!("{message_start} {}?", MarkdownInlineCode(name.as_ref())),
|
||||
_ => {
|
||||
const CUTOFF_POINT: usize = 10;
|
||||
let mut listed_names = names
|
||||
.iter()
|
||||
.take(CUTOFF_POINT)
|
||||
.map(|name| MarkdownInlineCode(name.as_ref()).to_string())
|
||||
.collect::<Vec<_>>();
|
||||
let omitted_count = names.len().saturating_sub(CUTOFF_POINT);
|
||||
if omitted_count == 1 {
|
||||
listed_names.push(".. 1 file not shown".into());
|
||||
} else if omitted_count > 1 {
|
||||
listed_names.push(format!(".. {omitted_count} files not shown"));
|
||||
}
|
||||
|
||||
format!(
|
||||
"{message_start} the following {} files?\n{}",
|
||||
names.len(),
|
||||
listed_names.join("\n")
|
||||
)
|
||||
}
|
||||
};
|
||||
match dirty_buffers {
|
||||
0 => {}
|
||||
1 if names.len() == 1 => {
|
||||
message.push_str("\n\nIt has unsaved changes, which will be lost.");
|
||||
}
|
||||
1 => {
|
||||
message.push_str("\n\n1 of these has unsaved changes, which will be lost.");
|
||||
}
|
||||
dirty_buffers => {
|
||||
message.push_str(&format!(
|
||||
"\n\n{dirty_buffers} of these have unsaved changes, which will be lost."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
RemovalPrompt {
|
||||
message,
|
||||
detail,
|
||||
confirmation_label,
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(yara|dino): trashing and deleting are conceptually distinct, even
|
||||
// more so with the fact that trashing can now be undone, whereas deleting
|
||||
// cannot.
|
||||
|
|
@ -2602,70 +2681,24 @@ impl ProjectPanel {
|
|||
return None;
|
||||
}
|
||||
let answer = if !skip_prompt {
|
||||
let operation = if trash { "Trash" } else { "Delete" };
|
||||
let message_start = if trash {
|
||||
"Do you want to trash"
|
||||
let names = file_paths
|
||||
.iter()
|
||||
.map(|(_, _, name)| name.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let removal_kind = if trash {
|
||||
RemovalKind::Trash
|
||||
} else {
|
||||
"Are you sure you want to permanently delete"
|
||||
RemovalKind::Delete
|
||||
};
|
||||
let prompt = match file_paths.first() {
|
||||
Some((_, _, path)) if file_paths.len() == 1 => {
|
||||
let unsaved_warning = if dirty_buffers > 0 {
|
||||
"\n\nIt has unsaved changes, which will be lost."
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
format!(
|
||||
"{message_start} {}?{unsaved_warning}",
|
||||
MarkdownInlineCode(path)
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
const CUTOFF_POINT: usize = 10;
|
||||
let names = if file_paths.len() > CUTOFF_POINT {
|
||||
let truncated_path_counts = file_paths.len() - CUTOFF_POINT;
|
||||
let mut paths = file_paths
|
||||
.iter()
|
||||
.map(|(_, _, path)| MarkdownInlineCode(path).to_string())
|
||||
.take(CUTOFF_POINT)
|
||||
.collect::<Vec<String>>();
|
||||
paths.truncate(CUTOFF_POINT);
|
||||
if truncated_path_counts == 1 {
|
||||
paths.push(".. 1 file not shown".into());
|
||||
} else {
|
||||
paths.push(format!(".. {} files not shown", truncated_path_counts));
|
||||
}
|
||||
paths
|
||||
} else {
|
||||
file_paths
|
||||
.iter()
|
||||
.map(|(_, _, path)| MarkdownInlineCode(path).to_string())
|
||||
.collect()
|
||||
};
|
||||
let unsaved_warning = if dirty_buffers == 0 {
|
||||
String::new()
|
||||
} else if dirty_buffers == 1 {
|
||||
"\n\n1 of these has unsaved changes, which will be lost.".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"\n\n{dirty_buffers} of these have unsaved changes, which will be lost."
|
||||
)
|
||||
};
|
||||
let prompt = Self::build_removal_prompt(removal_kind, &names, dirty_buffers);
|
||||
|
||||
format!(
|
||||
"{message_start} the following {} files?\n{}{unsaved_warning}",
|
||||
file_paths.len(),
|
||||
names.join("\n")
|
||||
)
|
||||
}
|
||||
};
|
||||
let detail = (!trash).then_some("This cannot be undone.");
|
||||
Some(window.prompt(
|
||||
PromptLevel::Info,
|
||||
&prompt,
|
||||
detail,
|
||||
&[operation, "Cancel"],
|
||||
&prompt.message,
|
||||
prompt.detail,
|
||||
&[prompt.confirmation_label, "Cancel"],
|
||||
cx,
|
||||
))
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -2,17 +2,18 @@
|
|||
|
||||
use client::proto;
|
||||
use collections::HashSet;
|
||||
use editor::Editor;
|
||||
use fs::{FakeFs, Fs};
|
||||
use gpui::{App, BorrowAppContext, Entity, VisualTestContext};
|
||||
use gpui::{App, BorrowAppContext, Context, Entity, VisualTestContext, Window};
|
||||
use project::Project;
|
||||
use serde_json::{Value, json};
|
||||
use settings::SettingsStore;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use workspace::{MultiWorkspace, register_project_item};
|
||||
use workspace::{Item, MultiWorkspace, register_project_item};
|
||||
|
||||
use crate::project_panel_tests::{self, TestProjectItemView, find_project_entry, select_path};
|
||||
use crate::{NewDirectory, NewFile, ProjectPanel, Redo, Rename, Trash, Undo};
|
||||
use crate::{NewDirectory, NewFile, Open, ProjectPanel, Redo, Rename, Trash, Undo};
|
||||
|
||||
struct TestContext {
|
||||
panel: Entity<ProjectPanel>,
|
||||
|
|
@ -50,6 +51,7 @@ impl TestContext {
|
|||
});
|
||||
self.cx.run_until_parked();
|
||||
}
|
||||
|
||||
async fn redo(&mut self) {
|
||||
self.panel.update_in(&mut self.cx, |panel, window, cx| {
|
||||
panel.redo(&Redo, window, cx);
|
||||
|
|
@ -57,7 +59,40 @@ impl TestContext {
|
|||
self.cx.run_until_parked();
|
||||
}
|
||||
|
||||
fn open_path(&mut self, path: &str) {
|
||||
select_path(&self.panel, &format!("workspace/{path}"), &mut self.cx);
|
||||
self.panel.update_in(&mut self.cx, |panel, window, cx| {
|
||||
panel.open(&Open, window, cx)
|
||||
});
|
||||
self.cx.run_until_parked();
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn assert_prompt(&self, expected: &str) {
|
||||
let (prompt, _) = self
|
||||
.cx
|
||||
.cx
|
||||
.pending_prompt()
|
||||
.expect("should have pending prompt");
|
||||
assert!(
|
||||
prompt.contains(expected),
|
||||
"expected prompt to contain {expected:?}, got {prompt:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn answer(&self, answer: &str) {
|
||||
assert!(
|
||||
self.cx.cx.has_pending_prompt(),
|
||||
"should have pending prompt"
|
||||
);
|
||||
|
||||
self.cx.cx.simulate_prompt_answer(answer);
|
||||
self.cx.run_until_parked();
|
||||
}
|
||||
|
||||
/// Note this only works when every file has an extension
|
||||
#[track_caller]
|
||||
fn assert_fs_state_is(&mut self, state: &[&str]) {
|
||||
let state: HashSet<_> = state
|
||||
.into_iter()
|
||||
|
|
@ -94,6 +129,7 @@ impl TestContext {
|
|||
);
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn assert_not_exists(&mut self, file: &str) {
|
||||
assert_eq!(
|
||||
find_project_entry(&self.panel, &format!("workspace/{file}"), &mut self.cx),
|
||||
|
|
@ -231,6 +267,7 @@ impl TestContext {
|
|||
|
||||
async fn new_with_tree(cx: &mut gpui::TestAppContext, tree: Value) -> TestContext {
|
||||
project_panel_tests::init_test(cx);
|
||||
cx.update(register_project_item::<Editor>);
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree("/workspace", tree).await;
|
||||
|
|
@ -256,6 +293,23 @@ impl TestContext {
|
|||
fn update_app<R>(&mut self, update: impl FnOnce(&mut App) -> R) -> R {
|
||||
self.cx.cx.update(update)
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn update_active_editor<R>(
|
||||
&mut self,
|
||||
update: impl FnOnce(&mut Editor, &mut Window, &mut Context<'_, Editor>) -> R,
|
||||
) -> R {
|
||||
let editor = self
|
||||
.panel
|
||||
.read_with(&self.cx, |panel, _| panel.workspace.upgrade())
|
||||
.expect("workspace should still exist")
|
||||
.read_with(&self.cx, |workspace, cx| {
|
||||
workspace.active_item_as::<Editor>(cx)
|
||||
})
|
||||
.expect("editor should be open");
|
||||
|
||||
editor.update_in(&mut self.cx, update)
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
|
|
@ -287,6 +341,7 @@ async fn create_undo_redo(cx: &mut gpui::TestAppContext) {
|
|||
cx.fs.write(Path::new(&path), b"Hello!").await.unwrap();
|
||||
|
||||
cx.undo().await;
|
||||
cx.answer("Trash");
|
||||
cx.assert_not_exists("c.txt");
|
||||
|
||||
cx.redo().await;
|
||||
|
|
@ -294,6 +349,45 @@ async fn create_undo_redo(cx: &mut gpui::TestAppContext) {
|
|||
assert_eq!(cx.fs.load(Path::new(&path)).await.unwrap(), "Hello!");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn undo_create_cancel_trash(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = TestContext::new(cx).await;
|
||||
|
||||
cx.create_file("c.txt").await;
|
||||
|
||||
cx.undo().await;
|
||||
cx.answer("Cancel");
|
||||
cx.assert_exists("c.txt");
|
||||
|
||||
cx.undo().await;
|
||||
cx.answer("Trash");
|
||||
cx.assert_not_exists("c.txt");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn undo_create_dirty_file(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = TestContext::new(cx).await;
|
||||
|
||||
cx.create_file("c.txt").await;
|
||||
|
||||
// After `c.txt` is created, it should now be open in the editor, so we'll
|
||||
// simulate inserting some content into the file, without saving, in order
|
||||
// to make it dirty.
|
||||
cx.update_active_editor(|editor, window, cx| {
|
||||
editor.handle_input("unsaved changes", window, cx);
|
||||
assert!(editor.is_dirty(cx));
|
||||
});
|
||||
|
||||
cx.undo().await;
|
||||
cx.answer("Cancel");
|
||||
cx.assert_exists("c.txt");
|
||||
cx.update_active_editor(|editor, _window, cx| assert!(editor.is_dirty(cx)));
|
||||
|
||||
cx.undo().await;
|
||||
cx.answer("Don't Save");
|
||||
cx.assert_not_exists("c.txt");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn create_dir_undo(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = TestContext::new(cx).await;
|
||||
|
|
@ -301,6 +395,7 @@ async fn create_dir_undo(cx: &mut gpui::TestAppContext) {
|
|||
cx.create_directory("new_dir").await;
|
||||
cx.assert_exists("new_dir");
|
||||
cx.undo().await;
|
||||
cx.answer("Trash");
|
||||
cx.assert_not_exists("new_dir");
|
||||
}
|
||||
|
||||
|
|
@ -365,6 +460,7 @@ async fn two_sequential_undos(cx: &mut gpui::TestAppContext) {
|
|||
cx.assert_fs_state_is(&["b.txt", "x.txt", "y.txt"]);
|
||||
|
||||
cx.undo().await;
|
||||
cx.answer("Trash");
|
||||
cx.assert_fs_state_is(&["b.txt", "x.txt"]);
|
||||
|
||||
cx.undo().await;
|
||||
|
|
@ -392,6 +488,7 @@ async fn trash_undo_redo(cx: &mut gpui::TestAppContext) {
|
|||
cx.assert_fs_state_is(&["a.txt", "b.txt"]);
|
||||
|
||||
cx.redo().await;
|
||||
cx.answer("Trash");
|
||||
cx.assert_fs_state_is(&[]);
|
||||
}
|
||||
|
||||
|
|
@ -423,6 +520,7 @@ async fn trash_directory_undo_redo(cx: &mut gpui::TestAppContext) {
|
|||
);
|
||||
|
||||
cx.redo().await;
|
||||
cx.answer("Trash");
|
||||
cx.assert_fs_state_is(&["a.txt"]);
|
||||
}
|
||||
|
||||
|
|
@ -543,3 +641,41 @@ async fn excluded_create_is_not_recorded(cx: &mut gpui::TestAppContext) {
|
|||
cx.undo().await;
|
||||
cx.assert_fs_state_is(&["a.txt", "token.secret", "banana.secret"]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn cancel_partial_trash_batch(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = TestContext::new(cx).await;
|
||||
|
||||
cx.trash(&["a.txt", "b.txt"]).await;
|
||||
cx.undo().await;
|
||||
cx.assert_fs_state_is(&["a.txt", "b.txt"]);
|
||||
|
||||
cx.redo().await;
|
||||
cx.answer("Cancel");
|
||||
cx.assert_fs_state_is(&["a.txt", "b.txt"]);
|
||||
|
||||
cx.redo().await;
|
||||
cx.answer("Trash");
|
||||
cx.assert_fs_state_is(&[]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn batch_trash_warns_about_unsaved_changes(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = TestContext::new(cx).await;
|
||||
|
||||
cx.trash(&["a.txt", "b.txt"]).await;
|
||||
cx.undo().await;
|
||||
cx.assert_fs_state_is(&["a.txt", "b.txt"]);
|
||||
|
||||
cx.open_path("a.txt");
|
||||
cx.update_active_editor(|editor, window, cx| {
|
||||
editor.handle_input("unsaved changes", window, cx);
|
||||
});
|
||||
|
||||
cx.redo().await;
|
||||
cx.assert_prompt("1 of these has unsaved changes, which will be lost.");
|
||||
|
||||
cx.answer("Cancel");
|
||||
cx.assert_fs_state_is(&["a.txt", "b.txt"]);
|
||||
cx.update_active_editor(|editor, _window, cx| assert!(editor.is_dirty(cx)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -130,11 +130,13 @@
|
|||
|
||||
//! List of "tainted files" that the user may not operate on
|
||||
|
||||
use crate::ProjectPanel;
|
||||
use crate::{ProjectPanel, RemovalKind};
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use fs::{TrashId, TrashRestoreError};
|
||||
use futures::channel::mpsc;
|
||||
use gpui::{AppContext, AsyncApp, IntoElement, SharedString, Styled, Task, WeakEntity};
|
||||
use gpui::{
|
||||
AppContext, AsyncApp, IntoElement, PromptLevel, SharedString, Styled, Task, WeakEntity,
|
||||
};
|
||||
use markdown::{Markdown, MarkdownElement};
|
||||
use project::Project;
|
||||
use project::{ProjectPath, WorktreeId};
|
||||
|
|
@ -143,7 +145,7 @@ use std::{collections::VecDeque, sync::Arc};
|
|||
use ui::{App, TextSize};
|
||||
use util::{paths::PathStyle, rel_path::RelPath};
|
||||
use workspace::{
|
||||
Workspace,
|
||||
Pane, SaveIntent, Workspace,
|
||||
notifications::{
|
||||
NotificationId, markdown_style, simple_message_notification::MessageNotification,
|
||||
},
|
||||
|
|
@ -158,10 +160,63 @@ enum Operation {
|
|||
}
|
||||
|
||||
impl Operation {
|
||||
async fn execute(self, undo_manager: &Inner, cx: &mut AsyncApp) -> Result<Change> {
|
||||
Ok(match self {
|
||||
/// Attempts to execute the operation, returning an `Err` if the operation
|
||||
/// failed to execute or `Ok(None)` if the operation was cancelled by the
|
||||
/// user, for example, cancelling trashing a file with unsaved edits.
|
||||
async fn execute(self, undo_manager: &Inner, cx: &mut AsyncApp) -> Result<Option<Change>> {
|
||||
let change = match self {
|
||||
Operation::Trash(project_path) => {
|
||||
let trash_id = undo_manager.trash(&project_path, cx).await?;
|
||||
let Some(trash_id) = undo_manager.trash(&project_path, cx).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
Change::Trashed(project_path.worktree_id, trash_id)
|
||||
}
|
||||
Operation::Batch(operations) => {
|
||||
let mut trash_paths = Vec::new();
|
||||
|
||||
for operation in &operations {
|
||||
operation.trash_paths(&mut trash_paths);
|
||||
}
|
||||
|
||||
if !trash_paths.is_empty()
|
||||
&& !undo_manager.confirm_batch_trash(trash_paths, cx).await?
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut changes = Vec::new();
|
||||
|
||||
for operation in operations {
|
||||
changes.push(Box::pin(operation.execute_confirmed(undo_manager, cx)).await?);
|
||||
}
|
||||
|
||||
Change::Batched(changes)
|
||||
}
|
||||
operation => {
|
||||
return operation
|
||||
.execute_confirmed(undo_manager, cx)
|
||||
.await
|
||||
.map(Some);
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Some(change))
|
||||
}
|
||||
|
||||
/// Same as [`Self::execute`], but assumes the operation has already been
|
||||
/// confirmed in order to avoid showing confirmation modals to the user.
|
||||
///
|
||||
/// Useful in scenarios where undoing a batch of operations would lead to
|
||||
/// multiple files being trashed, in which case we only ask the user once
|
||||
/// whether they'd like to trash the files, and then execute each trash
|
||||
/// operation without confirming each file one by one.
|
||||
async fn execute_confirmed(self, undo_manager: &Inner, cx: &mut AsyncApp) -> Result<Change> {
|
||||
let change = match self {
|
||||
Operation::Trash(project_path) => {
|
||||
let trash_id = undo_manager
|
||||
.trash_without_confirmation(&project_path, cx)
|
||||
.await?;
|
||||
|
||||
Change::Trashed(project_path.worktree_id, trash_id)
|
||||
}
|
||||
Operation::Rename(from, to) => {
|
||||
|
|
@ -173,13 +228,29 @@ impl Operation {
|
|||
Change::Restored(project_path)
|
||||
}
|
||||
Operation::Batch(operations) => {
|
||||
let mut res = Vec::new();
|
||||
for op in operations {
|
||||
res.push(Box::pin(op.execute(undo_manager, cx)).await?);
|
||||
let mut changes = Vec::new();
|
||||
|
||||
for operation in operations {
|
||||
changes.push(Box::pin(operation.execute_confirmed(undo_manager, cx)).await?);
|
||||
}
|
||||
Change::Batched(res)
|
||||
|
||||
Change::Batched(changes)
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
Ok(change)
|
||||
}
|
||||
|
||||
fn trash_paths<'a>(&'a self, paths: &mut Vec<&'a ProjectPath>) {
|
||||
match self {
|
||||
Operation::Trash(path) => paths.push(path),
|
||||
Operation::Batch(operations) => {
|
||||
for operation in operations {
|
||||
operation.trash_paths(paths);
|
||||
}
|
||||
}
|
||||
Operation::Rename(..) | Operation::Restore(..) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -449,14 +520,17 @@ impl Inner {
|
|||
// manual intervention would likely be needed in order to undo. As such,
|
||||
// we remove the change from the `history` even before attempting to
|
||||
// execute its inversion.
|
||||
let undo_change = self
|
||||
.history
|
||||
.remove(before_cursor)
|
||||
.expect("we can undo")
|
||||
.to_inverse()
|
||||
.execute(self, cx)
|
||||
.await?;
|
||||
self.history.insert(before_cursor, undo_change);
|
||||
let change = self.history.remove(before_cursor).expect("we can undo");
|
||||
let operation = change.clone().to_inverse();
|
||||
|
||||
match operation.execute(self, cx).await? {
|
||||
Some(undo_change) => self.history.insert(before_cursor, undo_change),
|
||||
None => {
|
||||
self.history.insert(before_cursor, change);
|
||||
self.cursor += 1;
|
||||
}
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -469,15 +543,15 @@ impl Inner {
|
|||
// manual intervention would likely be needed in order to redo. As such,
|
||||
// we remove the change from the `history` even before attempting to
|
||||
// execute its inversion.
|
||||
let redo_change = self
|
||||
.history
|
||||
.remove(self.cursor)
|
||||
.expect("we can redo")
|
||||
.to_inverse()
|
||||
.execute(self, cx)
|
||||
.await?;
|
||||
self.history.insert(self.cursor, redo_change);
|
||||
self.cursor += 1;
|
||||
let change = self.history.remove(self.cursor).expect("we can redo");
|
||||
let operation = change.clone().to_inverse();
|
||||
match operation.execute(self, cx).await? {
|
||||
Some(redo_change) => {
|
||||
self.history.insert(self.cursor, redo_change);
|
||||
self.cursor += 1;
|
||||
}
|
||||
None => self.history.insert(self.cursor, change),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -569,7 +643,43 @@ impl Inner {
|
|||
})
|
||||
}
|
||||
|
||||
async fn trash(&self, project_path: &ProjectPath, cx: &mut AsyncApp) -> Result<TrashId> {
|
||||
/// Trashes the specified `project_path` after prompting the user for
|
||||
/// confirmation. If the path has unsaved changes, the prompt also lets the
|
||||
/// user save or discard them.
|
||||
///
|
||||
/// Returns `Ok(None)` if the user cancels the operation.
|
||||
async fn trash(
|
||||
&self,
|
||||
project_path: &ProjectPath,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<Option<TrashId>> {
|
||||
let Some(workspace) = self.workspace.upgrade() else {
|
||||
return Err(anyhow!("Failed to obtain workspace."));
|
||||
};
|
||||
|
||||
let name = workspace.update(cx, |workspace, cx| {
|
||||
let project = workspace.project().read(cx);
|
||||
let path_style = project.path_style(cx);
|
||||
|
||||
project_path_display(project, project_path, path_style, cx)
|
||||
});
|
||||
|
||||
if !self.confirm_trash(project_path, &name, cx).await? {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
self.trash_without_confirmation(project_path, cx)
|
||||
.await
|
||||
.map(Some)
|
||||
}
|
||||
|
||||
/// Same as [`Self::trash`] but proceeds without confirming if the user
|
||||
/// wishes to trash the file.
|
||||
async fn trash_without_confirmation(
|
||||
&self,
|
||||
project_path: &ProjectPath,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<TrashId> {
|
||||
let Some(workspace) = self.workspace.upgrade() else {
|
||||
return Err(anyhow!("Failed to obtain workspace."));
|
||||
};
|
||||
|
|
@ -683,4 +793,111 @@ impl Inner {
|
|||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// Prompts the user to confirm whether they really want to trash the file.
|
||||
/// In the case the file has unsaved changes, the prompt will ask whether
|
||||
/// the user wants to save or discard these changes.
|
||||
///
|
||||
/// Returns `true` if the user confirmed they want to trash the file,
|
||||
/// `false` otherwise.
|
||||
async fn confirm_trash(
|
||||
&self,
|
||||
project_path: &ProjectPath,
|
||||
name: &str,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<bool> {
|
||||
let open_item = self.workspace.update(cx, |workspace, cx| {
|
||||
workspace.panes().iter().find_map(|pane| {
|
||||
pane.read(cx).items().find_map(|item| {
|
||||
(item.is_dirty(cx)
|
||||
&& item
|
||||
.project_path(cx)
|
||||
.iter()
|
||||
.any(|item_path| item_path == project_path))
|
||||
.then(|| (pane.clone(), item.boxed_clone()))
|
||||
})
|
||||
})
|
||||
})?;
|
||||
|
||||
let Some((pane, item)) = open_item else {
|
||||
return self.trash_prompt(&[name], 0, cx).await;
|
||||
};
|
||||
|
||||
let mut async_window_cx = self
|
||||
.panel
|
||||
.update_in(cx, |_panel, window, cx| window.to_async(cx))?;
|
||||
let project = self
|
||||
.panel
|
||||
.read_with(cx, |panel, _cx| panel.project.clone())?;
|
||||
|
||||
Pane::save_item(
|
||||
project,
|
||||
pane,
|
||||
item.as_ref(),
|
||||
SaveIntent::Close,
|
||||
&mut async_window_cx,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Prompts the user to confirm whether they really want to trash all of the
|
||||
/// provided `trash_paths`.
|
||||
///
|
||||
/// Meant to be used when executing a batch of operations that will lead to
|
||||
/// one or more paths being trashed.
|
||||
///
|
||||
/// Returns `true` if the user confirmed they want to trash the files,
|
||||
/// `false` otherwise.
|
||||
async fn confirm_batch_trash(
|
||||
&self,
|
||||
trash_paths: Vec<&ProjectPath>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<bool> {
|
||||
let (names, dirty_buffers) = self.workspace.update(cx, |workspace, cx| {
|
||||
let project = workspace.project().read(cx);
|
||||
let path_style = project.path_style(cx);
|
||||
let dirty_buffers = project
|
||||
.dirty_buffers(cx)
|
||||
.filter(|dirty_path| trash_paths.contains(&dirty_path))
|
||||
.count();
|
||||
let names = trash_paths
|
||||
.iter()
|
||||
.map(|project_path| project_path_display(project, project_path, path_style, cx))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
(names, dirty_buffers)
|
||||
})?;
|
||||
|
||||
self.trash_prompt(&names, dirty_buffers, cx).await
|
||||
}
|
||||
|
||||
/// Prompts the user to confirm whether they actually want to trash the
|
||||
/// file.
|
||||
///
|
||||
/// Returns `true` if the user confirms, `false` otherwise.
|
||||
async fn trash_prompt<S>(
|
||||
&self,
|
||||
names: &[S],
|
||||
dirty_buffers: usize,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<bool>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let prompt = ProjectPanel::build_removal_prompt(RemovalKind::Trash, names, dirty_buffers);
|
||||
let answer = self
|
||||
.panel
|
||||
.update_in(cx, |_panel, window, cx| {
|
||||
window.prompt(
|
||||
PromptLevel::Info,
|
||||
&prompt.message,
|
||||
prompt.detail,
|
||||
&[prompt.confirmation_label, "Cancel"],
|
||||
cx,
|
||||
)
|
||||
})?
|
||||
.await?;
|
||||
|
||||
Ok(answer == 0)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue