copilot: Fix onboarding into Copilot requires Zed restart (#26330)

Smit Barmase created

Closes #25594

This PR fixes an issue where signing into Copilot required restarting
Zed.

Copilot depends on an OAuth token that comes from either `hosts.json` or
`apps.json`. Initially, both files don't exist. If neither file is
found, we fallback to watching `hosts.json` for updates. However, if the
auth process creates `apps.json`, we won't receive updates from it,
causing the UI to remain outdated.

This PR fixes that by watching the parent `github-copilot` directory
instead, which will always contain one of those files along with an
additional version file.

I have tested this on macOS and Linux Wayland.

Release Notes:

- Fixed an issue where signing into Copilot required restarting Zed.

Change summary

crates/copilot/src/copilot_chat.rs   | 30 ++++++----------
crates/settings/src/settings_file.rs | 52 +++++++++++++++++++++++++++++
2 files changed, 62 insertions(+), 20 deletions(-)

Detailed changes

crates/copilot/src/copilot_chat.rs 🔗

@@ -4,13 +4,14 @@ use std::sync::OnceLock;
 
 use anyhow::{anyhow, Result};
 use chrono::DateTime;
+use collections::HashSet;
 use fs::Fs;
 use futures::{io::BufReader, stream::BoxStream, AsyncBufReadExt, AsyncReadExt, StreamExt};
 use gpui::{prelude::*, App, AsyncApp, Global};
 use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest};
 use paths::home_dir;
 use serde::{Deserialize, Serialize};
-use settings::watch_config_file;
+use settings::watch_config_dir;
 use strum::EnumIter;
 
 pub const COPILOT_CHAT_COMPLETION_URL: &str = "https://api.githubcopilot.com/chat/completions";
@@ -237,27 +238,18 @@ impl CopilotChat {
     }
 
     pub fn new(fs: Arc<dyn Fs>, client: Arc<dyn HttpClient>, cx: &App) -> Self {
-        let config_paths = copilot_chat_config_paths();
-
-        let resolve_config_path = {
-            let fs = fs.clone();
-            async move {
-                for config_path in config_paths.iter() {
-                    if fs.metadata(config_path).await.is_ok_and(|v| v.is_some()) {
-                        return config_path.clone();
-                    }
-                }
-                config_paths[0].clone()
-            }
-        };
+        let config_paths: HashSet<PathBuf> = copilot_chat_config_paths().into_iter().collect();
+        let dir_path = copilot_chat_config_dir();
 
         cx.spawn(|cx| async move {
-            let config_file = resolve_config_path.await;
-            let mut config_file_rx = watch_config_file(cx.background_executor(), fs, config_file);
-
-            while let Some(contents) = config_file_rx.next().await {
+            let mut parent_watch_rx = watch_config_dir(
+                cx.background_executor(),
+                fs.clone(),
+                dir_path.clone(),
+                config_paths,
+            );
+            while let Some(contents) = parent_watch_rx.next().await {
                 let oauth_token = extract_oauth_token(contents);
-
                 cx.update(|cx| {
                     if let Some(this) = Self::global(cx).as_ref() {
                         this.update(cx, |this, cx| {

crates/settings/src/settings_file.rs 🔗

@@ -1,5 +1,6 @@
 use crate::{settings_store::SettingsStore, Settings};
-use fs::Fs;
+use collections::HashSet;
+use fs::{Fs, PathEventKind};
 use futures::{channel::mpsc, StreamExt};
 use gpui::{App, BackgroundExecutor, ReadGlobal};
 use std::{path::PathBuf, sync::Arc, time::Duration};
@@ -78,6 +79,55 @@ pub fn watch_config_file(
     rx
 }
 
+pub fn watch_config_dir(
+    executor: &BackgroundExecutor,
+    fs: Arc<dyn Fs>,
+    dir_path: PathBuf,
+    config_paths: HashSet<PathBuf>,
+) -> mpsc::UnboundedReceiver<String> {
+    let (tx, rx) = mpsc::unbounded();
+    executor
+        .spawn(async move {
+            for file_path in &config_paths {
+                if fs.metadata(file_path).await.is_ok_and(|v| v.is_some()) {
+                    if let Ok(contents) = fs.load(file_path).await {
+                        if tx.unbounded_send(contents).is_err() {
+                            return;
+                        }
+                    }
+                }
+            }
+
+            let (events, _) = fs.watch(&dir_path, Duration::from_millis(100)).await;
+            futures::pin_mut!(events);
+
+            while let Some(event_batch) = events.next().await {
+                for event in event_batch {
+                    if config_paths.contains(&event.path) {
+                        match event.kind {
+                            Some(PathEventKind::Removed) => {
+                                if tx.unbounded_send(String::new()).is_err() {
+                                    return;
+                                }
+                            }
+                            Some(PathEventKind::Created) | Some(PathEventKind::Changed) => {
+                                if let Ok(contents) = fs.load(&event.path).await {
+                                    if tx.unbounded_send(contents).is_err() {
+                                        return;
+                                    }
+                                }
+                            }
+                            _ => {}
+                        }
+                    }
+                }
+            }
+        })
+        .detach();
+
+    rx
+}
+
 pub fn update_settings_file<T: Settings>(
     fs: Arc<dyn Fs>,
     cx: &App,