diff --git a/Cargo.lock b/Cargo.lock index ade31e169e..36c32924c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1339,6 +1339,7 @@ dependencies = [ "async-compression", "client", "collections", + "context_menu", "futures 0.3.25", "gpui", "language", diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index 03e24c8bc3..1a8350bb53 100644 --- a/assets/keymaps/default.json +++ b/assets/keymaps/default.json @@ -177,7 +177,9 @@ "focus": false } ], - "alt-]": "copilot::NextSuggestion" + "alt-]": "copilot::NextSuggestion", + "alt-[": "copilot::PreviousSuggestion", + "alt-\\": "copilot::Toggle" } }, { diff --git a/assets/settings/default.json b/assets/settings/default.json index 7b775d6309..fbb52e00dc 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -13,6 +13,11 @@ // The factor to grow the active pane by. Defaults to 1.0 // which gives the same size as all other panes. "active_pane_magnification": 1.0, + // Enable / disable copilot integration. + "enable_copilot_integration": true, + // Controls whether copilot provides suggestion immediately + // or waits for a `copilot::Toggle` + "copilot": "on", // Whether to enable vim modes and key bindings "vim_mode": false, // Whether to show the informational hover box when moving the mouse @@ -120,7 +125,7 @@ // Settings specific to the terminal "terminal": { // What shell to use when opening a terminal. May take 3 values: - // 1. Use the system's default terminal configuration (e.g. $TERM). + // 1. Use the system's default terminal configuration in /etc/passwd // "shell": "system" // 2. A program: // "shell": { @@ -200,7 +205,9 @@ // Different settings for specific languages. "languages": { "Plain Text": { - "soft_wrap": "preferred_line_length" + "soft_wrap": "preferred_line_length", + // Copilot can be a little strange on non-code files + "copilot": "off" }, "Elixir": { "tab_size": 2 @@ -210,7 +217,9 @@ "hard_tabs": true }, "Markdown": { - "soft_wrap": "preferred_line_length" + "soft_wrap": "preferred_line_length", + // Copilot can be a little strange on non-code files + "copilot": "off" }, "JavaScript": { "tab_size": 2 @@ -223,6 +232,9 @@ }, "YAML": { "tab_size": 2 + }, + "JSON": { + "copilot": "off" } }, // LSP Specific settings. diff --git a/crates/copilot/Cargo.toml b/crates/copilot/Cargo.toml index f39ab604e2..47f49f9910 100644 --- a/crates/copilot/Cargo.toml +++ b/crates/copilot/Cargo.toml @@ -10,6 +10,7 @@ doctest = false [dependencies] collections = { path = "../collections" } +context_menu = { path = "../context_menu" } gpui = { path = "../gpui" } language = { path = "../language" } settings = { path = "../settings" } diff --git a/crates/copilot/src/copilot.rs b/crates/copilot/src/copilot.rs index 61bb408de4..efa693278e 100644 --- a/crates/copilot/src/copilot.rs +++ b/crates/copilot/src/copilot.rs @@ -1,3 +1,4 @@ +pub mod copilot_button; mod request; mod sign_in; @@ -24,7 +25,7 @@ const COPILOT_AUTH_NAMESPACE: &'static str = "copilot_auth"; actions!(copilot_auth, [SignIn, SignOut]); const COPILOT_NAMESPACE: &'static str = "copilot"; -actions!(copilot, [NextSuggestion]); +actions!(copilot, [NextSuggestion, PreviousSuggestion, Toggle]); pub fn init(client: Arc, node_runtime: Arc, cx: &mut MutableAppContext) { let copilot = cx.add_model(|cx| Copilot::start(client.http_client(), node_runtime, cx)); @@ -67,9 +68,11 @@ pub fn init(client: Arc, node_runtime: Arc, cx: &mut Mutabl } enum CopilotServer { - Downloading, - Error(Arc), Disabled, + Starting { + _task: Shared>, + }, + Error(Arc), Started { server: Arc, status: SignInStatus, @@ -93,7 +96,7 @@ enum SignInStatus { #[derive(Debug, PartialEq, Eq)] pub enum Status { - Downloading, + Starting, Error(Arc), Disabled, SignedOut, @@ -138,45 +141,46 @@ impl Copilot { node_runtime: Arc, cx: &mut ModelContext, ) -> Self { - // TODO: Make this task resilient to users thrashing the copilot setting cx.observe_global::({ let http = http.clone(); let node_runtime = node_runtime.clone(); move |this, cx| { - if cx.global::().copilot.as_bool() { + if cx.global::().enable_copilot_integration { if matches!(this.server, CopilotServer::Disabled) { - cx.spawn({ - let http = http.clone(); - let node_runtime = node_runtime.clone(); - move |this, cx| { - Self::start_language_server(http, node_runtime, this, cx) - } - }) - .detach(); + let start_task = cx + .spawn({ + let http = http.clone(); + let node_runtime = node_runtime.clone(); + move |this, cx| { + Self::start_language_server(http, node_runtime, this, cx) + } + }) + .shared(); + this.server = CopilotServer::Starting { _task: start_task } } } else { - // TODO: What else needs to be turned off here? this.server = CopilotServer::Disabled } } }) .detach(); - if !cx.global::().copilot.as_bool() { - return Self { + if cx.global::().enable_copilot_integration { + let start_task = cx + .spawn({ + let http = http.clone(); + let node_runtime = node_runtime.clone(); + move |this, cx| Self::start_language_server(http, node_runtime, this, cx) + }) + .shared(); + + Self { + server: CopilotServer::Starting { _task: start_task }, + } + } else { + Self { server: CopilotServer::Disabled, - }; - } - - cx.spawn({ - let http = http.clone(); - let node_runtime = node_runtime.clone(); - move |this, cx| Self::start_language_server(http, node_runtime, this, cx) - }) - .detach(); - - Self { - server: CopilotServer::Downloading, + } } } @@ -216,6 +220,7 @@ impl Copilot { } Err(error) => { this.server = CopilotServer::Error(error.to_string().into()); + cx.notify() } } }) @@ -226,11 +231,10 @@ impl Copilot { if let CopilotServer::Started { server, status } = &mut self.server { let task = match status { SignInStatus::Authorized { .. } | SignInStatus::Unauthorized { .. } => { - cx.notify(); Task::ready(Ok(())).shared() } SignInStatus::SigningIn { task, .. } => { - cx.notify(); // To re-show the prompt, just in case. + cx.notify(); task.clone() } SignInStatus::SignedOut => { @@ -382,7 +386,7 @@ impl Copilot { pub fn status(&self) -> Status { match &self.server { - CopilotServer::Downloading => Status::Downloading, + CopilotServer::Starting { .. } => Status::Starting, CopilotServer::Disabled => Status::Disabled, CopilotServer::Error(error) => Status::Error(error.clone()), CopilotServer::Started { status, .. } => match status { @@ -403,13 +407,15 @@ impl Copilot { ) { if let CopilotServer::Started { status, .. } = &mut self.server { *status = match lsp_status { - request::SignInStatus::Ok { user } | request::SignInStatus::MaybeOk { user } => { + request::SignInStatus::Ok { user } + | request::SignInStatus::MaybeOk { user } + | request::SignInStatus::AlreadySignedIn { user } => { SignInStatus::Authorized { _user: user } } request::SignInStatus::NotAuthorized { user } => { SignInStatus::Unauthorized { _user: user } } - _ => SignInStatus::SignedOut, + request::SignInStatus::NotSignedIn => SignInStatus::SignedOut, }; cx.notify(); } @@ -417,7 +423,7 @@ impl Copilot { fn authorized_server(&self) -> Result> { match &self.server { - CopilotServer::Downloading => Err(anyhow!("copilot is still downloading")), + CopilotServer::Starting { .. } => Err(anyhow!("copilot is still starting")), CopilotServer::Disabled => Err(anyhow!("copilot is disabled")), CopilotServer::Error(error) => Err(anyhow!( "copilot was not started because of an error: {}", diff --git a/crates/copilot/src/copilot_button.rs b/crates/copilot/src/copilot_button.rs new file mode 100644 index 0000000000..9c8a8c4d6e --- /dev/null +++ b/crates/copilot/src/copilot_button.rs @@ -0,0 +1,172 @@ +// use context_menu::{ContextMenu, ContextMenuItem}; +// use gpui::{ +// elements::*, impl_internal_actions, CursorStyle, Element, ElementBox, Entity, MouseButton, +// MutableAppContext, RenderContext, View, ViewContext, ViewHandle, WeakModelHandle, +// WeakViewHandle, +// }; +// use settings::Settings; +// use std::any::TypeId; +// use workspace::{dock::FocusDock, item::ItemHandle, NewTerminal, StatusItemView, Workspace}; + +// #[derive(Clone, PartialEq)] +// pub struct DeployTerminalMenu; + +// impl_internal_actions!(terminal, [DeployTerminalMenu]); + +// pub fn init(cx: &mut MutableAppContext) { +// cx.add_action(CopilotButton::deploy_terminal_menu); +// } + +// pub struct CopilotButton { +// workspace: WeakViewHandle, +// popup_menu: ViewHandle, +// } + +// impl Entity for CopilotButton { +// type Event = (); +// } + +// impl View for CopilotButton { +// fn ui_name() -> &'static str { +// "TerminalButton" +// } + +// fn render(&mut self, cx: &mut RenderContext<'_, Self>) -> ElementBox { +// let workspace = self.workspace.upgrade(cx); +// let project = match workspace { +// Some(workspace) => workspace.read(cx).project().read(cx), +// None => return Empty::new().boxed(), +// }; + +// let focused_view = cx.focused_view_id(cx.window_id()); +// let active = focused_view +// .map(|view_id| { +// cx.view_type_id(cx.window_id(), view_id) == Some(TypeId::of::()) +// }) +// .unwrap_or(false); + +// let has_terminals = !project.local_terminal_handles().is_empty(); +// let terminal_count = project.local_terminal_handles().len() as i32; +// let theme = cx.global::().theme.clone(); + +// Stack::new() +// .with_child( +// MouseEventHandler::::new(0, cx, { +// let theme = theme.clone(); +// move |state, _cx| { +// let style = theme +// .workspace +// .status_bar +// .sidebar_buttons +// .item +// .style_for(state, active); + +// Flex::row() +// .with_child( +// Svg::new("icons/terminal_12.svg") +// .with_color(style.icon_color) +// .constrained() +// .with_width(style.icon_size) +// .aligned() +// .named("terminals-icon"), +// ) +// .with_children(has_terminals.then(|| { +// Label::new(terminal_count.to_string(), style.label.text.clone()) +// .contained() +// .with_style(style.label.container) +// .aligned() +// .boxed() +// })) +// .constrained() +// .with_height(style.icon_size) +// .contained() +// .with_style(style.container) +// .boxed() +// } +// }) +// .with_cursor_style(CursorStyle::PointingHand) +// .on_click(MouseButton::Left, move |_, cx| { +// if has_terminals { +// cx.dispatch_action(DeployTerminalMenu); +// } else { +// if !active { +// cx.dispatch_action(FocusDock); +// } +// }; +// }) +// .with_tooltip::( +// 0, +// "Show Terminal".into(), +// Some(Box::new(FocusDock)), +// theme.tooltip.clone(), +// cx, +// ) +// .boxed(), +// ) +// .with_child( +// ChildView::new(&self.popup_menu, cx) +// .aligned() +// .top() +// .right() +// .boxed(), +// ) +// .boxed() +// } +// } + +// impl CopilotButton { +// pub fn new(workspace: ViewHandle, cx: &mut ViewContext) -> Self { +// cx.observe(&workspace, |_, _, cx| cx.notify()).detach(); +// Self { +// workspace: workspace.downgrade(), +// popup_menu: cx.add_view(|cx| { +// let mut menu = ContextMenu::new(cx); +// menu.set_position_mode(OverlayPositionMode::Local); +// menu +// }), +// } +// } + +// pub fn deploy_terminal_menu( +// &mut self, +// _action: &DeployTerminalMenu, +// cx: &mut ViewContext, +// ) { +// let mut menu_options = vec![ContextMenuItem::item("New Terminal", NewTerminal)]; + +// if let Some(workspace) = self.workspace.upgrade(cx) { +// let project = workspace.read(cx).project().read(cx); +// let local_terminal_handles = project.local_terminal_handles(); + +// if !local_terminal_handles.is_empty() { +// menu_options.push(ContextMenuItem::Separator) +// } + +// for local_terminal_handle in local_terminal_handles { +// if let Some(terminal) = local_terminal_handle.upgrade(cx) { +// menu_options.push(ContextMenuItem::item( +// terminal.read(cx).title(), +// // FocusTerminal { +// // terminal_handle: local_terminal_handle.clone(), +// // }, +// )) +// } +// } +// } + +// self.popup_menu.update(cx, |menu, cx| { +// menu.show( +// Default::default(), +// AnchorCorner::BottomRight, +// menu_options, +// cx, +// ); +// }); +// } +// } + +// impl StatusItemView for CopilotButton { +// fn set_active_pane_item(&mut self, _: Option<&dyn ItemHandle>, cx: &mut ViewContext) { +// cx.notify(); +// } +// } diff --git a/crates/copilot/src/sign_in.rs b/crates/copilot/src/sign_in.rs index fb31f9a8e8..0a9299f512 100644 --- a/crates/copilot/src/sign_in.rs +++ b/crates/copilot/src/sign_in.rs @@ -1,9 +1,7 @@ use crate::{request::PromptUserDeviceFlow, Copilot, Status}; use gpui::{ - elements::*, - geometry::{rect::RectF, vector::vec2f}, - ClipboardItem, Element, Entity, MutableAppContext, View, ViewContext, ViewHandle, WindowKind, - WindowOptions, + elements::*, geometry::rect::RectF, ClipboardItem, Element, Entity, MutableAppContext, View, + ViewContext, ViewHandle, WindowKind, WindowOptions, }; use settings::Settings; diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index ccd090c409..4dbbf66a84 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -390,6 +390,8 @@ pub fn init(cx: &mut MutableAppContext) { cx.add_async_action(Editor::confirm_rename); cx.add_async_action(Editor::find_all_references); cx.add_action(Editor::next_copilot_suggestion); + cx.add_action(Editor::previous_copilot_suggestion); + cx.add_action(Editor::toggle_copilot_suggestions); hover_popover::init(cx); link_go_to_definition::init(cx); @@ -1011,6 +1013,7 @@ struct CopilotState { pending_refresh: Task>, completions: Vec, active_completion_index: usize, + user_enabled: Option, } impl Default for CopilotState { @@ -1020,6 +1023,7 @@ impl Default for CopilotState { pending_refresh: Task::ready(Some(())), completions: Default::default(), active_completion_index: 0, + user_enabled: None, } } } @@ -2745,12 +2749,40 @@ impl Editor { fn refresh_copilot_suggestions(&mut self, cx: &mut ViewContext) -> Option<()> { let copilot = Copilot::global(cx)?; + if self.mode != EditorMode::Full { return None; } + let settings = cx.global::(); + + dbg!(self.copilot_state.user_enabled); + + if !self + .copilot_state + .user_enabled + .unwrap_or_else(|| settings.copilot_on(None)) + { + return None; + } + let snapshot = self.buffer.read(cx).snapshot(cx); let selection = self.selections.newest_anchor(); + + if !self.copilot_state.user_enabled.is_some() { + let language_name = snapshot + .language_at(selection.start) + .map(|language| language.name()); + + let copilot_enabled = settings.copilot_on(language_name.as_deref()); + + dbg!(language_name, copilot_enabled); + + if !copilot_enabled { + return None; + } + } + let cursor = if selection.start == selection.end { selection.start.bias_left(&snapshot) } else { @@ -2829,16 +2861,76 @@ impl Editor { } fn next_copilot_suggestion(&mut self, _: &copilot::NextSuggestion, cx: &mut ViewContext) { + // Auto re-enable copilot if you're asking for a suggestion + if self.copilot_state.user_enabled == Some(false) { + self.copilot_state.user_enabled = Some(true); + } + if self.copilot_state.completions.is_empty() { self.refresh_copilot_suggestions(cx); return; } + self.copilot_state.active_completion_index = + (self.copilot_state.active_completion_index + 1) % self.copilot_state.completions.len(); + + self.sync_suggestion(cx); + } + + fn previous_copilot_suggestion( + &mut self, + _: &copilot::PreviousSuggestion, + cx: &mut ViewContext, + ) { + // Auto re-enable copilot if you're asking for a suggestion + if self.copilot_state.user_enabled == Some(false) { + self.copilot_state.user_enabled = Some(true); + } + + if self.copilot_state.completions.is_empty() { + self.refresh_copilot_suggestions(cx); + return; + } + + self.copilot_state.active_completion_index = + if self.copilot_state.active_completion_index == 0 { + self.copilot_state.completions.len() - 1 + } else { + self.copilot_state.active_completion_index - 1 + }; + + self.sync_suggestion(cx); + } + + fn toggle_copilot_suggestions(&mut self, _: &copilot::Toggle, cx: &mut ViewContext) { + self.copilot_state.user_enabled = match self.copilot_state.user_enabled { + Some(enabled) => Some(!enabled), + None => { + let selection = self.selections.newest_anchor().start; + + let language_name = self + .snapshot(cx) + .language_at(selection) + .map(|language| language.name()); + + let copilot_enabled = cx.global::().copilot_on(language_name.as_deref()); + + Some(!copilot_enabled) + } + }; + + // We know this can't be None, as we just set it to Some above + if self.copilot_state.user_enabled == Some(true) { + self.refresh_copilot_suggestions(cx); + } else { + self.clear_copilot_suggestions(cx); + } + } + + fn sync_suggestion(&mut self, cx: &mut ViewContext) { let snapshot = self.buffer.read(cx).snapshot(cx); let cursor = self.selections.newest_anchor().head(); - self.copilot_state.active_completion_index = - (self.copilot_state.active_completion_index + 1) % self.copilot_state.completions.len(); if let Some(text) = self .copilot_state .text_for_active_completion(cursor, &snapshot) diff --git a/crates/settings/src/settings.rs b/crates/settings/src/settings.rs index f56364cfa8..6688b3c4d4 100644 --- a/crates/settings/src/settings.rs +++ b/crates/settings/src/settings.rs @@ -32,6 +32,7 @@ pub struct Settings { pub buffer_font_features: fonts::Features, pub buffer_font_family: FamilyId, pub default_buffer_font_size: f32, + pub enable_copilot_integration: bool, pub buffer_font_size: f32, pub active_pane_magnification: f32, pub cursor_blink: bool, @@ -58,10 +59,10 @@ pub struct Settings { pub telemetry_overrides: TelemetrySettings, pub auto_update: bool, pub base_keymap: BaseKeymap, - pub copilot: CopilotSettings, } #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] pub enum CopilotSettings { #[default] On, @@ -78,7 +79,7 @@ impl From for bool { } impl CopilotSettings { - pub fn as_bool(&self) -> bool { + pub fn is_on(&self) -> bool { >::into(*self) } } @@ -176,6 +177,29 @@ pub struct EditorSettings { pub ensure_final_newline_on_save: Option, pub formatter: Option, pub enable_language_server: Option, + pub copilot: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum OnOff { + On, + Off, +} + +impl OnOff { + fn as_bool(&self) -> bool { + match self { + OnOff::On => true, + OnOff::Off => false, + } + } +} + +impl Into for OnOff { + fn into(self) -> bool { + self.as_bool() + } } #[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -399,7 +423,7 @@ pub struct SettingsFileContent { #[serde(default)] pub base_keymap: Option, #[serde(default)] - pub copilot: Option, + pub enable_copilot_integration: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -461,6 +485,7 @@ impl Settings { format_on_save: required(defaults.editor.format_on_save), formatter: required(defaults.editor.formatter), enable_language_server: required(defaults.editor.enable_language_server), + copilot: required(defaults.editor.copilot), }, editor_overrides: Default::default(), git: defaults.git.unwrap(), @@ -477,7 +502,7 @@ impl Settings { telemetry_overrides: Default::default(), auto_update: defaults.auto_update.unwrap(), base_keymap: Default::default(), - copilot: Default::default(), + enable_copilot_integration: defaults.enable_copilot_integration.unwrap(), } } @@ -529,7 +554,6 @@ impl Settings { merge(&mut self.autosave, data.autosave); merge(&mut self.default_dock_anchor, data.default_dock_anchor); merge(&mut self.base_keymap, data.base_keymap); - merge(&mut self.copilot, data.copilot); self.editor_overrides = data.editor; self.git_overrides = data.git.unwrap_or_default(); @@ -553,6 +577,14 @@ impl Settings { self } + pub fn copilot_on(&self, language: Option<&str>) -> bool { + if self.enable_copilot_integration { + self.language_setting(language, |settings| settings.copilot.map(Into::into)) + } else { + false + } + } + pub fn tab_size(&self, language: Option<&str>) -> NonZeroU32 { self.language_setting(language, |settings| settings.tab_size) } @@ -689,6 +721,7 @@ impl Settings { format_on_save: Some(FormatOnSave::On), formatter: Some(Formatter::LanguageServer), enable_language_server: Some(true), + copilot: Some(OnOff::On), }, editor_overrides: Default::default(), journal_defaults: Default::default(), @@ -708,7 +741,7 @@ impl Settings { telemetry_overrides: Default::default(), auto_update: true, base_keymap: Default::default(), - copilot: Default::default(), + enable_copilot_integration: true, } }