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

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

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)]

View file

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