diff --git a/assets/settings/default.json b/assets/settings/default.json index 26df4527bc..6fffbe2702 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -128,6 +128,8 @@ // // Default: true "restore_on_file_reopen": true, + // Whether to automatically close files that have been deleted on disk. + "close_on_file_delete": false, // Size of the drop target in the editor. "drop_target_size": 0.2, // Whether the window should be closed when using 'close active item' on a window with no tabs. diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index 518e17c19e..b96557b391 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -11,7 +11,7 @@ use gpui::{ InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, Styled, Task, WeakEntity, Window, canvas, div, fill, img, opaque_grey, point, size, }; -use language::File as _; +use language::{DiskState, File as _}; use persistence::IMAGE_VIEWER; use project::{ImageItem, Project, ProjectPath, image_store::ImageItemEvent}; use settings::Settings; @@ -191,6 +191,10 @@ impl Item for ImageView { focus_handle: cx.focus_handle(), })) } + + fn has_deleted_file(&self, cx: &App) -> bool { + self.image_item.read(cx).file.disk_state() == DiskState::Deleted + } } fn breadcrumbs_text_for_image(project: &Project, image: &ImageItem, cx: &App) -> String { diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index ffcb73d728..6b3e1a3911 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -856,10 +856,36 @@ impl ItemHandle for Entity { ItemEvent::UpdateTab => { workspace.update_item_dirty_state(item, window, cx); - pane.update(cx, |_, cx| { - cx.emit(pane::Event::ChangeItemTitle); - cx.notify(); - }); + + if item.has_deleted_file(cx) + && !item.is_dirty(cx) + && item.workspace_settings(cx).close_on_file_delete + { + let item_id = item.item_id(); + let close_item_task = pane.update(cx, |pane, cx| { + pane.close_item_by_id( + item_id, + crate::SaveIntent::Close, + window, + cx, + ) + }); + cx.spawn_in(window, { + let pane = pane.clone(); + async move |_workspace, cx| { + close_item_task.await?; + pane.update(cx, |pane, _cx| { + pane.nav_history_mut().remove_item(item_id); + }) + } + }) + .detach_and_log_err(cx); + } else { + pane.update(cx, |_, cx| { + cx.emit(pane::Event::ChangeItemTitle); + cx.notify(); + }); + } } ItemEvent::Edit => { @@ -1303,6 +1329,7 @@ pub mod test { pub is_dirty: bool, pub is_singleton: bool, pub has_conflict: bool, + pub has_deleted_file: bool, pub project_items: Vec>, pub nav_history: Option, pub tab_descriptions: Option>, @@ -1382,6 +1409,7 @@ pub mod test { reload_count: 0, is_dirty: false, has_conflict: false, + has_deleted_file: false, project_items: Vec::new(), is_singleton: true, nav_history: None, @@ -1409,6 +1437,10 @@ pub mod test { self } + pub fn set_has_deleted_file(&mut self, deleted: bool) { + self.has_deleted_file = deleted; + } + pub fn with_dirty(mut self, dirty: bool) -> Self { self.is_dirty = dirty; self @@ -1546,6 +1578,7 @@ pub mod test { is_dirty: self.is_dirty, is_singleton: self.is_singleton, has_conflict: self.has_conflict, + has_deleted_file: self.has_deleted_file, project_items: self.project_items.clone(), nav_history: None, tab_descriptions: None, @@ -1564,6 +1597,10 @@ pub mod test { self.has_conflict } + fn has_deleted_file(&self, _: &App) -> bool { + self.has_deleted_file + } + fn can_save(&self, cx: &App) -> bool { !self.project_items.is_empty() && self diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 40bd6c99ee..8dd4253ed8 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -9223,6 +9223,332 @@ mod tests { ); } + /// Tests that when `close_on_file_delete` is enabled, files are automatically + /// closed when they are deleted from disk. + #[gpui::test] + async fn test_close_on_disk_deletion_enabled(cx: &mut TestAppContext) { + init_test(cx); + + // Enable the close_on_disk_deletion setting + cx.update_global(|store: &mut SettingsStore, cx| { + store.update_user_settings::(cx, |settings| { + settings.close_on_file_delete = Some(true); + }); + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + let project = Project::test(fs, [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + + // Create a test item that simulates a file + let item = cx.new(|cx| { + TestItem::new(cx) + .with_label("test.txt") + .with_project_items(&[TestProjectItem::new(1, "test.txt", cx)]) + }); + + // Add item to workspace + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item( + pane.clone(), + Box::new(item.clone()), + None, + false, + false, + window, + cx, + ); + }); + + // Verify the item is in the pane + pane.read_with(cx, |pane, _| { + assert_eq!(pane.items().count(), 1); + }); + + // Simulate file deletion by setting the item's deleted state + item.update(cx, |item, _| { + item.set_has_deleted_file(true); + }); + + // Emit UpdateTab event to trigger the close behavior + cx.run_until_parked(); + item.update(cx, |_, cx| { + cx.emit(ItemEvent::UpdateTab); + }); + + // Allow the close operation to complete + cx.run_until_parked(); + + // Verify the item was automatically closed + pane.read_with(cx, |pane, _| { + assert_eq!( + pane.items().count(), + 0, + "Item should be automatically closed when file is deleted" + ); + }); + } + + /// Tests that when `close_on_file_delete` is disabled (default), files remain + /// open with a strikethrough when they are deleted from disk. + #[gpui::test] + async fn test_close_on_disk_deletion_disabled(cx: &mut TestAppContext) { + init_test(cx); + + // Ensure close_on_disk_deletion is disabled (default) + cx.update_global(|store: &mut SettingsStore, cx| { + store.update_user_settings::(cx, |settings| { + settings.close_on_file_delete = Some(false); + }); + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + let project = Project::test(fs, [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + + // Create a test item that simulates a file + let item = cx.new(|cx| { + TestItem::new(cx) + .with_label("test.txt") + .with_project_items(&[TestProjectItem::new(1, "test.txt", cx)]) + }); + + // Add item to workspace + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item( + pane.clone(), + Box::new(item.clone()), + None, + false, + false, + window, + cx, + ); + }); + + // Verify the item is in the pane + pane.read_with(cx, |pane, _| { + assert_eq!(pane.items().count(), 1); + }); + + // Simulate file deletion + item.update(cx, |item, _| { + item.set_has_deleted_file(true); + }); + + // Emit UpdateTab event + cx.run_until_parked(); + item.update(cx, |_, cx| { + cx.emit(ItemEvent::UpdateTab); + }); + + // Allow any potential close operation to complete + cx.run_until_parked(); + + // Verify the item remains open (with strikethrough) + pane.read_with(cx, |pane, _| { + assert_eq!( + pane.items().count(), + 1, + "Item should remain open when close_on_disk_deletion is disabled" + ); + }); + + // Verify the item shows as deleted + item.read_with(cx, |item, _| { + assert!( + item.has_deleted_file, + "Item should be marked as having deleted file" + ); + }); + } + + /// Tests that dirty files are not automatically closed when deleted from disk, + /// even when `close_on_file_delete` is enabled. This ensures users don't lose + /// unsaved changes without being prompted. + #[gpui::test] + async fn test_close_on_disk_deletion_with_dirty_file(cx: &mut TestAppContext) { + init_test(cx); + + // Enable the close_on_file_delete setting + cx.update_global(|store: &mut SettingsStore, cx| { + store.update_user_settings::(cx, |settings| { + settings.close_on_file_delete = Some(true); + }); + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + let project = Project::test(fs, [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + + // Create a dirty test item + let item = cx.new(|cx| { + TestItem::new(cx) + .with_dirty(true) + .with_label("test.txt") + .with_project_items(&[TestProjectItem::new(1, "test.txt", cx)]) + }); + + // Add item to workspace + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item( + pane.clone(), + Box::new(item.clone()), + None, + false, + false, + window, + cx, + ); + }); + + // Simulate file deletion + item.update(cx, |item, _| { + item.set_has_deleted_file(true); + }); + + // Emit UpdateTab event to trigger the close behavior + cx.run_until_parked(); + item.update(cx, |_, cx| { + cx.emit(ItemEvent::UpdateTab); + }); + + // Allow any potential close operation to complete + cx.run_until_parked(); + + // Verify the item remains open (dirty files are not auto-closed) + pane.read_with(cx, |pane, _| { + assert_eq!( + pane.items().count(), + 1, + "Dirty items should not be automatically closed even when file is deleted" + ); + }); + + // Verify the item is marked as deleted and still dirty + item.read_with(cx, |item, _| { + assert!( + item.has_deleted_file, + "Item should be marked as having deleted file" + ); + assert!(item.is_dirty, "Item should still be dirty"); + }); + } + + /// Tests that navigation history is cleaned up when files are auto-closed + /// due to deletion from disk. + #[gpui::test] + async fn test_close_on_disk_deletion_cleans_navigation_history(cx: &mut TestAppContext) { + init_test(cx); + + // Enable the close_on_file_delete setting + cx.update_global(|store: &mut SettingsStore, cx| { + store.update_user_settings::(cx, |settings| { + settings.close_on_file_delete = Some(true); + }); + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + let project = Project::test(fs, [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + + // Create test items + let item1 = cx.new(|cx| { + TestItem::new(cx) + .with_label("test1.txt") + .with_project_items(&[TestProjectItem::new(1, "test1.txt", cx)]) + }); + let item1_id = item1.item_id(); + + let item2 = cx.new(|cx| { + TestItem::new(cx) + .with_label("test2.txt") + .with_project_items(&[TestProjectItem::new(2, "test2.txt", cx)]) + }); + + // Add items to workspace + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item( + pane.clone(), + Box::new(item1.clone()), + None, + false, + false, + window, + cx, + ); + workspace.add_item( + pane.clone(), + Box::new(item2.clone()), + None, + false, + false, + window, + cx, + ); + }); + + // Activate item1 to ensure it gets navigation entries + pane.update_in(cx, |pane, window, cx| { + pane.activate_item(0, true, true, window, cx); + }); + + // Switch to item2 and back to create navigation history + pane.update_in(cx, |pane, window, cx| { + pane.activate_item(1, true, true, window, cx); + }); + cx.run_until_parked(); + + pane.update_in(cx, |pane, window, cx| { + pane.activate_item(0, true, true, window, cx); + }); + cx.run_until_parked(); + + // Simulate file deletion for item1 + item1.update(cx, |item, _| { + item.set_has_deleted_file(true); + }); + + // Emit UpdateTab event to trigger the close behavior + item1.update(cx, |_, cx| { + cx.emit(ItemEvent::UpdateTab); + }); + cx.run_until_parked(); + + // Verify item1 was closed + pane.read_with(cx, |pane, _| { + assert_eq!( + pane.items().count(), + 1, + "Should have 1 item remaining after auto-close" + ); + }); + + // Check navigation history after close + let has_item = pane.read_with(cx, |pane, cx| { + let mut has_item = false; + pane.nav_history().for_each_entry(cx, |entry, _| { + if entry.item.id() == item1_id { + has_item = true; + } + }); + has_item + }); + + assert!( + !has_item, + "Navigation history should not contain closed item entries" + ); + } + #[gpui::test] async fn test_no_save_prompt_when_dirty_multi_buffer_closed_with_all_of_its_dirty_items_present_in_the_pane( cx: &mut TestAppContext, diff --git a/crates/workspace/src/workspace_settings.rs b/crates/workspace/src/workspace_settings.rs index a3da12f9fc..3c1838be97 100644 --- a/crates/workspace/src/workspace_settings.rs +++ b/crates/workspace/src/workspace_settings.rs @@ -26,6 +26,7 @@ pub struct WorkspaceSettings { pub max_tabs: Option, pub when_closing_with_no_tabs: CloseWindowWhenNoItems, pub on_last_window_closed: OnLastWindowClosed, + pub close_on_file_delete: bool, } #[derive(Copy, Clone, Default, Serialize, Deserialize, JsonSchema)] @@ -197,6 +198,10 @@ pub struct WorkspaceSettingsContent { /// /// Default: auto (nothing on macOS, "app quit" otherwise) pub on_last_window_closed: Option, + /// Whether to automatically close files that have been deleted on disk. + /// + /// Default: false + pub close_on_file_delete: Option, } #[derive(Deserialize)] diff --git a/docs/src/configuring-zed.md b/docs/src/configuring-zed.md index 9f988869b2..73a347286f 100644 --- a/docs/src/configuring-zed.md +++ b/docs/src/configuring-zed.md @@ -389,6 +389,20 @@ For example, to use `Nerd Font` as a fallback, add the following to your setting `"standard"`, `"comfortable"` or `{ "custom": float }` (`1` is compact, `2` is loose) +## Close on File Delete + +- Description: Whether to automatically close editor tabs when their corresponding files are deleted from disk. +- Setting: `close_on_file_delete` +- Default: `false` + +**Options** + +`boolean` values + +When enabled, this setting will automatically close tabs for files that have been deleted from the file system. This is particularly useful for workflows involving temporary or scratch files that are frequently created and deleted. When disabled (default), deleted files remain open with a strikethrough through their tab title. + +Note: Dirty files (files with unsaved changes) will not be automatically closed even when this setting is enabled, ensuring you don't lose unsaved work. + ## Confirm Quit - Description: Whether or not to prompt the user to confirm before closing the application.