copilot: Use separate authentication paths for edit prediction and chat (#60535)
Release Notes: - copilot: Improve robustness of copilot chat provider by splitting authorisation paths for edit predictions and chat. Note: Existing users will have to re-authenticate with Copilot --------- Co-authored-by: cameron <cameron.studdstreet@gmail.com> Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
This commit is contained in:
14 files changed
+805
-433
No files matched your search
Generated
+3
-5
@@ -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",
|
||||
|
||||
@@ -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::<Vec<_>>() {
|
||||
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::<Vec<_>>() {
|
||||
self.unregister_buffer(&buffer);
|
||||
}
|
||||
@@ -1297,40 +1286,15 @@ impl Copilot {
|
||||
}
|
||||
|
||||
fn update_action_visibilities(&self, cx: &mut App) {
|
||||
let signed_in_actions = [
|
||||
TypeId::of::<Suggest>(),
|
||||
TypeId::of::<NextSuggestion>(),
|
||||
TypeId::of::<PreviousSuggestion>(),
|
||||
TypeId::of::<Reinstall>(),
|
||||
];
|
||||
let auth_actions = [TypeId::of::<SignOut>()];
|
||||
let no_auth_actions = [TypeId::of::<SignIn>()];
|
||||
let status = self.status();
|
||||
let signed_in_actions = [TypeId::of::<Reinstall>()];
|
||||
|
||||
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<Copilot>) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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<CopilotChat>);
|
||||
|
||||
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<str>),
|
||||
}
|
||||
|
||||
pub struct CopilotChat {
|
||||
status: CopilotChatStatus,
|
||||
oauth_token: Option<String>,
|
||||
api_endpoint: Option<String>,
|
||||
configuration: CopilotChatConfiguration,
|
||||
models: Option<Vec<Model>>,
|
||||
client: Arc<dyn HttpClient>,
|
||||
fs: Arc<dyn Fs>,
|
||||
credentials_provider: Arc<dyn CredentialsProvider>,
|
||||
sign_in_task: Option<Task<()>>,
|
||||
}
|
||||
|
||||
pub fn init(
|
||||
fs: Arc<dyn Fs>,
|
||||
client: Arc<dyn HttpClient>,
|
||||
configuration: CopilotChatConfiguration,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let copilot_chat = cx.new(|cx| CopilotChat::new(fs, client, configuration, cx));
|
||||
pub fn init(client: Arc<dyn HttpClient>, 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<String> {
|
||||
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<dyn CredentialsProvider>,
|
||||
cx: &AsyncApp,
|
||||
) -> Option<String> {
|
||||
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<gpui::Entity<Self>> {
|
||||
cx.try_global::<GlobalCopilotChat>()
|
||||
@@ -551,55 +582,144 @@ impl CopilotChat {
|
||||
}
|
||||
|
||||
fn new(
|
||||
fs: Arc<dyn Fs>,
|
||||
client: Arc<dyn HttpClient>,
|
||||
credentials_provider: Arc<dyn CredentialsProvider>,
|
||||
configuration: CopilotChatConfiguration,
|
||||
cx: &mut Context<Self>,
|
||||
) -> 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<PathBuf> =
|
||||
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<Self>) {
|
||||
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<Self>) -> Task<Result<()>> {
|
||||
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<Self>, cx: &mut AsyncApp) -> Result<()> {
|
||||
@@ -776,39 +896,6 @@ impl CopilotChat {
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reload_auth(&mut self, cx: &mut Context<Self>) {
|
||||
let fs = self.fs.clone();
|
||||
let oauth_domain = self.configuration.oauth_domain();
|
||||
cx.spawn(async move |this, cx| {
|
||||
let config_paths: HashSet<PathBuf> = 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<dyn Fs>,
|
||||
config_paths: &HashSet<PathBuf>,
|
||||
oauth_domain: &str,
|
||||
auth_db_path: &std::path::Path,
|
||||
cx: &AsyncApp,
|
||||
) -> Option<String> {
|
||||
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<String> {
|
||||
serde_json::from_str::<serde_json::Value>(&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<String> {
|
||||
if !db_path.exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let db = sqlez::connection::Connection::open_file(db_path.to_str()?);
|
||||
|
||||
let token_bytes: Option<Vec<u8>> = db
|
||||
.select_row_bound::<&str, Vec<u8>>(
|
||||
"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_<alphanumeric>)"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
async fn stream_completion(
|
||||
client: Arc<dyn HttpClient>,
|
||||
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<u8>, 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());
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// Begins a device-code authorization by requesting a user code from GitHub.
|
||||
pub async fn request_device_code(
|
||||
client: &Arc<dyn HttpClient>,
|
||||
configuration: &CopilotChatConfiguration,
|
||||
) -> Result<DeviceFlow> {
|
||||
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<dyn HttpClient>,
|
||||
configuration: &CopilotChatConfiguration,
|
||||
device_flow: &DeviceFlow,
|
||||
executor: &BackgroundExecutor,
|
||||
) -> Result<String> {
|
||||
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::<Vec<_>>()
|
||||
.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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<Copilot>, window: &mut Window, cx: &mut
|
||||
}
|
||||
|
||||
pub fn initiate_sign_out(copilot: Entity<Copilot>, 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<Copilot>, window: &Win
|
||||
.log_err();
|
||||
}
|
||||
|
||||
fn open_copilot_chat_code_verification_window(
|
||||
copilot_chat: &Entity<CopilotChat>,
|
||||
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::<CopilotStatusToast>();
|
||||
|
||||
@@ -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<CopilotChat>,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl Focusable for CopilotChatCodeVerification {
|
||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<DismissEvent> for CopilotChatCodeVerification {}
|
||||
|
||||
impl CopilotChatCodeVerification {
|
||||
pub fn new(
|
||||
copilot_chat: &Entity<CopilotChat>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
window.on_window_should_close(cx, |window, cx| {
|
||||
if let Some(this) = window.root::<CopilotChatCodeVerification>().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<Self>) -> 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<Self>,
|
||||
) -> 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<Self>) -> 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<Self>) -> 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<Self>) -> 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<Self>) -> 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<Status>,
|
||||
chat_status: Option<CopilotChatStatus>,
|
||||
is_authenticated: Box<dyn Fn(&mut App) -> 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>,
|
||||
) -> 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<SharedString> {
|
||||
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<Self>) -> 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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -117,10 +117,13 @@ impl Render for EditPredictionButton {
|
||||
workspace.show_toast(
|
||||
Toast::new(
|
||||
NotificationId::unique::<CopilotErrorToast>(),
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Result<(), AuthenticateError>> {
|
||||
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<ProviderSettingsView> {
|
||||
@@ -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<String>, cx: &mut App) -> Task<Result<()>> {
|
||||
// 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 {
|
||||
|
||||
@@ -681,7 +681,6 @@ fn main() {
|
||||
.clone(),
|
||||
};
|
||||
copilot_chat::init(
|
||||
app_state.fs.clone(),
|
||||
app_state.client.http_client(),
|
||||
copilot_chat_configuration,
|
||||
cx,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -29,8 +29,6 @@ pub fn init(client: Arc<Client>, user_store: Entity<UserStore>, 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<Editor>) {
|
||||
// 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>| {
|
||||
editor.show_edit_prediction(&Default::default(), window, cx);
|
||||
},
|
||||
))
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn assign_edit_prediction_provider(
|
||||
editor: &mut Editor,
|
||||
provider_config: Option<EditPredictionProviderConfig>,
|
||||
|
||||
Reference in new issue
Block a user