Fix the opening of the agent panel when clicking Try Now in new onboarding notification (#53845)

Max Brunsfeld and Anthony Eid created

This fixes an issue reported by @Veykril where the "Try Now" button
would open a different panel than the agent panel. The bug was caused by
us attempting to focus the agent panel before layout settings updates
had been applied.

This should also make all programmatic settings updates more responsive,
because they won't have to wait for FS watchers to take effect.

Release Notes:

- N/A

---------

Co-authored-by: Anthony Eid <hello@anthonyeid.me>

Change summary

Cargo.lock                                      |   1 
crates/agent_settings/Cargo.toml                |   1 
crates/agent_settings/src/agent_settings.rs     |  27 +
crates/agent_ui/src/agent_panel.rs              |  34 ++
crates/auto_update_ui/src/auto_update_ui.rs     | 124 +++++++----
crates/settings/src/settings_file.rs            |  10 
crates/settings/src/settings_store.rs           | 193 ++++++++++++++++++
crates/settings_content/src/settings_content.rs |   2 
crates/zed/src/main.rs                          |  21 -
crates/zed/src/zed.rs                           | 123 ++---------
crates/zed_actions/src/lib.rs                   |   5 
11 files changed, 343 insertions(+), 198 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -301,6 +301,7 @@ dependencies = [
  "collections",
  "convert_case 0.8.0",
  "fs",
+ "futures 0.3.32",
  "gpui",
  "language_model",
  "log",

crates/agent_settings/Cargo.toml 🔗

@@ -17,6 +17,7 @@ anyhow.workspace = true
 collections.workspace = true
 convert_case.workspace = true
 fs.workspace = true
+futures.workspace = true
 gpui.workspace = true
 language_model.workspace = true
 log.workspace = true

crates/agent_settings/src/agent_settings.rs 🔗

@@ -6,6 +6,7 @@ use std::sync::{Arc, LazyLock};
 use agent_client_protocol::ModelId;
 use collections::{HashSet, IndexMap};
 use fs::Fs;
+use futures::channel::oneshot;
 use gpui::{App, Pixels, px};
 use language_model::LanguageModel;
 use project::DisableAiSettings;
@@ -15,7 +16,7 @@ use settings::{
     DockPosition, DockSide, LanguageModelParameters, LanguageModelSelection, NewThreadLocation,
     NotifyWhenAgentWaiting, PlaySoundWhenAgentDone, RegisterSetting, Settings, SettingsContent,
     SettingsStore, SidebarDockPosition, SidebarSide, ThinkingBlockDisplay, ToolPermissionMode,
-    update_settings_file,
+    update_settings_file, update_settings_file_with_completion,
 };
 
 pub use crate::agent_profile::*;
@@ -242,26 +243,30 @@ impl AgentSettings {
         });
     }
 
-    pub fn set_layout(layout: WindowLayout, fs: Arc<dyn Fs>, cx: &App) {
+    pub fn set_layout(
+        layout: WindowLayout,
+        fs: Arc<dyn Fs>,
+        cx: &App,
+    ) -> oneshot::Receiver<anyhow::Result<()>> {
         let merged = PanelLayout::read_from(cx.global::<SettingsStore>().merged_settings());
 
         match layout {
             WindowLayout::Agent(None) => {
-                update_settings_file(fs, cx, move |settings, _cx| {
+                update_settings_file_with_completion(fs, cx, move |settings, _cx| {
                     PanelLayout::AGENT.write_diff_to(&merged, settings);
-                });
+                })
             }
             WindowLayout::Editor(None) => {
-                update_settings_file(fs, cx, move |settings, _cx| {
+                update_settings_file_with_completion(fs, cx, move |settings, _cx| {
                     PanelLayout::EDITOR.write_diff_to(&merged, settings);
-                });
+                })
             }
             WindowLayout::Agent(Some(saved))
             | WindowLayout::Editor(Some(saved))
             | WindowLayout::Custom(saved) => {
-                update_settings_file(fs, cx, move |settings, _cx| {
+                update_settings_file_with_completion(fs, cx, move |settings, _cx| {
                     saved.write_to(settings);
-                });
+                })
             }
         }
     }
@@ -1356,8 +1361,10 @@ mod tests {
             let layout = AgentSettings::get_layout(cx);
             assert!(matches!(layout, WindowLayout::Custom(_)));
 
-            AgentSettings::set_layout(WindowLayout::agent(), fs.clone(), cx);
-        });
+            AgentSettings::set_layout(WindowLayout::agent(), fs.clone(), cx)
+        })
+        .await
+        .ok();
 
         cx.run_until_parked();
 

crates/agent_ui/src/agent_panel.rs 🔗

@@ -19,9 +19,14 @@ use project::AgentId;
 use serde::{Deserialize, Serialize};
 use settings::{LanguageModelProviderSetting, LanguageModelSelection};
 
