Show warning when deleting files with unsaved changes (#20172)

Richard Feldman created

Closes #9905

<img width="172" alt="Screenshot 2024-11-04 at 10 16 34 AM"
src="https://github.com/user-attachments/assets/5fa84e06-bcb9-471d-adab-e06881fbd3ca">
<img width="172" alt="Screenshot 2024-11-04 at 10 16 22 AM"
src="https://github.com/user-attachments/assets/d7def162-e910-4061-a160-6178c9d344e5">
<img width="172" alt="Screenshot 2024-11-04 at 10 17 17 AM"
src="https://github.com/user-attachments/assets/43c7e4fe-1b71-4786-bc05-44f34ed15dc5">
<img width="172" alt="Screenshot 2024-11-04 at 10 17 09 AM"
src="https://github.com/user-attachments/assets/17263782-c706-44b2-acbc-c3d2d14c20ac">


Release Notes:

- When deleting or trashing files, the confirmation prompt now warns if
files have unsaved changes.

Change summary

crates/project/src/project.rs             | 15 +++++++++++
crates/project_panel/src/project_panel.rs | 34 +++++++++++++++++++-----
2 files changed, 41 insertions(+), 8 deletions(-)

Detailed changes

crates/project/src/project.rs 🔗

@@ -3970,6 +3970,21 @@ impl Project {
         self.worktree_store.read(cx).worktree_metadata_protos(cx)
     }
 
+    /// Iterator of all open buffers that have unsaved changes
+    pub fn dirty_buffers<'a>(
+        &'a self,
+        cx: &'a AppContext,
+    ) -> impl Iterator<Item = ProjectPath> + 'a {
+        self.buffer_store.read(cx).buffers().filter_map(|buf| {
+            let buf = buf.read(cx);
+            if buf.is_dirty() {
+                buf.project_path(cx)
+            } else {
+                None
+            }
+        })
+    }
+
     fn set_worktrees_from_proto(
         &mut self,
         worktrees: Vec<proto::WorktreeMetadata>,

crates/project_panel/src/project_panel.rs 🔗

@@ -1108,13 +1108,17 @@ impl ProjectPanel {
             }
             let project = self.project.read(cx);
             let items_to_delete = self.marked_entries();
+
+            let mut dirty_buffers = 0;
             let file_paths = items_to_delete
                 .into_iter()
                 .filter_map(|selection| {
+                    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_for_entry(selection.entry_id, cx)?
+                        project_path
                             .path
                             .file_name()?
                             .to_string_lossy()
@@ -1127,11 +1131,17 @@ impl ProjectPanel {
             }
             let answer = if !skip_prompt {
                 let operation = if trash { "Trash" } else { "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 {
+                            ""
+                        };
 
-                let prompt =
-                    if let Some((_, path)) = file_paths.first().filter(|_| file_paths.len() == 1) {
-                        format!("{operation} {path}?")
-                    } else {
+                        format!("{operation} {path}?{unsaved_warning}")
+                    }
+                    _ => {
                         const CUTOFF_POINT: usize = 10;
                         let names = if file_paths.len() > CUTOFF_POINT {
                             let truncated_path_counts = file_paths.len() - CUTOFF_POINT;
@@ -1150,14 +1160,22 @@ impl ProjectPanel {
                         } else {
                             file_paths.iter().map(|(_, path)| path.clone()).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.")
+                        };
 
                         format!(
-                            "Do you want to {} the following {} files?\n{}",
+                            "Do you want to {} the following {} files?\n{}{unsaved_warning}",
                             operation.to_lowercase(),
                             file_paths.len(),
                             names.join("\n")
                         )
-                    };
+                    }
+                };
                 Some(cx.prompt(PromptLevel::Info, &prompt, None, &[operation, "Cancel"]))
             } else {
                 None