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

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