From fb6cff89d77f733c39d6975a6c7b821b45ad71b3 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 26 Mar 2024 13:28:06 +0100 Subject: [PATCH] Introduce `InlineCompletionProvider` (#9777) This pull request introduces a new `InlineCompletionProvider` trait, which enables making `Editor` copilot-agnostic and lets us push all the copilot functionality into the `copilot_ui` module. Long-term, I would like to merge `copilot` and `copilot_ui`, but right now `project` depends on `copilot`, which makes this impossible. The reason for adding this new trait is so that we can experiment with other inline completion providers and swap them at runtime using config settings. Please, note also that we renamed some of the existing copilot actions to be more agnostic (see release notes below). We still kept the old actions bound for backwards-compatibility, but we should probably remove them at some later version. Also, as a drive-by, we added new methods to the `Global` trait that let you read or mutate a global directly, e.g.: ```rs MyGlobal::update(cx, |global, cx| { }); ``` Release Notes: - Renamed the `copilot::Suggest` action to `editor::ShowInlineCompletion` - Renamed the `copilot::NextSuggestion` action to `editor::NextInlineCompletion` - Renamed the `copilot::PreviousSuggestion` action to `editor::PreviousInlineCompletion` - Renamed the `editor::AcceptPartialCopilotSuggestion` action to `editor::AcceptPartialInlineCompletion` --------- Co-authored-by: Nathan Co-authored-by: Kyle Co-authored-by: Kyle Kelley --- Cargo.lock | 8 +- assets/keymaps/default-linux.json | 12 +- assets/keymaps/default-macos.json | 12 +- assets/keymaps/vim.json | 2 +- crates/assistant/src/assistant.rs | 2 +- crates/assistant/src/assistant_settings.rs | 2 +- crates/assistant/src/completion_provider.rs | 2 +- crates/audio/src/audio.rs | 2 +- crates/client/src/client.rs | 3 +- crates/collab/src/tests/editor_tests.rs | 2 +- crates/collab/src/tests/following_tests.rs | 4 +- crates/collab/src/tests/integration_tests.rs | 4 +- .../src/command_palette_hooks.rs | 2 +- crates/copilot_ui/Cargo.toml | 8 + .../src/copilot_completion_provider.rs | 1021 +++++++++++++++++ crates/copilot_ui/src/copilot_ui.rs | 2 + crates/editor/Cargo.toml | 3 - crates/editor/src/actions.rs | 14 +- crates/editor/src/display_map.rs | 2 +- crates/editor/src/editor.rs | 505 +++----- crates/editor/src/editor_tests.rs | 666 ----------- crates/editor/src/element.rs | 8 +- .../editor/src/inline_completion_provider.rs | 121 ++ crates/feedback/src/feedback_modal.rs | 2 +- crates/gpui/src/app.rs | 11 - crates/gpui/src/app/async_context.rs | 8 +- crates/gpui/src/app/model_context.rs | 13 +- crates/gpui/src/app/test_context.rs | 24 +- crates/gpui/src/gpui.rs | 30 +- crates/gpui/src/window.rs | 23 - crates/language/src/buffer_tests.rs | 2 +- crates/language_tools/src/lsp_log.rs | 2 +- crates/languages/src/c.rs | 2 +- crates/languages/src/python.rs | 2 +- crates/languages/src/rust.rs | 2 +- crates/project/src/project.rs | 4 +- crates/settings/src/settings_file.rs | 2 +- crates/settings/src/settings_store.rs | 2 +- crates/vim/src/editor_events.rs | 4 +- .../src/test/neovim_backed_test_context.rs | 2 +- crates/vim/src/vim.rs | 1 + crates/workspace/src/workspace.rs | 4 +- crates/worktree/src/worktree_tests.rs | 2 +- crates/zed/src/main.rs | 55 +- crates/zed/src/zed.rs | 4 +- 45 files changed, 1471 insertions(+), 1137 deletions(-) create mode 100644 crates/copilot_ui/src/copilot_completion_provider.rs create mode 100644 crates/editor/src/inline_completion_provider.rs diff --git a/Cargo.lock b/Cargo.lock index 3528cc0d7e551b3940159953f15419e3864d7932..25b9edaffea80eff27cf02b7044c278e07effdf4 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 384c47e5d1ec7dec796b7529bd8f55483565c286..4cebc4c705f77513b085050b6e614a43d4030f16 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 97d7f6836bb2d39c91647a233995f94dca247999..ae5cf7be1875b8448d72be77ccb49c51c8648514 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 9e150b8d0f7be89a33ceb52667fb9a75cd68e19b..5a71d8920028822ca340525a2312795e425c864f 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 cb2ad19ccbc5fee11f8e24e9b68be163f445baff..c5ba22e623e97744e2a5e4341dcbf43328b710d2 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 9a7572f55b3cdc659f10740622ca5c1b7d265b55..fa68eaa918ec65e67d56799c089a532e250b32dc 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 d3cdc9e71614e3129080a98cfd3866a8ba370c8b..73fd7b52d14351b92ff2e988924758d809a7dc36 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 defa1e2fa92c47f18a23f6da9d4e27b5add58c66..0389bd6824ca30b65e80f4fa7dc7cab58d3ba157 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 4c84935584f7e68bc843dffc2d8593c969aed2f8..fa0733fb749b089f5778e64d9568ab3d0040116b 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 c9c69f20ae5003c4713b12f03b3ea265c04a3606..689a6a33803912fdf04d673cfc6d90f41d0d7c73 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 db63ddf20206978a3c162ce69bba19a499f3a0a6..7c1179c69bee87d573b6a8a7ae65d5a7d7504a8d 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 4877336326094d6b1679c04615fb735856be3bf8..e4ce29586f7d851a989731eef863d962dc16a26b 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 5752899dd15c52c05c6d73541d0d483f4e7e679b..5c9b7979e383827f6a83138d0de7fba70b1bf143 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 d35c13699ad699b6650088308b493c1e5e09cf3a..4bf3240aabd95a7cd671a828978a8f0542d272e6 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 0000000000000000000000000000000000000000..371496a393d77ca27a3e1015905d00d43632b42d --- /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 f55090ebcb2cc636214176bc7da0d2c41e8a26ea..63bd03102fd3bb6e26ee8d1d44008335c31372e0 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 4848801cd21127957f92e168e6cbd0507c83df01..a1349a00a39b06fff0459530fa23db582bd94b03 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 bbc68be47297c92c19ea90e8469e0c6025abd266..f36d24967d88ec0ccb367b98c7307c166a5d98dd 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 3bcaf8803ae3fbecb103327b4e2d4f9bd346d75e..e8a67a9456ef3a77f88ffc44cb61c3ea4fd970b6 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 f936657f0ade4b4e835f61df27e0aa1b1b97e6d7..3308f5c9b569b23efbe564b644064656e1543d65 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 fad6485a7aed182e31dfae664ad5a530c617227b..54dbee3e9bcd2a88f7ccad33c3189e3c58a32fb0 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 3a30bddbbc1ea74d572d529a6c045085b0853da0..2c92d82d1e540a0d3c3604e9c6209b5106964b27 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 0000000000000000000000000000000000000000..31edf806239bf3a22361be9373a42ed5698aa610 --- /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 510d31375b12480fecf68a24363c7eec4b8f46c3..04fa8740122f1eeec6ca2a95e1c8d78e6d80adf4 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 af4bedabef292a430d61a4c1bbb5e7dac29b6996..ef4292791d7e701a60c392969db7a8f41c91c822 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 6252da6c18be480c460220e5775aeecc2f664e92..2835a2af98a5cbeae4a8eb65839cc493dfccb1ce 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 74569d5e5b2f441a7fe565b582ae8ff74376189c..0bdbf20988bbd33262aa950e41c92edf9926f697 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 0b0ee3b200dccc3143a8816249582fd0c3d32403..0ca6f038a472947bf561b5ee8695edcb9b916099 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 d9365c282ac490f6ec6834f081f5b9c30b0a6aa7..adde92670c26ff27bea0e537695cf78277500e15 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 fd1d549ad6739a75c953b785aecd1137e9bf3471..5baea12dc011006fb5c1cf778ca01b0a6f2118db 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 ce0160f2808d17b6096c3d6bac505be5f99ff626..d0192be2443393a8cca76c24a5b3901f6e2a18b9 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 6de1df95803ffc393dd2c2020ef0e324bd7390d0..c03f4dc90a3301718479f41e3559e19fc0329423 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 965e6e72fb39c34aaa6cedc12123fb1c3801adc0..5cc1ea986b1a34a440f2e5389b318dfe445ea847 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 8983be0c214ba4bbca32648656e6455e34001151..7ee37d27ad05b8c3c5ba523109a786ea7e6a91b9 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};