Fix auto_save on_focus_change with modals (#54455)

Self-Review Checklist:

Closes #53863

Updates #53920
Updates #51949
Updates #45166

Release Notes:

- Updated auto_save_on_focus_change to handle modals better.
This commit is contained in:
Conrad Irwin 2026-04-22 11:03:34 -06:00 committed by GitHub
parent 828a64c3da
commit 9b8c468d80
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 186 additions and 55 deletions

1
Cargo.lock generated
View file

@ -21936,7 +21936,6 @@ dependencies = [
"ui",
"util",
"uuid",
"vim_mode_setting",
"windows 0.61.3",
"zed_actions",
"zlog",

View file

@ -33,11 +33,7 @@ pub fn init(cx: &mut App) {
cx.observe_new(CommandPalette::register).detach();
}
impl ModalView for CommandPalette {
fn is_command_palette(&self) -> bool {
true
}
}
impl ModalView for CommandPalette {}
pub struct CommandPalette {
picker: Entity<Picker<CommandPaletteDelegate>>,

View file

@ -66,7 +66,6 @@ theme_settings.workspace = true
ui.workspace = true
util.workspace = true
uuid.workspace = true
vim_mode_setting.workspace = true
zed_actions.workspace = true
[target.'cfg(target_os = "windows")'.dependencies]

View file

@ -953,24 +953,23 @@ impl<T: Item> ItemHandle for Entity<T> {
return;
}
let vim_mode = vim_mode_setting::VimModeSetting::is_enabled(cx);
let helix_mode = vim_mode_setting::HelixModeSetting::is_enabled(cx);
// Add the item to a deferred save list. The actual save will happen when
// focus lands on a pane or panel (via handle_pane_focused or
// handle_panel_focused), or when the window deactivates.
// This avoids saving when opening modals and skips saving if focus
// returns to the same item.
workspace.deferred_save_items.push(item.downgrade_item());
if vim_mode || helix_mode {
// We use the command palette for executing commands in Vim and Helix modes (e.g., `:w`), so
// in those cases we don't want to trigger auto-save if the focus has just been transferred
// to the command palette.
//
// This isn't totally perfect, as you could still switch files indirectly via the command
// palette (such as by opening up the tab switcher from it and then switching tabs that
// way).
if workspace.is_active_modal_command_palette(cx) {
return;
// Defer the flush to ensure all focus events are processed first.
// This is needed because on_focus_out fires before handle_pane_focused
// when switching items.
cx.defer_in(window, |workspace, window, cx| {
// Don't flush if a modal is active - the user might return
// to the original item when the modal is dismissed.
if !workspace.has_active_modal(window, cx) {
workspace.flush_deferred_saves(window, cx);
}
}
Pane::autosave_item(&item, workspace.project.clone(), window, cx)
.detach_and_log_err(cx);
});
}
},
)

View file

@ -26,15 +26,6 @@ pub trait ModalView: ManagedView {
fn render_bare(&self) -> bool {
false
}
/// Returns whether this [`ModalView`] is the command palette.
///
/// This breaks the encapsulation of the [`ModalView`] trait a little bit, but there doesn't seem to be an
/// immediate, more elegant way to have the workspace know about the command palette (due to dependency arrow
/// directions).
fn is_command_palette(&self) -> bool {
false
}
}
trait ModalViewHandle {
@ -42,7 +33,6 @@ trait ModalViewHandle {
fn view(&self) -> AnyView;
fn fade_out_background(&self, cx: &mut App) -> bool;
fn render_bare(&self, cx: &mut App) -> bool;
fn is_command_palette(&self, cx: &App) -> bool;
}
impl<V: ModalView> ModalViewHandle for Entity<V> {
@ -61,10 +51,6 @@ impl<V: ModalView> ModalViewHandle for Entity<V> {
fn render_bare(&self, cx: &mut App) -> bool {
self.read(cx).render_bare()
}
fn is_command_palette(&self, cx: &App) -> bool {
self.read(cx).is_command_palette()
}
}
pub struct ActiveModal {
@ -203,13 +189,6 @@ impl ModalLayer {
pub fn has_active_modal(&self) -> bool {
self.active_modal.is_some()
}
/// Returns whether the active modal is the command palette.
pub fn is_active_modal_command_palette(&self, cx: &App) -> bool {
self.active_modal
.as_ref()
.map_or(false, |modal| modal.modal.is_command_palette(cx))
}
}
impl Render for ModalLayer {

View file

@ -1398,6 +1398,7 @@ pub struct Workspace {
sidebar_focus_handle: Option<FocusHandle>,
multi_workspace: Option<WeakEntity<MultiWorkspace>>,
active_worktree_creation: ActiveWorktreeCreation,
deferred_save_items: Vec<Box<dyn WeakItemHandle>>,
}
impl EventEmitter<Event> for Workspace {}
@ -1829,6 +1830,7 @@ impl Workspace {
active_worktree_creation: ActiveWorktreeCreation::default(),
open_in_dev_container: false,
_dev_container_task: None,
deferred_save_items: Vec::new(),
}
}
@ -5219,6 +5221,8 @@ impl Workspace {
window: &mut Window,
cx: &mut Context<Self>,
) {
self.flush_deferred_saves(window, cx);
// This is explicitly hoisted out of the following check for pane identity as
// terminal panel panes are not registered as a center panes.
self.status_bar.update(cx, |status_bar, cx| {
@ -5276,9 +5280,26 @@ impl Workspace {
}
fn handle_panel_focused(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.flush_deferred_saves(window, cx);
self.update_active_view_for_followers(window, cx);
}
fn flush_deferred_saves(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let deferred = std::mem::take(&mut self.deferred_save_items);
for weak_item in deferred {
let Some(item) = weak_item.upgrade() else {
continue;
};
// Skip if focus returned to this item
let focus_handle = item.item_focus_handle(cx);
if focus_handle.contains_focused(window, cx) {
continue;
}
Pane::autosave_item(item.as_ref(), self.project.clone(), window, cx)
.detach_and_log_err(cx);
}
}
fn handle_pane_event(
&mut self,
pane: &Entity<Pane>,
@ -6454,6 +6475,8 @@ impl Workspace {
.detach();
}
} else {
// When window is deactivated, flush any deferred saves since focus has left the window
self.flush_deferred_saves(window, cx);
for pane in &self.panes {
pane.update(cx, |pane, cx| {
if let Some(item) = pane.active_item() {
@ -7495,12 +7518,6 @@ impl Workspace {
self.modal_layer.read(cx).has_active_modal()
}
pub fn is_active_modal_command_palette(&self, cx: &mut App) -> bool {
self.modal_layer
.read(cx)
.is_active_modal_command_palette(cx)
}
pub fn active_modal<V: ManagedView + 'static>(&self, cx: &App) -> Option<Entity<V>> {
self.modal_layer.read(cx).active_modal()
}
@ -11637,10 +11654,13 @@ mod tests {
});
item.is_dirty = true;
});
// Blurring the item saves the file.
item.update_in(cx, |_, window, _| window.blur());
// Focus leaving the item (via window deactivation) saves the file.
// Deferred autosaves are flushed when focus lands elsewhere (pane, panel)
// or when the window is deactivated.
cx.deactivate_window();
cx.executor().run_until_parked();
item.read_with(cx, |item, _| assert_eq!(item.save_count, 2));
cx.update(|window, _| window.activate_window());
// Deactivating the window still saves the file.
item.update_in(cx, |item, window, cx| {
@ -11792,19 +11812,23 @@ mod tests {
);
});
// Blurring the item saves the file. This is the core regression scenario:
// Focus leaving the item saves the file. This is the core regression scenario:
// with `on_blur`, this would NOT trigger because `on_blur` only fires when
// the item's own focus handle is the leaf that lost focus. In a multibuffer,
// the leaf is always a child focus handle, so `on_blur` never detected
// focus leaving the item.
item.update_in(cx, |_, window, _| window.blur());
//
// With deferred saves, the save happens when focus lands on a pane/panel or
// the window deactivates.
cx.deactivate_window();
cx.executor().run_until_parked();
item.read_with(cx, |item, _| {
assert_eq!(
item.save_count, 1,
"Blurring should trigger autosave when focus was on a child of the item"
"Window deactivation should trigger autosave when focus was on a child of the item"
);
});
cx.update(|window, _| window.activate_window());
// Deactivating the window should also trigger autosave when a child of
// the multibuffer item currently owns focus.
@ -11824,6 +11848,141 @@ mod tests {
});
}
#[gpui::test]
async fn test_autosave_deferred_for_modals(cx: &mut gpui::TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, [], cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
let item = cx.new(|cx| {
TestItem::new(cx).with_project_items(&[TestProjectItem::new(1, "1.txt", cx)])
});
workspace.update_in(cx, |workspace, window, cx| {
workspace.add_item_to_active_pane(Box::new(item.clone()), None, true, window, cx);
});
item.update_in(cx, |item, window, cx| {
SettingsStore::update_global(cx, |settings, cx| {
settings.update_user_settings(cx, |settings| {
settings.workspace.autosave = Some(AutosaveSetting::OnFocusChange);
})
});
item.is_dirty = true;
cx.focus_self(window);
});
cx.executor().run_until_parked();
// Opening a modal moves focus away from the item, but autosave should be
// deferred until focus lands on a pane or panel (not saved immediately).
workspace.update_in(cx, |workspace, window, cx| {
workspace.toggle_modal(window, cx, TestModal::new);
});
cx.executor().run_until_parked();
item.read_with(cx, |item, _| {
assert_eq!(
item.save_count, 0,
"Opening a modal should NOT immediately trigger autosave"
);
});
// If focus returns to the same item (modal dismissed), the deferred save
// should be skipped.
workspace.update_in(cx, |workspace, window, cx| {
workspace.modal_layer.update(cx, |modal, cx| {
modal.hide_modal(window, cx);
});
});
cx.executor().run_until_parked();
item.read_with(cx, |item, _| {
assert_eq!(
item.save_count, 0,
"Returning focus to the same item should skip deferred save"
);
});
// Open modal again with a dirty item.
item.update_in(cx, |item, window, cx| {
item.is_dirty = true;
cx.focus_self(window);
});
workspace.update_in(cx, |workspace, window, cx| {
workspace.toggle_modal(window, cx, TestModal::new);
});
cx.executor().run_until_parked();
item.read_with(cx, |item, _| {
assert_eq!(item.save_count, 0, "Modal open should not trigger save");
});
// Window deactivation should flush deferred saves.
cx.deactivate_window();
cx.executor().run_until_parked();
item.read_with(cx, |item, _| {
assert_eq!(
item.save_count, 1,
"Window deactivation should flush deferred saves"
);
});
}
#[gpui::test]
async fn test_autosave_deferred_until_pane_focus(cx: &mut gpui::TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, [], cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
let item1 = cx.new(|cx| {
TestItem::new(cx).with_project_items(&[TestProjectItem::new(1, "1.txt", cx)])
});
let item2 = cx.new(|cx| {
TestItem::new(cx).with_project_items(&[TestProjectItem::new(2, "2.txt", cx)])
});
let pane = workspace.update_in(cx, |workspace, window, cx| {
workspace.add_item_to_active_pane(Box::new(item1.clone()), None, false, window, cx);
workspace.add_item_to_active_pane(Box::new(item2.clone()), None, false, window, cx);
workspace.active_pane().clone()
});
// Ensure added_to_pane is called for both items (sets up focus handlers)
cx.executor().run_until_parked();
// Activate item1 (at index 0) and focus it.
pane.update_in(cx, |pane, window, cx| {
pane.activate_item(0, true, true, window, cx);
});
cx.executor().run_until_parked();
// Set up OnFocusChange autosave and make item1 dirty.
item1.update(cx, |item, cx| {
SettingsStore::update_global(cx, |settings, cx| {
settings.update_user_settings(cx, |settings| {
settings.workspace.autosave = Some(AutosaveSetting::OnFocusChange);
})
});
item.is_dirty = true;
});
cx.executor().run_until_parked();
// Activate item2 via the pane - this should trigger autosave of item1.
pane.update_in(cx, |pane, window, cx| {
pane.activate_item(1, true, true, window, cx);
});
cx.executor().run_until_parked();
item1.read_with(cx, |item, _| {
assert_eq!(
item.save_count, 1,
"Switching to another item should trigger deferred save of the previous item"
);
});
}
#[gpui::test]
async fn test_pane_navigation(cx: &mut gpui::TestAppContext) {
init_test(cx);