Option to auto-close deleted files with no unsaved edits (#31920)

Closes #27982

Release Notes:

- Added `close_on_file_delete` setting (off by default) to allow closing
open files after they have been deleted on disk

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
This commit is contained in:
Ben Brandt 2025-06-03 13:18:29 +02:00 committed by GitHub
parent 3077abf9cf
commit b74477d12e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 393 additions and 5 deletions

View file

@ -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 {

View file

@ -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

View file

@ -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,

View file

@ -26,6 +26,7 @@ pub struct WorkspaceSettings {
pub max_tabs: Option<NonZeroUsize>,
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<OnLastWindowClosed>,
/// Whether to automatically close files that have been deleted on disk.
///
/// Default: false
pub close_on_file_delete: Option<bool>,
}
#[derive(Deserialize)]