
This changes the behavior of how we display inline completions and non-inline completions (i.e. completion menu). Previously we would never show inline completions if a completion menu was visible, meaning that we'd never show Copilot/Supermaven/... suggestions if the language server had a suggestion. With this change, we now display the inline completions even if there is a completion menu visible. In that case `<tab>` then accepts the inline completion and `<enter>` accepts the selected entry in the completion menu. Release Notes: - Changed how inline completions (Copilot, Supermaven, ...) and normal completions (from language servers) interact. Zed will now also show inline completions when the completion menu is visible. The user can accept the inline completion with `<tab>` and the active entry in the completion menu with `<enter>`. Previously, `<tab>` would also select the active entry in the completion menu. --------- Co-authored-by: Antonio <antonio@zed.dev>
1092 lines
41 KiB
Rust
1092 lines
41 KiB
Rust
use crate::{Completion, Copilot};
|
|
use anyhow::Result;
|
|
use gpui::{AppContext, EntityId, Model, ModelContext, Task};
|
|
use inline_completion::{Direction, InlineCompletion, InlineCompletionProvider};
|
|
use language::{
|
|
language_settings::{all_language_settings, AllLanguageSettings},
|
|
Buffer, OffsetRangeExt, ToOffset,
|
|
};
|
|
use settings::Settings;
|
|
use std::{path::Path, time::Duration};
|
|
|
|
pub const COPILOT_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(75);
|
|
|
|
pub struct CopilotCompletionProvider {
|
|
cycled: bool,
|
|
buffer_id: Option<EntityId>,
|
|
completions: Vec<Completion>,
|
|
active_completion_index: usize,
|
|
file_extension: Option<String>,
|
|
pending_refresh: Task<Result<()>>,
|
|
pending_cycling_refresh: Task<Result<()>>,
|
|
copilot: Model<Copilot>,
|
|
}
|
|
|
|
impl CopilotCompletionProvider {
|
|
pub fn new(copilot: Model<Copilot>) -> 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,
|
|
}
|
|
}
|
|
|
|
fn active_completion(&self) -> Option<&Completion> {
|
|
self.completions.get(self.active_completion_index)
|
|
}
|
|
|
|
fn push_completion(&mut self, new_completion: 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 name() -> &'static str {
|
|
"copilot"
|
|
}
|
|
|
|
fn is_enabled(
|
|
&self,
|
|
buffer: &Model<Buffer>,
|
|
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.inline_completions_enabled(language.as_ref(), file.map(|f| f.path().as_ref()), cx)
|
|
}
|
|
|
|
fn refresh(
|
|
&mut self,
|
|
buffer: Model<Buffer>,
|
|
cursor_position: language::Anchor,
|
|
debounce: bool,
|
|
cx: &mut ModelContext<Self>,
|
|
) {
|
|
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<Buffer>,
|
|
cursor_position: language::Anchor,
|
|
direction: Direction,
|
|
cx: &mut ModelContext<Self>,
|
|
) {
|
|
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.is_empty() {
|
|
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<Self>) {
|
|
if let Some(completion) = self.active_completion() {
|
|
self.copilot
|
|
.update(cx, |copilot, cx| copilot.accept_completion(completion, cx))
|
|
.detach_and_log_err(cx);
|
|
}
|
|
}
|
|
|
|
fn discard(&mut self, cx: &mut ModelContext<Self>) {
|
|
let settings = AllLanguageSettings::get_global(cx);
|
|
|
|
let copilot_enabled = settings.inline_completions_enabled(None, None, cx);
|
|
|
|
if !copilot_enabled {
|
|
return;
|
|
}
|
|
|
|
self.copilot
|
|
.update(cx, |copilot, cx| {
|
|
copilot.discard_completions(&self.completions, cx)
|
|
})
|
|
.detach_and_log_err(cx);
|
|
}
|
|
|
|
fn suggest(
|
|
&mut self,
|
|
buffer: &Model<Buffer>,
|
|
cursor_position: language::Anchor,
|
|
cx: &mut ModelContext<Self>,
|
|
) -> Option<InlineCompletion> {
|
|
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 {
|
|
let position = cursor_position.bias_right(buffer);
|
|
Some(InlineCompletion {
|
|
edits: vec![(position..position, completion_text.into())],
|
|
})
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
fn common_prefix<T1: Iterator<Item = char>, T2: Iterator<Item = char>>(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, Context, TestAppContext, UpdateGlobal};
|
|
use indoc::indoc;
|
|
use language::{
|
|
language_settings::{AllLanguageSettings, AllLanguageSettingsContent},
|
|
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(Some(copilot_provider), cx)
|
|
});
|
|
|
|
cx.set_state(indoc! {"
|
|
oneˇ
|
|
two
|
|
three
|
|
"});
|
|
cx.simulate_keystroke(".");
|
|
drop(handle_completion_request(
|
|
&mut cx,
|
|
indoc! {"
|
|
one.|<>
|
|
two
|
|
three
|
|
"},
|
|
vec!["completion_a", "completion_b"],
|
|
));
|
|
handle_copilot_completion_request(
|
|
&copilot_lsp,
|
|
vec![crate::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| {
|
|
// We want to show both: the inline completion and the completion menu
|
|
assert!(editor.context_menu_visible());
|
|
assert!(editor.has_active_inline_completion());
|
|
|
|
// 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());
|
|
assert_eq!(editor.text(cx), "one.completion_a\ntwo\nthree\n");
|
|
assert_eq!(editor.display_text(cx), "one.completion_a\ntwo\nthree\n");
|
|
});
|
|
|
|
// Reset editor and test that accepting completions works
|
|
cx.set_state(indoc! {"
|
|
oneˇ
|
|
two
|
|
three
|
|
"});
|
|
cx.simulate_keystroke(".");
|
|
drop(handle_completion_request(
|
|
&mut cx,
|
|
indoc! {"
|
|
one.|<>
|
|
two
|
|
three
|
|
"},
|
|
vec!["completion_a", "completion_b"],
|
|
));
|
|
handle_copilot_completion_request(
|
|
&copilot_lsp,
|
|
vec![crate::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());
|
|
assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n");
|
|
assert_eq!(editor.text(cx), "one.\ntwo\nthree\n");
|
|
});
|
|
|
|
// Ensure existing inline 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());
|
|
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![crate::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());
|
|
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());
|
|
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());
|
|
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());
|
|
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());
|
|
assert_eq!(editor.display_text(cx), "one.copilot2\ntwo\nthree\n");
|
|
assert_eq!(editor.text(cx), "one.co\ntwo\nthree\n");
|
|
|
|
// AcceptInlineCompletion when there is an active suggestion inserts it.
|
|
editor.accept_inline_completion(&Default::default(), cx);
|
|
assert!(!editor.has_active_inline_completion());
|
|
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());
|
|
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());
|
|
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());
|
|
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![crate::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());
|
|
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());
|
|
assert_eq!(editor.text(cx), "fn foo() {\n \n}");
|
|
assert_eq!(editor.display_text(cx), "fn foo() {\n let x = 4;\n}");
|
|
|
|
// Using AcceptInlineCompletion again accepts the suggestion.
|
|
editor.accept_inline_completion(&Default::default(), cx);
|
|
assert!(!editor.has_active_inline_completion());
|
|
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(Some(copilot_provider), cx)
|
|
});
|
|
|
|
// Setup the editor with a completion request.
|
|
cx.set_state(indoc! {"
|
|
oneˇ
|
|
two
|
|
three
|
|
"});
|
|
cx.simulate_keystroke(".");
|
|
drop(handle_completion_request(
|
|
&mut cx,
|
|
indoc! {"
|
|
one.|<>
|
|
two
|
|
three
|
|
"},
|
|
vec![],
|
|
));
|
|
handle_copilot_completion_request(
|
|
&copilot_lsp,
|
|
vec![crate::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());
|
|
|
|
// 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());
|
|
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());
|
|
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(".");
|
|
drop(handle_completion_request(
|
|
&mut cx,
|
|
indoc! {"
|
|
one.|<>
|
|
two
|
|
three
|
|
"},
|
|
vec![],
|
|
));
|
|
handle_copilot_completion_request(
|
|
&copilot_lsp,
|
|
vec![crate::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());
|
|
|
|
// 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());
|
|
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());
|
|
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());
|
|
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(Some(copilot_provider), cx)
|
|
});
|
|
|
|
cx.set_state(indoc! {"
|
|
one
|
|
twˇ
|
|
three
|
|
"});
|
|
|
|
handle_copilot_completion_request(
|
|
&copilot_lsp,
|
|
vec![crate::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());
|
|
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());
|
|
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());
|
|
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());
|
|
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());
|
|
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::local("a = 1\nb = 2\n", cx));
|
|
let buffer_2 = cx.new_model(|cx| Buffer::local("c = 3\nd = 4\n", cx));
|
|
let multibuffer = cx.new_model(|cx| {
|
|
let mut multibuffer = MultiBuffer::new(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, true, cx));
|
|
editor.update(cx, |editor, cx| editor.focus(cx)).unwrap();
|
|
let copilot_provider = cx.new_model(|_| CopilotCompletionProvider::new(copilot));
|
|
editor
|
|
.update(cx, |editor, cx| {
|
|
editor.set_inline_completion_provider(Some(copilot_provider), cx)
|
|
})
|
|
.unwrap();
|
|
|
|
handle_copilot_completion_request(
|
|
&copilot_lsp,
|
|
vec![crate::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());
|
|
assert_eq!(
|
|
editor.display_text(cx),
|
|
"\n\n\na = 1\nb = 2 + a\n\n\n\n\n\nc = 3\nd = 4\n\n"
|
|
);
|
|
assert_eq!(editor.text(cx), "a = 1\nb = 2\n\nc = 3\nd = 4\n");
|
|
});
|
|
|
|
handle_copilot_completion_request(
|
|
&copilot_lsp,
|
|
vec![crate::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());
|
|
assert_eq!(
|
|
editor.display_text(cx),
|
|
"\n\n\na = 1\nb = 2\n\n\n\n\n\nc = 3\nd = 4\n\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());
|
|
assert_eq!(
|
|
editor.display_text(cx),
|
|
"\n\n\na = 1\nb = 2\n\n\n\n\n\nc = 3\nd = 4 \n\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());
|
|
assert_eq!(
|
|
editor.display_text(cx),
|
|
"\n\n\na = 1\nb = 2\n\n\n\n\n\nc = 3\nd = 4 + c\n\n"
|
|
);
|
|
assert_eq!(editor.text(cx), "a = 1\nb = 2\n\nc = 3\nd = 4 \n");
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_copilot_does_not_prevent_completion_triggers(
|
|
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()]),
|
|
..lsp::CompletionOptions::default()
|
|
}),
|
|
..lsp::ServerCapabilities::default()
|
|
},
|
|
cx,
|
|
)
|
|
.await;
|
|
let copilot_provider = cx.new_model(|_| CopilotCompletionProvider::new(copilot));
|
|
cx.update_editor(|editor, cx| {
|
|
editor.set_inline_completion_provider(Some(copilot_provider), cx)
|
|
});
|
|
|
|
cx.set_state(indoc! {"
|
|
one
|
|
twˇ
|
|
three
|
|
"});
|
|
|
|
drop(handle_completion_request(
|
|
&mut cx,
|
|
indoc! {"
|
|
one
|
|
tw|<>
|
|
three
|
|
"},
|
|
vec!["completion_a", "completion_b"],
|
|
));
|
|
handle_copilot_completion_request(
|
|
&copilot_lsp,
|
|
vec![crate::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.context_menu_visible());
|
|
assert!(editor.has_active_inline_completion());
|
|
assert_eq!(editor.display_text(cx), "one\ntwo.foo()\nthree\n");
|
|
assert_eq!(editor.text(cx), "one\ntw\nthree\n");
|
|
});
|
|
|
|
cx.simulate_keystroke("o");
|
|
drop(handle_completion_request(
|
|
&mut cx,
|
|
indoc! {"
|
|
one
|
|
two|<>
|
|
three
|
|
"},
|
|
vec!["completion_a_2", "completion_b_2"],
|
|
));
|
|
handle_copilot_completion_request(
|
|
&copilot_lsp,
|
|
vec![crate::request::Completion {
|
|
text: "two.foo()".into(),
|
|
range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 3)),
|
|
..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());
|
|
assert_eq!(editor.display_text(cx), "one\ntwo.foo()\nthree\n");
|
|
assert_eq!(editor.text(cx), "one\ntwo\nthree\n");
|
|
});
|
|
|
|
cx.simulate_keystroke(".");
|
|
drop(handle_completion_request(
|
|
&mut cx,
|
|
indoc! {"
|
|
one
|
|
two.|<>
|
|
three
|
|
"},
|
|
vec!["something_else()"],
|
|
));
|
|
handle_copilot_completion_request(
|
|
&copilot_lsp,
|
|
vec![crate::request::Completion {
|
|
text: "two.foo()".into(),
|
|
range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 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(),);
|
|
assert_eq!(editor.display_text(cx), "one\ntwo.foo()\nthree\n");
|
|
assert_eq!(editor.text(cx), "one\ntwo.\nthree\n");
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_copilot_disabled_globs(executor: BackgroundExecutor, cx: &mut TestAppContext) {
|
|
init_test(cx, |settings| {
|
|
settings
|
|
.inline_completions
|
|
.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\nworld\nhow\nare\nyou\ntoday"
|
|
}),
|
|
)
|
|
.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(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(6, 0),
|
|
primary: None,
|
|
}],
|
|
cx,
|
|
);
|
|
multibuffer
|
|
});
|
|
let editor = cx.add_window(|cx| Editor::for_multibuffer(multibuffer, None, true, cx));
|
|
editor.update(cx, |editor, cx| editor.focus(cx)).unwrap();
|
|
let copilot_provider = cx.new_model(|_| CopilotCompletionProvider::new(copilot));
|
|
editor
|
|
.update(cx, |editor, cx| {
|
|
editor.set_inline_completion_provider(Some(copilot_provider), cx)
|
|
})
|
|
.unwrap();
|
|
|
|
let mut copilot_requests = copilot_lsp
|
|
.handle_request::<crate::request::GetCompletions, _, _>(
|
|
move |_params, _cx| async move {
|
|
Ok(crate::request::GetCompletionsResult {
|
|
completions: vec![crate::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.refresh_inline_completion(true, false, 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(5, 0)..Point::new(5, 0)])
|
|
});
|
|
editor.refresh_inline_completion(true, false, cx);
|
|
});
|
|
|
|
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
|
|
assert!(copilot_requests.try_next().is_ok());
|
|
}
|
|
|
|
fn handle_copilot_completion_request(
|
|
lsp: &lsp::FakeLanguageServer,
|
|
completions: Vec<crate::request::Completion>,
|
|
completions_cycling: Vec<crate::request::Completion>,
|
|
) {
|
|
lsp.handle_request::<crate::request::GetCompletions, _, _>(move |_params, _cx| {
|
|
let completions = completions.clone();
|
|
async move {
|
|
Ok(crate::request::GetCompletionsResult {
|
|
completions: completions.clone(),
|
|
})
|
|
}
|
|
});
|
|
lsp.handle_request::<crate::request::GetCompletionsCycling, _, _>(move |_params, _cx| {
|
|
let completions_cycling = completions_cycling.clone();
|
|
async move {
|
|
Ok(crate::request::GetCompletionsResult {
|
|
completions: completions_cycling.clone(),
|
|
})
|
|
}
|
|
});
|
|
}
|
|
|
|
fn handle_completion_request(
|
|
cx: &mut EditorLspTestContext,
|
|
marked_string: &str,
|
|
completions: Vec<&'static str>,
|
|
) -> impl Future<Output = ()> {
|
|
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::<lsp::request::Completion, _, _>(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);
|
|
SettingsStore::update_global(cx, |store: &mut SettingsStore, cx| {
|
|
store.update_user_settings::<AllLanguageSettings>(cx, f);
|
|
});
|
|
});
|
|
}
|
|
}
|