-use zed_actions::agent::{
-    AddSelectionToThread, ConflictContent, ReauthenticateAgent, ResolveConflictedFilesWithAgent,
-    ResolveConflictsWithAgent, ReviewBranchDiff,
+use zed_actions::{
+    DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize,
+    agent::{
+        AddSelectionToThread, ConflictContent, OpenSettings, ReauthenticateAgent, ResetAgentZoom,
+        ResetOnboarding, ResolveConflictedFilesWithAgent, ResolveConflictsWithAgent,
+        ReviewBranchDiff,
+    },
+    assistant::{FocusAgent, OpenRulesLibrary, Toggle, ToggleFocus},
 };
 
 use crate::DEFAULT_THREAD_TITLE;
@@ -83,11 +88,6 @@ use workspace::{
     ToggleWorkspaceSidebar, ToggleZoom, Workspace, WorkspaceId, WorkspaceSidebarDelegate,
     dock::{DockPosition, Panel, PanelEvent},
 };
-use zed_actions::{
-    DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize,
-    agent::{OpenSettings, ResetAgentZoom, ResetOnboarding},
-    assistant::{OpenRulesLibrary, Toggle, ToggleFocus},
-};
 
 const AGENT_PANEL_KEY: &str = "agent_panel";
 const MIN_PANEL_WIDTH: Pixels = px(300.);
@@ -1142,7 +1142,7 @@ impl AgentPanel {
                 let fs = fs.clone();
                 let weak_panel = weak_panel.clone();
                 move |_window, cx| {
-                    AgentSettings::set_layout(WindowLayout::Agent(None), fs.clone(), cx);
+                    let _ = AgentSettings::set_layout(WindowLayout::Agent(None), fs.clone(), cx);
                     weak_panel
                         .update(cx, |panel, cx| {
                             panel.dismiss_agent_layout_onboarding(cx);
@@ -1154,7 +1154,7 @@ impl AgentPanel {
                 let fs = fs.clone();
                 let weak_panel = weak_panel.clone();
                 move |_window, cx| {
-                    AgentSettings::set_layout(WindowLayout::Editor(None), fs.clone(), cx);
+                    let _ = AgentSettings::set_layout(WindowLayout::Editor(None), fs.clone(), cx);
                     weak_panel
                         .update(cx, |panel, cx| {
                             panel.dismiss_agent_layout_onboarding(cx);
@@ -1294,6 +1294,20 @@ impl AgentPanel {
         }
     }
 
+    pub fn focus(
+        workspace: &mut Workspace,
+        _: &FocusAgent,
+        window: &mut Window,
+        cx: &mut Context<Workspace>,
+    ) {
+        if workspace
+            .panel::<Self>(cx)
+            .is_some_and(|panel| panel.read(cx).enabled(cx))
+        {
+            workspace.focus_panel::<Self>(window, cx);
+        }
+    }
+
     pub fn toggle(
         workspace: &mut Workspace,
         _: &Toggle,

crates/auto_update_ui/src/auto_update_ui.rs 🔗

@@ -22,6 +22,7 @@ use workspace::{
         simple_message_notification::MessageNotification,
     },
 };
+use zed_actions::{ShowUpdateNotification, assistant::FocusAgent};
 
 actions!(
     auto_update,
@@ -33,10 +34,19 @@ actions!(
 
 pub fn init(cx: &mut App) {
     notify_if_app_was_updated(cx);
-    cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
+    cx.observe_new(|workspace: &mut Workspace, _window, cx| {
         workspace.register_action(|workspace, _: &ViewReleaseNotesLocally, window, cx| {
             view_release_notes_locally(workspace, window, cx);
         });
+
+        if matches!(
+            ReleaseChannel::global(cx),
+            ReleaseChannel::Nightly | ReleaseChannel::Dev
+        ) {
+            workspace.register_action(|_workspace, _: &ShowUpdateNotification, _window, cx| {
+                show_update_notification(cx);
+            });
+        }
     })
     .detach();
 }
@@ -187,7 +197,7 @@ impl Dismissable for ParallelAgentAnnouncement {
 
 fn announcement_for_version(version: &Version, cx: &App) -> Option<AnnouncementContent> {
     match (version.major, version.minor, version.patch) {
-        (0, 232, _) => {
+        (0, _, _) => {
             if ParallelAgentAnnouncement::dismissed(cx) {
                 None
             } else {
@@ -207,12 +217,29 @@ fn announcement_for_version(version: &Version, cx: &App) -> Option<AnnouncementC
                         let already_agent_layout =
                             matches!(AgentSettings::get_layout(cx), WindowLayout::Agent(_));
 
+                        let update;
                         if !already_agent_layout {
-                            AgentSettings::set_layout(WindowLayout::Agent(None), fs.clone(), cx);
+                            update = Some(AgentSettings::set_layout(
+                                WindowLayout::Agent(None),
+                                fs.clone(),
+                                cx,
+                            ));
+                        } else {
+                            update = None;
                         }
 
-                        window.dispatch_action(Box::new(FocusWorkspaceSidebar), cx);
-                        window.dispatch_action(Box::new(zed_actions::assistant::ToggleFocus), cx);
+                        window
+                            .spawn(cx, async move |cx| {
+                                if let Some(update) = update {
+                                    update.await.ok();
+                                }
+
+                                cx.update(|window, cx| {
+                                    window.dispatch_action(Box::new(FocusWorkspaceSidebar), cx);
+                                    window.dispatch_action(Box::new(FocusAgent), cx);
+                                })
+                            })
+                            .detach();
                     })),
                     on_dismiss: Some(Arc::new(|cx| {
                         ParallelAgentAnnouncement::set_dismissed(true, cx)
@@ -299,6 +326,48 @@ impl Render for AnnouncementToastNotification {
     }
 }
 
+struct UpdateNotification;
+
+fn show_update_notification(cx: &mut App) {
+    let Some(updater) = AutoUpdater::get(cx) else {
+        return;
+    };
+
+    let mut version = updater.read(cx).current_version();
+    version.pre = semver::Prerelease::EMPTY;
+    version.build = semver::BuildMetadata::EMPTY;
+    let app_name = ReleaseChannel::global(cx).display_name();
+
+    if let Some(content) = announcement_for_version(&version, cx) {
+        show_app_notification(
+            NotificationId::unique::<UpdateNotification>(),
+            cx,
+            move |cx| cx.new(|cx| AnnouncementToastNotification::new(content.clone(), cx)),
+        );
+    } else {
+        show_app_notification(
+            NotificationId::unique::<UpdateNotification>(),
+            cx,
+            move |cx| {
+                let workspace_handle = cx.entity().downgrade();
+                cx.new(|cx| {
+                    MessageNotification::new(format!("Updated to {app_name} {}", version), cx)
+                        .primary_message("View Release Notes")
+                        .primary_on_click(move |window, cx| {
+                            if let Some(workspace) = workspace_handle.upgrade() {
+                                workspace.update(cx, |workspace, cx| {
+                                    crate::view_release_notes_locally(workspace, window, cx);
+                                })
+                            }
+                            cx.emit(DismissEvent);
+                        })
+                        .show_suppress_button(false)
+                })
+            },
+        );
+    }
+}
+
 /// Shows a notification across all workspaces if an update was previously automatically installed
 /// and this notification had not yet been shown.
 pub fn notify_if_app_was_updated(cx: &mut App) {
@@ -310,55 +379,12 @@ pub fn notify_if_app_was_updated(cx: &mut App) {
         return;
     }
 
-    struct UpdateNotification;
-
     let should_show_notification = updater.read(cx).should_show_update_notification(cx);
     cx.spawn(async move |cx| {
         let should_show_notification = should_show_notification.await?;
-        // if true { // Hardcode it to true for testing it outside of the component preview
         if should_show_notification {
             cx.update(|cx| {
-                let mut version = updater.read(cx).current_version();
-                version.pre = semver::Prerelease::EMPTY;
-                version.build = semver::BuildMetadata::EMPTY;
-                let app_name = ReleaseChannel::global(cx).display_name();
-
-                if let Some(content) = announcement_for_version(&version, cx) {
-                    show_app_notification(
-                        NotificationId::unique::<UpdateNotification>(),
-                        cx,
-                        move |cx| {
-                            cx.new(|cx| AnnouncementToastNotification::new(content.clone(), cx))
-                        },
-                    );
-                } else {
-                    show_app_notification(
-                        NotificationId::unique::<UpdateNotification>(),
-                        cx,
-                        move |cx| {
-                            let workspace_handle = cx.entity().downgrade();
-                            cx.new(|cx| {
-                                MessageNotification::new(
-                                    format!("Updated to {app_name} {}", version),
-                                    cx,
-                                )
-                                .primary_message("View Release Notes")
-                                .primary_on_click(move |window, cx| {
-                                    if let Some(workspace) = workspace_handle.upgrade() {
-                                        workspace.update(cx, |workspace, cx| {
-                                            crate::view_release_notes_locally(
-                                                workspace, window, cx,
-                                            );
-                                        })
-                                    }
-                                    cx.emit(DismissEvent);
-                                })
-                                .show_suppress_button(false)
-                            })
-                        },
-                    );
-                }
-
+                show_update_notification(cx);
                 updater.update(cx, |updater, cx| {
                     updater
                         .set_should_show_update_notification(false, cx)

crates/settings/src/settings_file.rs 🔗

@@ -227,5 +227,13 @@ pub fn update_settings_file(
     cx: &App,
     update: impl 'static + Send + FnOnce(&mut SettingsContent, &App),
 ) {
-    SettingsStore::global(cx).update_settings_file(fs, update);
+    SettingsStore::global(cx).update_settings_file(fs, update)
+}
+
+pub fn update_settings_file_with_completion(
+    fs: Arc<dyn Fs>,
+    cx: &App,
+    update: impl 'static + Send + FnOnce(&mut SettingsContent, &App),
+) -> futures::channel::oneshot::Receiver<anyhow::Result<()>> {
+    SettingsStore::global(cx).update_settings_file_with_completion(fs, update)
 }

crates/settings/src/settings_store.rs 🔗

@@ -155,9 +155,12 @@ pub struct SettingsStore {
 
     merged_settings: Rc<SettingsContent>,
 
+    last_user_settings_content: Option<String>,
+    last_global_settings_content: Option<String>,
     local_settings: BTreeMap<(WorktreeId, Arc<RelPath>), SettingsContent>,
     pub editorconfig_store: Entity<EditorconfigStore>,
 
+    _settings_files_watcher: Option<Task<()>>,
     _setting_file_updates: Task<()>,
     setting_file_updates_tx:
         mpsc::UnboundedSender<Box<dyn FnOnce(AsyncApp) -> LocalBoxFuture<'static, Result<()>>>>,
@@ -307,8 +310,11 @@ impl SettingsStore {
             language_semantic_token_rules: HashMap::default(),
 
             merged_settings: default_settings,
+            last_user_settings_content: None,
+            last_global_settings_content: None,
             local_settings: BTreeMap::default(),
             editorconfig_store: cx.new(|_| EditorconfigStore::default()),
+            _settings_files_watcher: None,
             setting_file_updates_tx,
             _setting_file_updates: cx.spawn(async move |cx| {
                 while let Some(setting_file_update) = setting_file_updates_rx.next().await {
@@ -338,6 +344,59 @@ impl SettingsStore {
         cx.update_global(f)
     }
 
+    pub fn watch_settings_files(
+        &mut self,
+        fs: Arc<dyn Fs>,
+        cx: &mut App,
+        settings_changed: impl 'static + Fn(SettingsFile, SettingsParseResult, &mut App),
+    ) {
+        let (mut user_settings_file_rx, user_settings_watcher) = crate::watch_config_file(
+            cx.background_executor(),
+            fs.clone(),
+            paths::settings_file().clone(),
+        );
+        let (mut global_settings_file_rx, global_settings_watcher) = crate::watch_config_file(
+            cx.background_executor(),
+            fs,
+            paths::global_settings_file().clone(),
+        );
+
+        let global_content = cx
+            .foreground_executor()
+            .block_on(global_settings_file_rx.next())
+            .unwrap();
+        let user_content = cx
+            .foreground_executor()
+            .block_on(user_settings_file_rx.next())
+            .unwrap();
+
+        let result = self.set_user_settings(&user_content, cx);
+        settings_changed(SettingsFile::User, result, cx);
+        let result = self.set_global_settings(&global_content, cx);
+        settings_changed(SettingsFile::Global, result, cx);
+
+        self._settings_files_watcher = Some(cx.spawn(async move |cx| {
+            let _user_settings_watcher = user_settings_watcher;
+            let _global_settings_watcher = global_settings_watcher;
+            let mut settings_streams = futures::stream::select(
+                global_settings_file_rx.map(|content| (SettingsFile::Global, content)),
+                user_settings_file_rx.map(|content| (SettingsFile::User, content)),
+            );
+
+            while let Some((settings_file, content)) = settings_streams.next().await {
+                cx.update_global(|store: &mut SettingsStore, cx| {
+                    let result = match settings_file {
+                        SettingsFile::User => store.set_user_settings(&content, cx),
+                        SettingsFile::Global => store.set_global_settings(&content, cx),
+                        _ => return,
+                    };
+                    settings_changed(settings_file, result, cx);
+                    cx.refresh_windows();
+                });
+            }
+        }));
+    }
+
     /// Add a new type of setting to the store.
     pub fn register_setting<T: Settings>(&mut self) {
         self.register_setting_internal(&RegisteredSetting {
@@ -498,7 +557,8 @@ impl SettingsStore {
                 async move {
                     let res = async move {
                         let old_text = Self::load_settings(&fs).await?;
-                        let new_text = update(old_text, cx)?;
+                        let new_text = update(old_text, cx.clone())?;
+
                         let settings_path = paths::settings_file().as_path();
                         if fs.is_file(settings_path).await {
                             let resolved_path =
@@ -509,25 +569,28 @@ impl SettingsStore {
                                     )
                                 })?;
 
-                            fs.atomic_write(resolved_path.clone(), new_text)
+                            fs.atomic_write(resolved_path.clone(), new_text.clone())
                                 .await
                                 .with_context(|| {
                                     format!("Failed to write settings to file {:?}", resolved_path)
                                 })?;
                         } else {
-                            fs.atomic_write(settings_path.to_path_buf(), new_text)
+                            fs.atomic_write(settings_path.to_path_buf(), new_text.clone())
                                 .await
                                 .with_context(|| {
                                     format!("Failed to write settings to file {:?}", settings_path)
                                 })?;
                         }
-                        anyhow::Ok(())
+
+                        cx.update_global(|store: &mut SettingsStore, cx| {
+                            store.set_user_settings(&new_text, cx).result().map(|_| ())
+                        })
                     }
                     .await;
 
                     let new_res = match &res {
                         Ok(_) => anyhow::Ok(()),
-                        Err(e) => Err(anyhow::anyhow!("Failed to write settings to file {:?}", e)),
+                        Err(e) => Err(anyhow::anyhow!("{:?}", e)),
                     };
 
                     _ = tx.send(new_res);
@@ -545,11 +608,19 @@ impl SettingsStore {
         fs: Arc<dyn Fs>,
         update: impl 'static + Send + FnOnce(&mut SettingsContent, &App),
     ) {
-        _ = self.update_settings_file_inner(fs, move |old_text: String, cx: AsyncApp| {
+        _ = self.update_settings_file_with_completion(fs, update);
+    }
+
+    pub fn update_settings_file_with_completion(
+        &self,
+        fs: Arc<dyn Fs>,
+        update: impl 'static + Send + FnOnce(&mut SettingsContent, &App),
+    ) -> oneshot::Receiver<Result<()>> {
+        self.update_settings_file_inner(fs, move |old_text: String, cx: AsyncApp| {
             cx.read_global(|store: &SettingsStore, cx| {
                 store.new_text_for_update(old_text, |content| update(content, cx))
             })
-        });
+        })
     }
 
     pub fn import_vscode_settings(
@@ -836,6 +907,14 @@ impl SettingsStore {
         user_settings_content: &str,
         cx: &mut App,
     ) -> SettingsParseResult {
+        if self.last_user_settings_content.as_deref() == Some(user_settings_content) {
+            return SettingsParseResult {
+                parse_status: ParseStatus::Unchanged,
+                migration_status: MigrationStatus::NotNeeded,
+            };
+        }
+        self.last_user_settings_content = Some(user_settings_content.to_string());
+
         let (settings, parse_result) = self.parse_and_migrate_zed_settings::<UserSettingsContent>(
             user_settings_content,
             SettingsFile::User,
@@ -855,6 +934,14 @@ impl SettingsStore {
         global_settings_content: &str,
         cx: &mut App,
     ) -> SettingsParseResult {
+        if self.last_global_settings_content.as_deref() == Some(global_settings_content) {
+            return SettingsParseResult {
+                parse_status: ParseStatus::Unchanged,
+                migration_status: MigrationStatus::NotNeeded,
+            };
+        }
+        self.last_global_settings_content = Some(global_settings_content.to_string());
+
         let (settings, parse_result) = self.parse_and_migrate_zed_settings::<SettingsContent>(
             global_settings_content,
             SettingsFile::Global,
@@ -973,6 +1060,7 @@ impl SettingsStore {
                     );
                 match parse_result.parse_status {
                     ParseStatus::Success => Ok(()),
+                    ParseStatus::Unchanged => Ok(()),
                     ParseStatus::Failed { error } => Err(InvalidSettingsError::LocalSettings {
                         path: directory_path.join(local_settings_file_relative_path()),
                         message: error,
@@ -1368,7 +1456,7 @@ impl SettingsParseResult {
         };
 
         let parse_result = match self.parse_status {
-            ParseStatus::Success => Ok(()),
+            ParseStatus::Success | ParseStatus::Unchanged => Ok(()),
             ParseStatus::Failed { error } => {
                 Err(anyhow::format_err!(error)).context("Failed to parse settings")
             }
@@ -1397,7 +1485,7 @@ impl SettingsParseResult {
     pub fn parse_error(&self) -> Option<String> {
         match &self.parse_status {
             ParseStatus::Failed { error } => Some(error.clone()),
-            ParseStatus::Success => None,
+            ParseStatus::Success | ParseStatus::Unchanged => None,
         }
     }
 }
@@ -1517,7 +1605,7 @@ impl<T: Settings> AnySettingValue for SettingValue<T> {
 
 #[cfg(test)]
 mod tests {
-    use std::num::NonZeroU32;
+    use std::{cell::RefCell, num::NonZeroU32};
 
     use crate::{
         ClosePosition, ItemSettingsContent, VsCodeSettingsSource, default_settings,
@@ -1525,6 +1613,7 @@ mod tests {
     };
 
     use super::*;
+    use fs::FakeFs;
     use unindent::Unindent;
     use util::rel_path::rel_path;
 
@@ -1589,6 +1678,90 @@ mod tests {
         }
     }
 
+    #[gpui::test]
+    async fn test_update_settings_file_updates_store_before_watcher(cx: &mut gpui::TestAppContext) {
+        let fs = FakeFs::new(cx.background_executor.clone());
+        fs.create_dir(paths::settings_file().parent().unwrap())
+            .await
+            .unwrap();
+        fs.insert_file(
+            paths::settings_file(),
+            r#"{ "tabs": { "close_position": "right" } }"#.as_bytes().to_vec(),
+        )
+        .await;
+        fs.pause_events();
+        cx.run_until_parked();
+
+        let success = SettingsParseResult {
+            parse_status: ParseStatus::Success,
+            migration_status: MigrationStatus::NotNeeded,
+        };
+        let parse_results = Rc::new(RefCell::new(Vec::new()));
+
+        cx.update(|cx| {
+            let mut store = SettingsStore::new(cx, &default_settings());
+            store.register_setting::<ItemSettings>();
+            store.watch_settings_files(fs.clone(), cx, {
+                let parse_results = parse_results.clone();
+                move |_, result, _| {
+                    parse_results.borrow_mut().push(result);
+                }
+            });
+            cx.set_global(store);
+        });
+
+        // Calling watch_settings_files loads user and global settings.
+        assert_eq!(
+            parse_results.borrow().as_slice(),
+            &[success.clone(), success.clone()]
+        );
+        cx.update(|cx| {
+            assert_eq!(
+                cx.global::<SettingsStore>()
+                    .get::<ItemSettings>(None)
+                    .close_position,
+                ClosePosition::Right
+            );
+        });
+
+        // Updating the settings file returns a channel that resolves once the settings are loaded.
+        let rx = cx.update(|cx| {
+            cx.global::<SettingsStore>()
+                .update_settings_file_with_completion(fs.clone(), move |settings, _| {
+                    settings.tabs.get_or_insert_default().close_position =
+                        Some(ClosePosition::Left);
+                })
+        });
+        assert!(rx.await.unwrap().is_ok());
+        assert_eq!(
+            parse_results.borrow().as_slice(),
+            &[success.clone(), success.clone()]
+        );
+        cx.update(|cx| {
+            assert_eq!(
+                cx.global::<SettingsStore>()
+                    .get::<ItemSettings>(None)
+                    .close_position,
+                ClosePosition::Left
+            );
+        });
+
+        // When the FS event occurs, the settings are recognized as unchanged.
+        fs.flush_events(100);
+        cx.run_until_parked();
+        assert_eq!(
+            parse_results.borrow().as_slice(),
+            &[
+                success.clone(),
+                success.clone(),
+                SettingsParseResult {
+                    parse_status: ParseStatus::Unchanged,
+                    migration_status: MigrationStatus::NotNeeded
+                }
+            ]
+        );
+    }
+
     #[gpui::test]
     fn test_settings_store_basic(cx: &mut App) {
         let mut store = SettingsStore::new(cx, &default_settings());

crates/settings_content/src/settings_content.rs 🔗

@@ -74,6 +74,8 @@ pub use util::serde::default_true;
 pub enum ParseStatus {
     /// Settings were parsed successfully
     Success,
+    /// Settings file was not changed, so no parsing was performed
+    Unchanged,
     /// Settings failed to parse
     Failed { error: String },
 }

crates/zed/src/main.rs 🔗

@@ -62,8 +62,7 @@ use workspace::{
 use zed::{
     OpenListener, OpenRequest, RawOpenRequest, app_menus, build_window_options,
     derive_paths_with_position, edit_prediction_registry, handle_cli_connection,
-    handle_keymap_file_changes, handle_settings_file_changes, initialize_workspace,
-    open_paths_with_positions,
+    handle_keymap_file_changes, initialize_workspace, open_paths_with_positions,
 };
 
 use crate::zed::{OpenRequestKind, eager_load_active_theme_and_icon_theme};
@@ -401,16 +400,6 @@ fn main() {
     }
 
     let fs = Arc::new(RealFs::new(git_binary_path, app.background_executor()));
-    let (user_settings_file_rx, user_settings_watcher) = watch_config_file(
-        &app.background_executor(),
-        fs.clone(),
-        paths::settings_file().clone(),
-    );
-    let (global_settings_file_rx, global_settings_watcher) = watch_config_file(
-        &app.background_executor(),
-        fs.clone(),
-        paths::global_settings_file().clone(),
-    );
     let (user_keymap_file_rx, user_keymap_watcher) = watch_config_file(
         &app.background_executor(),
         fs.clone(),
@@ -473,13 +462,7 @@ fn main() {
         }
         settings::init(cx);
         zlog_settings::init(cx);
-        handle_settings_file_changes(
-            user_settings_file_rx,
-            user_settings_watcher,
-            global_settings_file_rx,
-            global_settings_watcher,
-            cx,
-        );
+        zed::watch_settings_files(fs.clone(), cx);
         handle_keymap_file_changes(user_keymap_file_rx, user_keymap_watcher, cx);
 
         let user_agent = format!(

crates/zed/src/zed.rs 🔗

@@ -27,7 +27,6 @@ use extension_host::ExtensionStore;
 use feature_flags::{FeatureFlagAppExt as _, PanicFeatureFlag};
 use fs::Fs;
 use futures::FutureExt as _;
-use futures::future::Either;
 use futures::{StreamExt, channel::mpsc, select_biased};
 use git_ui::commit_view::CommitViewToolbar;
 use git_ui::git_panel::GitPanel;
@@ -64,7 +63,7 @@ use rope::Rope;
 use search::project_search::ProjectSearchBar;
 use settings::{
     BaseKeymap, DEFAULT_KEYMAP_PATH, InvalidSettingsError, KeybindSource, KeymapFile,
-    KeymapFileLoadResult, MigrationStatus, Settings, SettingsStore, VIM_KEYMAP_PATH,
+    KeymapFileLoadResult, MigrationStatus, Settings, SettingsFile, SettingsStore, VIM_KEYMAP_PATH,
     initial_local_debug_tasks_content, initial_project_settings_content, initial_tasks_content,
     update_settings_file,
 };
@@ -749,6 +748,7 @@ async fn initialize_agent_panel(
         if !cfg!(test) {
             workspace
                 .register_action(agent_ui::AgentPanel::toggle_focus)
+                .register_action(agent_ui::AgentPanel::focus)
                 .register_action(agent_ui::AgentPanel::toggle)
                 .register_action(agent_ui::InlineAssistant::inline_assist);
         }
@@ -1677,6 +1677,7 @@ fn notify_settings_errors(result: settings::SettingsParseResult, is_user: bool,
     let error = match result.parse_status {
         settings::ParseStatus::Failed { error } => Some(anyhow::format_err!(error)),
         settings::ParseStatus::Success => None,
+        settings::ParseStatus::Unchanged => return,
     };
     let id = NotificationId::Named(format!("failed-to-parse-settings-{is_user}").into());
 
@@ -1740,67 +1741,25 @@ fn notify_settings_errors(result: settings::SettingsParseResult, is_user: bool,
     };
 }
 
-pub fn handle_settings_file_changes(
-    mut user_settings_file_rx: mpsc::UnboundedReceiver<String>,
-    user_settings_watcher: gpui::Task<()>,
-    mut global_settings_file_rx: mpsc::UnboundedReceiver<String>,
-    global_settings_watcher: gpui::Task<()>,
-    cx: &mut App,
-) {
+pub fn watch_settings_files(fs: Arc<dyn fs::Fs>, cx: &mut App) {
     MigrationNotification::set_global(cx.new(|_| MigrationNotification), cx);
 
-    // Initial load of both settings files
-    let global_content = cx
-        .foreground_executor()
-        .block_on(global_settings_file_rx.next())
-        .unwrap();
-    let user_content = cx
-        .foreground_executor()
-        .block_on(user_settings_file_rx.next())
-        .unwrap();
-
-    SettingsStore::update_global(cx, |store, cx| {
-        notify_settings_errors(store.set_user_settings(&user_content, cx), true, cx);
-        notify_settings_errors(store.set_global_settings(&global_content, cx), false, cx);
-    });
-
-    // Watch for changes in both files
-    cx.spawn(async move |cx| {
-        let _user_settings_watcher = user_settings_watcher;
-        let _global_settings_watcher = global_settings_watcher;
-        let mut settings_streams = futures::stream::select(
-            global_settings_file_rx.map(Either::Left),
-            user_settings_file_rx.map(Either::Right),
-        );
-
-        while let Some(content) = settings_streams.next().await {
-            let (content, is_user) = match content {
-                Either::Left(content) => (content, false),
-                Either::Right(content) => (content, true),
-            };
-
-            cx.update_global(|store: &mut SettingsStore, cx| {
-                let result = if is_user {
-                    store.set_user_settings(&content, cx)
-                } else {
-                    store.set_global_settings(&content, cx)
-                };
-                let migrating_in_memory =
-                    matches!(&result.migration_status, MigrationStatus::Succeeded);
-                notify_settings_errors(result, is_user, cx);
-                if let Some(notifier) = MigrationNotification::try_global(cx) {
-                    notifier.update(cx, |_, cx| {
-                        cx.emit(MigrationEvent::ContentChanged {
-                            migration_type: MigrationType::Settings,
-                            migrating_in_memory,
-                        });
+    SettingsStore::update_global(cx, move |store, cx| {
+        store.watch_settings_files(fs, cx, |settings_file, result, cx| {
+            let is_user = matches!(settings_file, SettingsFile::User);
+            let migrating_in_memory =
+                matches!(&result.migration_status, MigrationStatus::Succeeded);
+            notify_settings_errors(result, is_user, cx);
+            if let Some(notifier) = MigrationNotification::try_global(cx) {
+                notifier.update(cx, |_, cx| {
+                    cx.emit(MigrationEvent::ContentChanged {
+                        migration_type: MigrationType::Settings,
+                        migrating_in_memory,
                     });
-                }
-                cx.refresh_windows();
-            });
-        }
-    })
-    .detach();
+                });
+            }
+        });
+    });
 }
 
 pub fn handle_keymap_file_changes(
@@ -4804,7 +4763,7 @@ mod tests {
         app_state
             .fs
             .save(
-                "/settings.json".as_ref(),
+                paths::settings_file(),
                 &r#"{"base_keymap": "Atom"}"#.into(),
                 Default::default(),
             )
@@ -4822,28 +4781,12 @@ mod tests {
             .unwrap();
         executor.run_until_parked();
         cx.update(|cx| {
-            let (settings_rx, settings_watcher) = watch_config_file(
-                &executor,
-                app_state.fs.clone(),
-                PathBuf::from("/settings.json"),
-            );
             let (keymap_rx, keymap_watcher) = watch_config_file(
                 &executor,
                 app_state.fs.clone(),
                 PathBuf::from("/keymap.json"),
             );
-            let (global_settings_rx, global_settings_watcher) = watch_config_file(
-                &executor,
-                app_state.fs.clone(),
-                PathBuf::from("/global_settings.json"),
-            );
-            handle_settings_file_changes(
-                settings_rx,
-                settings_watcher,
-                global_settings_rx,
-                global_settings_watcher,
-                cx,
-            );
+            watch_settings_files(app_state.fs.clone(), cx);
             handle_keymap_file_changes(keymap_rx, keymap_watcher, cx);
         });
         window
@@ -4890,7 +4833,7 @@ mod tests {
         app_state
             .fs
             .save(
-                "/settings.json".as_ref(),
+                paths::settings_file(),
                 &r#"{"base_keymap": "JetBrains"}"#.into(),
                 Default::default(),
             )
@@ -4939,7 +4882,7 @@ mod tests {
         app_state
             .fs
             .save(
-                "/settings.json".as_ref(),
+                paths::settings_file(),
                 &r#"{"base_keymap": "Atom"}"#.into(),
                 Default::default(),
             )
@@ -4956,29 +4899,13 @@ mod tests {
             .unwrap();
 
         cx.update(|cx| {
-            let (settings_rx, settings_watcher) = watch_config_file(
-                &executor,
-                app_state.fs.clone(),
-                PathBuf::from("/settings.json"),
-            );
             let (keymap_rx, keymap_watcher) = watch_config_file(
                 &executor,
                 app_state.fs.clone(),
                 PathBuf::from("/keymap.json"),
             );
 
-            let (global_settings_rx, global_settings_watcher) = watch_config_file(
-                &executor,
-                app_state.fs.clone(),
-                PathBuf::from("/global_settings.json"),
-            );
-            handle_settings_file_changes(
-                settings_rx,
-                settings_watcher,
-                global_settings_rx,
-                global_settings_watcher,
-                cx,
-            );
+            watch_settings_files(app_state.fs.clone(), cx);
             handle_keymap_file_changes(keymap_rx, keymap_watcher, cx);
         });
 
@@ -5017,7 +4944,7 @@ mod tests {
         app_state
             .fs
             .save(
-                "/settings.json".as_ref(),
+                paths::settings_file(),
                 &r#"{"base_keymap": "JetBrains"}"#.into(),
                 Default::default(),
             )

crates/zed_actions/src/lib.rs 🔗

@@ -72,6 +72,8 @@ actions!(
         OpenPerformanceProfiler,
         /// Opens the onboarding view.
         OpenOnboarding,
+        /// Shows the auto-update notification for testing.
+        ShowUpdateNotification,
     ]
 );
 
@@ -518,7 +520,8 @@ pub mod assistant {
             /// Toggles the agent panel.
             Toggle,
             #[action(deprecated_aliases = ["assistant::ToggleFocus"])]
-            ToggleFocus
+            ToggleFocus,
+            FocusAgent,
         ]
     );