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:
parent
3077abf9cf
commit
b74477d12e
6 changed files with 393 additions and 5 deletions
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)]
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue