@@ -856,10 +856,36 @@ impl<T: Item> ItemHandle for Entity<T> {
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<Entity<TestProjectItem>>,
pub nav_history: Option<ItemNavHistory>,
pub tab_descriptions: Option<Vec<&'static str>>,
@@ -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
@@ -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::<WorkspaceSettings>(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::<WorkspaceSettings>(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::<WorkspaceSettings>(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::<WorkspaceSettings>(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,