Introduce staff-only inline completion provider (#21739)

Release Notes:

- N/A

---------

Co-authored-by: Thorsten Ball <mrnugget@gmail.com>
Co-authored-by: Bennet <bennet@zed.dev>
Co-authored-by: Thorsten <thorsten@zed.dev>
This commit is contained in:
Antonio Scandurra 2024-12-09 14:26:36 +01:00 committed by GitHub
parent 39e8944dcc
commit 77b8296fbb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 2890 additions and 356 deletions

View file

@ -1125,6 +1125,12 @@ impl DisplaySnapshot {
DisplayRow(self.block_snapshot.longest_row())
}
pub fn longest_row_in_range(&self, range: Range<DisplayRow>) -> DisplayRow {
let block_range = BlockRow(range.start.0)..BlockRow(range.end.0);
let longest_row = self.block_snapshot.longest_row_in_range(block_range);
DisplayRow(longest_row.0)
}
pub fn starts_indent(&self, buffer_row: MultiBufferRow) -> bool {
let max_row = self.buffer_snapshot.max_row();
if buffer_row >= max_row {

View file

@ -1339,6 +1339,57 @@ impl BlockSnapshot {
self.transforms.summary().longest_row
}
pub fn longest_row_in_range(&self, range: Range<BlockRow>) -> BlockRow {
let mut cursor = self.transforms.cursor::<(BlockRow, WrapRow)>(&());
cursor.seek(&range.start, Bias::Right, &());
let mut longest_row = range.start;
let mut longest_row_chars = 0;
if let Some(transform) = cursor.item() {
if transform.block.is_none() {
let (output_start, input_start) = cursor.start();
let overshoot = range.start.0 - output_start.0;
let wrap_start_row = input_start.0 + overshoot;
let wrap_end_row = cmp::min(
input_start.0 + (range.end.0 - output_start.0),
cursor.end(&()).1 .0,
);
let summary = self
.wrap_snapshot
.text_summary_for_range(wrap_start_row..wrap_end_row);
longest_row = BlockRow(range.start.0 + summary.longest_row);
longest_row_chars = summary.longest_row_chars;
}
cursor.next(&());
}
let cursor_start_row = cursor.start().0;
if range.end > cursor_start_row {
let summary = cursor.summary::<_, TransformSummary>(&range.end, Bias::Right, &());
if summary.longest_row_chars > longest_row_chars {
longest_row = BlockRow(cursor_start_row.0 + summary.longest_row);
longest_row_chars = summary.longest_row_chars;
}
if let Some(transform) = cursor.item() {
if transform.block.is_none() {
let (output_start, input_start) = cursor.start();
let overshoot = range.end.0 - output_start.0;
let wrap_start_row = input_start.0;
let wrap_end_row = input_start.0 + overshoot;
let summary = self
.wrap_snapshot
.text_summary_for_range(wrap_start_row..wrap_end_row);
if summary.longest_row_chars > longest_row_chars {
longest_row = BlockRow(output_start.0 + summary.longest_row);
}
}
}
}
longest_row
}
pub(super) fn line_len(&self, row: BlockRow) -> u32 {
let mut cursor = self.transforms.cursor::<(BlockRow, WrapRow)>(&());
cursor.seek(&BlockRow(row.0), Bias::Right, &());
@ -2705,6 +2756,40 @@ mod tests {
longest_line_len,
);
for _ in 0..10 {
let end_row = rng.gen_range(1..=expected_lines.len());
let start_row = rng.gen_range(0..end_row);
let mut expected_longest_rows_in_range = vec![];
let mut longest_line_len_in_range = 0;
let mut row = start_row as u32;
for line in &expected_lines[start_row..end_row] {
let line_char_count = line.chars().count() as isize;
match line_char_count.cmp(&longest_line_len_in_range) {
Ordering::Less => {}
Ordering::Equal => expected_longest_rows_in_range.push(row),
Ordering::Greater => {
longest_line_len_in_range = line_char_count;
expected_longest_rows_in_range.clear();
expected_longest_rows_in_range.push(row);
}
}
row += 1;
}
let longest_row_in_range = blocks_snapshot
.longest_row_in_range(BlockRow(start_row as u32)..BlockRow(end_row as u32));
assert!(
expected_longest_rows_in_range.contains(&longest_row_in_range.0),
"incorrect longest row {} in range {:?}. expected {:?} with length {}",
longest_row,
start_row..end_row,
expected_longest_rows_in_range,
longest_line_len_in_range,
);
}
// Ensure that conversion between block points and wrap points is stable.
for row in 0..=blocks_snapshot.wrap_snapshot.max_point().row() {
let wrap_point = WrapPoint::new(row, 0);

View file

@ -42,6 +42,8 @@ pub mod tasks;
#[cfg(test)]
mod editor_tests;
#[cfg(test)]
mod inline_completion_tests;
mod signature_help;
#[cfg(any(test, feature = "test-support"))]
pub mod test;
@ -87,7 +89,7 @@ use hunk_diff::{diff_hunk_to_display, DiffMap, DiffMapSnapshot};
use indent_guides::ActiveIndentGuidesState;
use inlay_hint_cache::{InlayHintCache, InlaySplice, InvalidationStrategy};
pub use inline_completion::Direction;
use inline_completion::{InlayProposal, InlineCompletionProvider, InlineCompletionProviderHandle};
use inline_completion::{InlineCompletionProvider, InlineCompletionProviderHandle};
pub use items::MAX_TAB_TITLE_LEN;
use itertools::Itertools;
use language::{
@ -438,22 +440,19 @@ pub fn make_inlay_hints_style(cx: &WindowContext) -> HighlightStyle {
type CompletionId = usize;
#[derive(Clone, Debug)]
struct CompletionState {
// render_inlay_ids represents the inlay hints that are inserted
// for rendering the inline completions. They may be discontinuous
// in the event that the completion provider returns some intersection
// with the existing content.
render_inlay_ids: Vec<InlayId>,
// text is the resulting rope that is inserted when the user accepts a completion.
text: Rope,
// position is the position of the cursor when the completion was triggered.
position: multi_buffer::Anchor,
// delete_range is the range of text that this completion state covers.
// if the completion is accepted, this range should be deleted.
delete_range: Option<Range<multi_buffer::Anchor>>,
enum InlineCompletion {
Edit(Vec<(Range<Anchor>, String)>),
Move(Anchor),
}
struct InlineCompletionState {
inlay_ids: Vec<InlayId>,
completion: InlineCompletion,
invalidation_range: Range<Anchor>,
}
enum InlineCompletionHighlight {}
#[derive(Copy, Clone, Eq, PartialEq, PartialOrd, Ord, Debug, Default)]
struct EditorActionId(usize);
@ -619,7 +618,7 @@ pub struct Editor {
hovered_link_state: Option<HoveredLinkState>,
inline_completion_provider: Option<RegisteredInlineCompletionProvider>,
code_action_providers: Vec<Arc<dyn CodeActionProvider>>,
active_inline_completion: Option<CompletionState>,
active_inline_completion: Option<InlineCompletionState>,
// enable_inline_completions is a switch that Vim can use to disable
// inline completions based on its mode.
enable_inline_completions: bool,
@ -2250,7 +2249,7 @@ impl Editor {
key_context.set("extension", extension.to_string());
}
if self.has_active_inline_completion(cx) {
if self.has_active_inline_completion() {
key_context.add("copilot_suggestion");
key_context.add("inline_completion");
}
@ -2760,7 +2759,7 @@ impl Editor {
self.refresh_code_actions(cx);
self.refresh_document_highlights(cx);
refresh_matching_bracket_highlights(self, cx);
self.discard_inline_completion(false, cx);
self.update_visible_inline_completion(cx);
linked_editing_ranges::refresh_linked_ranges(self, cx);
if self.git_blame_inline_enabled {
self.start_inline_blame_timer(cx);
@ -3651,7 +3650,7 @@ impl Editor {
);
}
let had_active_inline_completion = this.has_active_inline_completion(cx);
let had_active_inline_completion = this.has_active_inline_completion();
this.change_selections_inner(Some(Autoscroll::fit()), false, cx, |s| {
s.select(new_selections)
});
@ -4386,7 +4385,7 @@ impl Editor {
cx: &mut ViewContext<Self>,
) {
self.display_map.update(cx, |display_map, cx| {
display_map.splice_inlays(to_remove, to_insert, cx);
display_map.splice_inlays(to_remove, to_insert, cx)
});
cx.notify();
}
@ -5243,7 +5242,8 @@ impl Editor {
if !user_requested
&& (!self.enable_inline_completions
|| !self.should_show_inline_completions(&buffer, cursor_buffer_position, cx))
|| !self.should_show_inline_completions(&buffer, cursor_buffer_position, cx)
|| !self.is_focused(cx))
{
self.discard_inline_completion(false, cx);
return None;
@ -5276,7 +5276,7 @@ impl Editor {
}
pub fn show_inline_completion(&mut self, _: &ShowInlineCompletion, cx: &mut ViewContext<Self>) {
if !self.has_active_inline_completion(cx) {
if !self.has_active_inline_completion() {
self.refresh_inline_completion(false, true, cx);
return;
}
@ -5303,7 +5303,7 @@ impl Editor {
}
pub fn next_inline_completion(&mut self, _: &NextInlineCompletion, cx: &mut ViewContext<Self>) {
if self.has_active_inline_completion(cx) {
if self.has_active_inline_completion() {
self.cycle_inline_completion(Direction::Next, cx);
} else {
let is_copilot_disabled = self.refresh_inline_completion(false, true, cx).is_none();
@ -5318,7 +5318,7 @@ impl Editor {
_: &PreviousInlineCompletion,
cx: &mut ViewContext<Self>,
) {
if self.has_active_inline_completion(cx) {
if self.has_active_inline_completion() {
self.cycle_inline_completion(Direction::Prev, cx);
} else {
let is_copilot_disabled = self.refresh_inline_completion(false, true, cx).is_none();
@ -5333,24 +5333,43 @@ impl Editor {
_: &AcceptInlineCompletion,
cx: &mut ViewContext<Self>,
) {
let Some(completion) = self.take_active_inline_completion(cx) else {
let Some(active_inline_completion) = self.active_inline_completion.as_ref() else {
return;
};
if let Some(provider) = self.inline_completion_provider() {
provider.accept(cx);
}
cx.emit(EditorEvent::InputHandled {
utf16_range_to_replace: None,
text: completion.text.to_string().into(),
});
self.report_inline_completion_event(true, cx);
if let Some(range) = completion.delete_range {
self.change_selections(None, cx, |s| s.select_ranges([range]))
match &active_inline_completion.completion {
InlineCompletion::Move(position) => {
let position = *position;
self.change_selections(Some(Autoscroll::newest()), cx, |selections| {
selections.select_anchor_ranges([position..position]);
});
}
InlineCompletion::Edit(edits) => {
if let Some(provider) = self.inline_completion_provider() {
provider.accept(cx);
}
let snapshot = self.buffer.read(cx).snapshot(cx);
let last_edit_end = edits.last().unwrap().0.end.bias_right(&snapshot);
self.buffer.update(cx, |buffer, cx| {
buffer.edit(edits.iter().cloned(), None, cx)
});
self.change_selections(None, cx, |s| {
s.select_anchor_ranges([last_edit_end..last_edit_end])
});
self.update_visible_inline_completion(cx);
if self.active_inline_completion.is_none() {
self.refresh_inline_completion(true, true, cx);
}
cx.notify();
}
}
self.insert_with_autoindent_mode(&completion.text.to_string(), None, cx);
self.refresh_inline_completion(true, true, cx);
cx.notify();
}
pub fn accept_partial_inline_completion(
@ -5358,35 +5377,48 @@ impl Editor {
_: &AcceptPartialInlineCompletion,
cx: &mut ViewContext<Self>,
) {
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::<String>();
if partial_completion.is_empty() {
partial_completion = completion
.text
let Some(active_inline_completion) = self.active_inline_completion.as_ref() else {
return;
};
if self.selections.count() != 1 {
return;
}
self.report_inline_completion_event(true, cx);
match &active_inline_completion.completion {
InlineCompletion::Move(position) => {
let position = *position;
self.change_selections(Some(Autoscroll::newest()), cx, |selections| {
selections.select_anchor_ranges([position..position]);
});
}
InlineCompletion::Edit(edits) => {
if edits.len() == 1 && edits[0].0.start == edits[0].0.end {
let text = edits[0].1.as_str();
let mut partial_completion = text
.chars()
.by_ref()
.take_while(|c| c.is_whitespace() || !c.is_alphabetic())
.take_while(|c| c.is_alphabetic())
.collect::<String>();
if partial_completion.is_empty() {
partial_completion = text
.chars()
.by_ref()
.take_while(|c| c.is_whitespace() || !c.is_alphabetic())
.collect::<String>();
}
cx.emit(EditorEvent::InputHandled {
utf16_range_to_replace: None,
text: partial_completion.clone().into(),
});
self.insert_with_autoindent_mode(&partial_completion, None, cx);
self.refresh_inline_completion(true, true, cx);
cx.notify();
}
cx.emit(EditorEvent::InputHandled {
utf16_range_to_replace: None,
text: partial_completion.clone().into(),
});
if let Some(range) = completion.delete_range {
self.change_selections(None, cx, |s| s.select_ranges([range]))
}
self.insert_with_autoindent_mode(&partial_completion, None, cx);
self.refresh_inline_completion(true, true, cx);
cx.notify();
}
}
}
@ -5396,106 +5428,178 @@ impl Editor {
should_report_inline_completion_event: bool,
cx: &mut ViewContext<Self>,
) -> bool {
if should_report_inline_completion_event {
self.report_inline_completion_event(false, cx);
}
if let Some(provider) = self.inline_completion_provider() {
provider.discard(should_report_inline_completion_event, cx);
provider.discard(cx);
}
self.take_active_inline_completion(cx).is_some()
}
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);
completion.position.is_valid(&buffer)
} else {
false
}
fn report_inline_completion_event(&self, accepted: bool, cx: &AppContext) {
let Some(provider) = self.inline_completion_provider() else {
return;
};
let Some(project) = self.project.as_ref() else {
return;
};
let Some((_, buffer, _)) = self
.buffer
.read(cx)
.excerpt_containing(self.selections.newest_anchor().head(), cx)
else {
return;
};
let project = project.read(cx);
let extension = buffer
.read(cx)
.file()
.and_then(|file| Some(file.path().extension()?.to_string_lossy().to_string()));
project.client().telemetry().report_inline_completion_event(
provider.name().into(),
accepted,
extension,
);
}
pub fn has_active_inline_completion(&self) -> bool {
self.active_inline_completion.is_some()
}
fn take_active_inline_completion(
&mut self,
cx: &mut ViewContext<Self>,
) -> Option<CompletionState> {
let completion = self.active_inline_completion.take()?;
let render_inlay_ids = completion.render_inlay_ids.clone();
self.display_map.update(cx, |map, cx| {
map.splice_inlays(render_inlay_ids, Default::default(), cx);
});
let buffer = self.buffer.read(cx).read(cx);
if completion.position.is_valid(&buffer) {
Some(completion)
} else {
None
}
) -> Option<InlineCompletion> {
let active_inline_completion = self.active_inline_completion.take()?;
self.splice_inlays(active_inline_completion.inlay_ids, Default::default(), cx);
self.clear_highlights::<InlineCompletionHighlight>(cx);
Some(active_inline_completion.completion)
}
fn update_visible_inline_completion(&mut self, cx: &mut ViewContext<Self>) {
fn update_visible_inline_completion(&mut self, cx: &mut ViewContext<Self>) -> Option<()> {
let selection = self.selections.newest_anchor();
let cursor = selection.head();
let multibuffer = self.buffer.read(cx).snapshot(cx);
let offset_selection = selection.map(|endpoint| endpoint.to_offset(&multibuffer));
let excerpt_id = cursor.excerpt_id;
if self.context_menu.read().is_none()
&& self.completion_tasks.is_empty()
&& selection.start == selection.end
if self.context_menu.read().is_some()
|| (!self.completion_tasks.is_empty() && !self.has_active_inline_completion())
|| !offset_selection.is_empty()
|| self
.active_inline_completion
.as_ref()
.map_or(false, |completion| {
let invalidation_range = completion.invalidation_range.to_offset(&multibuffer);
let invalidation_range = invalidation_range.start..=invalidation_range.end;
!invalidation_range.contains(&offset_selection.head())
})
{
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(proposal) =
provider.active_completion_text(&buffer, cursor_buffer_position, cx)
{
let mut to_remove = Vec::new();
if let Some(completion) = self.active_inline_completion.take() {
to_remove.extend(completion.render_inlay_ids.iter());
}
let to_add = proposal
.inlays
.iter()
.filter_map(|inlay| {
let snapshot = self.buffer.read(cx).snapshot(cx);
let id = post_inc(&mut self.next_inlay_id);
match inlay {
InlayProposal::Hint(position, hint) => {
let position =
snapshot.anchor_in_excerpt(excerpt_id, *position)?;
Some(Inlay::hint(id, position, hint))
}
InlayProposal::Suggestion(position, text) => {
let position =
snapshot.anchor_in_excerpt(excerpt_id, *position)?;
Some(Inlay::suggestion(id, position, text.clone()))
}
}
})
.collect_vec();
self.active_inline_completion = Some(CompletionState {
position: cursor,
text: proposal.text,
delete_range: proposal.delete_range.and_then(|range| {
let snapshot = self.buffer.read(cx).snapshot(cx);
let start = snapshot.anchor_in_excerpt(excerpt_id, range.start);
let end = snapshot.anchor_in_excerpt(excerpt_id, range.end);
Some(start?..end?)
}),
render_inlay_ids: to_add.iter().map(|i| i.id).collect(),
});
self.display_map
.update(cx, move |map, cx| map.splice_inlays(to_remove, to_add, cx));
cx.notify();
return;
}
}
}
self.discard_inline_completion(false, cx);
return None;
}
self.discard_inline_completion(false, cx);
self.take_active_inline_completion(cx);
let provider = self.inline_completion_provider()?;
let (buffer, cursor_buffer_position) =
self.buffer.read(cx).text_anchor_for_position(cursor, cx)?;
let completion = provider.suggest(&buffer, cursor_buffer_position, cx)?;
let edits = completion
.edits
.into_iter()
.map(|(range, new_text)| {
(
multibuffer
.anchor_in_excerpt(excerpt_id, range.start)
.unwrap()
..multibuffer
.anchor_in_excerpt(excerpt_id, range.end)
.unwrap(),
new_text,
)
})
.collect::<Vec<_>>();
if edits.is_empty() {
return None;
}
let first_edit_start = edits.first().unwrap().0.start;
let edit_start_row = first_edit_start
.to_point(&multibuffer)
.row
.saturating_sub(2);
let last_edit_end = edits.last().unwrap().0.end;
let edit_end_row = cmp::min(
multibuffer.max_point().row,
last_edit_end.to_point(&multibuffer).row + 2,
);
let cursor_row = cursor.to_point(&multibuffer).row;
let mut inlay_ids = Vec::new();
let invalidation_row_range;
let completion;
if cursor_row < edit_start_row {
invalidation_row_range = cursor_row..edit_end_row;
completion = InlineCompletion::Move(first_edit_start);
} else if cursor_row > edit_end_row {
invalidation_row_range = edit_start_row..cursor_row;
completion = InlineCompletion::Move(first_edit_start);
} else {
if edits
.iter()
.all(|(range, _)| range.to_offset(&multibuffer).is_empty())
{
let mut inlays = Vec::new();
for (range, new_text) in &edits {
let inlay = Inlay::suggestion(
post_inc(&mut self.next_inlay_id),
range.start,
new_text.as_str(),
);
inlay_ids.push(inlay.id);
inlays.push(inlay);
}
self.splice_inlays(vec![], inlays, cx);
} else {
let background_color = cx.theme().status().deleted_background;
self.highlight_text::<InlineCompletionHighlight>(
edits.iter().map(|(range, _)| range.clone()).collect(),
HighlightStyle {
background_color: Some(background_color),
..Default::default()
},
cx,
);
}
invalidation_row_range = edit_start_row..edit_end_row;
completion = InlineCompletion::Edit(edits);
};
let invalidation_range = multibuffer
.anchor_before(Point::new(invalidation_row_range.start, 0))
..multibuffer.anchor_after(Point::new(
invalidation_row_range.end,
multibuffer.line_len(MultiBufferRow(invalidation_row_range.end)),
));
self.active_inline_completion = Some(InlineCompletionState {
inlay_ids,
completion,
invalidation_range,
});
cx.notify();
Some(())
}
fn inline_completion_provider(&self) -> Option<Arc<dyn InlineCompletionProviderHandle>> {
@ -12617,7 +12721,7 @@ impl Editor {
self.active_indent_guides_state.dirty = true;
self.refresh_active_diagnostics(cx);
self.refresh_code_actions(cx);
if self.has_active_inline_completion(cx) {
if self.has_active_inline_completion() {
self.update_visible_inline_completion(cx);
}
cx.emit(EditorEvent::BufferEdited);
@ -13310,10 +13414,10 @@ impl Editor {
}
pub fn display_to_pixel_point(
&mut self,
&self,
source: DisplayPoint,
editor_snapshot: &EditorSnapshot,
cx: &mut ViewContext<Self>,
cx: &WindowContext,
) -> Option<gpui::Point<Pixels>> {
let line_height = self.style()?.text.line_height_in_pixels(cx.rem_size());
let text_layout_details = self.text_layout_details(cx);

View file

@ -19,10 +19,10 @@ use crate::{
BlockId, ChunkReplacement, CodeActionsMenu, CursorShape, CustomBlockId, DisplayPoint,
DisplayRow, DocumentHighlightRead, DocumentHighlightWrite, Editor, EditorMode, EditorSettings,
EditorSnapshot, EditorStyle, ExpandExcerpts, FocusedBlock, GutterDimensions, HalfPageDown,
HalfPageUp, HandleInput, HoveredCursor, HoveredHunk, JumpData, LineDown, LineUp, OpenExcerpts,
PageDown, PageUp, Point, RowExt, RowRangeExt, SelectPhase, Selection, SoftWrap, ToPoint,
CURSORS_VISIBLE_FOR, FILE_HEADER_HEIGHT, GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED, MAX_LINE_LEN,
MULTI_BUFFER_EXCERPT_HEADER_HEIGHT,
HalfPageUp, HandleInput, HoveredCursor, HoveredHunk, InlineCompletion, JumpData, LineDown,
LineUp, OpenExcerpts, PageDown, PageUp, Point, RowExt, RowRangeExt, SelectPhase, Selection,
SoftWrap, ToPoint, CURSORS_VISIBLE_FOR, FILE_HEADER_HEIGHT,
GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED, MAX_LINE_LEN, MULTI_BUFFER_EXCERPT_HEADER_HEIGHT,
};
use client::ParticipantIndex;
use collections::{BTreeMap, HashMap, HashSet};
@ -31,7 +31,7 @@ use gpui::{
anchored, deferred, div, fill, outline, point, px, quad, relative, size, svg,
transparent_black, Action, AnchorCorner, AnyElement, AvailableSpace, Bounds, ClipboardItem,
ContentMask, Corners, CursorStyle, DispatchPhase, Edges, Element, ElementInputHandler, Entity,
FontId, GlobalElementId, Hitbox, Hsla, InteractiveElement, IntoElement, Length,
FontId, GlobalElementId, HighlightStyle, Hitbox, Hsla, InteractiveElement, IntoElement, Length,
ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad,
ParentElement, Pixels, ScrollDelta, ScrollWheelEvent, ShapedLine, SharedString, Size,
StatefulInteractiveElement, Style, Styled, TextRun, TextStyleRefinement, View, ViewContext,
@ -47,7 +47,10 @@ use language::{
ChunkRendererContext,
};
use lsp::DiagnosticSeverity;
use multi_buffer::{Anchor, ExcerptId, ExpandExcerptDirection, MultiBufferPoint, MultiBufferRow};
use multi_buffer::{
Anchor, AnchorRangeExt, ExcerptId, ExpandExcerptDirection, MultiBufferPoint, MultiBufferRow,
MultiBufferSnapshot,
};
use project::{
project_settings::{GitGutterSetting, ProjectSettings},
ProjectPath,
@ -2720,6 +2723,157 @@ impl EditorElement {
true
}
#[allow(clippy::too_many_arguments)]
fn layout_inline_completion_popover(
&self,
text_bounds: &Bounds<Pixels>,
editor_snapshot: &EditorSnapshot,
visible_row_range: Range<DisplayRow>,
scroll_top: f32,
scroll_bottom: f32,
line_layouts: &[LineWithInvisibles],
line_height: Pixels,
scroll_pixel_position: gpui::Point<Pixels>,
editor_width: Pixels,
style: &EditorStyle,
cx: &mut WindowContext,
) -> Option<AnyElement> {
const PADDING_X: Pixels = Pixels(25.);
const PADDING_Y: Pixels = Pixels(2.);
let active_inline_completion = self.editor.read(cx).active_inline_completion.as_ref()?;
match &active_inline_completion.completion {
InlineCompletion::Move(target_position) => {
let container_element = div()
.bg(cx.theme().colors().editor_background)
.border_1()
.border_color(cx.theme().colors().border)
.rounded_md()
.px_1();
let target_display_point = target_position.to_display_point(editor_snapshot);
if target_display_point.row().as_f32() < scroll_top {
let mut element = container_element
.child(
h_flex()
.gap_1()
.child(Icon::new(IconName::Tab))
.child(Label::new("Jump to Edit"))
.child(Icon::new(IconName::ArrowUp)),
)
.into_any();
let size = element.layout_as_root(AvailableSpace::min_size(), cx);
let offset = point((text_bounds.size.width - size.width) / 2., PADDING_Y);
element.prepaint_at(text_bounds.origin + offset, cx);
Some(element)
} else if (target_display_point.row().as_f32() + 1.) > scroll_bottom {
let mut element = container_element
.child(
h_flex()
.gap_1()
.child(Icon::new(IconName::Tab))
.child(Label::new("Jump to Edit"))
.child(Icon::new(IconName::ArrowDown)),
)
.into_any();
let size = element.layout_as_root(AvailableSpace::min_size(), cx);
let offset = point(
(text_bounds.size.width - size.width) / 2.,
text_bounds.size.height - size.height - PADDING_Y,
);
element.prepaint_at(text_bounds.origin + offset, cx);
Some(element)
} else {
let mut element = container_element
.child(
h_flex()
.gap_1()
.child(Icon::new(IconName::Tab))
.child(Label::new("Jump to Edit")),
)
.into_any();
let target_line_end = DisplayPoint::new(
target_display_point.row(),
editor_snapshot.line_len(target_display_point.row()),
);
let origin = self.editor.update(cx, |editor, cx| {
editor.display_to_pixel_point(target_line_end, editor_snapshot, cx)
})?;
element.prepaint_as_root(
text_bounds.origin + origin + point(PADDING_X, px(0.)),
AvailableSpace::min_size(),
cx,
);
Some(element)
}
}
InlineCompletion::Edit(edits) => {
let edit_start = edits
.first()
.unwrap()
.0
.start
.to_display_point(editor_snapshot);
let edit_end = edits
.last()
.unwrap()
.0
.end
.to_display_point(editor_snapshot);
let is_visible = visible_row_range.contains(&edit_start.row())
|| visible_row_range.contains(&edit_end.row());
if !is_visible {
return None;
}
if all_edits_insertions_or_deletions(edits, &editor_snapshot.buffer_snapshot) {
return None;
}
let (text, highlights) =
inline_completion_popover_text(edit_start, editor_snapshot, edits, cx);
let longest_row =
editor_snapshot.longest_row_in_range(edit_start.row()..edit_end.row() + 1);
let longest_line_width = if visible_row_range.contains(&longest_row) {
line_layouts[(longest_row.0 - visible_row_range.start.0) as usize].width
} else {
layout_line(
longest_row,
editor_snapshot,
style,
editor_width,
|_| false,
cx,
)
.width
};
let text = gpui::StyledText::new(text).with_highlights(&style.text, highlights);
let mut element = div()
.bg(cx.theme().colors().editor_background)
.border_1()
.border_color(cx.theme().colors().border)
.rounded_md()
.px_1()
.child(text)
.into_any();
let origin = text_bounds.origin
+ point(
longest_line_width + PADDING_X - scroll_pixel_position.x,
edit_start.row().as_f32() * line_height - scroll_pixel_position.y,
);
element.prepaint_as_root(origin, AvailableSpace::min_size(), cx);
Some(element)
}
}
}
fn layout_mouse_context_menu(
&self,
editor_snapshot: &EditorSnapshot,
@ -3942,6 +4096,16 @@ impl EditorElement {
}
}
fn paint_inline_completion_popover(
&mut self,
layout: &mut EditorLayout,
cx: &mut WindowContext,
) {
if let Some(inline_completion_popover) = layout.inline_completion_popover.as_mut() {
inline_completion_popover.paint(cx);
}
}
fn paint_mouse_context_menu(&mut self, layout: &mut EditorLayout, cx: &mut WindowContext) {
if let Some(mouse_context_menu) = layout.mouse_context_menu.as_mut() {
mouse_context_menu.paint(cx);
@ -4134,6 +4298,67 @@ impl EditorElement {
}
}
fn inline_completion_popover_text(
edit_start: DisplayPoint,
editor_snapshot: &EditorSnapshot,
edits: &Vec<(Range<Anchor>, String)>,
cx: &WindowContext,
) -> (String, Vec<(Range<usize>, HighlightStyle)>) {
let mut text = String::new();
let mut offset = DisplayPoint::new(edit_start.row(), 0).to_offset(editor_snapshot, Bias::Left);
let mut highlights = Vec::new();
for (old_range, new_text) in edits {
let old_offset_range = old_range.to_offset(&editor_snapshot.buffer_snapshot);
text.extend(
editor_snapshot
.buffer_snapshot
.chunks(offset..old_offset_range.start, false)
.map(|chunk| chunk.text),
);
offset = old_offset_range.end;
let start = text.len();
text.push_str(new_text);
let end = text.len();
highlights.push((
start..end,
HighlightStyle {
background_color: Some(cx.theme().status().created_background),
..Default::default()
},
));
}
(text, highlights)
}
fn all_edits_insertions_or_deletions(
edits: &Vec<(Range<Anchor>, String)>,
snapshot: &MultiBufferSnapshot,
) -> bool {
let mut all_insertions = true;
let mut all_deletions = true;
for (range, new_text) in edits.iter() {
let range_is_empty = range.to_offset(&snapshot).is_empty();
let text_is_empty = new_text.is_empty();
if range_is_empty != text_is_empty {
if range_is_empty {
all_deletions = false;
} else {
all_insertions = false;
}
} else {
return false;
}
if !all_insertions && !all_deletions {
return false;
}
}
all_insertions || all_deletions
}
#[allow(clippy::too_many_arguments)]
fn prepaint_gutter_button(
button: IconButton,
@ -5566,6 +5791,20 @@ impl Element for EditorElement {
);
}
let inline_completion_popover = self.layout_inline_completion_popover(
&text_hitbox.bounds,
&snapshot,
start_row..end_row,
scroll_position.y,
scroll_position.y + height_in_lines,
&line_layouts,
line_height,
scroll_pixel_position,
editor_width,
&style,
cx,
);
let mouse_context_menu = self.layout_mouse_context_menu(
&snapshot,
start_row..end_row,
@ -5652,6 +5891,7 @@ impl Element for EditorElement {
cursors,
visible_cursors,
selections,
inline_completion_popover,
mouse_context_menu,
test_indicators,
code_actions_indicator,
@ -5741,6 +5981,7 @@ impl Element for EditorElement {
}
self.paint_scrollbar(layout, cx);
self.paint_inline_completion_popover(layout, cx);
self.paint_mouse_context_menu(layout, cx);
});
})
@ -5796,6 +6037,7 @@ pub struct EditorLayout {
test_indicators: Vec<AnyElement>,
crease_toggles: Vec<Option<AnyElement>>,
crease_trailers: Vec<Option<CreaseTrailerLayout>>,
inline_completion_popover: Option<AnyElement>,
mouse_context_menu: Option<AnyElement>,
tab_invisible: ShapedLine,
space_invisible: ShapedLine,
@ -6837,6 +7079,169 @@ mod tests {
}
}
#[gpui::test]
fn test_inline_completion_popover_text(cx: &mut TestAppContext) {
init_test(cx, |_| {});
// Test case 1: Simple insertion
{
let window = cx.add_window(|cx| {
let buffer = MultiBuffer::build_simple("Hello, world!", cx);
Editor::new(EditorMode::Full, buffer, None, true, cx)
});
let cx = &mut VisualTestContext::from_window(*window, cx);
window
.update(cx, |editor, cx| {
let snapshot = editor.snapshot(cx);
let edit_range = snapshot.buffer_snapshot.anchor_after(Point::new(0, 6))
..snapshot.buffer_snapshot.anchor_before(Point::new(0, 6));
let edit_start = DisplayPoint::new(DisplayRow(0), 6);
let edits = vec![(edit_range, " beautiful".to_string())];
let (text, highlights) =
inline_completion_popover_text(edit_start, &snapshot, &edits, cx);
assert_eq!(text, "Hello, beautiful");
assert_eq!(highlights.len(), 1);
assert_eq!(highlights[0].0, 6..16);
assert_eq!(
highlights[0].1.background_color,
Some(cx.theme().status().created_background)
);
})
.unwrap();
}
// Test case 2: Replacement
{
let window = cx.add_window(|cx| {
let buffer = MultiBuffer::build_simple("This is a test.", cx);
Editor::new(EditorMode::Full, buffer, None, true, cx)
});
let cx = &mut VisualTestContext::from_window(*window, cx);
window
.update(cx, |editor, cx| {
let snapshot = editor.snapshot(cx);
let edit_start = DisplayPoint::new(DisplayRow(0), 0);
let edits = vec![(
snapshot.buffer_snapshot.anchor_after(Point::new(0, 0))
..snapshot.buffer_snapshot.anchor_before(Point::new(0, 4)),
"That".to_string(),
)];
let (text, highlights) =
inline_completion_popover_text(edit_start, &snapshot, &edits, cx);
assert_eq!(text, "That");
assert_eq!(highlights.len(), 1);
assert_eq!(highlights[0].0, 0..4);
assert_eq!(
highlights[0].1.background_color,
Some(cx.theme().status().created_background)
);
})
.unwrap();
}
// Test case 3: Multiple edits
{
let window = cx.add_window(|cx| {
let buffer = MultiBuffer::build_simple("Hello, world!", cx);
Editor::new(EditorMode::Full, buffer, None, true, cx)
});
let cx = &mut VisualTestContext::from_window(*window, cx);
window
.update(cx, |editor, cx| {
let snapshot = editor.snapshot(cx);
let edit_start = DisplayPoint::new(DisplayRow(0), 0);
let edits = vec![
(
snapshot.buffer_snapshot.anchor_after(Point::new(0, 0))
..snapshot.buffer_snapshot.anchor_before(Point::new(0, 5)),
"Greetings".into(),
),
(
snapshot.buffer_snapshot.anchor_after(Point::new(0, 12))
..snapshot.buffer_snapshot.anchor_before(Point::new(0, 13)),
" and universe".into(),
),
];
let (text, highlights) =
inline_completion_popover_text(edit_start, &snapshot, &edits, cx);
assert_eq!(text, "Greetings, world and universe");
assert_eq!(highlights.len(), 2);
assert_eq!(highlights[0].0, 0..9);
assert_eq!(highlights[1].0, 16..29);
assert_eq!(
highlights[0].1.background_color,
Some(cx.theme().status().created_background)
);
assert_eq!(
highlights[1].1.background_color,
Some(cx.theme().status().created_background)
);
})
.unwrap();
}
// Test case 4: Multiple lines with edits
{
let window = cx.add_window(|cx| {
let buffer = MultiBuffer::build_simple(
"First line\nSecond line\nThird line\nFourth line",
cx,
);
Editor::new(EditorMode::Full, buffer, None, true, cx)
});
let cx = &mut VisualTestContext::from_window(*window, cx);
window
.update(cx, |editor, cx| {
let snapshot = editor.snapshot(cx);
let edit_start = DisplayPoint::new(DisplayRow(1), 0);
let edits = vec![
(
snapshot.buffer_snapshot.anchor_before(Point::new(1, 7))
..snapshot.buffer_snapshot.anchor_before(Point::new(1, 11)),
"modified".to_string(),
),
(
snapshot.buffer_snapshot.anchor_before(Point::new(2, 0))
..snapshot.buffer_snapshot.anchor_before(Point::new(2, 10)),
"New third line".to_string(),
),
(
snapshot.buffer_snapshot.anchor_before(Point::new(3, 6))
..snapshot.buffer_snapshot.anchor_before(Point::new(3, 6)),
" updated".to_string(),
),
];
let (text, highlights) =
inline_completion_popover_text(edit_start, &snapshot, &edits, cx);
assert_eq!(text, "Second modified\nNew third line\nFourth updated");
assert_eq!(highlights.len(), 3);
assert_eq!(highlights[0].0, 7..15); // "modified"
assert_eq!(highlights[1].0, 16..30); // "New third line"
assert_eq!(highlights[2].0, 37..45); // " updated"
for highlight in &highlights {
assert_eq!(
highlight.1.background_color,
Some(cx.theme().status().created_background)
);
}
})
.unwrap();
}
}
fn collect_invisibles_from_new_editor(
cx: &mut TestAppContext,
editor_mode: EditorMode,

View file

@ -0,0 +1,360 @@
use gpui::Model;
use indoc::indoc;
use inline_completion::InlineCompletionProvider;
use multi_buffer::{Anchor, MultiBufferSnapshot, ToPoint};
use std::ops::Range;
use text::{Point, ToOffset};
use ui::Context;
use crate::{
editor_tests::init_test, test::editor_test_context::EditorTestContext, InlineCompletion,
};
#[gpui::test]
async fn test_inline_completion_insert(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
let provider = cx.new_model(|_| FakeInlineCompletionProvider::default());
assign_editor_completion_provider(provider.clone(), &mut cx);
cx.set_state("let absolute_zero_celsius = ˇ;");
propose_edits(&provider, vec![(28..28, "-273.15")], &mut cx);
cx.update_editor(|editor, cx| editor.update_visible_inline_completion(cx));
assert_editor_active_edit_completion(&mut cx, |_, edits| {
assert_eq!(edits.len(), 1);
assert_eq!(edits[0].1.as_str(), "-273.15");
});
accept_completion(&mut cx);
cx.assert_editor_state("let absolute_zero_celsius = -273.15ˇ;")
}
#[gpui::test]
async fn test_inline_completion_modification(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
let provider = cx.new_model(|_| FakeInlineCompletionProvider::default());
assign_editor_completion_provider(provider.clone(), &mut cx);
cx.set_state("let pi = ˇ\"foo\";");
propose_edits(&provider, vec![(9..14, "3.14159")], &mut cx);
cx.update_editor(|editor, cx| editor.update_visible_inline_completion(cx));
assert_editor_active_edit_completion(&mut cx, |_, edits| {
assert_eq!(edits.len(), 1);
assert_eq!(edits[0].1.as_str(), "3.14159");
});
accept_completion(&mut cx);
cx.assert_editor_state("let pi = 3.14159ˇ;")
}
#[gpui::test]
async fn test_inline_completion_jump_button(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
let provider = cx.new_model(|_| FakeInlineCompletionProvider::default());
assign_editor_completion_provider(provider.clone(), &mut cx);
// Cursor is 2+ lines above the proposed edit
cx.set_state(indoc! {"
line 0
line ˇ1
line 2
line 3
line
"});
propose_edits(
&provider,
vec![(Point::new(4, 3)..Point::new(4, 3), " 4")],
&mut cx,
);
cx.update_editor(|editor, cx| editor.update_visible_inline_completion(cx));
assert_editor_active_move_completion(&mut cx, |snapshot, move_target| {
assert_eq!(move_target.to_point(&snapshot), Point::new(4, 3));
});
// When accepting, cursor is moved to the proposed location
accept_completion(&mut cx);
cx.assert_editor_state(indoc! {"
line 0
line 1
line 2
line 3
linˇe
"});
// Cursor is 2+ lines below the proposed edit
cx.set_state(indoc! {"
line 0
line
line 2
line 3
line ˇ4
"});
propose_edits(
&provider,
vec![(Point::new(1, 3)..Point::new(1, 3), " 1")],
&mut cx,
);
cx.update_editor(|editor, cx| editor.update_visible_inline_completion(cx));
assert_editor_active_move_completion(&mut cx, |snapshot, move_target| {
assert_eq!(move_target.to_point(&snapshot), Point::new(1, 3));
});
// When accepting, cursor is moved to the proposed location
accept_completion(&mut cx);
cx.assert_editor_state(indoc! {"
line 0
linˇe
line 2
line 3
line 4
"});
}
#[gpui::test]
async fn test_inline_completion_invalidation_range(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
let provider = cx.new_model(|_| FakeInlineCompletionProvider::default());
assign_editor_completion_provider(provider.clone(), &mut cx);
// Cursor is 3+ lines above the proposed edit
cx.set_state(indoc! {"
line 0
line ˇ1
line 2
line 3
line 4
line
"});
let edit_location = Point::new(5, 3);
propose_edits(
&provider,
vec![(edit_location..edit_location, " 5")],
&mut cx,
);
cx.update_editor(|editor, cx| editor.update_visible_inline_completion(cx));
assert_editor_active_move_completion(&mut cx, |snapshot, move_target| {
assert_eq!(move_target.to_point(&snapshot), edit_location);
});
// If we move *towards* the completion, it stays active
cx.set_selections_state(indoc! {"
line 0
line 1
line ˇ2
line 3
line 4
line
"});
assert_editor_active_move_completion(&mut cx, |snapshot, move_target| {
assert_eq!(move_target.to_point(&snapshot), edit_location);
});
// If we move *away* from the completion, it is discarded
cx.set_selections_state(indoc! {"
line ˇ0
line 1
line 2
line 3
line 4
line
"});
cx.editor(|editor, _| {
assert!(editor.active_inline_completion.is_none());
});
// Cursor is 3+ lines below the proposed edit
cx.set_state(indoc! {"
line
line 1
line 2
line 3
line ˇ4
line 5
"});
let edit_location = Point::new(0, 3);
propose_edits(
&provider,
vec![(edit_location..edit_location, " 0")],
&mut cx,
);
cx.update_editor(|editor, cx| editor.update_visible_inline_completion(cx));
assert_editor_active_move_completion(&mut cx, |snapshot, move_target| {
assert_eq!(move_target.to_point(&snapshot), edit_location);
});
// If we move *towards* the completion, it stays active
cx.set_selections_state(indoc! {"
line
line 1
line 2
line ˇ3
line 4
line 5
"});
assert_editor_active_move_completion(&mut cx, |snapshot, move_target| {
assert_eq!(move_target.to_point(&snapshot), edit_location);
});
// If we move *away* from the completion, it is discarded
cx.set_selections_state(indoc! {"
line
line 1
line 2
line 3
line 4
line ˇ5
"});
cx.editor(|editor, _| {
assert!(editor.active_inline_completion.is_none());
});
}
fn assert_editor_active_edit_completion(
cx: &mut EditorTestContext,
assert: impl FnOnce(MultiBufferSnapshot, &Vec<(Range<Anchor>, String)>),
) {
cx.editor(|editor, cx| {
let completion_state = editor
.active_inline_completion
.as_ref()
.expect("editor has no active completion");
if let InlineCompletion::Edit(edits) = &completion_state.completion {
assert(editor.buffer().read(cx).snapshot(cx), edits);
} else {
panic!("expected edit completion");
}
})
}
fn assert_editor_active_move_completion(
cx: &mut EditorTestContext,
assert: impl FnOnce(MultiBufferSnapshot, Anchor),
) {
cx.editor(|editor, cx| {
let completion_state = editor
.active_inline_completion
.as_ref()
.expect("editor has no active completion");
if let InlineCompletion::Move(anchor) = &completion_state.completion {
assert(editor.buffer().read(cx).snapshot(cx), *anchor);
} else {
panic!("expected move completion");
}
})
}
fn accept_completion(cx: &mut EditorTestContext) {
cx.update_editor(|editor, cx| {
editor.accept_inline_completion(&crate::AcceptInlineCompletion, cx)
})
}
fn propose_edits<T: ToOffset>(
provider: &Model<FakeInlineCompletionProvider>,
edits: Vec<(Range<T>, &str)>,
cx: &mut EditorTestContext,
) {
let snapshot = cx.buffer_snapshot();
let edits = edits.into_iter().map(|(range, text)| {
let range = snapshot.anchor_after(range.start)..snapshot.anchor_before(range.end);
(range, text.into())
});
cx.update(|cx| {
provider.update(cx, |provider, _| {
provider.set_inline_completion(Some(inline_completion::InlineCompletion {
edits: edits.collect(),
}))
})
});
}
fn assign_editor_completion_provider(
provider: Model<FakeInlineCompletionProvider>,
cx: &mut EditorTestContext,
) {
cx.update_editor(|editor, cx| {
editor.set_inline_completion_provider(Some(provider), cx);
})
}
#[derive(Default, Clone)]
struct FakeInlineCompletionProvider {
completion: Option<inline_completion::InlineCompletion>,
}
impl FakeInlineCompletionProvider {
pub fn set_inline_completion(
&mut self,
completion: Option<inline_completion::InlineCompletion>,
) {
self.completion = completion;
}
}
impl InlineCompletionProvider for FakeInlineCompletionProvider {
fn name() -> &'static str {
"fake-completion-provider"
}
fn is_enabled(
&self,
_buffer: &gpui::Model<language::Buffer>,
_cursor_position: language::Anchor,
_cx: &gpui::AppContext,
) -> bool {
true
}
fn refresh(
&mut self,
_buffer: gpui::Model<language::Buffer>,
_cursor_position: language::Anchor,
_debounce: bool,
_cx: &mut gpui::ModelContext<Self>,
) {
}
fn cycle(
&mut self,
_buffer: gpui::Model<language::Buffer>,
_cursor_position: language::Anchor,
_direction: inline_completion::Direction,
_cx: &mut gpui::ModelContext<Self>,
) {
}
fn accept(&mut self, _cx: &mut gpui::ModelContext<Self>) {}
fn discard(&mut self, _cx: &mut gpui::ModelContext<Self>) {}
fn suggest<'a>(
&mut self,
_buffer: &gpui::Model<language::Buffer>,
_cursor_position: language::Anchor,
_cx: &mut gpui::ModelContext<Self>,
) -> Option<inline_completion::InlineCompletion> {
self.completion.clone()
}
}