diff --git a/Cargo.lock b/Cargo.lock index 3528cc0d7e5..25b9edaffea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2442,13 +2442,20 @@ name = "copilot_ui" version = "0.1.0" dependencies = [ "anyhow", + "client", "copilot", "editor", "fs", + "futures 0.3.28", "gpui", + "indoc", "language", + "lsp", "menu", + "project", + "serde_json", "settings", + "theme", "ui", "util", "workspace", @@ -3215,7 +3222,6 @@ dependencies = [ "clock", "collections", "convert_case 0.6.0", - "copilot", "ctor", "db", "emojis", diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 384c47e5d1e..4cebc4c705f 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -140,17 +140,17 @@ } }, { - "context": "Editor && mode == full && copilot_suggestion", + "context": "Editor && mode == full && inline_completion", "bindings": { - "alt-]": "copilot::NextSuggestion", - "alt-[": "copilot::PreviousSuggestion", - "alt-right": "editor::AcceptPartialCopilotSuggestion" + "alt-]": "editor::NextInlineCompletion", + "alt-[": "editor::PreviousInlineCompletion", + "alt-right": "editor::AcceptPartialInlineCompletion" } }, { - "context": "Editor && !copilot_suggestion", + "context": "Editor && !inline_completion", "bindings": { - "alt-\\": "copilot::Suggest" + "alt-\\": "editor::ShowInlineCompletion" } }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 97d7f6836bb..ae5cf7be187 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -182,17 +182,17 @@ } }, { - "context": "Editor && mode == full && copilot_suggestion", + "context": "Editor && mode == full && inline_completion", "bindings": { - "alt-]": "copilot::NextSuggestion", - "alt-[": "copilot::PreviousSuggestion", - "alt-right": "editor::AcceptPartialCopilotSuggestion" + "alt-]": "editor::NextInlineCompletion", + "alt-[": "editor::PreviousInlineCompletion", + "alt-right": "editor::AcceptPartialInlineCompletion" } }, { - "context": "Editor && !copilot_suggestion", + "context": "Editor && !inline_completion", "bindings": { - "alt-\\": "copilot::Suggest" + "alt-\\": "editor::ShowInlineCompletion" } }, { diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 9e150b8d0f7..5a71d892002 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -510,7 +510,7 @@ "ctrl-[": "vim::NormalBefore", "ctrl-x ctrl-o": "editor::ShowCompletions", "ctrl-x ctrl-a": "assistant::InlineAssist", // zed specific - "ctrl-x ctrl-c": "copilot::Suggest", // zed specific + "ctrl-x ctrl-c": "editor::ShowInlineCompletion", // zed specific "ctrl-x ctrl-l": "editor::ToggleCodeActions", // zed specific "ctrl-x ctrl-z": "editor::Cancel", "ctrl-w": "editor::DeleteToPreviousWordStart", diff --git a/crates/assistant/src/assistant.rs b/crates/assistant/src/assistant.rs index cb2ad19ccbc..c5ba22e623e 100644 --- a/crates/assistant/src/assistant.rs +++ b/crates/assistant/src/assistant.rs @@ -12,7 +12,7 @@ use chrono::{DateTime, Local}; use client::{proto, Client}; use command_palette_hooks::CommandPaletteFilter; pub(crate) use completion_provider::*; -use gpui::{actions, AppContext, Global, SharedString}; +use gpui::{actions, AppContext, BorrowAppContext, Global, SharedString}; pub(crate) use saved_conversation::*; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsStore}; diff --git a/crates/assistant/src/assistant_settings.rs b/crates/assistant/src/assistant_settings.rs index 9a7572f55b3..fa68eaa918e 100644 --- a/crates/assistant/src/assistant_settings.rs +++ b/crates/assistant/src/assistant_settings.rs @@ -383,7 +383,7 @@ fn merge(target: &mut T, value: Option) { #[cfg(test)] mod tests { - use gpui::AppContext; + use gpui::{AppContext, BorrowAppContext}; use settings::SettingsStore; use super::*; diff --git a/crates/assistant/src/completion_provider.rs b/crates/assistant/src/completion_provider.rs index d3cdc9e7161..73fd7b52d14 100644 --- a/crates/assistant/src/completion_provider.rs +++ b/crates/assistant/src/completion_provider.rs @@ -15,7 +15,7 @@ use crate::{ use anyhow::Result; use client::Client; use futures::{future::BoxFuture, stream::BoxStream}; -use gpui::{AnyView, AppContext, Task, WindowContext}; +use gpui::{AnyView, AppContext, BorrowAppContext, Task, WindowContext}; use settings::{Settings, SettingsStore}; use std::sync::Arc; diff --git a/crates/audio/src/audio.rs b/crates/audio/src/audio.rs index defa1e2fa92..0389bd6824c 100644 --- a/crates/audio/src/audio.rs +++ b/crates/audio/src/audio.rs @@ -1,6 +1,6 @@ use assets::SoundRegistry; use derive_more::{Deref, DerefMut}; -use gpui::{AppContext, AssetSource, Global}; +use gpui::{AppContext, AssetSource, BorrowAppContext, Global}; use rodio::{OutputStream, OutputStreamHandle}; use util::ResultExt; diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 4c84935584f..fa0733fb749 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -17,7 +17,8 @@ use futures::{ TryFutureExt as _, TryStreamExt, }; use gpui::{ - actions, AnyModel, AnyWeakModel, AppContext, AsyncAppContext, Global, Model, Task, WeakModel, + actions, AnyModel, AnyWeakModel, AppContext, AsyncAppContext, BorrowAppContext, Global, Model, + Task, WeakModel, }; use lazy_static::lazy_static; use parking_lot::RwLock; diff --git a/crates/collab/src/tests/editor_tests.rs b/crates/collab/src/tests/editor_tests.rs index c9c69f20ae5..689a6a33803 100644 --- a/crates/collab/src/tests/editor_tests.rs +++ b/crates/collab/src/tests/editor_tests.rs @@ -12,7 +12,7 @@ use editor::{ Editor, }; use futures::StreamExt; -use gpui::{TestAppContext, VisualContext, VisualTestContext}; +use gpui::{BorrowAppContext, TestAppContext, VisualContext, VisualTestContext}; use indoc::indoc; use language::{ language_settings::{AllLanguageSettings, InlayHintSettings}, diff --git a/crates/collab/src/tests/following_tests.rs b/crates/collab/src/tests/following_tests.rs index db63ddf2020..7c1179c69be 100644 --- a/crates/collab/src/tests/following_tests.rs +++ b/crates/collab/src/tests/following_tests.rs @@ -7,8 +7,8 @@ use collab_ui::{ }; use editor::{Editor, ExcerptRange, MultiBuffer}; use gpui::{ - point, BackgroundExecutor, Context, Entity, SharedString, TestAppContext, View, VisualContext, - VisualTestContext, + point, BackgroundExecutor, BorrowAppContext, Context, Entity, SharedString, TestAppContext, + View, VisualContext, VisualTestContext, }; use language::Capability; use live_kit_client::MacOSDisplay; diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index 48773363260..e4ce29586f7 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -8,8 +8,8 @@ use collections::{HashMap, HashSet}; use fs::{repository::GitFileStatus, FakeFs, Fs as _, RemoveOptions}; use futures::StreamExt as _; use gpui::{ - px, size, AppContext, BackgroundExecutor, Model, Modifiers, MouseButton, MouseDownEvent, - TestAppContext, + px, size, AppContext, BackgroundExecutor, BorrowAppContext, Model, Modifiers, MouseButton, + MouseDownEvent, TestAppContext, }; use language::{ language_settings::{AllLanguageSettings, Formatter}, diff --git a/crates/command_palette_hooks/src/command_palette_hooks.rs b/crates/command_palette_hooks/src/command_palette_hooks.rs index 5752899dd15..5c9b7979e38 100644 --- a/crates/command_palette_hooks/src/command_palette_hooks.rs +++ b/crates/command_palette_hooks/src/command_palette_hooks.rs @@ -6,7 +6,7 @@ use std::any::TypeId; use collections::HashSet; use derive_more::{Deref, DerefMut}; -use gpui::{Action, AppContext, Global}; +use gpui::{Action, AppContext, BorrowAppContext, Global}; /// Initializes the command palette hooks. pub fn init(cx: &mut AppContext) { diff --git a/crates/copilot_ui/Cargo.toml b/crates/copilot_ui/Cargo.toml index d35c13699ad..4bf3240aabd 100644 --- a/crates/copilot_ui/Cargo.toml +++ b/crates/copilot_ui/Cargo.toml @@ -14,6 +14,7 @@ doctest = false [dependencies] anyhow.workspace = true +client.workspace = true copilot.workspace = true editor.workspace = true fs.workspace = true @@ -27,4 +28,11 @@ workspace.workspace = true zed_actions.workspace = true [dev-dependencies] +copilot = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } +futures.workspace = true +indoc.workspace = true +lsp = { workspace = true, features = ["test-support"] } +project = { workspace = true, features = ["test-support"] } +serde_json.workspace = true +theme = { workspace = true, features = ["test-support"] } diff --git a/crates/copilot_ui/src/copilot_completion_provider.rs b/crates/copilot_ui/src/copilot_completion_provider.rs new file mode 100644 index 00000000000..371496a393d --- /dev/null +++ b/crates/copilot_ui/src/copilot_completion_provider.rs @@ -0,0 +1,1021 @@ +use anyhow::Result; +use client::telemetry::Telemetry; +use copilot::Copilot; +use editor::{Direction, InlineCompletionProvider}; +use gpui::{AppContext, EntityId, Model, ModelContext, Task}; +use language::{language_settings::all_language_settings, Buffer, OffsetRangeExt, ToOffset}; +use std::{path::Path, sync::Arc, time::Duration}; + +pub const COPILOT_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(75); + +pub struct CopilotCompletionProvider { + cycled: bool, + buffer_id: Option, + completions: Vec, + active_completion_index: usize, + file_extension: Option, + pending_refresh: Task>, + pending_cycling_refresh: Task>, + copilot: Model, + telemetry: Option>, +} + +impl CopilotCompletionProvider { + pub fn new(copilot: Model) -> Self { + Self { + cycled: false, + buffer_id: None, + completions: Vec::new(), + active_completion_index: 0, + file_extension: None, + pending_refresh: Task::ready(Ok(())), + pending_cycling_refresh: Task::ready(Ok(())), + copilot, + telemetry: None, + } + } + + pub fn with_telemetry(mut self, telemetry: Arc) -> Self { + self.telemetry = Some(telemetry); + self + } + + fn active_completion(&self) -> Option<&copilot::Completion> { + self.completions.get(self.active_completion_index) + } + + fn push_completion(&mut self, new_completion: copilot::Completion) { + for completion in &self.completions { + if completion.text == new_completion.text && completion.range == new_completion.range { + return; + } + } + self.completions.push(new_completion); + } +} + +impl InlineCompletionProvider for CopilotCompletionProvider { + fn is_enabled( + &self, + buffer: &Model, + cursor_position: language::Anchor, + cx: &AppContext, + ) -> bool { + if !self.copilot.read(cx).status().is_authorized() { + return false; + } + + let buffer = buffer.read(cx); + let file = buffer.file(); + let language = buffer.language_at(cursor_position); + let settings = all_language_settings(file, cx); + settings.copilot_enabled(language.as_ref(), file.map(|f| f.path().as_ref())) + } + + fn refresh( + &mut self, + buffer: Model, + cursor_position: language::Anchor, + debounce: bool, + cx: &mut ModelContext, + ) { + let copilot = self.copilot.clone(); + self.pending_refresh = cx.spawn(|this, mut cx| async move { + if debounce { + cx.background_executor() + .timer(COPILOT_DEBOUNCE_TIMEOUT) + .await; + } + + let completions = copilot + .update(&mut cx, |copilot, cx| { + copilot.completions(&buffer, cursor_position, cx) + })? + .await?; + + this.update(&mut cx, |this, cx| { + if !completions.is_empty() { + this.cycled = false; + this.pending_cycling_refresh = Task::ready(Ok(())); + this.completions.clear(); + this.active_completion_index = 0; + this.buffer_id = Some(buffer.entity_id()); + this.file_extension = buffer.read(cx).file().and_then(|file| { + Some( + Path::new(file.file_name(cx)) + .extension()? + .to_str()? + .to_string(), + ) + }); + + for completion in completions { + this.push_completion(completion); + } + cx.notify(); + } + })?; + + Ok(()) + }); + } + + fn cycle( + &mut self, + buffer: Model, + cursor_position: language::Anchor, + direction: Direction, + cx: &mut ModelContext, + ) { + if self.cycled { + match direction { + Direction::Prev => { + self.active_completion_index = if self.active_completion_index == 0 { + self.completions.len().saturating_sub(1) + } else { + self.active_completion_index - 1 + }; + } + Direction::Next => { + if self.completions.len() == 0 { + self.active_completion_index = 0 + } else { + self.active_completion_index = + (self.active_completion_index + 1) % self.completions.len(); + } + } + } + + cx.notify(); + } else { + let copilot = self.copilot.clone(); + self.pending_cycling_refresh = cx.spawn(|this, mut cx| async move { + let completions = copilot + .update(&mut cx, |copilot, cx| { + copilot.completions_cycling(&buffer, cursor_position, cx) + })? + .await?; + + this.update(&mut cx, |this, cx| { + this.cycled = true; + this.file_extension = buffer.read(cx).file().and_then(|file| { + Some( + Path::new(file.file_name(cx)) + .extension()? + .to_str()? + .to_string(), + ) + }); + for completion in completions { + this.push_completion(completion); + } + this.cycle(buffer, cursor_position, direction, cx); + })?; + + Ok(()) + }); + } + } + + fn accept(&mut self, cx: &mut ModelContext) { + if let Some(completion) = self.active_completion() { + self.copilot + .update(cx, |copilot, cx| copilot.accept_completion(completion, cx)) + .detach_and_log_err(cx); + if let Some(telemetry) = self.telemetry.as_ref() { + telemetry.report_copilot_event( + Some(completion.uuid.clone()), + true, + self.file_extension.clone(), + ); + } + } + } + + fn discard(&mut self, cx: &mut ModelContext) { + self.copilot + .update(cx, |copilot, cx| { + copilot.discard_completions(&self.completions, cx) + }) + .detach_and_log_err(cx); + if let Some(telemetry) = self.telemetry.as_ref() { + telemetry.report_copilot_event(None, false, self.file_extension.clone()); + } + } + + fn active_completion_text( + &self, + buffer: &Model, + cursor_position: language::Anchor, + cx: &AppContext, + ) -> Option<&str> { + let buffer_id = buffer.entity_id(); + let buffer = buffer.read(cx); + let completion = self.active_completion()?; + if Some(buffer_id) != self.buffer_id + || !completion.range.start.is_valid(buffer) + || !completion.range.end.is_valid(buffer) + { + return None; + } + + let mut completion_range = completion.range.to_offset(buffer); + let prefix_len = common_prefix( + buffer.chars_for_range(completion_range.clone()), + completion.text.chars(), + ); + completion_range.start += prefix_len; + let suffix_len = common_prefix( + buffer.reversed_chars_for_range(completion_range.clone()), + completion.text[prefix_len..].chars().rev(), + ); + completion_range.end = completion_range.end.saturating_sub(suffix_len); + + if completion_range.is_empty() + && completion_range.start == cursor_position.to_offset(buffer) + { + let completion_text = &completion.text[prefix_len..completion.text.len() - suffix_len]; + if completion_text.trim().is_empty() { + None + } else { + Some(completion_text) + } + } else { + None + } + } +} + +fn common_prefix, T2: Iterator>(a: T1, b: T2) -> usize { + a.zip(b) + .take_while(|(a, b)| a == b) + .map(|(a, _)| a.len_utf8()) + .sum() +} + +#[cfg(test)] +mod tests { + use super::*; + use editor::{ + test::editor_lsp_test_context::EditorLspTestContext, Editor, ExcerptRange, MultiBuffer, + }; + use fs::FakeFs; + use futures::StreamExt; + use gpui::{BackgroundExecutor, BorrowAppContext, Context, TestAppContext}; + use indoc::indoc; + use language::{ + language_settings::{AllLanguageSettings, AllLanguageSettingsContent}, + BufferId, Point, + }; + use project::Project; + use serde_json::json; + use settings::SettingsStore; + use std::future::Future; + use util::test::{marked_text_ranges_by, TextRangeMarker}; + + #[gpui::test(iterations = 10)] + async fn test_copilot(executor: BackgroundExecutor, cx: &mut TestAppContext) { + // flaky + init_test(cx, |_| {}); + + let (copilot, copilot_lsp) = Copilot::fake(cx); + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string(), ":".to_string()]), + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + let copilot_provider = cx.new_model(|_| CopilotCompletionProvider::new(copilot)); + cx.update_editor(|editor, cx| editor.set_inline_completion_provider(copilot_provider, cx)); + + // When inserting, ensure autocompletion is favored over Copilot suggestions. + cx.set_state(indoc! {" + oneˇ + two + three + "}); + cx.simulate_keystroke("."); + let _ = handle_completion_request( + &mut cx, + indoc! {" + one.|<> + two + three + "}, + vec!["completion_a", "completion_b"], + ); + handle_copilot_completion_request( + &copilot_lsp, + vec![copilot::request::Completion { + text: "one.copilot1".into(), + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)), + ..Default::default() + }], + vec![], + ); + executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); + cx.update_editor(|editor, cx| { + assert!(editor.context_menu_visible()); + assert!(!editor.has_active_inline_completion(cx)); + + // Confirming a completion inserts it and hides the context menu, without showing + // the copilot suggestion afterwards. + editor + .confirm_completion(&Default::default(), cx) + .unwrap() + .detach(); + assert!(!editor.context_menu_visible()); + assert!(!editor.has_active_inline_completion(cx)); + assert_eq!(editor.text(cx), "one.completion_a\ntwo\nthree\n"); + assert_eq!(editor.display_text(cx), "one.completion_a\ntwo\nthree\n"); + }); + + // Ensure Copilot suggestions are shown right away if no autocompletion is available. + cx.set_state(indoc! {" + oneˇ + two + three + "}); + cx.simulate_keystroke("."); + let _ = handle_completion_request( + &mut cx, + indoc! {" + one.|<> + two + three + "}, + vec![], + ); + handle_copilot_completion_request( + &copilot_lsp, + vec![copilot::request::Completion { + text: "one.copilot1".into(), + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)), + ..Default::default() + }], + vec![], + ); + executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); + cx.update_editor(|editor, cx| { + assert!(!editor.context_menu_visible()); + assert!(editor.has_active_inline_completion(cx)); + assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n"); + assert_eq!(editor.text(cx), "one.\ntwo\nthree\n"); + }); + + // Reset editor, and ensure autocompletion is still favored over Copilot suggestions. + cx.set_state(indoc! {" + oneˇ + two + three + "}); + cx.simulate_keystroke("."); + let _ = handle_completion_request( + &mut cx, + indoc! {" + one.|<> + two + three + "}, + vec!["completion_a", "completion_b"], + ); + handle_copilot_completion_request( + &copilot_lsp, + vec![copilot::request::Completion { + text: "one.copilot1".into(), + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)), + ..Default::default() + }], + vec![], + ); + executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); + cx.update_editor(|editor, cx| { + assert!(editor.context_menu_visible()); + assert!(!editor.has_active_inline_completion(cx)); + + // When hiding the context menu, the Copilot suggestion becomes visible. + editor.cancel(&Default::default(), cx); + assert!(!editor.context_menu_visible()); + assert!(editor.has_active_inline_completion(cx)); + assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n"); + assert_eq!(editor.text(cx), "one.\ntwo\nthree\n"); + }); + + // Ensure existing completion is interpolated when inserting again. + cx.simulate_keystroke("c"); + executor.run_until_parked(); + cx.update_editor(|editor, cx| { + assert!(!editor.context_menu_visible()); + assert!(editor.has_active_inline_completion(cx)); + assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n"); + assert_eq!(editor.text(cx), "one.c\ntwo\nthree\n"); + }); + + // After debouncing, new Copilot completions should be requested. + handle_copilot_completion_request( + &copilot_lsp, + vec![copilot::request::Completion { + text: "one.copilot2".into(), + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 5)), + ..Default::default() + }], + vec![], + ); + executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); + cx.update_editor(|editor, cx| { + assert!(!editor.context_menu_visible()); + assert!(editor.has_active_inline_completion(cx)); + assert_eq!(editor.display_text(cx), "one.copilot2\ntwo\nthree\n"); + assert_eq!(editor.text(cx), "one.c\ntwo\nthree\n"); + + // Canceling should remove the active Copilot suggestion. + editor.cancel(&Default::default(), cx); + assert!(!editor.has_active_inline_completion(cx)); + assert_eq!(editor.display_text(cx), "one.c\ntwo\nthree\n"); + assert_eq!(editor.text(cx), "one.c\ntwo\nthree\n"); + + // After canceling, tabbing shouldn't insert the previously shown suggestion. + editor.tab(&Default::default(), cx); + assert!(!editor.has_active_inline_completion(cx)); + assert_eq!(editor.display_text(cx), "one.c \ntwo\nthree\n"); + assert_eq!(editor.text(cx), "one.c \ntwo\nthree\n"); + + // When undoing the previously active suggestion is shown again. + editor.undo(&Default::default(), cx); + assert!(editor.has_active_inline_completion(cx)); + assert_eq!(editor.display_text(cx), "one.copilot2\ntwo\nthree\n"); + assert_eq!(editor.text(cx), "one.c\ntwo\nthree\n"); + }); + + // If an edit occurs outside of this editor, the suggestion is still correctly interpolated. + cx.update_buffer(|buffer, cx| buffer.edit([(5..5, "o")], None, cx)); + cx.update_editor(|editor, cx| { + assert!(editor.has_active_inline_completion(cx)); + assert_eq!(editor.display_text(cx), "one.copilot2\ntwo\nthree\n"); + assert_eq!(editor.text(cx), "one.co\ntwo\nthree\n"); + + // Tabbing when there is an active suggestion inserts it. + editor.tab(&Default::default(), cx); + assert!(!editor.has_active_inline_completion(cx)); + assert_eq!(editor.display_text(cx), "one.copilot2\ntwo\nthree\n"); + assert_eq!(editor.text(cx), "one.copilot2\ntwo\nthree\n"); + + // When undoing the previously active suggestion is shown again. + editor.undo(&Default::default(), cx); + assert!(editor.has_active_inline_completion(cx)); + assert_eq!(editor.display_text(cx), "one.copilot2\ntwo\nthree\n"); + assert_eq!(editor.text(cx), "one.co\ntwo\nthree\n"); + + // Hide suggestion. + editor.cancel(&Default::default(), cx); + assert!(!editor.has_active_inline_completion(cx)); + assert_eq!(editor.display_text(cx), "one.co\ntwo\nthree\n"); + assert_eq!(editor.text(cx), "one.co\ntwo\nthree\n"); + }); + + // If an edit occurs outside of this editor but no suggestion is being shown, + // we won't make it visible. + cx.update_buffer(|buffer, cx| buffer.edit([(6..6, "p")], None, cx)); + cx.update_editor(|editor, cx| { + assert!(!editor.has_active_inline_completion(cx)); + assert_eq!(editor.display_text(cx), "one.cop\ntwo\nthree\n"); + assert_eq!(editor.text(cx), "one.cop\ntwo\nthree\n"); + }); + + // Reset the editor to verify how suggestions behave when tabbing on leading indentation. + cx.update_editor(|editor, cx| { + editor.set_text("fn foo() {\n \n}", cx); + editor.change_selections(None, cx, |s| { + s.select_ranges([Point::new(1, 2)..Point::new(1, 2)]) + }); + }); + handle_copilot_completion_request( + &copilot_lsp, + vec![copilot::request::Completion { + text: " let x = 4;".into(), + range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 2)), + ..Default::default() + }], + vec![], + ); + + cx.update_editor(|editor, cx| editor.next_inline_completion(&Default::default(), cx)); + executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); + cx.update_editor(|editor, cx| { + assert!(editor.has_active_inline_completion(cx)); + assert_eq!(editor.display_text(cx), "fn foo() {\n let x = 4;\n}"); + assert_eq!(editor.text(cx), "fn foo() {\n \n}"); + + // Tabbing inside of leading whitespace inserts indentation without accepting the suggestion. + editor.tab(&Default::default(), cx); + assert!(editor.has_active_inline_completion(cx)); + assert_eq!(editor.text(cx), "fn foo() {\n \n}"); + assert_eq!(editor.display_text(cx), "fn foo() {\n let x = 4;\n}"); + + // Tabbing again accepts the suggestion. + editor.tab(&Default::default(), cx); + assert!(!editor.has_active_inline_completion(cx)); + assert_eq!(editor.text(cx), "fn foo() {\n let x = 4;\n}"); + assert_eq!(editor.display_text(cx), "fn foo() {\n let x = 4;\n}"); + }); + } + + #[gpui::test(iterations = 10)] + async fn test_accept_partial_copilot_suggestion( + executor: BackgroundExecutor, + cx: &mut TestAppContext, + ) { + // flaky + init_test(cx, |_| {}); + + let (copilot, copilot_lsp) = Copilot::fake(cx); + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string(), ":".to_string()]), + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + let copilot_provider = cx.new_model(|_| CopilotCompletionProvider::new(copilot)); + cx.update_editor(|editor, cx| editor.set_inline_completion_provider(copilot_provider, cx)); + + // Setup the editor with a completion request. + cx.set_state(indoc! {" + oneˇ + two + three + "}); + cx.simulate_keystroke("."); + let _ = handle_completion_request( + &mut cx, + indoc! {" + one.|<> + two + three + "}, + vec![], + ); + handle_copilot_completion_request( + &copilot_lsp, + vec![copilot::request::Completion { + text: "one.copilot1".into(), + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)), + ..Default::default() + }], + vec![], + ); + executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); + cx.update_editor(|editor, cx| { + assert!(editor.has_active_inline_completion(cx)); + + // Accepting the first word of the suggestion should only accept the first word and still show the rest. + editor.accept_partial_inline_completion(&Default::default(), cx); + assert!(editor.has_active_inline_completion(cx)); + assert_eq!(editor.text(cx), "one.copilot\ntwo\nthree\n"); + assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n"); + + // Accepting next word should accept the non-word and copilot suggestion should be gone + editor.accept_partial_inline_completion(&Default::default(), cx); + assert!(!editor.has_active_inline_completion(cx)); + assert_eq!(editor.text(cx), "one.copilot1\ntwo\nthree\n"); + assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n"); + }); + + // Reset the editor and check non-word and whitespace completion + cx.set_state(indoc! {" + oneˇ + two + three + "}); + cx.simulate_keystroke("."); + let _ = handle_completion_request( + &mut cx, + indoc! {" + one.|<> + two + three + "}, + vec![], + ); + handle_copilot_completion_request( + &copilot_lsp, + vec![copilot::request::Completion { + text: "one.123. copilot\n 456".into(), + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)), + ..Default::default() + }], + vec![], + ); + executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); + cx.update_editor(|editor, cx| { + assert!(editor.has_active_inline_completion(cx)); + + // Accepting the first word (non-word) of the suggestion should only accept the first word and still show the rest. + editor.accept_partial_inline_completion(&Default::default(), cx); + assert!(editor.has_active_inline_completion(cx)); + assert_eq!(editor.text(cx), "one.123. \ntwo\nthree\n"); + assert_eq!( + editor.display_text(cx), + "one.123. copilot\n 456\ntwo\nthree\n" + ); + + // Accepting next word should accept the next word and copilot suggestion should still exist + editor.accept_partial_inline_completion(&Default::default(), cx); + assert!(editor.has_active_inline_completion(cx)); + assert_eq!(editor.text(cx), "one.123. copilot\ntwo\nthree\n"); + assert_eq!( + editor.display_text(cx), + "one.123. copilot\n 456\ntwo\nthree\n" + ); + + // Accepting the whitespace should accept the non-word/whitespaces with newline and copilot suggestion should be gone + editor.accept_partial_inline_completion(&Default::default(), cx); + assert!(!editor.has_active_inline_completion(cx)); + assert_eq!(editor.text(cx), "one.123. copilot\n 456\ntwo\nthree\n"); + assert_eq!( + editor.display_text(cx), + "one.123. copilot\n 456\ntwo\nthree\n" + ); + }); + } + + #[gpui::test] + async fn test_copilot_completion_invalidation( + executor: BackgroundExecutor, + cx: &mut TestAppContext, + ) { + init_test(cx, |_| {}); + + let (copilot, copilot_lsp) = Copilot::fake(cx); + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string(), ":".to_string()]), + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + let copilot_provider = cx.new_model(|_| CopilotCompletionProvider::new(copilot)); + cx.update_editor(|editor, cx| editor.set_inline_completion_provider(copilot_provider, cx)); + + cx.set_state(indoc! {" + one + twˇ + three + "}); + + handle_copilot_completion_request( + &copilot_lsp, + vec![copilot::request::Completion { + text: "two.foo()".into(), + range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 2)), + ..Default::default() + }], + vec![], + ); + cx.update_editor(|editor, cx| editor.next_inline_completion(&Default::default(), cx)); + executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); + cx.update_editor(|editor, cx| { + assert!(editor.has_active_inline_completion(cx)); + assert_eq!(editor.display_text(cx), "one\ntwo.foo()\nthree\n"); + assert_eq!(editor.text(cx), "one\ntw\nthree\n"); + + editor.backspace(&Default::default(), cx); + assert!(editor.has_active_inline_completion(cx)); + assert_eq!(editor.display_text(cx), "one\ntwo.foo()\nthree\n"); + assert_eq!(editor.text(cx), "one\nt\nthree\n"); + + editor.backspace(&Default::default(), cx); + assert!(editor.has_active_inline_completion(cx)); + assert_eq!(editor.display_text(cx), "one\ntwo.foo()\nthree\n"); + assert_eq!(editor.text(cx), "one\n\nthree\n"); + + // Deleting across the original suggestion range invalidates it. + editor.backspace(&Default::default(), cx); + assert!(!editor.has_active_inline_completion(cx)); + assert_eq!(editor.display_text(cx), "one\nthree\n"); + assert_eq!(editor.text(cx), "one\nthree\n"); + + // Undoing the deletion restores the suggestion. + editor.undo(&Default::default(), cx); + assert!(editor.has_active_inline_completion(cx)); + assert_eq!(editor.display_text(cx), "one\ntwo.foo()\nthree\n"); + assert_eq!(editor.text(cx), "one\n\nthree\n"); + }); + } + + #[gpui::test] + async fn test_copilot_multibuffer(executor: BackgroundExecutor, cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let (copilot, copilot_lsp) = Copilot::fake(cx); + + let buffer_1 = cx.new_model(|cx| { + Buffer::new( + 0, + BufferId::new(cx.entity_id().as_u64()).unwrap(), + "a = 1\nb = 2\n", + ) + }); + let buffer_2 = cx.new_model(|cx| { + Buffer::new( + 0, + BufferId::new(cx.entity_id().as_u64()).unwrap(), + "c = 3\nd = 4\n", + ) + }); + let multibuffer = cx.new_model(|cx| { + let mut multibuffer = MultiBuffer::new(0, language::Capability::ReadWrite); + multibuffer.push_excerpts( + buffer_1.clone(), + [ExcerptRange { + context: Point::new(0, 0)..Point::new(2, 0), + primary: None, + }], + cx, + ); + multibuffer.push_excerpts( + buffer_2.clone(), + [ExcerptRange { + context: Point::new(0, 0)..Point::new(2, 0), + primary: None, + }], + cx, + ); + multibuffer + }); + let editor = cx.add_window(|cx| Editor::for_multibuffer(multibuffer, None, cx)); + let copilot_provider = cx.new_model(|_| CopilotCompletionProvider::new(copilot)); + editor + .update(cx, |editor, cx| { + editor.set_inline_completion_provider(copilot_provider, cx) + }) + .unwrap(); + + handle_copilot_completion_request( + &copilot_lsp, + vec![copilot::request::Completion { + text: "b = 2 + a".into(), + range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 5)), + ..Default::default() + }], + vec![], + ); + _ = editor.update(cx, |editor, cx| { + // Ensure copilot suggestions are shown for the first excerpt. + editor.change_selections(None, cx, |s| { + s.select_ranges([Point::new(1, 5)..Point::new(1, 5)]) + }); + editor.next_inline_completion(&Default::default(), cx); + }); + executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); + _ = editor.update(cx, |editor, cx| { + assert!(editor.has_active_inline_completion(cx)); + assert_eq!( + editor.display_text(cx), + "\n\na = 1\nb = 2 + a\n\n\n\nc = 3\nd = 4\n" + ); + assert_eq!(editor.text(cx), "a = 1\nb = 2\n\nc = 3\nd = 4\n"); + }); + + handle_copilot_completion_request( + &copilot_lsp, + vec![copilot::request::Completion { + text: "d = 4 + c".into(), + range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 6)), + ..Default::default() + }], + vec![], + ); + _ = editor.update(cx, |editor, cx| { + // Move to another excerpt, ensuring the suggestion gets cleared. + editor.change_selections(None, cx, |s| { + s.select_ranges([Point::new(4, 5)..Point::new(4, 5)]) + }); + assert!(!editor.has_active_inline_completion(cx)); + assert_eq!( + editor.display_text(cx), + "\n\na = 1\nb = 2\n\n\n\nc = 3\nd = 4\n" + ); + assert_eq!(editor.text(cx), "a = 1\nb = 2\n\nc = 3\nd = 4\n"); + + // Type a character, ensuring we don't even try to interpolate the previous suggestion. + editor.handle_input(" ", cx); + assert!(!editor.has_active_inline_completion(cx)); + assert_eq!( + editor.display_text(cx), + "\n\na = 1\nb = 2\n\n\n\nc = 3\nd = 4 \n" + ); + assert_eq!(editor.text(cx), "a = 1\nb = 2\n\nc = 3\nd = 4 \n"); + }); + + // Ensure the new suggestion is displayed when the debounce timeout expires. + executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); + _ = editor.update(cx, |editor, cx| { + assert!(editor.has_active_inline_completion(cx)); + assert_eq!( + editor.display_text(cx), + "\n\na = 1\nb = 2\n\n\n\nc = 3\nd = 4 + c\n" + ); + assert_eq!(editor.text(cx), "a = 1\nb = 2\n\nc = 3\nd = 4 \n"); + }); + } + + #[gpui::test] + async fn test_copilot_disabled_globs(executor: BackgroundExecutor, cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings + .copilot + .get_or_insert(Default::default()) + .disabled_globs = Some(vec![".env*".to_string()]); + }); + + let (copilot, copilot_lsp) = Copilot::fake(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/test", + json!({ + ".env": "SECRET=something\n", + "README.md": "hello\n" + }), + ) + .await; + let project = Project::test(fs, ["/test".as_ref()], cx).await; + + let private_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer("/test/.env", cx) + }) + .await + .unwrap(); + let public_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer("/test/README.md", cx) + }) + .await + .unwrap(); + + let multibuffer = cx.new_model(|cx| { + let mut multibuffer = MultiBuffer::new(0, language::Capability::ReadWrite); + multibuffer.push_excerpts( + private_buffer.clone(), + [ExcerptRange { + context: Point::new(0, 0)..Point::new(1, 0), + primary: None, + }], + cx, + ); + multibuffer.push_excerpts( + public_buffer.clone(), + [ExcerptRange { + context: Point::new(0, 0)..Point::new(1, 0), + primary: None, + }], + cx, + ); + multibuffer + }); + let editor = cx.add_window(|cx| Editor::for_multibuffer(multibuffer, None, cx)); + let copilot_provider = cx.new_model(|_| CopilotCompletionProvider::new(copilot)); + editor + .update(cx, |editor, cx| { + editor.set_inline_completion_provider(copilot_provider, cx) + }) + .unwrap(); + + let mut copilot_requests = copilot_lsp + .handle_request::( + move |_params, _cx| async move { + Ok(copilot::request::GetCompletionsResult { + completions: vec![copilot::request::Completion { + text: "next line".into(), + range: lsp::Range::new( + lsp::Position::new(1, 0), + lsp::Position::new(1, 0), + ), + ..Default::default() + }], + }) + }, + ); + + _ = editor.update(cx, |editor, cx| { + editor.change_selections(None, cx, |selections| { + selections.select_ranges([Point::new(0, 0)..Point::new(0, 0)]) + }); + editor.next_inline_completion(&Default::default(), cx); + }); + + executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); + assert!(copilot_requests.try_next().is_err()); + + _ = editor.update(cx, |editor, cx| { + editor.change_selections(None, cx, |s| { + s.select_ranges([Point::new(2, 0)..Point::new(2, 0)]) + }); + editor.next_inline_completion(&Default::default(), cx); + }); + + executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); + assert!(copilot_requests.try_next().is_ok()); + } + + fn handle_copilot_completion_request( + lsp: &lsp::FakeLanguageServer, + completions: Vec, + completions_cycling: Vec, + ) { + lsp.handle_request::(move |_params, _cx| { + let completions = completions.clone(); + async move { + Ok(copilot::request::GetCompletionsResult { + completions: completions.clone(), + }) + } + }); + lsp.handle_request::(move |_params, _cx| { + let completions_cycling = completions_cycling.clone(); + async move { + Ok(copilot::request::GetCompletionsResult { + completions: completions_cycling.clone(), + }) + } + }); + } + + fn handle_completion_request( + cx: &mut EditorLspTestContext, + marked_string: &str, + completions: Vec<&'static str>, + ) -> impl Future { + let complete_from_marker: TextRangeMarker = '|'.into(); + let replace_range_marker: TextRangeMarker = ('<', '>').into(); + let (_, mut marked_ranges) = marked_text_ranges_by( + marked_string, + vec![complete_from_marker.clone(), replace_range_marker.clone()], + ); + + let complete_from_position = + cx.to_lsp(marked_ranges.remove(&complete_from_marker).unwrap()[0].start); + let replace_range = + cx.to_lsp_range(marked_ranges.remove(&replace_range_marker).unwrap()[0].clone()); + + let mut request = + cx.handle_request::(move |url, params, _| { + let completions = completions.clone(); + async move { + assert_eq!(params.text_document_position.text_document.uri, url.clone()); + assert_eq!( + params.text_document_position.position, + complete_from_position + ); + Ok(Some(lsp::CompletionResponse::Array( + completions + .iter() + .map(|completion_text| lsp::CompletionItem { + label: completion_text.to_string(), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: replace_range, + new_text: completion_text.to_string(), + })), + ..Default::default() + }) + .collect(), + ))) + } + }); + + async move { + request.next().await; + } + } + + fn init_test(cx: &mut TestAppContext, f: fn(&mut AllLanguageSettingsContent)) { + _ = cx.update(|cx| { + let store = SettingsStore::test(cx); + cx.set_global(store); + theme::init(theme::LoadThemes::JustBase, cx); + client::init_settings(cx); + language::init(cx); + editor::init_settings(cx); + Project::init_settings(cx); + workspace::init_settings(cx); + cx.update_global(|store: &mut SettingsStore, cx| { + store.update_user_settings::(cx, f); + }); + }); + } +} diff --git a/crates/copilot_ui/src/copilot_ui.rs b/crates/copilot_ui/src/copilot_ui.rs index f55090ebcb2..63bd03102fd 100644 --- a/crates/copilot_ui/src/copilot_ui.rs +++ b/crates/copilot_ui/src/copilot_ui.rs @@ -1,5 +1,7 @@ pub mod copilot_button; +mod copilot_completion_provider; mod sign_in; pub use copilot_button::*; +pub use copilot_completion_provider::*; pub use sign_in::*; diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index 4848801cd21..a1349a00a39 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -14,7 +14,6 @@ doctest = false [features] test-support = [ - "copilot/test-support", "text/test-support", "language/test-support", "gpui/test-support", @@ -34,7 +33,6 @@ client.workspace = true clock.workspace = true collections.workspace = true convert_case = "0.6.0" -copilot.workspace = true db.workspace = true emojis.workspace = true futures.workspace = true @@ -73,7 +71,6 @@ util.workspace = true workspace.workspace = true [dev-dependencies] -copilot = { workspace = true, features = ["test-support"] } ctor.workspace = true env_logger.workspace = true gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index bbc68be4729..f36d24967d8 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -127,6 +127,7 @@ gpui::actions!( editor, [ AcceptPartialCopilotSuggestion, + AcceptPartialInlineCompletion, AddSelectionAbove, AddSelectionBelow, Backspace, @@ -168,13 +169,12 @@ gpui::actions!( GoToDefinitionSplit, GoToDiagnostic, GoToHunk, + GoToImplementation, + GoToImplementationSplit, GoToPrevDiagnostic, GoToPrevHunk, GoToTypeDefinition, GoToTypeDefinitionSplit, - GoToImplementation, - GoToImplementationSplit, - OpenUrl, HalfPageDown, HalfPageUp, Hover, @@ -202,21 +202,24 @@ gpui::actions!( Newline, NewlineAbove, NewlineBelow, + NextInlineCompletion, NextScreen, OpenExcerpts, OpenExcerptsSplit, OpenPermalinkToLine, + OpenUrl, Outdent, PageDown, PageUp, Paste, - RevertSelectedHunks, + PreviousInlineCompletion, Redo, RedoSelection, Rename, RestartLanguageServer, RevealInFinder, ReverseLines, + RevertSelectedHunks, ScrollCursorBottom, ScrollCursorCenter, ScrollCursorTop, @@ -239,6 +242,7 @@ gpui::actions!( SelectUp, ShowCharacterPalette, ShowCompletions, + ShowInlineCompletion, ShuffleLines, SortLinesCaseInsensitive, SortLinesCaseSensitive, @@ -246,8 +250,8 @@ gpui::actions!( Tab, TabPrev, ToggleInlayHints, - ToggleSoftWrap, ToggleLineNumbers, + ToggleSoftWrap, Transpose, Undo, UndoSelection, diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 3bcaf8803ae..e8a67a9456e 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -1015,7 +1015,7 @@ pub mod tests { movement, test::{editor_test_context::EditorTestContext, marked_display_snapshot}, }; - use gpui::{div, font, observe, px, AppContext, Context, Element, Hsla}; + use gpui::{div, font, observe, px, AppContext, BorrowAppContext, Context, Element, Hsla}; use language::{ language_settings::{AllLanguageSettings, AllLanguageSettingsContent}, Buffer, Language, LanguageConfig, LanguageMatcher, SelectionGoal, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index f936657f0ad..3308f5c9b56 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -24,6 +24,7 @@ mod git; mod highlight_matching_bracket; mod hover_links; mod hover_popover; +mod inline_completion_provider; pub mod items; mod mouse_context_menu; pub mod movement; @@ -45,7 +46,6 @@ use client::{Collaborator, ParticipantIndex}; use clock::ReplicaId; use collections::{hash_map, BTreeMap, Bound, HashMap, HashSet, VecDeque}; use convert_case::{Case, Casing}; -use copilot::Copilot; use debounced_delay::DebouncedDelay; pub use display_map::DisplayPoint; use display_map::*; @@ -69,6 +69,7 @@ use gpui::{ use highlight_matching_bracket::refresh_matching_bracket_highlights; use hover_popover::{hide_hover, HoverState}; use inlay_hint_cache::{InlayHintCache, InlaySplice, InvalidationStrategy}; +pub use inline_completion_provider::*; pub use items::MAX_TAB_TITLE_LEN; use itertools::Itertools; use language::{char_kind, CharKind}; @@ -135,7 +136,6 @@ const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500); const MAX_LINE_LEN: usize = 1024; const MIN_NAVIGATION_HISTORY_ROW_DELTA: i64 = 10; const MAX_SELECTION_HISTORY_LEN: usize = 1024; -const COPILOT_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(75); pub(crate) const CURSORS_VISIBLE_FOR: Duration = Duration::from_millis(2000); #[doc(hidden)] pub const CODE_ACTIONS_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(250); @@ -419,7 +419,9 @@ pub struct Editor { hover_state: HoverState, gutter_hovered: bool, hovered_link_state: Option, - copilot_state: CopilotState, + inline_completion_provider: Option, + active_inline_completion: Option, + show_inline_completions: bool, inlay_hint_cache: InlayHintCache, next_inlay_id: usize, _subscriptions: Vec, @@ -428,7 +430,6 @@ pub struct Editor { pub vim_replace_map: HashMap, String>, style: Option, editor_actions: Vec)>>, - show_copilot_suggestions: bool, use_autoclose: bool, auto_replace_emoji_shortcode: bool, custom_context_menu: Option< @@ -625,6 +626,11 @@ pub struct RenameState { struct InvalidationStack(Vec); +struct RegisteredInlineCompletionProvider { + provider: Arc, + _subscription: Subscription, +} + enum ContextMenu { Completions(CompletionsMenu), CodeActions(CodeActionsMenu), @@ -1230,116 +1236,6 @@ impl CodeActionsMenu { } } -#[derive(Debug)] -pub(crate) struct CopilotState { - excerpt_id: Option, - pending_refresh: Task>, - pending_cycling_refresh: Task>, - cycled: bool, - completions: Vec, - active_completion_index: usize, - suggestion: Option, -} - -impl Default for CopilotState { - fn default() -> Self { - Self { - excerpt_id: None, - pending_cycling_refresh: Task::ready(Some(())), - pending_refresh: Task::ready(Some(())), - completions: Default::default(), - active_completion_index: 0, - cycled: false, - suggestion: None, - } - } -} - -impl CopilotState { - fn active_completion(&self) -> Option<&copilot::Completion> { - self.completions.get(self.active_completion_index) - } - - fn text_for_active_completion( - &self, - cursor: Anchor, - buffer: &MultiBufferSnapshot, - ) -> Option<&str> { - use language::ToOffset as _; - - let completion = self.active_completion()?; - let excerpt_id = self.excerpt_id?; - let completion_buffer = buffer.buffer_for_excerpt(excerpt_id)?; - if excerpt_id != cursor.excerpt_id - || !completion.range.start.is_valid(completion_buffer) - || !completion.range.end.is_valid(completion_buffer) - { - return None; - } - - let mut completion_range = completion.range.to_offset(&completion_buffer); - let prefix_len = Self::common_prefix( - completion_buffer.chars_for_range(completion_range.clone()), - completion.text.chars(), - ); - completion_range.start += prefix_len; - let suffix_len = Self::common_prefix( - completion_buffer.reversed_chars_for_range(completion_range.clone()), - completion.text[prefix_len..].chars().rev(), - ); - completion_range.end = completion_range.end.saturating_sub(suffix_len); - - if completion_range.is_empty() - && completion_range.start == cursor.text_anchor.to_offset(&completion_buffer) - { - let completion_text = &completion.text[prefix_len..completion.text.len() - suffix_len]; - if completion_text.trim().is_empty() { - None - } else { - Some(completion_text) - } - } else { - None - } - } - - fn cycle_completions(&mut self, direction: Direction) { - match direction { - Direction::Prev => { - self.active_completion_index = if self.active_completion_index == 0 { - self.completions.len().saturating_sub(1) - } else { - self.active_completion_index - 1 - }; - } - Direction::Next => { - if self.completions.len() == 0 { - self.active_completion_index = 0 - } else { - self.active_completion_index = - (self.active_completion_index + 1) % self.completions.len(); - } - } - } - } - - fn push_completion(&mut self, new_completion: copilot::Completion) { - for completion in &self.completions { - if completion.text == new_completion.text && completion.range == new_completion.range { - return; - } - } - self.completions.push(new_completion); - } - - fn common_prefix, T2: Iterator>(a: T1, b: T2) -> usize { - a.zip(b) - .take_while(|(a, b)| a == b) - .map(|(a, _)| a.len_utf8()) - .sum() - } -} - #[derive(Debug)] struct ActiveDiagnosticGroup { primary_range: Range, @@ -1562,7 +1458,8 @@ impl Editor { remote_id: None, hover_state: Default::default(), hovered_link_state: Default::default(), - copilot_state: Default::default(), + inline_completion_provider: None, + active_inline_completion: None, inlay_hint_cache: InlayHintCache::new(inlay_hint_settings), gutter_hovered: false, pixel_position_of_newest_cursor: None, @@ -1572,7 +1469,7 @@ impl Editor { hovered_cursors: Default::default(), editor_actions: Default::default(), vim_replace_map: Default::default(), - show_copilot_suggestions: mode == EditorMode::Full, + show_inline_completions: mode == EditorMode::Full, custom_context_menu: None, _subscriptions: vec![ cx.observe(&buffer, Self::on_buffer_changed), @@ -1648,8 +1545,9 @@ impl Editor { key_context.set("extension", extension.to_string()); } - if self.has_active_copilot_suggestion(cx) { + if self.has_active_inline_completion(cx) { key_context.add("copilot_suggestion"); + key_context.add("inline_completion"); } key_context @@ -1771,6 +1669,20 @@ impl Editor { self.completion_provider = Some(hub); } + pub fn set_inline_completion_provider( + &mut self, + provider: Model, + cx: &mut ViewContext, + ) { + self.inline_completion_provider = Some(RegisteredInlineCompletionProvider { + _subscription: cx.observe(&provider, |this, _, cx| { + this.update_visible_inline_completion(cx); + }), + provider: Arc::new(provider), + }); + self.refresh_inline_completion(false, cx); + } + pub fn placeholder_text(&self, _cx: &mut WindowContext) -> Option<&str> { self.placeholder_text.as_deref() } @@ -1853,8 +1765,8 @@ impl Editor { self.auto_replace_emoji_shortcode = auto_replace; } - pub fn set_show_copilot_suggestions(&mut self, show_copilot_suggestions: bool) { - self.show_copilot_suggestions = show_copilot_suggestions; + pub fn set_show_inline_completions(&mut self, show_inline_completions: bool) { + self.show_inline_completions = show_inline_completions; } pub fn set_use_modal_editing(&mut self, to: bool) { @@ -1966,7 +1878,7 @@ impl Editor { self.refresh_code_actions(cx); self.refresh_document_highlights(cx); refresh_matching_bracket_highlights(self, cx); - self.discard_copilot_suggestion(cx); + self.discard_inline_completion(cx); } self.blink_manager.update(cx, BlinkManager::pause_blinking); @@ -2392,7 +2304,7 @@ impl Editor { return true; } - if self.discard_copilot_suggestion(cx) { + if self.discard_inline_completion(cx) { return true; } @@ -2647,7 +2559,7 @@ impl Editor { } drop(snapshot); - let had_active_copilot_suggestion = this.has_active_copilot_suggestion(cx); + let had_active_copilot_completion = this.has_active_inline_completion(cx); this.change_selections(Some(Autoscroll::fit()), cx, |s| s.select(new_selections)); if brace_inserted { @@ -2663,14 +2575,14 @@ impl Editor { } } - if had_active_copilot_suggestion { - this.refresh_copilot_suggestions(true, cx); - if !this.has_active_copilot_suggestion(cx) { + if had_active_copilot_completion { + this.refresh_inline_completion(true, cx); + if !this.has_active_inline_completion(cx) { this.trigger_completion_on_input(&text, cx); } } else { this.trigger_completion_on_input(&text, cx); - this.refresh_copilot_suggestions(true, cx); + this.refresh_inline_completion(true, cx); } }); } @@ -2856,7 +2768,7 @@ impl Editor { .collect(); this.change_selections(Some(Autoscroll::fit()), cx, |s| s.select(new_selections)); - this.refresh_copilot_suggestions(true, cx); + this.refresh_inline_completion(true, cx); }); } @@ -3503,15 +3415,15 @@ impl Editor { let menu = menu.unwrap(); *context_menu = Some(ContextMenu::Completions(menu)); drop(context_menu); - this.discard_copilot_suggestion(cx); + this.discard_inline_completion(cx); cx.notify(); } else if this.completion_tasks.len() <= 1 { // If there are no more completion tasks and the last menu was // empty, we should hide it. If it was already hidden, we should - // also show the copilot suggestion when available. + // also show the copilot completion when available. drop(context_menu); if this.hide_context_menu(cx).is_none() { - this.update_visible_copilot_suggestion(cx); + this.update_visible_inline_completion(cx); } } })?; @@ -3637,7 +3549,7 @@ impl Editor { }); } - this.refresh_copilot_suggestions(true, cx); + this.refresh_inline_completion(true, cx); }); let provider = self.completion_provider.as_ref()?; @@ -3674,7 +3586,7 @@ impl Editor { if this.focus_handle.is_focused(cx) { if let Some((buffer, actions)) = this.available_code_actions.clone() { this.completion_tasks.clear(); - this.discard_copilot_suggestion(cx); + this.discard_inline_completion(cx); *this.context_menu.write() = Some(ContextMenu::CodeActions(CodeActionsMenu { buffer, @@ -3949,115 +3861,55 @@ impl Editor { None } - fn refresh_copilot_suggestions( + fn refresh_inline_completion( &mut self, debounce: bool, cx: &mut ViewContext, ) -> Option<()> { - let copilot = Copilot::global(cx)?; - if !self.show_copilot_suggestions || !copilot.read(cx).status().is_authorized() { - self.clear_copilot_suggestions(cx); - return None; - } - self.update_visible_copilot_suggestion(cx); - - let snapshot = self.buffer.read(cx).snapshot(cx); + let provider = self.inline_completion_provider()?; let cursor = self.selections.newest_anchor().head(); - if !self.is_copilot_enabled_at(cursor, &snapshot, cx) { - self.clear_copilot_suggestions(cx); + let (buffer, cursor_buffer_position) = + self.buffer.read(cx).text_anchor_for_position(cursor, cx)?; + if !self.show_inline_completions + || !provider.is_enabled(&buffer, cursor_buffer_position, cx) + { + self.clear_inline_completion(cx); return None; } - let (buffer, buffer_position) = - self.buffer.read(cx).text_anchor_for_position(cursor, cx)?; - self.copilot_state.pending_refresh = cx.spawn(|this, mut cx| async move { - if debounce { - cx.background_executor() - .timer(COPILOT_DEBOUNCE_TIMEOUT) - .await; - } - - let completions = copilot - .update(&mut cx, |copilot, cx| { - copilot.completions(&buffer, buffer_position, cx) - }) - .log_err() - .unwrap_or(Task::ready(Ok(Vec::new()))) - .await - .log_err() - .into_iter() - .flatten() - .collect_vec(); - - this.update(&mut cx, |this, cx| { - if !completions.is_empty() { - this.copilot_state.cycled = false; - this.copilot_state.pending_cycling_refresh = Task::ready(None); - this.copilot_state.completions.clear(); - this.copilot_state.active_completion_index = 0; - this.copilot_state.excerpt_id = Some(cursor.excerpt_id); - for completion in completions { - this.copilot_state.push_completion(completion); - } - this.update_visible_copilot_suggestion(cx); - } - }) - .log_err()?; - Some(()) - }); - + self.update_visible_inline_completion(cx); + provider.refresh(buffer, cursor_buffer_position, debounce, cx); Some(()) } - fn cycle_copilot_suggestions( + fn cycle_inline_completion( &mut self, direction: Direction, cx: &mut ViewContext, ) -> Option<()> { - let copilot = Copilot::global(cx)?; - if !self.show_copilot_suggestions || !copilot.read(cx).status().is_authorized() { + let provider = self.inline_completion_provider()?; + let cursor = self.selections.newest_anchor().head(); + let (buffer, cursor_buffer_position) = + self.buffer.read(cx).text_anchor_for_position(cursor, cx)?; + if !self.show_inline_completions + || !provider.is_enabled(&buffer, cursor_buffer_position, cx) + { return None; } - if self.copilot_state.cycled { - self.copilot_state.cycle_completions(direction); - self.update_visible_copilot_suggestion(cx); - } else { - let cursor = self.selections.newest_anchor().head(); - let (buffer, buffer_position) = - self.buffer.read(cx).text_anchor_for_position(cursor, cx)?; - self.copilot_state.pending_cycling_refresh = cx.spawn(|this, mut cx| async move { - let completions = copilot - .update(&mut cx, |copilot, cx| { - copilot.completions_cycling(&buffer, buffer_position, cx) - }) - .log_err()? - .await; - - this.update(&mut cx, |this, cx| { - this.copilot_state.cycled = true; - for completion in completions.log_err().into_iter().flatten() { - this.copilot_state.push_completion(completion); - } - this.copilot_state.cycle_completions(direction); - this.update_visible_copilot_suggestion(cx); - }) - .log_err()?; - - Some(()) - }); - } + provider.cycle(buffer, cursor_buffer_position, direction, cx); + self.update_visible_inline_completion(cx); Some(()) } - fn copilot_suggest(&mut self, _: &copilot::Suggest, cx: &mut ViewContext) { - if !self.has_active_copilot_suggestion(cx) { - self.refresh_copilot_suggestions(false, cx); + pub fn show_inline_completion(&mut self, _: &ShowInlineCompletion, cx: &mut ViewContext) { + if !self.has_active_inline_completion(cx) { + self.refresh_inline_completion(false, cx); return; } - self.update_visible_copilot_suggestion(cx); + self.update_visible_inline_completion(cx); } pub fn display_cursor_names(&mut self, _: &DisplayCursorNames, cx: &mut ViewContext) { @@ -4078,48 +3930,43 @@ impl Editor { .detach(); } - fn next_copilot_suggestion(&mut self, _: &copilot::NextSuggestion, cx: &mut ViewContext) { - if self.has_active_copilot_suggestion(cx) { - self.cycle_copilot_suggestions(Direction::Next, cx); + pub fn next_inline_completion(&mut self, _: &NextInlineCompletion, cx: &mut ViewContext) { + if self.has_active_inline_completion(cx) { + self.cycle_inline_completion(Direction::Next, cx); } else { - let is_copilot_disabled = self.refresh_copilot_suggestions(false, cx).is_none(); + let is_copilot_disabled = self.refresh_inline_completion(false, cx).is_none(); if is_copilot_disabled { cx.propagate(); } } } - fn previous_copilot_suggestion( + pub fn previous_inline_completion( &mut self, - _: &copilot::PreviousSuggestion, + _: &PreviousInlineCompletion, cx: &mut ViewContext, ) { - if self.has_active_copilot_suggestion(cx) { - self.cycle_copilot_suggestions(Direction::Prev, cx); + if self.has_active_inline_completion(cx) { + self.cycle_inline_completion(Direction::Prev, cx); } else { - let is_copilot_disabled = self.refresh_copilot_suggestions(false, cx).is_none(); + let is_copilot_disabled = self.refresh_inline_completion(false, cx).is_none(); if is_copilot_disabled { cx.propagate(); } } } - fn accept_copilot_suggestion(&mut self, cx: &mut ViewContext) -> bool { - if let Some(suggestion) = self.take_active_copilot_suggestion(cx) { - if let Some((copilot, completion)) = - Copilot::global(cx).zip(self.copilot_state.active_completion()) - { - copilot - .update(cx, |copilot, cx| copilot.accept_completion(completion, cx)) - .detach_and_log_err(cx); - - self.report_copilot_event(Some(completion.uuid.clone()), true, cx) + fn accept_inline_completion(&mut self, cx: &mut ViewContext) -> bool { + if let Some(completion) = self.take_active_inline_completion(cx) { + if let Some(provider) = self.inline_completion_provider() { + provider.accept(cx); } + cx.emit(EditorEvent::InputHandled { utf16_range_to_replace: None, - text: suggestion.text.to_string().into(), + text: completion.text.to_string().into(), }); - self.insert_with_autoindent_mode(&suggestion.text.to_string(), None, cx); + self.insert_with_autoindent_mode(&completion.text.to_string(), None, cx); cx.notify(); true } else { @@ -4127,21 +3974,21 @@ impl Editor { } } - fn accept_partial_copilot_suggestion( + pub fn accept_partial_inline_completion( &mut self, - _: &AcceptPartialCopilotSuggestion, + _: &AcceptPartialInlineCompletion, cx: &mut ViewContext, ) { - if self.selections.count() == 1 && self.has_active_copilot_suggestion(cx) { - if let Some(suggestion) = self.take_active_copilot_suggestion(cx) { - let mut partial_suggestion = suggestion + if self.selections.count() == 1 && self.has_active_inline_completion(cx) { + if let Some(completion) = self.take_active_inline_completion(cx) { + let mut partial_completion = completion .text .chars() .by_ref() .take_while(|c| c.is_alphabetic()) .collect::(); - if partial_suggestion.is_empty() { - partial_suggestion = suggestion + if partial_completion.is_empty() { + partial_completion = completion .text .chars() .by_ref() @@ -4151,111 +3998,92 @@ impl Editor { cx.emit(EditorEvent::InputHandled { utf16_range_to_replace: None, - text: partial_suggestion.clone().into(), + text: partial_completion.clone().into(), }); - self.insert_with_autoindent_mode(&partial_suggestion, None, cx); - self.refresh_copilot_suggestions(true, cx); + self.insert_with_autoindent_mode(&partial_completion, None, cx); + self.refresh_inline_completion(true, cx); cx.notify(); } } } - fn discard_copilot_suggestion(&mut self, cx: &mut ViewContext) -> bool { - if let Some(suggestion) = self.take_active_copilot_suggestion(cx) { - if let Some(copilot) = Copilot::global(cx) { - copilot - .update(cx, |copilot, cx| { - copilot.discard_completions(&self.copilot_state.completions, cx) - }) - .detach_and_log_err(cx); - - self.report_copilot_event(None, false, cx) - } - - self.display_map.update(cx, |map, cx| { - map.splice_inlays(vec![suggestion.id], Vec::new(), cx) - }); - cx.notify(); - true - } else { - false + fn discard_inline_completion(&mut self, cx: &mut ViewContext) -> bool { + if let Some(provider) = self.inline_completion_provider() { + provider.discard(cx); } - } - fn is_copilot_enabled_at( - &self, - location: Anchor, - snapshot: &MultiBufferSnapshot, - cx: &mut ViewContext, - ) -> bool { - let file = snapshot.file_at(location); - let language = snapshot.language_at(location); - let settings = all_language_settings(file, cx); - self.show_copilot_suggestions - && settings.copilot_enabled(language, file.map(|f| f.path().as_ref())) + self.take_active_inline_completion(cx).is_some() } - fn has_active_copilot_suggestion(&self, cx: &AppContext) -> bool { - if let Some(suggestion) = self.copilot_state.suggestion.as_ref() { + pub fn has_active_inline_completion(&self, cx: &AppContext) -> bool { + if let Some(completion) = self.active_inline_completion.as_ref() { let buffer = self.buffer.read(cx).read(cx); - suggestion.position.is_valid(&buffer) + completion.position.is_valid(&buffer) } else { false } } - fn take_active_copilot_suggestion(&mut self, cx: &mut ViewContext) -> Option { - let suggestion = self.copilot_state.suggestion.take()?; + fn take_active_inline_completion(&mut self, cx: &mut ViewContext) -> Option { + let completion = self.active_inline_completion.take()?; self.display_map.update(cx, |map, cx| { - map.splice_inlays(vec![suggestion.id], Default::default(), cx); + map.splice_inlays(vec![completion.id], Default::default(), cx); }); let buffer = self.buffer.read(cx).read(cx); - if suggestion.position.is_valid(&buffer) { - Some(suggestion) + if completion.position.is_valid(&buffer) { + Some(completion) } else { None } } - fn update_visible_copilot_suggestion(&mut self, cx: &mut ViewContext) { - let snapshot = self.buffer.read(cx).snapshot(cx); + fn update_visible_inline_completion(&mut self, cx: &mut ViewContext) { let selection = self.selections.newest_anchor(); let cursor = selection.head(); - if self.context_menu.read().is_some() - || !self.completion_tasks.is_empty() - || selection.start != selection.end + if self.context_menu.read().is_none() + && self.completion_tasks.is_empty() + && selection.start == selection.end { - self.discard_copilot_suggestion(cx); - } else if let Some(text) = self - .copilot_state - .text_for_active_completion(cursor, &snapshot) - { - let text = Rope::from(text); - let mut to_remove = Vec::new(); - if let Some(suggestion) = self.copilot_state.suggestion.take() { - to_remove.push(suggestion.id); - } + if let Some(provider) = self.inline_completion_provider() { + if let Some((buffer, cursor_buffer_position)) = + self.buffer.read(cx).text_anchor_for_position(cursor, cx) + { + if let Some(text) = + provider.active_completion_text(&buffer, cursor_buffer_position, cx) + { + let text = Rope::from(text); + let mut to_remove = Vec::new(); + if let Some(completion) = self.active_inline_completion.take() { + to_remove.push(completion.id); + } - let suggestion_inlay = - Inlay::suggestion(post_inc(&mut self.next_inlay_id), cursor, text); - self.copilot_state.suggestion = Some(suggestion_inlay.clone()); - self.display_map.update(cx, move |map, cx| { - map.splice_inlays(to_remove, vec![suggestion_inlay], cx) - }); - cx.notify(); - } else { - self.discard_copilot_suggestion(cx); + let completion_inlay = + Inlay::suggestion(post_inc(&mut self.next_inlay_id), cursor, text); + self.active_inline_completion = Some(completion_inlay.clone()); + self.display_map.update(cx, move |map, cx| { + map.splice_inlays(to_remove, vec![completion_inlay], cx) + }); + cx.notify(); + return; + } + } + } } + + self.discard_inline_completion(cx); } - fn clear_copilot_suggestions(&mut self, cx: &mut ViewContext) { - if let Some(old_suggestion) = self.copilot_state.suggestion.take() { - self.splice_inlays(vec![old_suggestion.id], Vec::new(), cx); + fn clear_inline_completion(&mut self, cx: &mut ViewContext) { + if let Some(old_completion) = self.active_inline_completion.take() { + self.splice_inlays(vec![old_completion.id], Vec::new(), cx); } - self.copilot_state = CopilotState::default(); - self.discard_copilot_suggestion(cx); + self.discard_inline_completion(cx); + } + + fn inline_completion_provider(&self) -> Option> { + Some(self.inline_completion_provider.as_ref()?.provider.clone()) } pub fn render_code_actions_indicator( @@ -4353,7 +4181,7 @@ impl Editor { self.completion_tasks.clear(); let context_menu = self.context_menu.write().take(); if context_menu.is_some() { - self.update_visible_copilot_suggestion(cx); + self.update_visible_inline_completion(cx); } context_menu } @@ -4546,7 +4374,7 @@ impl Editor { this.change_selections(Some(Autoscroll::fit()), cx, |s| s.select(selections)); this.insert("", cx); - this.refresh_copilot_suggestions(true, cx); + this.refresh_inline_completion(true, cx); }); } @@ -4564,7 +4392,7 @@ impl Editor { }) }); this.insert("", cx); - this.refresh_copilot_suggestions(true, cx); + this.refresh_inline_completion(true, cx); }); } @@ -4626,13 +4454,13 @@ impl Editor { } } - // Accept copilot suggestion if there is only one selection and the cursor is not + // Accept copilot completion if there is only one selection and the cursor is not // in the leading whitespace. if self.selections.count() == 1 && cursor.column >= current_indent.len - && self.has_active_copilot_suggestion(cx) + && self.has_active_inline_completion(cx) { - self.accept_copilot_suggestion(cx); + self.accept_inline_completion(cx); return; } @@ -4659,7 +4487,7 @@ impl Editor { self.transact(cx, |this, cx| { this.buffer.update(cx, |b, cx| b.edit(edits, None, cx)); this.change_selections(Some(Autoscroll::fit()), cx, |s| s.select(selections)); - this.refresh_copilot_suggestions(true, cx); + this.refresh_inline_completion(true, cx); }); } @@ -5753,7 +5581,7 @@ impl Editor { } self.request_autoscroll(Autoscroll::fit(), cx); self.unmark_text(cx); - self.refresh_copilot_suggestions(true, cx); + self.refresh_inline_completion(true, cx); cx.emit(EditorEvent::Edited); cx.emit(EditorEvent::TransactionUndone { transaction_id: tx_id, @@ -5775,7 +5603,7 @@ impl Editor { } self.request_autoscroll(Autoscroll::fit(), cx); self.unmark_text(cx); - self.refresh_copilot_suggestions(true, cx); + self.refresh_inline_completion(true, cx); cx.emit(EditorEvent::Edited); } } @@ -9444,8 +9272,8 @@ impl Editor { } => { self.refresh_active_diagnostics(cx); self.refresh_code_actions(cx); - if self.has_active_copilot_suggestion(cx) { - self.update_visible_copilot_suggestion(cx); + if self.has_active_inline_completion(cx) { + self.update_visible_inline_completion(cx); } cx.emit(EditorEvent::BufferEdited); cx.emit(SearchEvent::MatchesInvalidated); @@ -9523,7 +9351,7 @@ impl Editor { } fn settings_changed(&mut self, cx: &mut ViewContext) { - self.refresh_copilot_suggestions(true, cx); + self.refresh_inline_completion(true, cx); self.refresh_inlay_hints( InlayHintRefreshReason::SettingsChange(inlay_hint_settings( self.selections.newest_anchor().head(), @@ -9687,29 +9515,6 @@ impl Editor { .collect() } - fn report_copilot_event( - &self, - suggestion_id: Option, - suggestion_accepted: bool, - cx: &AppContext, - ) { - let Some(project) = &self.project else { return }; - - // If None, we are either getting suggestions in a new, unsaved file, or in a file without an extension - let file_extension = self - .buffer - .read(cx) - .as_singleton() - .and_then(|b| b.read(cx).file()) - .and_then(|file| Path::new(file.file_name(cx)).extension()) - .and_then(|e| e.to_str()) - .map(|a| a.to_string()); - - let telemetry = project.read(cx).client().telemetry().clone(); - - telemetry.report_copilot_event(suggestion_id, suggestion_accepted, file_extension) - } - fn report_editor_event( &self, operation: &'static str, diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index fad6485a7ae..54dbee3e9bc 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -7,7 +7,6 @@ use crate::{ }, JoinLines, }; - use futures::StreamExt; use gpui::{div, TestAppContext, VisualTestContext, WindowOptions}; use indoc::indoc; @@ -7682,648 +7681,6 @@ async fn test_move_to_enclosing_bracket(cx: &mut gpui::TestAppContext) { ); } -#[gpui::test(iterations = 10)] -async fn test_copilot(executor: BackgroundExecutor, cx: &mut gpui::TestAppContext) { - // flaky - init_test(cx, |_| {}); - - let (copilot, copilot_lsp) = Copilot::fake(cx); - _ = cx.update(|cx| Copilot::set_global(copilot, cx)); - let mut cx = EditorLspTestContext::new_rust( - lsp::ServerCapabilities { - completion_provider: Some(lsp::CompletionOptions { - trigger_characters: Some(vec![".".to_string(), ":".to_string()]), - ..Default::default() - }), - ..Default::default() - }, - cx, - ) - .await; - - // When inserting, ensure autocompletion is favored over Copilot suggestions. - cx.set_state(indoc! {" - oneˇ - two - three - "}); - cx.simulate_keystroke("."); - let _ = handle_completion_request( - &mut cx, - indoc! {" - one.|<> - two - three - "}, - vec!["completion_a", "completion_b"], - ); - handle_copilot_completion_request( - &copilot_lsp, - vec![copilot::request::Completion { - text: "one.copilot1".into(), - range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)), - ..Default::default() - }], - vec![], - ); - executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); - cx.update_editor(|editor, cx| { - assert!(editor.context_menu_visible()); - assert!(!editor.has_active_copilot_suggestion(cx)); - - // Confirming a completion inserts it and hides the context menu, without showing - // the copilot suggestion afterwards. - editor - .confirm_completion(&Default::default(), cx) - .unwrap() - .detach(); - assert!(!editor.context_menu_visible()); - assert!(!editor.has_active_copilot_suggestion(cx)); - assert_eq!(editor.text(cx), "one.completion_a\ntwo\nthree\n"); - assert_eq!(editor.display_text(cx), "one.completion_a\ntwo\nthree\n"); - }); - - // Ensure Copilot suggestions are shown right away if no autocompletion is available. - cx.set_state(indoc! {" - oneˇ - two - three - "}); - cx.simulate_keystroke("."); - let _ = handle_completion_request( - &mut cx, - indoc! {" - one.|<> - two - three - "}, - vec![], - ); - handle_copilot_completion_request( - &copilot_lsp, - vec![copilot::request::Completion { - text: "one.copilot1".into(), - range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)), - ..Default::default() - }], - vec![], - ); - executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); - cx.update_editor(|editor, cx| { - assert!(!editor.context_menu_visible()); - assert!(editor.has_active_copilot_suggestion(cx)); - assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n"); - assert_eq!(editor.text(cx), "one.\ntwo\nthree\n"); - }); - - // Reset editor, and ensure autocompletion is still favored over Copilot suggestions. - cx.set_state(indoc! {" - oneˇ - two - three - "}); - cx.simulate_keystroke("."); - let _ = handle_completion_request( - &mut cx, - indoc! {" - one.|<> - two - three - "}, - vec!["completion_a", "completion_b"], - ); - handle_copilot_completion_request( - &copilot_lsp, - vec![copilot::request::Completion { - text: "one.copilot1".into(), - range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)), - ..Default::default() - }], - vec![], - ); - executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); - cx.update_editor(|editor, cx| { - assert!(editor.context_menu_visible()); - assert!(!editor.has_active_copilot_suggestion(cx)); - - // When hiding the context menu, the Copilot suggestion becomes visible. - editor.hide_context_menu(cx); - assert!(!editor.context_menu_visible()); - assert!(editor.has_active_copilot_suggestion(cx)); - assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n"); - assert_eq!(editor.text(cx), "one.\ntwo\nthree\n"); - }); - - // Ensure existing completion is interpolated when inserting again. - cx.simulate_keystroke("c"); - executor.run_until_parked(); - cx.update_editor(|editor, cx| { - assert!(!editor.context_menu_visible()); - assert!(editor.has_active_copilot_suggestion(cx)); - assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n"); - assert_eq!(editor.text(cx), "one.c\ntwo\nthree\n"); - }); - - // After debouncing, new Copilot completions should be requested. - handle_copilot_completion_request( - &copilot_lsp, - vec![copilot::request::Completion { - text: "one.copilot2".into(), - range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 5)), - ..Default::default() - }], - vec![], - ); - executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); - cx.update_editor(|editor, cx| { - assert!(!editor.context_menu_visible()); - assert!(editor.has_active_copilot_suggestion(cx)); - assert_eq!(editor.display_text(cx), "one.copilot2\ntwo\nthree\n"); - assert_eq!(editor.text(cx), "one.c\ntwo\nthree\n"); - - // Canceling should remove the active Copilot suggestion. - editor.cancel(&Default::default(), cx); - assert!(!editor.has_active_copilot_suggestion(cx)); - assert_eq!(editor.display_text(cx), "one.c\ntwo\nthree\n"); - assert_eq!(editor.text(cx), "one.c\ntwo\nthree\n"); - - // After canceling, tabbing shouldn't insert the previously shown suggestion. - editor.tab(&Default::default(), cx); - assert!(!editor.has_active_copilot_suggestion(cx)); - assert_eq!(editor.display_text(cx), "one.c \ntwo\nthree\n"); - assert_eq!(editor.text(cx), "one.c \ntwo\nthree\n"); - - // When undoing the previously active suggestion is shown again. - editor.undo(&Default::default(), cx); - assert!(editor.has_active_copilot_suggestion(cx)); - assert_eq!(editor.display_text(cx), "one.copilot2\ntwo\nthree\n"); - assert_eq!(editor.text(cx), "one.c\ntwo\nthree\n"); - }); - - // If an edit occurs outside of this editor, the suggestion is still correctly interpolated. - cx.update_buffer(|buffer, cx| buffer.edit([(5..5, "o")], None, cx)); - cx.update_editor(|editor, cx| { - assert!(editor.has_active_copilot_suggestion(cx)); - assert_eq!(editor.display_text(cx), "one.copilot2\ntwo\nthree\n"); - assert_eq!(editor.text(cx), "one.co\ntwo\nthree\n"); - - // Tabbing when there is an active suggestion inserts it. - editor.tab(&Default::default(), cx); - assert!(!editor.has_active_copilot_suggestion(cx)); - assert_eq!(editor.display_text(cx), "one.copilot2\ntwo\nthree\n"); - assert_eq!(editor.text(cx), "one.copilot2\ntwo\nthree\n"); - - // When undoing the previously active suggestion is shown again. - editor.undo(&Default::default(), cx); - assert!(editor.has_active_copilot_suggestion(cx)); - assert_eq!(editor.display_text(cx), "one.copilot2\ntwo\nthree\n"); - assert_eq!(editor.text(cx), "one.co\ntwo\nthree\n"); - - // Hide suggestion. - editor.cancel(&Default::default(), cx); - assert!(!editor.has_active_copilot_suggestion(cx)); - assert_eq!(editor.display_text(cx), "one.co\ntwo\nthree\n"); - assert_eq!(editor.text(cx), "one.co\ntwo\nthree\n"); - }); - - // If an edit occurs outside of this editor but no suggestion is being shown, - // we won't make it visible. - cx.update_buffer(|buffer, cx| buffer.edit([(6..6, "p")], None, cx)); - cx.update_editor(|editor, cx| { - assert!(!editor.has_active_copilot_suggestion(cx)); - assert_eq!(editor.display_text(cx), "one.cop\ntwo\nthree\n"); - assert_eq!(editor.text(cx), "one.cop\ntwo\nthree\n"); - }); - - // Reset the editor to verify how suggestions behave when tabbing on leading indentation. - cx.update_editor(|editor, cx| { - editor.set_text("fn foo() {\n \n}", cx); - editor.change_selections(None, cx, |s| { - s.select_ranges([Point::new(1, 2)..Point::new(1, 2)]) - }); - }); - handle_copilot_completion_request( - &copilot_lsp, - vec![copilot::request::Completion { - text: " let x = 4;".into(), - range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 2)), - ..Default::default() - }], - vec![], - ); - - cx.update_editor(|editor, cx| editor.next_copilot_suggestion(&Default::default(), cx)); - executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); - cx.update_editor(|editor, cx| { - assert!(editor.has_active_copilot_suggestion(cx)); - assert_eq!(editor.display_text(cx), "fn foo() {\n let x = 4;\n}"); - assert_eq!(editor.text(cx), "fn foo() {\n \n}"); - - // Tabbing inside of leading whitespace inserts indentation without accepting the suggestion. - editor.tab(&Default::default(), cx); - assert!(editor.has_active_copilot_suggestion(cx)); - assert_eq!(editor.text(cx), "fn foo() {\n \n}"); - assert_eq!(editor.display_text(cx), "fn foo() {\n let x = 4;\n}"); - - // Tabbing again accepts the suggestion. - editor.tab(&Default::default(), cx); - assert!(!editor.has_active_copilot_suggestion(cx)); - assert_eq!(editor.text(cx), "fn foo() {\n let x = 4;\n}"); - assert_eq!(editor.display_text(cx), "fn foo() {\n let x = 4;\n}"); - }); -} - -#[gpui::test(iterations = 10)] -async fn test_accept_partial_copilot_suggestion( - executor: BackgroundExecutor, - cx: &mut gpui::TestAppContext, -) { - // flaky - init_test(cx, |_| {}); - - let (copilot, copilot_lsp) = Copilot::fake(cx); - _ = cx.update(|cx| Copilot::set_global(copilot, cx)); - let mut cx = EditorLspTestContext::new_rust( - lsp::ServerCapabilities { - completion_provider: Some(lsp::CompletionOptions { - trigger_characters: Some(vec![".".to_string(), ":".to_string()]), - ..Default::default() - }), - ..Default::default() - }, - cx, - ) - .await; - - // Setup the editor with a completion request. - cx.set_state(indoc! {" - oneˇ - two - three - "}); - cx.simulate_keystroke("."); - let _ = handle_completion_request( - &mut cx, - indoc! {" - one.|<> - two - three - "}, - vec![], - ); - handle_copilot_completion_request( - &copilot_lsp, - vec![copilot::request::Completion { - text: "one.copilot1".into(), - range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)), - ..Default::default() - }], - vec![], - ); - executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); - cx.update_editor(|editor, cx| { - assert!(editor.has_active_copilot_suggestion(cx)); - - // Accepting the first word of the suggestion should only accept the first word and still show the rest. - editor.accept_partial_copilot_suggestion(&Default::default(), cx); - assert!(editor.has_active_copilot_suggestion(cx)); - assert_eq!(editor.text(cx), "one.copilot\ntwo\nthree\n"); - assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n"); - - // Accepting next word should accept the non-word and copilot suggestion should be gone - editor.accept_partial_copilot_suggestion(&Default::default(), cx); - assert!(!editor.has_active_copilot_suggestion(cx)); - assert_eq!(editor.text(cx), "one.copilot1\ntwo\nthree\n"); - assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n"); - }); - - // Reset the editor and check non-word and whitespace completion - cx.set_state(indoc! {" - oneˇ - two - three - "}); - cx.simulate_keystroke("."); - let _ = handle_completion_request( - &mut cx, - indoc! {" - one.|<> - two - three - "}, - vec![], - ); - handle_copilot_completion_request( - &copilot_lsp, - vec![copilot::request::Completion { - text: "one.123. copilot\n 456".into(), - range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)), - ..Default::default() - }], - vec![], - ); - executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); - cx.update_editor(|editor, cx| { - assert!(editor.has_active_copilot_suggestion(cx)); - - // Accepting the first word (non-word) of the suggestion should only accept the first word and still show the rest. - editor.accept_partial_copilot_suggestion(&Default::default(), cx); - assert!(editor.has_active_copilot_suggestion(cx)); - assert_eq!(editor.text(cx), "one.123. \ntwo\nthree\n"); - assert_eq!( - editor.display_text(cx), - "one.123. copilot\n 456\ntwo\nthree\n" - ); - - // Accepting next word should accept the next word and copilot suggestion should still exist - editor.accept_partial_copilot_suggestion(&Default::default(), cx); - assert!(editor.has_active_copilot_suggestion(cx)); - assert_eq!(editor.text(cx), "one.123. copilot\ntwo\nthree\n"); - assert_eq!( - editor.display_text(cx), - "one.123. copilot\n 456\ntwo\nthree\n" - ); - - // Accepting the whitespace should accept the non-word/whitespaces with newline and copilot suggestion should be gone - editor.accept_partial_copilot_suggestion(&Default::default(), cx); - assert!(!editor.has_active_copilot_suggestion(cx)); - assert_eq!(editor.text(cx), "one.123. copilot\n 456\ntwo\nthree\n"); - assert_eq!( - editor.display_text(cx), - "one.123. copilot\n 456\ntwo\nthree\n" - ); - }); -} - -#[gpui::test] -async fn test_copilot_completion_invalidation( - executor: BackgroundExecutor, - cx: &mut gpui::TestAppContext, -) { - init_test(cx, |_| {}); - - let (copilot, copilot_lsp) = Copilot::fake(cx); - _ = cx.update(|cx| Copilot::set_global(copilot, cx)); - let mut cx = EditorLspTestContext::new_rust( - lsp::ServerCapabilities { - completion_provider: Some(lsp::CompletionOptions { - trigger_characters: Some(vec![".".to_string(), ":".to_string()]), - ..Default::default() - }), - ..Default::default() - }, - cx, - ) - .await; - - cx.set_state(indoc! {" - one - twˇ - three - "}); - - handle_copilot_completion_request( - &copilot_lsp, - vec![copilot::request::Completion { - text: "two.foo()".into(), - range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 2)), - ..Default::default() - }], - vec![], - ); - cx.update_editor(|editor, cx| editor.next_copilot_suggestion(&Default::default(), cx)); - executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); - cx.update_editor(|editor, cx| { - assert!(editor.has_active_copilot_suggestion(cx)); - assert_eq!(editor.display_text(cx), "one\ntwo.foo()\nthree\n"); - assert_eq!(editor.text(cx), "one\ntw\nthree\n"); - - editor.backspace(&Default::default(), cx); - assert!(editor.has_active_copilot_suggestion(cx)); - assert_eq!(editor.display_text(cx), "one\ntwo.foo()\nthree\n"); - assert_eq!(editor.text(cx), "one\nt\nthree\n"); - - editor.backspace(&Default::default(), cx); - assert!(editor.has_active_copilot_suggestion(cx)); - assert_eq!(editor.display_text(cx), "one\ntwo.foo()\nthree\n"); - assert_eq!(editor.text(cx), "one\n\nthree\n"); - - // Deleting across the original suggestion range invalidates it. - editor.backspace(&Default::default(), cx); - assert!(!editor.has_active_copilot_suggestion(cx)); - assert_eq!(editor.display_text(cx), "one\nthree\n"); - assert_eq!(editor.text(cx), "one\nthree\n"); - - // Undoing the deletion restores the suggestion. - editor.undo(&Default::default(), cx); - assert!(editor.has_active_copilot_suggestion(cx)); - assert_eq!(editor.display_text(cx), "one\ntwo.foo()\nthree\n"); - assert_eq!(editor.text(cx), "one\n\nthree\n"); - }); -} - -#[gpui::test] -async fn test_copilot_multibuffer(executor: BackgroundExecutor, cx: &mut gpui::TestAppContext) { - init_test(cx, |_| {}); - - let (copilot, copilot_lsp) = Copilot::fake(cx); - _ = cx.update(|cx| Copilot::set_global(copilot, cx)); - - let buffer_1 = cx.new_model(|cx| { - Buffer::new( - 0, - BufferId::new(cx.entity_id().as_u64()).unwrap(), - "a = 1\nb = 2\n", - ) - }); - let buffer_2 = cx.new_model(|cx| { - Buffer::new( - 0, - BufferId::new(cx.entity_id().as_u64()).unwrap(), - "c = 3\nd = 4\n", - ) - }); - let multibuffer = cx.new_model(|cx| { - let mut multibuffer = MultiBuffer::new(0, ReadWrite); - multibuffer.push_excerpts( - buffer_1.clone(), - [ExcerptRange { - context: Point::new(0, 0)..Point::new(2, 0), - primary: None, - }], - cx, - ); - multibuffer.push_excerpts( - buffer_2.clone(), - [ExcerptRange { - context: Point::new(0, 0)..Point::new(2, 0), - primary: None, - }], - cx, - ); - multibuffer - }); - let editor = cx.add_window(|cx| build_editor(multibuffer, cx)); - - handle_copilot_completion_request( - &copilot_lsp, - vec![copilot::request::Completion { - text: "b = 2 + a".into(), - range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 5)), - ..Default::default() - }], - vec![], - ); - _ = editor.update(cx, |editor, cx| { - // Ensure copilot suggestions are shown for the first excerpt. - editor.change_selections(None, cx, |s| { - s.select_ranges([Point::new(1, 5)..Point::new(1, 5)]) - }); - editor.next_copilot_suggestion(&Default::default(), cx); - }); - executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); - _ = editor.update(cx, |editor, cx| { - assert!(editor.has_active_copilot_suggestion(cx)); - assert_eq!( - editor.display_text(cx), - "\n\na = 1\nb = 2 + a\n\n\n\nc = 3\nd = 4\n" - ); - assert_eq!(editor.text(cx), "a = 1\nb = 2\n\nc = 3\nd = 4\n"); - }); - - handle_copilot_completion_request( - &copilot_lsp, - vec![copilot::request::Completion { - text: "d = 4 + c".into(), - range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 6)), - ..Default::default() - }], - vec![], - ); - _ = editor.update(cx, |editor, cx| { - // Move to another excerpt, ensuring the suggestion gets cleared. - editor.change_selections(None, cx, |s| { - s.select_ranges([Point::new(4, 5)..Point::new(4, 5)]) - }); - assert!(!editor.has_active_copilot_suggestion(cx)); - assert_eq!( - editor.display_text(cx), - "\n\na = 1\nb = 2\n\n\n\nc = 3\nd = 4\n" - ); - assert_eq!(editor.text(cx), "a = 1\nb = 2\n\nc = 3\nd = 4\n"); - - // Type a character, ensuring we don't even try to interpolate the previous suggestion. - editor.handle_input(" ", cx); - assert!(!editor.has_active_copilot_suggestion(cx)); - assert_eq!( - editor.display_text(cx), - "\n\na = 1\nb = 2\n\n\n\nc = 3\nd = 4 \n" - ); - assert_eq!(editor.text(cx), "a = 1\nb = 2\n\nc = 3\nd = 4 \n"); - }); - - // Ensure the new suggestion is displayed when the debounce timeout expires. - executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); - _ = editor.update(cx, |editor, cx| { - assert!(editor.has_active_copilot_suggestion(cx)); - assert_eq!( - editor.display_text(cx), - "\n\na = 1\nb = 2\n\n\n\nc = 3\nd = 4 + c\n" - ); - assert_eq!(editor.text(cx), "a = 1\nb = 2\n\nc = 3\nd = 4 \n"); - }); -} - -#[gpui::test] -async fn test_copilot_disabled_globs(executor: BackgroundExecutor, cx: &mut gpui::TestAppContext) { - init_test(cx, |settings| { - settings - .copilot - .get_or_insert(Default::default()) - .disabled_globs = Some(vec![".env*".to_string()]); - }); - - let (copilot, copilot_lsp) = Copilot::fake(cx); - _ = cx.update(|cx| Copilot::set_global(copilot, cx)); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/test", - json!({ - ".env": "SECRET=something\n", - "README.md": "hello\n" - }), - ) - .await; - let project = Project::test(fs, ["/test".as_ref()], cx).await; - - let private_buffer = project - .update(cx, |project, cx| { - project.open_local_buffer("/test/.env", cx) - }) - .await - .unwrap(); - let public_buffer = project - .update(cx, |project, cx| { - project.open_local_buffer("/test/README.md", cx) - }) - .await - .unwrap(); - - let multibuffer = cx.new_model(|cx| { - let mut multibuffer = MultiBuffer::new(0, ReadWrite); - multibuffer.push_excerpts( - private_buffer.clone(), - [ExcerptRange { - context: Point::new(0, 0)..Point::new(1, 0), - primary: None, - }], - cx, - ); - multibuffer.push_excerpts( - public_buffer.clone(), - [ExcerptRange { - context: Point::new(0, 0)..Point::new(1, 0), - primary: None, - }], - cx, - ); - multibuffer - }); - let editor = cx.add_window(|cx| build_editor(multibuffer, cx)); - - let mut copilot_requests = copilot_lsp - .handle_request::(move |_params, _cx| async move { - Ok(copilot::request::GetCompletionsResult { - completions: vec![copilot::request::Completion { - text: "next line".into(), - range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 0)), - ..Default::default() - }], - }) - }); - - _ = editor.update(cx, |editor, cx| { - editor.change_selections(None, cx, |selections| { - selections.select_ranges([Point::new(0, 0)..Point::new(0, 0)]) - }); - editor.next_copilot_suggestion(&Default::default(), cx); - }); - - executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); - assert!(copilot_requests.try_next().is_err()); - - _ = editor.update(cx, |editor, cx| { - editor.change_selections(None, cx, |s| { - s.select_ranges([Point::new(2, 0)..Point::new(2, 0)]) - }); - editor.next_copilot_suggestion(&Default::default(), cx); - }); - - executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); - assert!(copilot_requests.try_next().is_ok()); -} - #[gpui::test] async fn test_on_type_formatting_not_triggered(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); @@ -9902,29 +9259,6 @@ fn handle_resolve_completion_request( } } -fn handle_copilot_completion_request( - lsp: &lsp::FakeLanguageServer, - completions: Vec, - completions_cycling: Vec, -) { - lsp.handle_request::(move |_params, _cx| { - let completions = completions.clone(); - async move { - Ok(copilot::request::GetCompletionsResult { - completions: completions.clone(), - }) - } - }); - lsp.handle_request::(move |_params, _cx| { - let completions_cycling = completions_cycling.clone(); - async move { - Ok(copilot::request::GetCompletionsResult { - completions: completions_cycling.clone(), - }) - } - }); -} - pub(crate) fn update_test_language_settings( cx: &mut TestAppContext, f: impl Fn(&mut AllLanguageSettingsContent), diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 3a30bddbbc1..2c92d82d1e5 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -344,9 +344,9 @@ impl EditorElement { cx.propagate(); } }); - register_action(view, cx, Editor::next_copilot_suggestion); - register_action(view, cx, Editor::previous_copilot_suggestion); - register_action(view, cx, Editor::copilot_suggest); + register_action(view, cx, Editor::next_inline_completion); + register_action(view, cx, Editor::previous_inline_completion); + register_action(view, cx, Editor::show_inline_completion); register_action(view, cx, Editor::context_menu_first); register_action(view, cx, Editor::context_menu_prev); register_action(view, cx, Editor::context_menu_next); @@ -354,7 +354,7 @@ impl EditorElement { register_action(view, cx, Editor::display_cursor_names); register_action(view, cx, Editor::unique_lines_case_insensitive); register_action(view, cx, Editor::unique_lines_case_sensitive); - register_action(view, cx, Editor::accept_partial_copilot_suggestion); + register_action(view, cx, Editor::accept_partial_inline_completion); register_action(view, cx, Editor::revert_selected_hunks); } diff --git a/crates/editor/src/inline_completion_provider.rs b/crates/editor/src/inline_completion_provider.rs new file mode 100644 index 00000000000..31edf806239 --- /dev/null +++ b/crates/editor/src/inline_completion_provider.rs @@ -0,0 +1,121 @@ +use crate::Direction; +use gpui::{AppContext, Model, ModelContext}; +use language::Buffer; + +pub trait InlineCompletionProvider: 'static + Sized { + fn is_enabled( + &self, + buffer: &Model, + cursor_position: language::Anchor, + cx: &AppContext, + ) -> bool; + fn refresh( + &mut self, + buffer: Model, + cursor_position: language::Anchor, + debounce: bool, + cx: &mut ModelContext, + ); + fn cycle( + &mut self, + buffer: Model, + cursor_position: language::Anchor, + direction: Direction, + cx: &mut ModelContext, + ); + fn accept(&mut self, cx: &mut ModelContext); + fn discard(&mut self, cx: &mut ModelContext); + fn active_completion_text( + &self, + buffer: &Model, + cursor_position: language::Anchor, + cx: &AppContext, + ) -> Option<&str>; +} + +pub trait InlineCompletionProviderHandle { + fn is_enabled( + &self, + buffer: &Model, + cursor_position: language::Anchor, + cx: &AppContext, + ) -> bool; + fn refresh( + &self, + buffer: Model, + cursor_position: language::Anchor, + debounce: bool, + cx: &mut AppContext, + ); + fn cycle( + &self, + buffer: Model, + cursor_position: language::Anchor, + direction: Direction, + cx: &mut AppContext, + ); + fn accept(&self, cx: &mut AppContext); + fn discard(&self, cx: &mut AppContext); + fn active_completion_text<'a>( + &self, + buffer: &Model, + cursor_position: language::Anchor, + cx: &'a AppContext, + ) -> Option<&'a str>; +} + +impl InlineCompletionProviderHandle for Model +where + T: InlineCompletionProvider, +{ + fn is_enabled( + &self, + buffer: &Model, + cursor_position: language::Anchor, + cx: &AppContext, + ) -> bool { + self.read(cx).is_enabled(buffer, cursor_position, cx) + } + + fn refresh( + &self, + buffer: Model, + cursor_position: language::Anchor, + debounce: bool, + cx: &mut AppContext, + ) { + self.update(cx, |this, cx| { + this.refresh(buffer, cursor_position, debounce, cx) + }) + } + + fn cycle( + &self, + buffer: Model, + cursor_position: language::Anchor, + direction: Direction, + cx: &mut AppContext, + ) { + self.update(cx, |this, cx| { + this.cycle(buffer, cursor_position, direction, cx) + }) + } + + fn accept(&self, cx: &mut AppContext) { + self.update(cx, |this, cx| this.accept(cx)) + } + + fn discard(&self, cx: &mut AppContext) { + self.update(cx, |this, cx| this.discard(cx)) + } + + fn active_completion_text<'a>( + &self, + buffer: &Model, + cursor_position: language::Anchor, + cx: &'a AppContext, + ) -> Option<&'a str> { + self.read(cx) + .active_completion_text(buffer, cursor_position, cx) + } +} diff --git a/crates/feedback/src/feedback_modal.rs b/crates/feedback/src/feedback_modal.rs index 510d31375b1..04fa8740122 100644 --- a/crates/feedback/src/feedback_modal.rs +++ b/crates/feedback/src/feedback_modal.rs @@ -185,7 +185,7 @@ impl FeedbackModal { cx, ); editor.set_show_gutter(false, cx); - editor.set_show_copilot_suggestions(false); + editor.set_show_inline_completions(false); editor.set_vertical_scroll_margin(5, cx); editor.set_use_modal_editing(false); editor diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index af4bedabef2..ef4292791d7 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -897,17 +897,6 @@ impl AppContext { .unwrap() } - /// Updates the global of the given type with a closure. Unlike `global_mut`, this method provides - /// your closure with mutable access to the `AppContext` and the global simultaneously. - pub fn update_global(&mut self, f: impl FnOnce(&mut G, &mut Self) -> R) -> R { - self.update(|cx| { - let mut global = cx.lease_global::(); - let result = f(&mut global, cx); - cx.end_global_lease(global); - result - }) - } - /// Register a callback to be invoked when a global of the given type is updated. pub fn observe_global( &mut self, diff --git a/crates/gpui/src/app/async_context.rs b/crates/gpui/src/app/async_context.rs index 6252da6c18b..2835a2af98a 100644 --- a/crates/gpui/src/app/async_context.rs +++ b/crates/gpui/src/app/async_context.rs @@ -1,7 +1,7 @@ use crate::{ - AnyView, AnyWindowHandle, AppCell, AppContext, BackgroundExecutor, Context, DismissEvent, - FocusableView, ForegroundExecutor, Global, Model, ModelContext, Render, Result, Task, View, - ViewContext, VisualContext, WindowContext, WindowHandle, + AnyView, AnyWindowHandle, AppCell, AppContext, BackgroundExecutor, BorrowAppContext, Context, + DismissEvent, FocusableView, ForegroundExecutor, Global, Model, ModelContext, Render, Result, + Task, View, ViewContext, VisualContext, WindowContext, WindowHandle, }; use anyhow::{anyhow, Context as _}; use derive_more::{Deref, DerefMut}; @@ -192,7 +192,7 @@ impl AsyncAppContext { .upgrade() .ok_or_else(|| anyhow!("app was released"))?; let mut app = app.borrow_mut(); - Ok(app.update_global(update)) + Ok(app.update(|cx| cx.update_global(update))) } } diff --git a/crates/gpui/src/app/model_context.rs b/crates/gpui/src/app/model_context.rs index 74569d5e5b2..0bdbf20988b 100644 --- a/crates/gpui/src/app/model_context.rs +++ b/crates/gpui/src/app/model_context.rs @@ -1,6 +1,6 @@ use crate::{ AnyView, AnyWindowHandle, AppContext, AsyncAppContext, Context, Effect, Entity, EntityId, - EventEmitter, Global, Model, Subscription, Task, View, WeakModel, WindowContext, WindowHandle, + EventEmitter, Model, Subscription, Task, View, WeakModel, WindowContext, WindowHandle, }; use anyhow::Result; use derive_more::{Deref, DerefMut}; @@ -190,17 +190,6 @@ impl<'a, T: 'static> ModelContext<'a, T> { } } - /// Updates the given global - pub fn update_global(&mut self, f: impl FnOnce(&mut G, &mut Self) -> R) -> R - where - G: Global, - { - let mut global = self.app.lease_global::(); - let result = f(&mut global, self); - self.app.end_global_lease(global); - result - } - /// Spawn the future returned by the given function. /// The function is provided a weak handle to the model owned by this context and a context that can be held across await points. /// The returned task must be held or detached. diff --git a/crates/gpui/src/app/test_context.rs b/crates/gpui/src/app/test_context.rs index 0b0ee3b200d..0ca6f038a47 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -1,7 +1,7 @@ use crate::{ Action, AnyElement, AnyView, AnyWindowHandle, AppCell, AppContext, AsyncAppContext, - AvailableSpace, BackgroundExecutor, Bounds, ClipboardItem, Context, Empty, Entity, - EventEmitter, ForegroundExecutor, Global, InputEvent, Keystroke, Model, ModelContext, + AvailableSpace, BackgroundExecutor, BorrowAppContext, Bounds, ClipboardItem, Context, Empty, + Entity, EventEmitter, ForegroundExecutor, Global, InputEvent, Keystroke, Model, ModelContext, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Platform, Point, Render, Result, Size, Task, TestDispatcher, TestPlatform, TestWindow, TextSystem, View, ViewContext, VisualContext, WindowContext, WindowHandle, WindowOptions, @@ -51,14 +51,6 @@ impl Context for TestAppContext { app.update_model(handle, update) } - fn update_window(&mut self, window: AnyWindowHandle, f: F) -> Result - where - F: FnOnce(AnyView, &mut WindowContext<'_>) -> T, - { - let mut lock = self.app.borrow_mut(); - lock.update_window(window, f) - } - fn read_model( &self, handle: &Model, @@ -71,6 +63,14 @@ impl Context for TestAppContext { app.read_model(handle, read) } + fn update_window(&mut self, window: AnyWindowHandle, f: F) -> Result + where + F: FnOnce(AnyView, &mut WindowContext<'_>) -> T, + { + let mut lock = self.app.borrow_mut(); + lock.update_window(window, f) + } + fn read_window( &self, window: &WindowHandle, @@ -309,7 +309,7 @@ impl TestAppContext { /// sets the global in this context. pub fn set_global(&mut self, global: G) { let mut lock = self.app.borrow_mut(); - lock.set_global(global); + lock.update(|cx| cx.set_global(global)) } /// updates the global in this context. (panics if `has_global` would return false) @@ -318,7 +318,7 @@ impl TestAppContext { update: impl FnOnce(&mut G, &mut AppContext) -> R, ) -> R { let mut lock = self.app.borrow_mut(); - lock.update_global(update) + lock.update(|cx| cx.update_global(update)) } /// Returns an `AsyncAppContext` which can be used to run tasks that expect to be on a background diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs index d9365c282ac..adde92670c2 100644 --- a/crates/gpui/src/gpui.rs +++ b/crates/gpui/src/gpui.rs @@ -261,6 +261,10 @@ pub trait EventEmitter: 'static {} pub trait BorrowAppContext { /// Set a global value on the context. fn set_global(&mut self, global: T); + /// Updates the global state of the given type. + fn update_global(&mut self, f: impl FnOnce(&mut G, &mut Self) -> R) -> R + where + G: Global; } impl BorrowAppContext for C @@ -270,6 +274,16 @@ where fn set_global(&mut self, global: G) { self.borrow_mut().set_global(global) } + + fn update_global(&mut self, f: impl FnOnce(&mut G, &mut Self) -> R) -> R + where + G: Global, + { + let mut global = self.borrow_mut().lease_global::(); + let result = f(&mut global, self); + self.borrow_mut().end_global_lease(global); + result + } } /// A flatten equivalent for anyhow `Result`s. @@ -293,4 +307,18 @@ impl Flatten for Result { /// A marker trait for types that can be stored in GPUI's global state. /// /// Implement this on types you want to store in the context as a global. -pub trait Global: 'static {} +pub trait Global: 'static + Sized { + /// Access the global of the implementing type. Panics if a global for that type has not been assigned. + fn get(cx: &AppContext) -> &Self { + cx.global() + } + + /// Updates the global of the implementing type with a closure. Unlike `global_mut`, this method provides + /// your closure with mutable access to the `AppContext` and the global simultaneously. + fn update(cx: &mut C, f: impl FnOnce(&mut Self, &mut C) -> R) -> R + where + C: BorrowAppContext, + { + cx.update_global(f) + } +} diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index fd1d549ad67..5baea12dc01 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -854,18 +854,6 @@ impl<'a> WindowContext<'a> { .spawn(|app| f(AsyncWindowContext::new(app, self.window.handle))) } - /// Updates the global of the given type. The given closure is given simultaneous mutable - /// access both to the global and the context. - pub fn update_global(&mut self, f: impl FnOnce(&mut G, &mut Self) -> R) -> R - where - G: Global, - { - let mut global = self.app.lease_global::(); - let result = f(&mut global, self); - self.app.end_global_lease(global); - result - } - fn window_bounds_changed(&mut self) { self.window.scale_factor = self.window.platform_window.scale_factor(); self.window.viewport_size = self.window.platform_window.content_size(); @@ -2388,17 +2376,6 @@ impl<'a, V: 'static> ViewContext<'a, V> { self.window_cx.spawn(|cx| f(view, cx)) } - /// Updates the global state of the given type. - pub fn update_global(&mut self, f: impl FnOnce(&mut G, &mut Self) -> R) -> R - where - G: Global, - { - let mut global = self.app.lease_global::(); - let result = f(&mut global, self); - self.app.end_global_lease(global); - result - } - /// Register a callback to be invoked when the given global state changes. pub fn observe_global( &mut self, diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index ce0160f2808..d0192be2443 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -6,7 +6,7 @@ use crate::Buffer; use clock::ReplicaId; use collections::BTreeMap; use futures::FutureExt as _; -use gpui::{AppContext, Model}; +use gpui::{AppContext, BorrowAppContext, Model}; use gpui::{Context, TestAppContext}; use indoc::indoc; use proto::deserialize_operation; diff --git a/crates/language_tools/src/lsp_log.rs b/crates/language_tools/src/lsp_log.rs index 6de1df95803..c03f4dc90a3 100644 --- a/crates/language_tools/src/lsp_log.rs +++ b/crates/language_tools/src/lsp_log.rs @@ -456,7 +456,7 @@ impl LspLogView { editor.set_text(log_contents, cx); editor.move_to_end(&MoveToEnd, cx); editor.set_read_only(true); - editor.set_show_copilot_suggestions(false); + editor.set_show_inline_completions(false); editor }); let editor_subscription = cx.subscribe( diff --git a/crates/languages/src/c.rs b/crates/languages/src/c.rs index 965e6e72fb3..5cc1ea986b1 100644 --- a/crates/languages/src/c.rs +++ b/crates/languages/src/c.rs @@ -293,7 +293,7 @@ async fn get_cached_server_binary(container_dir: PathBuf) -> Option, cx: &mut AppContext) { fn watch_file_types(fs: Arc, cx: &mut AppContext) { use std::time::Duration; + use gpui::BorrowAppContext; + let path = { let p = Path::new("assets/icons/file_icons/file_types.json"); let Ok(full_path) = p.canonicalize() else { @@ -1065,3 +1072,45 @@ fn watch_file_types(fs: Arc, cx: &mut AppContext) { #[cfg(not(debug_assertions))] fn watch_file_types(_fs: Arc, _cx: &mut AppContext) {} + +fn init_inline_completion_provider(telemetry: Arc, cx: &mut AppContext) { + if let Some(copilot) = Copilot::global(cx) { + cx.observe_new_views(move |editor: &mut Editor, cx: &mut ViewContext| { + if editor.mode() == EditorMode::Full { + // 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, cx: &mut ViewContext| { + editor.show_inline_completion(&Default::default(), cx); + }, + )) + .register_action(cx.listener( + |editor, _: &copilot::NextSuggestion, cx: &mut ViewContext| { + editor.next_inline_completion(&Default::default(), cx); + }, + )) + .register_action(cx.listener( + |editor, _: &copilot::PreviousSuggestion, cx: &mut ViewContext| { + editor.previous_inline_completion(&Default::default(), cx); + }, + )) + .register_action(cx.listener( + |editor, + _: &editor::actions::AcceptPartialCopilotSuggestion, + cx: &mut ViewContext| { + editor.accept_partial_inline_completion(&Default::default(), cx); + }, + )); + + let provider = cx.new_model(|_| { + CopilotCompletionProvider::new(copilot.clone()) + .with_telemetry(telemetry.clone()) + }); + editor.set_inline_completion_provider(provider, cx) + } + }) + .detach(); + } +} diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 8983be0c214..7ee37d27ad0 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -879,8 +879,8 @@ mod tests { use collections::HashSet; use editor::{scroll::Autoscroll, DisplayPoint, Editor}; use gpui::{ - actions, Action, AnyWindowHandle, AppContext, AssetSource, Entity, TestAppContext, - VisualTestContext, WindowHandle, + actions, Action, AnyWindowHandle, AppContext, AssetSource, BorrowAppContext, Entity, + TestAppContext, VisualTestContext, WindowHandle, }; use language::{LanguageMatcher, LanguageRegistry}; use project::{Project, ProjectPath, WorktreeSettings};