diff --git a/Cargo.lock b/Cargo.lock index 14de5a2f1d..201fa6304b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3887,9 +3887,8 @@ version = "0.1.0" dependencies = [ "anthropic", "anyhow", - "collections", + "credentials_provider", "dirs", - "fs", "futures 0.3.32", "gpui", "http_client", @@ -3898,8 +3897,7 @@ dependencies = [ "serde", "serde_json", "settings", - "sqlez", - "tempfile", + "zed_credentials_provider", ] [[package]] @@ -3908,6 +3906,7 @@ version = "0.1.0" dependencies = [ "anyhow", "copilot", + "copilot_chat", "gpui", "language", "log", @@ -9866,7 +9865,6 @@ dependencies = [ "collections", "component", "convert_case 0.11.0", - "copilot", "copilot_chat", "copilot_ui", "credentials_provider", diff --git a/crates/copilot/src/copilot.rs b/crates/copilot/src/copilot.rs index 6936a5a416..6e9bc4ad2e 100644 --- a/crates/copilot/src/copilot.rs +++ b/crates/copilot/src/copilot.rs @@ -46,20 +46,11 @@ use workspace::AppState; pub use crate::copilot_edit_prediction_delegate::CopilotEditPredictionDelegate; actions!( - copilot, + copilot_edit_predictions, [ - /// Requests a code completion suggestion from Copilot. - Suggest, - /// Cycles to the next Copilot suggestion. - NextSuggestion, - /// Cycles to the previous Copilot suggestion. - PreviousSuggestion, - /// Reinstalls the Copilot language server. + /// Reinstalls the Copilot Edit Predictions language server. + #[action(deprecated_aliases = ["copilot::Reinstall"])] Reinstall, - /// Signs in to GitHub Copilot. - SignIn, - /// Signs out of GitHub Copilot. - SignOut ] ); @@ -1265,7 +1256,6 @@ impl Copilot { | request::SignInStatus::AlreadySignedIn { .. } => { server.sign_in_status = SignInStatus::Authorized; cx.emit(Event::CopilotAuthSignedIn); - notify_copilot_chat_auth_changed(cx); for buffer in self.buffers.iter().cloned().collect::>() { if let Some(buffer) = buffer.upgrade() { self.register_buffer(&buffer, cx); @@ -1285,7 +1275,6 @@ impl Copilot { }; } cx.emit(Event::CopilotAuthSignedOut); - notify_copilot_chat_auth_changed(cx); for buffer in self.buffers.iter().cloned().collect::>() { self.unregister_buffer(&buffer); } @@ -1297,40 +1286,15 @@ impl Copilot { } fn update_action_visibilities(&self, cx: &mut App) { - let signed_in_actions = [ - TypeId::of::(), - TypeId::of::(), - TypeId::of::(), - TypeId::of::(), - ]; - let auth_actions = [TypeId::of::()]; - let no_auth_actions = [TypeId::of::()]; - let status = self.status(); + let signed_in_actions = [TypeId::of::()]; let is_ai_disabled = DisableAiSettings::get_global(cx).disable_ai; let filter = CommandPaletteFilter::global_mut(cx); if is_ai_disabled { filter.hide_action_types(&signed_in_actions); - filter.hide_action_types(&auth_actions); - filter.hide_action_types(&no_auth_actions); } else { - match status { - Status::Disabled => { - filter.hide_action_types(&signed_in_actions); - filter.hide_action_types(&auth_actions); - filter.hide_action_types(&no_auth_actions); - } - Status::Authorized => { - filter.hide_action_types(&no_auth_actions); - filter.show_action_types(signed_in_actions.iter().chain(&auth_actions)); - } - _ => { - filter.hide_action_types(&signed_in_actions); - filter.hide_action_types(&auth_actions); - filter.show_action_types(&no_auth_actions); - } - } + filter.show_action_types(&signed_in_actions); } } } @@ -1389,15 +1353,6 @@ fn notify_did_change_config_to_server( Ok(()) } -/// Notify Copilot Chat after the Copilot LSP reports an auth state change. -/// This replaces watching the SDK's token files, which is unreliable for -/// SQLite backed auth because writes may go through WAL files. -fn notify_copilot_chat_auth_changed(cx: &mut Context) { - if let Some(copilot_chat) = copilot_chat::CopilotChat::global(cx) { - copilot_chat.update(cx, |chat, cx| chat.reload_auth(cx)); - } -} - async fn clear_copilot_dir() { remove_matching(paths::copilot_dir(), |_| true).await } diff --git a/crates/copilot_chat/Cargo.toml b/crates/copilot_chat/Cargo.toml index c6e6253bf4..652c736654 100644 --- a/crates/copilot_chat/Cargo.toml +++ b/crates/copilot_chat/Cargo.toml @@ -15,7 +15,6 @@ doctest = false [features] default = [] test-support = [ - "collections/test-support", "gpui/test-support", "settings/test-support", ] @@ -23,9 +22,8 @@ test-support = [ [dependencies] anthropic.workspace = true anyhow.workspace = true -collections.workspace = true +credentials_provider.workspace = true dirs.workspace = true -fs.workspace = true futures.workspace = true gpui.workspace = true http_client.workspace = true @@ -34,9 +32,8 @@ paths.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true -sqlez.workspace = true +zed_credentials_provider.workspace = true [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } serde_json.workspace = true -tempfile.workspace = true diff --git a/crates/copilot_chat/src/copilot_chat.rs b/crates/copilot_chat/src/copilot_chat.rs index 4d0e5e6c46..5d03a40b47 100644 --- a/crates/copilot_chat/src/copilot_chat.rs +++ b/crates/copilot_chat/src/copilot_chat.rs @@ -1,22 +1,23 @@ +pub mod copilot_oauth; pub mod responses; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::sync::Arc; use std::sync::OnceLock; use anyhow::Context as _; use anyhow::{Result, anyhow}; -use collections::HashSet; -use fs::Fs; +use credentials_provider::CredentialsProvider; use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream}; -use gpui::TaskExt; use gpui::WeakEntity; -use gpui::{App, AsyncApp, Global, prelude::*}; +use gpui::{App, AsyncApp, Global, Task, prelude::*}; use http_client::HttpRequestExt; use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest}; use paths::home_dir; use serde::{Deserialize, Serialize}; +pub use copilot_oauth::DeviceFlow; + // The Copilot language server unofficially supports both token env vars: // https://github.com/github/copilot-language-server-release/issues/3#issuecomment-2699433055 pub const COPILOT_OAUTH_ENV_VAR: &str = "GH_COPILOT_TOKEN"; @@ -62,6 +63,14 @@ impl CopilotChatConfiguration { format!("{}/models", api_endpoint) } + pub fn device_code_url(&self) -> String { + format!("https://{}/login/device/code", self.oauth_domain()) + } + + pub fn access_token_url(&self) -> String { + format!("https://{}/login/oauth/access_token", self.oauth_domain()) + } + fn parse_domain(enterprise_uri: &str) -> String { let uri = enterprise_uri.trim_end_matches('/'); @@ -496,22 +505,39 @@ struct GlobalCopilotChat(gpui::Entity); impl Global for GlobalCopilotChat {} +/// The keychain URL under which the Copilot agent's OAuth token is stored. This +/// is intentionally distinct from the edit-prediction token so the two Copilot +/// providers are authenticated entirely separately. +const COPILOT_AGENT_CREDENTIALS_URL: &str = "https://github.com/copilot-agent"; + +/// Authentication state for the Copilot agent (chat) provider. +#[derive(Clone, Debug)] +pub enum CopilotChatStatus { + /// Still loading a previously-stored token from the keychain. + Starting, + SignedOut, + SigningIn { + device_flow: DeviceFlow, + }, + Authorized, + Error(Arc), +} + pub struct CopilotChat { + status: CopilotChatStatus, oauth_token: Option, api_endpoint: Option, configuration: CopilotChatConfiguration, models: Option>, client: Arc, - fs: Arc, + credentials_provider: Arc, + sign_in_task: Option>, } -pub fn init( - fs: Arc, - client: Arc, - configuration: CopilotChatConfiguration, - cx: &mut App, -) { - let copilot_chat = cx.new(|cx| CopilotChat::new(fs, client, configuration, cx)); +pub fn init(client: Arc, configuration: CopilotChatConfiguration, cx: &mut App) { + let credentials_provider = zed_credentials_provider::global(cx); + let copilot_chat = + cx.new(|cx| CopilotChat::new(client, credentials_provider, configuration, cx)); cx.set_global(GlobalCopilotChat(copilot_chat)); } @@ -531,19 +557,24 @@ pub fn copilot_chat_config_dir() -> &'static PathBuf { }) } -/// Legacy JSON token-storage paths used by older Copilot SDK builds. -/// TODO(copilot): once Copilot SDK supports `auth.db`, remove these paths. -fn copilot_chat_config_paths() -> [PathBuf; 2] { - let base_dir = copilot_chat_config_dir(); - [base_dir.join("hosts.json"), base_dir.join("apps.json")] -} - fn oauth_token_from_env() -> Option { std::env::var(COPILOT_OAUTH_ENV_VAR) .ok() .or_else(|| std::env::var(GITHUB_COPILOT_OAUTH_ENV_VAR).ok()) } +async fn load_stored_token( + credentials_provider: &Arc, + cx: &AsyncApp, +) -> Option { + let (_, token) = credentials_provider + .read_credentials(COPILOT_AGENT_CREDENTIALS_URL, cx) + .await + .ok() + .flatten()?; + String::from_utf8(token).ok() +} + impl CopilotChat { pub fn global(cx: &App) -> Option> { cx.try_global::() @@ -551,55 +582,144 @@ impl CopilotChat { } fn new( - fs: Arc, client: Arc, + credentials_provider: Arc, configuration: CopilotChatConfiguration, cx: &mut Context, ) -> Self { - // Initial async scan of token sources. Live reload is driven by the - // Copilot LSP's auth status notifications instead of watching files, - // because SQLite WAL writes can make directory watchers racy. - cx.spawn({ - let fs = fs.clone(); - async move |this, cx| { - let oauth_domain = - this.read_with(cx, |this, _| this.configuration.oauth_domain())?; - let config_paths: HashSet = - copilot_chat_config_paths().into_iter().collect(); - let auth_db_path = copilot_chat_config_dir().join("auth.db"); + let env_token = oauth_token_from_env(); - let oauth_token = - read_oauth_token(&fs, &config_paths, &oauth_domain, &auth_db_path, cx).await; + // Load a previously-stored token (or the one from the environment) and + // fetch models if we end up authenticated. + cx.spawn(async move |this, cx| { + let (env_token, credentials_provider) = this.read_with(cx, |this, _| { + (this.oauth_token.clone(), this.credentials_provider.clone()) + })?; - if oauth_token.is_some() { - this.update(cx, |this, cx| { - this.oauth_token = oauth_token; - cx.notify(); - })?; - Self::update_models(&this, cx).await?; - } - anyhow::Ok(()) + let token = match env_token { + Some(token) => Some(token), + None => load_stored_token(&credentials_provider, cx).await, + }; + + this.update(cx, |this, cx| { + this.oauth_token = token.clone(); + this.status = if token.is_some() { + CopilotChatStatus::Authorized + } else { + CopilotChatStatus::SignedOut + }; + cx.notify(); + })?; + + if token.is_some() { + Self::update_models(&this, cx).await?; } + anyhow::Ok(()) }) - .detach_and_log_err(cx); + .detach(); - // Initial state uses env var because it's cheap. The others do IO, so - // are on the background. - let this = Self { - oauth_token: oauth_token_from_env(), + Self { + status: CopilotChatStatus::Starting, + oauth_token: env_token, api_endpoint: None, models: None, configuration, client, - fs, - }; + credentials_provider, + sign_in_task: None, + } + } - if this.oauth_token.is_some() { - cx.spawn(async move |this, cx| Self::update_models(&this, cx).await) - .detach_and_log_err(cx); + pub fn status(&self) -> CopilotChatStatus { + self.status.clone() + } + + /// Begins the GitHub OAuth device-code flow. Progress is reported through + /// [`Self::status`]; observe this entity to react to state changes. + pub fn sign_in(&mut self, cx: &mut Context) { + if matches!( + self.status, + CopilotChatStatus::SigningIn { .. } | CopilotChatStatus::Authorized + ) { + return; } - this + let client = self.client.clone(); + let configuration = self.configuration.clone(); + let credentials_provider = self.credentials_provider.clone(); + let executor = cx.background_executor().clone(); + + let task = cx.spawn(async move |this, cx| { + let result = async { + let device_flow = + copilot_oauth::request_device_code(&client, &configuration).await?; + this.update(cx, |this, cx| { + this.status = CopilotChatStatus::SigningIn { + device_flow: device_flow.clone(), + }; + cx.notify(); + })?; + + let token = copilot_oauth::poll_for_access_token( + &client, + &configuration, + &device_flow, + &executor, + ) + .await?; + + credentials_provider + .write_credentials( + COPILOT_AGENT_CREDENTIALS_URL, + "Bearer", + token.as_bytes(), + cx, + ) + .await + .context("writing Copilot agent credentials to the keychain")?; + + this.update(cx, |this, cx| { + this.oauth_token = Some(token); + this.api_endpoint = None; + this.status = CopilotChatStatus::Authorized; + cx.notify(); + })?; + + Self::update_models(&this, cx).await?; + anyhow::Ok(()) + } + .await; + + if let Err(error) = result { + log::error!("Copilot agent sign-in failed: {error:#}"); + this.update(cx, |this, cx| { + this.status = CopilotChatStatus::Error(error.to_string().into()); + cx.notify(); + }) + .ok(); + } + }); + + self.sign_in_task = Some(task); + cx.notify(); + } + + pub fn sign_out(&mut self, cx: &mut Context) -> Task> { + let credentials_provider = self.credentials_provider.clone(); + self.oauth_token = None; + self.api_endpoint = None; + self.models = None; + self.sign_in_task = None; + self.status = CopilotChatStatus::SignedOut; + cx.notify(); + + cx.spawn(async move |_, cx| { + credentials_provider + .delete_credentials(COPILOT_AGENT_CREDENTIALS_URL, cx) + .await + .context("deleting Copilot agent credentials from the keychain")?; + anyhow::Ok(()) + }) } async fn update_models(this: &WeakEntity, cx: &mut AsyncApp) -> Result<()> { @@ -776,39 +896,6 @@ impl CopilotChat { .detach(); } } - - pub fn reload_auth(&mut self, cx: &mut Context) { - let fs = self.fs.clone(); - let oauth_domain = self.configuration.oauth_domain(); - cx.spawn(async move |this, cx| { - let config_paths: HashSet = copilot_chat_config_paths().into_iter().collect(); - let auth_db_path = copilot_chat_config_dir().join("auth.db"); - - let new_token = - read_oauth_token(&fs, &config_paths, &oauth_domain, &auth_db_path, cx).await; - - let token_present = this.update(cx, |this, cx| { - let changed = this.oauth_token != new_token; - if changed { - this.oauth_token = new_token.clone(); - if new_token.is_none() { - // Sign-out: drop derived state so a future sign-in - // re-discovers the endpoint and re-fetches models. - this.api_endpoint = None; - this.models = None; - } - cx.notify(); - } - new_token.is_some() - })?; - - if token_present { - Self::update_models(&this, cx).await?; - } - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - } } async fn get_models( @@ -962,87 +1049,6 @@ async fn request_models( Ok(models) } -async fn read_oauth_token( - fs: &Arc, - config_paths: &HashSet, - oauth_domain: &str, - auth_db_path: &std::path::Path, - cx: &AsyncApp, -) -> Option { - if let Some(token) = oauth_token_from_env() { - return Some(token); - } - - let token_from_db = cx - .background_spawn({ - let auth_db_path = auth_db_path.to_path_buf(); - let oauth_domain = oauth_domain.to_string(); - async move { extract_oauth_token_from_db(&auth_db_path, &oauth_domain) } - }) - .await; - - if let Some(token) = token_from_db { - return Some(token); - } - - for file_path in config_paths { - if let Ok(contents) = fs.load(file_path).await { - if let Some(token) = extract_oauth_token(contents, oauth_domain) { - return Some(token); - } - } - } - - None -} - -fn extract_oauth_token(contents: String, domain: &str) -> Option { - serde_json::from_str::(&contents) - .map(|v| { - v.as_object().and_then(|obj| { - obj.iter().find_map(|(key, value)| { - if key.starts_with(domain) { - value["oauth_token"].as_str().map(|v| v.to_string()) - } else { - None - } - }) - }) - }) - .ok() - .flatten() -} - -fn extract_oauth_token_from_db(db_path: &Path, auth_authority: &str) -> Option { - if !db_path.exists() { - return None; - } - - let db = sqlez::connection::Connection::open_file(db_path.to_str()?); - - let token_bytes: Option> = db - .select_row_bound::<&str, Vec>( - "SELECT token_ciphertext FROM oauth_tokens WHERE auth_authority = ? ORDER BY last_used_at DESC, token_id DESC LIMIT 1", - ) - .ok() - .and_then(|mut select| select(auth_authority).ok().flatten()); - - let token = token_bytes.and_then(|bytes| String::from_utf8(bytes).ok())?; - - if token.starts_with("ghu_") - && token.len() >= 36 - && token.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') - { - log::debug!("Copilot OAuth token loaded from auth.db"); - Some(token) - } else { - log::warn!( - "Copilot auth.db: token does not match expected GitHub OAuth format (ghu_)" - ); - None - } -} - async fn stream_completion( client: Arc, oauth_token: String, @@ -1860,61 +1866,4 @@ mod tests { "\"none\"" ); } - - #[test] - fn test_extract_oauth_token_from_db_matches_auth_authority_and_recency() { - let dir = tempfile::tempdir().unwrap(); - let db_path = dir.path().join("auth.db"); - let older_github_token = "ghu_oldergithubtokenvalue000000000000"; - let newer_github_token = "ghu_newergithubtokenvalue000000000000"; - let enterprise_token = "ghu_enterprisetokenvalue0000000000000"; - - let connection = sqlez::connection::Connection::open_file(db_path.to_str().unwrap()); - connection - .exec( - "CREATE TABLE oauth_tokens ( - token_id INTEGER PRIMARY KEY AUTOINCREMENT, - auth_authority TEXT NOT NULL, - token_ciphertext BLOB NOT NULL, - last_used_at INTEGER NOT NULL - );", - ) - .unwrap()() - .unwrap(); - - { - let mut insert_token = connection - .exec_bound::<(&str, Vec, i64)>( - "INSERT INTO oauth_tokens (auth_authority, token_ciphertext, last_used_at) VALUES (?, ?, ?);", - ) - .unwrap(); - insert_token(("github.com", older_github_token.as_bytes().to_vec(), 10)).unwrap(); - insert_token(( - "github.enterprise.test", - enterprise_token.as_bytes().to_vec(), - 30, - )) - .unwrap(); - insert_token(("github.com", newer_github_token.as_bytes().to_vec(), 20)).unwrap(); - } - drop(connection); - - assert_eq!( - extract_oauth_token_from_db(&db_path, "github.com").as_deref(), - Some(newer_github_token) - ); - assert_eq!( - extract_oauth_token_from_db(&db_path, "github.enterprise.test").as_deref(), - Some(enterprise_token) - ); - } - - #[test] - fn test_extract_oauth_token_from_db_missing_db_does_not_create_file() { - let dir = tempfile::tempdir().unwrap(); - let db_path = dir.path().join("auth.db"); - - assert_eq!(extract_oauth_token_from_db(&db_path, "github.com"), None); - assert!(!db_path.exists()); - } } diff --git a/crates/copilot_chat/src/copilot_oauth.rs b/crates/copilot_chat/src/copilot_oauth.rs new file mode 100644 index 0000000000..c65e5ca243 --- /dev/null +++ b/crates/copilot_chat/src/copilot_oauth.rs @@ -0,0 +1,161 @@ +//! A native GitHub OAuth device-code flow for Copilot. +//! +//! Historically, Zed relied on the Copilot language server to perform the +//! OAuth flow and then read the resulting token out of the language server's +//! on-disk storage. That coupling was fragile. Instead, each Copilot provider +//! (agent and edit predictions) now drives this device-code flow itself and +//! stores the resulting token independently. + +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context as _, Result, bail}; +use futures::AsyncReadExt as _; +use gpui::BackgroundExecutor; +use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest}; +use serde::Deserialize; + +use crate::CopilotChatConfiguration; + +/// The public OAuth application client ID used by GitHub Copilot editor +/// integrations (the same one the Copilot language server uses). Reusing it +/// lets Zed request the same Copilot scopes without registering its own app. +pub const GITHUB_COPILOT_CLIENT_ID: &str = "6e3a0413e62d19d75ff1"; + +const DEVICE_CODE_SCOPE: &str = "read:user"; +const DEVICE_CODE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code"; + +/// An in-progress device-code authorization. The user must visit +/// [`Self::verification_uri`] and enter [`Self::user_code`] to complete it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DeviceFlow { + pub user_code: String, + pub verification_uri: String, + device_code: String, + interval: u64, +} + +#[derive(Deserialize)] +struct DeviceCodeResponse { + device_code: String, + user_code: String, + verification_uri: String, + #[serde(default = "default_interval")] + interval: u64, +} + +fn default_interval() -> u64 { + 5 +} + +#[derive(Deserialize)] +struct AccessTokenResponse { + access_token: Option, + error: Option, +} + +/// Begins a device-code authorization by requesting a user code from GitHub. +pub async fn request_device_code( + client: &Arc, + configuration: &CopilotChatConfiguration, +) -> Result { + let body = form_encode(&[ + ("client_id", GITHUB_COPILOT_CLIENT_ID), + ("scope", DEVICE_CODE_SCOPE), + ]); + + let request = HttpRequest::builder() + .method(Method::POST) + .uri(configuration.device_code_url()) + .header("Accept", "application/json") + .header("Content-Type", "application/x-www-form-urlencoded") + .body(AsyncBody::from(body))?; + + let mut response = client.send(request).await?; + anyhow::ensure!( + response.status().is_success(), + "GitHub device-code request failed: {}", + response.status() + ); + + let mut body = Vec::new(); + response.body_mut().read_to_end(&mut body).await?; + let parsed: DeviceCodeResponse = + serde_json::from_slice(&body).context("Failed to parse GitHub device-code response")?; + + Ok(DeviceFlow { + user_code: parsed.user_code, + verification_uri: parsed.verification_uri, + device_code: parsed.device_code, + interval: parsed.interval.max(1), + }) +} + +/// Polls GitHub until the user completes the device-code authorization, +/// returning the resulting OAuth (`ghu_…`) token. +pub async fn poll_for_access_token( + client: &Arc, + configuration: &CopilotChatConfiguration, + device_flow: &DeviceFlow, + executor: &BackgroundExecutor, +) -> Result { + let mut interval = device_flow.interval; + let body = form_encode(&[ + ("client_id", GITHUB_COPILOT_CLIENT_ID), + ("device_code", device_flow.device_code.as_str()), + ("grant_type", DEVICE_CODE_GRANT_TYPE), + ]); + + loop { + executor.timer(Duration::from_secs(interval)).await; + + let request = HttpRequest::builder() + .method(Method::POST) + .uri(configuration.access_token_url()) + .header("Accept", "application/json") + .header("Content-Type", "application/x-www-form-urlencoded") + .body(AsyncBody::from(body.clone()))?; + + let mut response = client.send(request).await?; + let mut response_body = Vec::new(); + response.body_mut().read_to_end(&mut response_body).await?; + + let parsed: AccessTokenResponse = serde_json::from_slice(&response_body) + .context("Failed to parse GitHub access-token response")?; + + if let Some(token) = parsed.access_token { + return Ok(token); + } + + match parsed.error.as_deref() { + Some("authorization_pending") => continue, + // GitHub asks us to back off; increase the interval and keep polling. + Some("slow_down") => interval += 5, + Some("expired_token") => bail!("The Copilot sign-in code expired. Please try again."), + Some("access_denied") => bail!("Copilot sign-in was cancelled."), + Some(other) => bail!("Copilot sign-in failed: {other}"), + None => bail!("Copilot sign-in failed: unexpected response from GitHub"), + } + } +} + +fn form_encode(fields: &[(&str, &str)]) -> String { + fields + .iter() + .map(|(key, value)| format!("{}={}", url_encode(key), url_encode(value))) + .collect::>() + .join("&") +} + +fn url_encode(value: &str) -> String { + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + encoded.push(byte as char) + } + _ => encoded.push_str(&format!("%{byte:02X}")), + } + } + encoded +} diff --git a/crates/copilot_ui/Cargo.toml b/crates/copilot_ui/Cargo.toml index ba5935c65e..315831525d 100644 --- a/crates/copilot_ui/Cargo.toml +++ b/crates/copilot_ui/Cargo.toml @@ -22,6 +22,7 @@ test-support = [ [dependencies] anyhow.workspace = true copilot.workspace = true +copilot_chat.workspace = true gpui.workspace = true language.workspace = true log.workspace = true diff --git a/crates/copilot_ui/src/sign_in.rs b/crates/copilot_ui/src/sign_in.rs index 5741d61348..ea1d1388e9 100644 --- a/crates/copilot_ui/src/sign_in.rs +++ b/crates/copilot_ui/src/sign_in.rs @@ -3,6 +3,7 @@ use copilot::{ Copilot, GlobalCopilotAuth, Status, request::{self, PromptUserDeviceFlow}, }; +use copilot_chat::{CopilotChat, CopilotChatStatus, DeviceFlow}; use gpui::{ App, ClipboardItem, Context, DismissEvent, Element, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, MouseDownEvent, ParentElement, Render, Styled, @@ -16,8 +17,7 @@ use util::ResultExt as _; use workspace::{AppState, Toast, Workspace, notifications::NotificationId}; const COPILOT_SIGN_UP_URL: &str = "https://github.com/features/copilot"; -const ERROR_LABEL: &str = - "Copilot had issues starting. You can try reinstalling it and signing in again."; +const ERROR_LABEL: &str = "Copilot Edit Predictions had issues starting. You can try reinstalling it and signing in again."; struct CopilotStatusToast; @@ -27,14 +27,14 @@ pub fn initiate_sign_in(copilot: Entity, window: &mut Window, cx: &mut } pub fn initiate_sign_out(copilot: Entity, window: &mut Window, cx: &mut App) { - copilot_toast(Some("Signing out of Copilot…"), window, cx); + copilot_toast(Some("Signing out of Copilot Edit Predictions…"), window, cx); let sign_out_task = copilot.update(cx, |copilot, cx| copilot.sign_out(cx)); window .spawn(cx, async move |cx| match sign_out_task.await { - Ok(()) => { - cx.update(|window, cx| copilot_toast(Some("Signed out of Copilot"), window, cx)) - } + Ok(()) => cx.update(|window, cx| { + copilot_toast(Some("Signed out of Copilot Edit Predictions"), window, cx) + }), Err(err) => cx.update(|window, cx| { if let Some(workspace) = Workspace::for_window(window, cx) { workspace.update(cx, |workspace, cx| { @@ -83,6 +83,36 @@ fn open_copilot_code_verification_window(copilot: &Entity, window: &Win .log_err(); } +fn open_copilot_chat_code_verification_window( + copilot_chat: &Entity, + window: &Window, + cx: &mut App, +) { + let current_window_center = window.bounds().center(); + let height = px(450.); + let width = px(350.); + let window_bounds = WindowBounds::Windowed(gpui::bounds( + current_window_center - point(height / 2.0, width / 2.0), + gpui::size(height, width), + )); + cx.open_window( + WindowOptions { + kind: gpui::WindowKind::Floating, + window_bounds: Some(window_bounds), + is_resizable: false, + is_movable: true, + titlebar: Some(gpui::TitlebarOptions { + appears_transparent: true, + ..Default::default() + }), + ..Default::default() + }, + |window, cx| cx.new(|cx| CopilotChatCodeVerification::new(&copilot_chat, window, cx)), + ) + .context("Failed to open Copilot Chat code verification window") + .log_err(); +} + fn copilot_toast(message: Option<&'static str>, window: &Window, cx: &mut App) { const NOTIFICATION_ID: NotificationId = NotificationId::unique::(); @@ -111,9 +141,9 @@ pub fn initiate_sign_in_impl( Status::Starting { task } => { copilot_toast( Some(if is_reinstall { - "Copilot is reinstalling…" + "Copilot Edit Predictions is reinstalling…" } else { - "Copilot is starting…" + "Copilot Edit Predictions is starting…" }), window, cx, @@ -124,7 +154,7 @@ pub fn initiate_sign_in_impl( task.await; cx.update(|window, cx| match copilot.read(cx).status() { Status::Authorized => { - copilot_toast(Some("Copilot has started."), window, cx) + copilot_toast(Some("Copilot Edit Predictions has started."), window, cx) } _ => { copilot_toast(None, window, cx); @@ -252,10 +282,15 @@ impl CopilotCodeVerification { .gap_2p5() .items_center() .text_center() - .child(Headline::new("Use GitHub Copilot in Zed").size(HeadlineSize::Large)) .child( - Label::new("Using Copilot requires an active subscription on GitHub.") - .color(Color::Muted), + Headline::new("Use GitHub Copilot Edit Predictions in Zed") + .size(HeadlineSize::Large), + ) + .child( + Label::new( + "Using Copilot edit predictions requires an active subscription on GitHub.", + ) + .color(Color::Muted), ) .child(Self::render_device_code(data, cx)) .child( @@ -336,8 +371,10 @@ impl CopilotCodeVerification { .gap_2() .text_center() .justify_center() - .child(Headline::new("Copilot Enabled!").size(HeadlineSize::Large)) - .child(Label::new("You're all set to use GitHub Copilot.").color(Color::Muted)) + .child(Headline::new("Copilot Edit Predictions Enabled!").size(HeadlineSize::Large)) + .child( + Label::new("You're all set to use Copilot edit predictions.").color(Color::Muted), + ) .child( Button::new("copilot-enabled-done-button", "Done") .full_width() @@ -353,7 +390,7 @@ impl CopilotCodeVerification { .as_deref() .unwrap_or(COPILOT_SIGN_UP_URL) .to_owned(); - let description = "Enable Copilot by connecting your existing license once you have subscribed or renewed your subscription."; + let description = "Enable Copilot edit predictions by connecting your existing license once you have subscribed or renewed your subscription."; v_flex() .gap_2() @@ -387,18 +424,19 @@ impl CopilotCodeVerification { .child(Headline::new("An Error Happened").size(HeadlineSize::Large)) .child(Label::new(ERROR_LABEL).color(Color::Muted)) .child( - Button::new("copilot-subscribe-button", "Reinstall Copilot and Sign In") - .full_width() - .style(ButtonStyle::Outlined) - .size(ButtonSize::Medium) - .start_icon( - Icon::new(IconName::Download) - .size(IconSize::Small) - .color(Color::Muted), - ) - .on_click(move |_, window, cx| { - reinstall_and_sign_in(copilot.clone(), window, cx) - }), + Button::new( + "copilot-subscribe-button", + "Reinstall Copilot Edit Predictions and Sign In", + ) + .full_width() + .style(ButtonStyle::Outlined) + .size(ButtonSize::Medium) + .start_icon( + Icon::new(IconName::Download) + .size(IconSize::Small) + .color(Color::Muted), + ) + .on_click(move |_, window, cx| reinstall_and_sign_in(copilot.clone(), window, cx)), ) } @@ -466,8 +504,241 @@ impl Render for CopilotCodeVerification { } } +pub struct CopilotChatCodeVerification { + status: CopilotChatStatus, + connect_clicked: bool, + focus_handle: FocusHandle, + copilot_chat: Entity, + _subscription: Subscription, +} + +impl Focusable for CopilotChatCodeVerification { + fn focus_handle(&self, _: &App) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl EventEmitter for CopilotChatCodeVerification {} + +impl CopilotChatCodeVerification { + pub fn new( + copilot_chat: &Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + window.on_window_should_close(cx, |window, cx| { + if let Some(this) = window.root::().flatten() { + this.update(cx, |this, cx| { + this.before_dismiss(cx); + }); + } + true + }); + cx.subscribe_in( + &cx.entity(), + window, + |this, _, _: &DismissEvent, window, cx| { + window.remove_window(); + this.before_dismiss(cx); + }, + ) + .detach(); + + let status = copilot_chat.read(cx).status(); + Self { + status, + connect_clicked: false, + focus_handle: cx.focus_handle(), + copilot_chat: copilot_chat.clone(), + _subscription: cx.observe(copilot_chat, |this, copilot_chat, cx| { + let status = copilot_chat.read(cx).status(); + let should_dismiss = matches!( + status, + CopilotChatStatus::Authorized | CopilotChatStatus::Error(_) + ); + this.status = status; + cx.notify(); + if should_dismiss { + cx.emit(DismissEvent); + } + }), + } + } + + fn render_device_code(user_code: &str, cx: &mut Context) -> impl IntoElement { + let copied = cx + .read_from_clipboard() + .map(|item| item.text().as_deref() == Some(user_code)) + .unwrap_or(false); + let user_code = user_code.to_owned(); + + ButtonLike::new("copy-button") + .full_width() + .style(ButtonStyle::Tinted(ui::TintColor::Accent)) + .size(ButtonSize::Medium) + .child( + h_flex() + .w_full() + .p_1() + .justify_between() + .child(Label::new(user_code.clone())) + .child(Label::new(if copied { "Copied!" } else { "Copy" })), + ) + .on_click(move |_, window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(user_code.clone())); + window.refresh(); + }) + } + + fn render_prompting_modal( + connect_clicked: bool, + device_flow: &DeviceFlow, + cx: &mut Context, + ) -> impl Element { + let connect_button_label = if connect_clicked { + "Waiting for connection…" + } else { + "Connect to GitHub" + }; + + v_flex() + .flex_1() + .gap_2p5() + .items_center() + .text_center() + .child(Headline::new("Use GitHub Copilot Chat in Zed").size(HeadlineSize::Large)) + .child( + Label::new("Using Copilot Chat requires an active subscription on GitHub.") + .color(Color::Muted), + ) + .child(Self::render_device_code(&device_flow.user_code, cx)) + .child( + Label::new("Paste this code into GitHub after clicking the button below.") + .color(Color::Muted), + ) + .child( + v_flex() + .w_full() + .gap_1() + .child( + Button::new("connect-button", connect_button_label) + .full_width() + .style(ButtonStyle::Outlined) + .size(ButtonSize::Medium) + .on_click({ + let verification_uri = device_flow.verification_uri.clone(); + cx.listener(move |this, _, _window, cx| { + cx.open_url(&verification_uri); + this.connect_clicked = true; + }) + }), + ) + .child( + Button::new("copilot-chat-enable-cancel-button", "Cancel") + .full_width() + .size(ButtonSize::Medium) + .on_click(cx.listener(|_, _, _, cx| { + cx.emit(DismissEvent); + })), + ), + ) + } + + fn render_enabled_modal(cx: &mut Context) -> impl Element { + v_flex() + .gap_2() + .text_center() + .justify_center() + .child(Headline::new("Copilot Chat Enabled!").size(HeadlineSize::Large)) + .child(Label::new("You're all set to use Copilot Chat.").color(Color::Muted)) + .child( + Button::new("copilot-chat-enabled-done-button", "Done") + .full_width() + .style(ButtonStyle::Outlined) + .size(ButtonSize::Medium) + .on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))), + ) + } + + fn render_error_modal(message: &str, cx: &mut Context) -> impl Element { + v_flex() + .gap_2() + .text_center() + .justify_center() + .child(Headline::new("An Error Happened").size(HeadlineSize::Large)) + .child(Label::new(message.to_owned()).color(Color::Muted)) + .child( + Button::new("copilot-chat-error-cancel-button", "Cancel") + .full_width() + .style(ButtonStyle::Outlined) + .size(ButtonSize::Medium) + .on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))), + ) + } + + fn before_dismiss(&mut self, cx: &mut Context) -> workspace::DismissDecision { + self.copilot_chat.update(cx, |chat, cx| { + if matches!( + chat.status(), + CopilotChatStatus::SigningIn { .. } | CopilotChatStatus::Starting + ) { + chat.sign_out(cx).detach_and_log_err(cx); + } + }); + workspace::DismissDecision::Dismiss(true) + } +} + +impl Render for CopilotChatCodeVerification { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let prompt = match self.status.clone() { + CopilotChatStatus::Starting | CopilotChatStatus::SignedOut => { + Icon::new(IconName::ArrowCircle) + .color(Color::Muted) + .with_rotate_animation(2) + .into_any_element() + } + CopilotChatStatus::SigningIn { device_flow } => { + Self::render_prompting_modal(self.connect_clicked, &device_flow, cx) + .into_any_element() + } + CopilotChatStatus::Authorized => { + self.connect_clicked = false; + Self::render_enabled_modal(cx).into_any_element() + } + CopilotChatStatus::Error(message) => { + self.connect_clicked = false; + Self::render_error_modal(&message, cx).into_any_element() + } + }; + + v_flex() + .id("copilot_chat_code_verification") + .track_focus(&self.focus_handle(cx)) + .size_full() + .px_4() + .py_8() + .gap_2() + .items_center() + .justify_center() + .elevation_3(cx) + .on_action(cx.listener(|_, _: &menu::Cancel, _, cx| { + cx.emit(DismissEvent); + })) + .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| { + window.focus(&this.focus_handle, cx); + })) + .child( + Vector::new(VectorName::ZedXCopilot, rems(8.), rems(4.)) + .color(Color::Custom(cx.theme().colors().icon)), + ) + .child(prompt) + } +} + pub struct ConfigurationView { copilot_status: Option, + chat_status: Option, is_authenticated: Box bool + 'static>, edit_prediction: bool, /// When `true`, renders a compact control suitable for an inline settings @@ -488,20 +759,46 @@ impl ConfigurationView { mode: ConfigurationMode, cx: &mut Context, ) -> Self { - let copilot = AppState::try_global(cx) - .and_then(|state| GlobalCopilotAuth::try_get_or_init(state, cx)); + let edit_prediction = matches!(mode, ConfigurationMode::EditPrediction); - Self { - copilot_status: copilot.as_ref().map(|copilot| copilot.0.read(cx).status()), - is_authenticated: Box::new(is_authenticated), - edit_prediction: matches!(mode, ConfigurationMode::EditPrediction), - compact: false, - _subscription: copilot.as_ref().map(|copilot| { - cx.observe(&copilot.0, |this, model, cx| { - this.copilot_status = Some(model.read(cx).status()); - cx.notify(); - }) - }), + match mode { + ConfigurationMode::EditPrediction => { + let copilot = AppState::try_global(cx) + .and_then(|state| GlobalCopilotAuth::try_get_or_init(state, cx)); + + Self { + copilot_status: copilot.as_ref().map(|copilot| copilot.0.read(cx).status()), + chat_status: None, + is_authenticated: Box::new(is_authenticated), + edit_prediction, + _subscription: copilot.as_ref().map(|copilot| { + cx.observe(&copilot.0, |this, model, cx| { + this.copilot_status = Some(model.read(cx).status()); + cx.notify(); + }) + }), + compact: false, + } + } + ConfigurationMode::Chat => { + let copilot_chat = CopilotChat::global(cx); + + Self { + copilot_status: None, + chat_status: copilot_chat + .as_ref() + .map(|copilot_chat| copilot_chat.read(cx).status()), + is_authenticated: Box::new(is_authenticated), + edit_prediction, + _subscription: copilot_chat.as_ref().map(|copilot_chat| { + cx.observe(copilot_chat, |this, model, cx| { + this.chat_status = Some(model.read(cx).status()); + cx.notify(); + }) + }), + compact: false, + } + } } } @@ -516,32 +813,56 @@ impl ConfigurationView { impl ConfigurationView { fn is_starting(&self) -> bool { - matches!(&self.copilot_status, Some(Status::Starting { .. })) + if self.edit_prediction { + matches!(&self.copilot_status, Some(Status::Starting { .. })) + } else { + matches!(&self.chat_status, Some(CopilotChatStatus::Starting)) + } } fn is_signing_in(&self) -> bool { - matches!( - &self.copilot_status, - Some(Status::SigningIn { .. }) - | Some(Status::SignedOut { - awaiting_signing_in: true - }) - ) + if self.edit_prediction { + matches!( + &self.copilot_status, + Some(Status::SigningIn { .. }) + | Some(Status::SignedOut { + awaiting_signing_in: true + }) + ) + } else { + matches!( + &self.chat_status, + Some(CopilotChatStatus::SigningIn { .. } | CopilotChatStatus::Starting) + ) + } } fn is_error(&self) -> bool { - matches!(&self.copilot_status, Some(Status::Error(_))) + if self.edit_prediction { + matches!(&self.copilot_status, Some(Status::Error(_))) + } else { + matches!(&self.chat_status, Some(CopilotChatStatus::Error(_))) + } } fn has_no_status(&self) -> bool { - self.copilot_status.is_none() + if self.edit_prediction { + self.copilot_status.is_none() + } else { + self.chat_status.is_none() + } } fn loading_message(&self) -> Option { + let product = if self.edit_prediction { + "Copilot Edit Predictions" + } else { + "Copilot Chat" + }; if self.is_starting() { - Some("Starting Copilot…".into()) + Some(format!("Starting {product}…").into()) } else if self.is_signing_in() { - Some("Signing into Copilot…".into()) + Some(format!("Signing into {product}…").into()) } else { None } @@ -582,10 +903,15 @@ impl ConfigurationView { }) .style(ButtonStyle::Outlined) .when(edit_prediction, |this| this.tab_index(0isize)) - .on_click(|_, window, cx| { - let app_state = AppState::global(cx); - if let Some(copilot) = GlobalCopilotAuth::try_get_or_init(app_state, cx) { - initiate_sign_in(copilot.0, window, cx) + .on_click(move |_, window, cx| { + if edit_prediction { + let app_state = AppState::global(cx); + if let Some(copilot) = GlobalCopilotAuth::try_get_or_init(app_state, cx) { + initiate_sign_in(copilot.0, window, cx) + } + } else if let Some(copilot_chat) = CopilotChat::global(cx) { + copilot_chat.update(cx, |copilot_chat, cx| copilot_chat.sign_in(cx)); + open_copilot_chat_code_verification_window(&copilot_chat, window, cx); } }) } @@ -640,7 +966,7 @@ impl ConfigurationView { }; let start_label = "To use Copilot for edit predictions, you need to be logged in to GitHub. Note that your GitHub account must have an active Copilot subscription.".into(); - let no_status_label = "Copilot requires an active GitHub Copilot subscription. Please ensure Copilot is configured and try again, or use a different edit predictions provider.".into(); + let no_status_label = "Copilot Edit Predictions requires an active GitHub Copilot subscription. Please ensure Copilot Edit Predictions is configured and try again, or use a different edit predictions provider.".into(); if let Some(msg) = self.loading_message() { container( @@ -670,8 +996,8 @@ impl ConfigurationView { } fn render_for_chat(&self) -> impl IntoElement { - let start_label = "To use Zed's agent with GitHub Copilot, you need to be logged in to GitHub. Note that your GitHub account must have an active Copilot Chat subscription."; - let no_status_label = "Copilot Chat requires an active GitHub Copilot subscription. Please ensure Copilot is configured and try again, or use a different LLM provider."; + let start_label = "To use Zed's agent with GitHub Copilot Chat, you need to be logged in to GitHub. Note that your GitHub account must have an active Copilot Chat subscription."; + let no_status_label = "Copilot Chat requires an active GitHub Copilot subscription. Please ensure Copilot Chat is configured and try again, or use a different LLM provider."; let (label, button) = if let Some(msg) = self.loading_message() { ( @@ -680,8 +1006,8 @@ impl ConfigurationView { ) } else if self.is_error() { ( - ERROR_LABEL, - self.render_reinstall_button(false).into_any_element(), + "Copilot Chat had an issue signing in. Please try again.", + self.render_sign_in_button(false).into_any_element(), ) } else if self.has_no_status() { ( @@ -705,17 +1031,22 @@ impl ConfigurationView { impl Render for ConfigurationView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let is_authenticated = &self.is_authenticated; + let edit_prediction = self.edit_prediction; if is_authenticated(cx) { - let sign_out = |_: &gpui::ClickEvent, window: &mut Window, cx: &mut App| { - if let Some(auth) = GlobalCopilotAuth::try_global(cx) { - initiate_sign_out(auth.0.clone(), window, cx); - } - }; - return ConfiguredApiCard::new("copilot-authorized", "Authorized") .button_label("Sign Out") - .on_click(sign_out) + .on_click(move |_, window, cx| { + if edit_prediction { + if let Some(auth) = GlobalCopilotAuth::try_global(cx) { + initiate_sign_out(auth.0.clone(), window, cx); + } + } else if let Some(copilot_chat) = CopilotChat::global(cx) { + copilot_chat + .update(cx, |copilot_chat, cx| copilot_chat.sign_out(cx)) + .detach_and_log_err(cx); + } + }) .into_any_element(); } diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index 850851dc69..32ac25e9f6 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -21,7 +21,7 @@ use cloud_llm_client::{ ZED_VERSION_HEADER_NAME, }; use collections::{HashMap, HashSet}; -use copilot::{Copilot, Reinstall, SignIn, SignOut}; +use copilot::{Copilot, Reinstall}; use credentials_provider::CredentialsProvider; use db::kvp::{Dismissable, KeyValueStore}; use edit_prediction_context::{RelatedExcerptStore, RelatedExcerptStoreEvent, RelatedFile}; @@ -3528,21 +3528,11 @@ pub fn init(cx: &mut App) { }) } - workspace.register_action(|workspace, _: &SignIn, window, cx| { - if let Some(copilot) = copilot_for_project(workspace.project(), cx) { - copilot_ui::initiate_sign_in(copilot, window, cx); - } - }); workspace.register_action(|workspace, _: &Reinstall, window, cx| { if let Some(copilot) = copilot_for_project(workspace.project(), cx) { copilot_ui::reinstall_and_sign_in(copilot, window, cx); } }); - workspace.register_action(|workspace, _: &SignOut, window, cx| { - if let Some(copilot) = copilot_for_project(workspace.project(), cx) { - copilot_ui::initiate_sign_out(copilot, window, cx); - } - }); }) .detach(); } diff --git a/crates/edit_prediction_ui/src/edit_prediction_button.rs b/crates/edit_prediction_ui/src/edit_prediction_button.rs index 703fe9f3d8..b4d74f967b 100644 --- a/crates/edit_prediction_ui/src/edit_prediction_button.rs +++ b/crates/edit_prediction_ui/src/edit_prediction_button.rs @@ -117,10 +117,13 @@ impl Render for EditPredictionButton { workspace.show_toast( Toast::new( NotificationId::unique::(), - format!("Copilot can't be started: {}", e), + format!( + "Copilot Edit Predictions can't be started: {}", + e + ), ) .on_click( - "Reinstall Copilot", + "Reinstall Copilot Edit Predictions", move |window, cx| { copilot_ui::reinstall_and_sign_in( copilot.clone(), @@ -135,7 +138,11 @@ impl Render for EditPredictionButton { } })) .tooltip(|_window, cx| { - Tooltip::for_action("GitHub Copilot", &ToggleMenu, cx) + Tooltip::for_action( + "GitHub Copilot Edit Predictions", + &ToggleMenu, + cx, + ) }), ); } @@ -177,7 +184,13 @@ impl Render for EditPredictionButton { IconButton::new("copilot-icon", icon) .tab_index(0isize) .aria_label("GitHub Copilot"), - |_window, cx| Tooltip::for_action("GitHub Copilot", &ToggleMenu, cx), + |_window, cx| { + Tooltip::for_action( + "GitHub Copilot Edit Predictions", + &ToggleMenu, + cx, + ) + }, ) .with_handle(self.popover_menu_handle.clone()), ) @@ -662,21 +675,27 @@ impl EditPredictionButton { let project = self.project.clone(); ContextMenu::build(window, cx, |menu, _, cx| { let menu = menu - .entry("Sign In to Copilot", None, move |window, cx| { - telemetry::event!( - "Edit Prediction Menu Action", - action = "sign_in", - provider = "copilot", - ); - if let Some(copilot) = EditPredictionStore::try_global(cx).and_then(|store| { - store.update(cx, |this, cx| { - this.start_copilot_for_project(&project.upgrade()?, cx) - }) - }) { - copilot_ui::initiate_sign_in(copilot, window, cx); - } - }) - .entry("Disable Copilot", None, { + .entry( + "Sign In to Copilot Edit Predictions", + None, + move |window, cx| { + telemetry::event!( + "Edit Prediction Menu Action", + action = "sign_in", + provider = "copilot", + ); + if let Some(copilot) = + EditPredictionStore::try_global(cx).and_then(|store| { + store.update(cx, |this, cx| { + this.start_copilot_for_project(&project.upgrade()?, cx) + }) + }) + { + copilot_ui::initiate_sign_in(copilot, window, cx); + } + }, + ) + .entry("Disable Copilot Edit Predictions", None, { let fs = fs.clone(); move |_window, cx| { telemetry::event!( @@ -1057,7 +1076,11 @@ impl EditPredictionButton { "Go to Copilot Settings", OpenBrowser { url: settings_url }.boxed_clone(), ) - .action("Sign Out", copilot::SignOut.boxed_clone()); + .entry("Sign Out", None, |window, cx| { + if let Some(auth) = copilot::GlobalCopilotAuth::try_global(cx) { + copilot_ui::initiate_sign_out(auth.0.clone(), window, cx); + } + }); menu }) } diff --git a/crates/language_models/Cargo.toml b/crates/language_models/Cargo.toml index dbefcd9a76..eb9eb03d3e 100644 --- a/crates/language_models/Cargo.toml +++ b/crates/language_models/Cargo.toml @@ -28,7 +28,6 @@ cloud_api_types.workspace = true collections.workspace = true component.workspace = true convert_case.workspace = true -copilot.workspace = true copilot_chat.workspace = true copilot_ui.workspace = true credentials_provider.workspace = true diff --git a/crates/language_models/src/provider/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs index f5d16161ba..8331eaac08 100644 --- a/crates/language_models/src/provider/copilot_chat.rs +++ b/crates/language_models/src/provider/copilot_chat.rs @@ -5,7 +5,6 @@ use std::sync::Arc; use anthropic::AnthropicModelMode; use anyhow::{Result, anyhow}; use collections::HashMap; -use copilot::{GlobalCopilotAuth, Status}; use copilot_chat::responses as copilot_responses; use copilot_chat::{ ChatLocation, ChatMessage, ChatMessageContent, ChatMessagePart, CopilotChat, @@ -144,37 +143,9 @@ impl LanguageModelProvider for CopilotChatLanguageModelProvider { fn authenticate(&self, cx: &mut App) -> Task> { if self.is_authenticated(cx) { return Task::ready(Ok(())); - }; + } - let Some(copilot) = GlobalCopilotAuth::try_global(cx).cloned() else { - return Task::ready(Err(anyhow!(concat!( - "Copilot must be enabled for Copilot Chat to work. ", - "Please enable Copilot and try again." - )) - .into())); - }; - - let err = match copilot.0.read(cx).status() { - Status::Authorized => return Task::ready(Ok(())), - Status::Disabled => anyhow!( - "Copilot must be enabled for Copilot Chat to work. Please enable Copilot and try again." - ), - Status::Error(err) => anyhow!(format!( - "Received the following error while signing into Copilot: {err}" - )), - Status::Starting { task: _ } => anyhow!( - "Copilot is still starting, please wait for Copilot to start then try again" - ), - Status::Unauthorized => anyhow!( - "Unable to authorize with Copilot. Please make sure that you have an active Copilot and Copilot Chat subscription." - ), - Status::SignedOut { .. } => { - anyhow!("You have signed out of Copilot. Please sign in to Copilot and try again.") - } - Status::SigningIn { prompt: _ } => anyhow!("Still signing into Copilot..."), - }; - - Task::ready(Err(err.into())) + Task::ready(Err(AuthenticateError::CredentialsNotFound)) } fn settings_view(&self, cx: &mut App) -> Option { @@ -182,7 +153,7 @@ impl LanguageModelProvider for CopilotChatLanguageModelProvider { let title = if is_authenticated { None } else { - Some("Configure Copilot".into()) + Some("Configure Copilot Chat".into()) }; let description = if is_authenticated { None @@ -214,6 +185,19 @@ impl LanguageModelProvider for CopilotChatLanguageModelProvider { }, )) } + + fn set_api_key(&self, key: Option, cx: &mut App) -> Task> { + // Copilot authenticates via an OAuth device flow rather than an API key, + // so the only meaningful credential change here is clearing it (which + // signs the user out of the agent provider). + if key.is_some() { + return Task::ready(Ok(())); + } + let Some(copilot_chat) = CopilotChat::global(cx) else { + return Task::ready(Ok(())); + }; + copilot_chat.update(cx, |chat, cx| chat.sign_out(cx)) + } } pub struct CopilotChatLanguageModel { diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 0c161e2e56..0ca0c53afb 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -681,7 +681,6 @@ fn main() { .clone(), }; copilot_chat::init( - app_state.fs.clone(), app_state.client.http_client(), copilot_chat_configuration, cx, diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index c5db036e50..72c6860c7d 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -5637,6 +5637,7 @@ mod tests { "console", "context_server", "copilot", + "copilot_edit_predictions", "csv", "debug_panel", "debugger", @@ -5890,7 +5891,6 @@ mod tests { outline_panel::init(cx); terminal_view::init(cx); copilot_chat::init( - app_state.fs.clone(), app_state.client.http_client(), copilot_chat::CopilotChatConfiguration::default(), cx, diff --git a/crates/zed/src/zed/edit_prediction_registry.rs b/crates/zed/src/zed/edit_prediction_registry.rs index 616063431b..9f50956044 100644 --- a/crates/zed/src/zed/edit_prediction_registry.rs +++ b/crates/zed/src/zed/edit_prediction_registry.rs @@ -29,8 +29,6 @@ pub fn init(client: Arc, user_store: Entity, cx: &mut App) { return; } - register_backward_compatible_actions(editor, cx); - let Some(window) = window else { return; }; @@ -229,19 +227,6 @@ fn assign_edit_prediction_providers( } } -fn register_backward_compatible_actions(editor: &mut Editor, cx: &mut Context) { - // We renamed some of these actions to not be copilot-specific, but that - // would have not been backwards-compatible. So here we are re-registering - // the actions with the old names to not break people's keymaps. - editor - .register_action(cx.listener( - |editor, _: &copilot::Suggest, window: &mut Window, cx: &mut Context| { - editor.show_edit_prediction(&Default::default(), window, cx); - }, - )) - .detach(); -} - fn assign_edit_prediction_provider( editor: &mut Editor, provider_config: Option,