zeta: Improve UX for simultaneous LSP and prediction completions (#24024)

Release Notes:

- N/A

---------

Co-authored-by: Michael Sloan <michael@zed.dev>
Co-authored-by: Danilo <danilo@zed.dev>
Co-authored-by: Richard <richard@zed.dev>
This commit is contained in:
Agus Zubiaga 2025-02-03 21:47:11 -03:00 committed by GitHub
parent b6e680ea3d
commit 4c29e1ff07
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1196 additions and 762 deletions

View file

@ -1,8 +1,8 @@
use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{
div, pulsating_between, px, uniform_list, Animation, AnimationExt, AnyElement,
BackgroundExecutor, Div, Entity, FontWeight, ListSizingBehavior, ScrollStrategy, SharedString,
Size, StrikethroughStyle, StyledText, UniformListScrollHandle, WeakEntity,
div, px, uniform_list, AnyElement, BackgroundExecutor, Div, Entity, FontWeight,
ListSizingBehavior, ScrollStrategy, SharedString, Size, StrikethroughStyle, StyledText,
UniformListScrollHandle, WeakEntity,
};
use language::Buffer;
use language::{CodeLabel, CompletionDocumentation};
@ -10,8 +10,7 @@ use lsp::LanguageServerId;
use multi_buffer::{Anchor, ExcerptId};
use ordered_float::OrderedFloat;
use project::{CodeAction, Completion, TaskSourceKind};
use settings::Settings;
use std::time::Duration;
use std::{
cell::RefCell,
cmp::{min, Reverse},
@ -26,11 +25,9 @@ use workspace::Workspace;
use crate::{
actions::{ConfirmCodeAction, ConfirmCompletion},
display_map::DisplayPoint,
render_parsed_markdown, split_words, styled_runs_for_code_label, CodeActionProvider,
CompletionId, CompletionProvider, DisplayRow, Editor, EditorStyle, ResolvedTasks,
};
use crate::{AcceptInlineCompletion, InlineCompletionMenuHint, InlineCompletionText};
pub const MENU_GAP: Pixels = px(4.);
pub const MENU_ASIDE_X_PADDING: Pixels = px(16.);
@ -114,10 +111,10 @@ impl CodeContextMenu {
}
}
pub fn origin(&self, cursor_position: DisplayPoint) -> ContextMenuOrigin {
pub fn origin(&self) -> ContextMenuOrigin {
match self {
CodeContextMenu::Completions(menu) => menu.origin(cursor_position),
CodeContextMenu::CodeActions(menu) => menu.origin(cursor_position),
CodeContextMenu::Completions(menu) => menu.origin(),
CodeContextMenu::CodeActions(menu) => menu.origin(),
}
}
@ -154,7 +151,7 @@ impl CodeContextMenu {
}
pub enum ContextMenuOrigin {
EditorPoint(DisplayPoint),
Cursor,
GutterIndicator(DisplayRow),
}
@ -166,18 +163,13 @@ pub struct CompletionsMenu {
pub buffer: Entity<Buffer>,
pub completions: Rc<RefCell<Box<[Completion]>>>,
match_candidates: Rc<[StringMatchCandidate]>,
pub entries: Rc<RefCell<Vec<CompletionEntry>>>,
pub entries: Rc<RefCell<Vec<StringMatch>>>,
pub selected_item: usize,
scroll_handle: UniformListScrollHandle,
resolve_completions: bool,
show_completion_documentation: bool,
last_rendered_range: Rc<RefCell<Option<Range<usize>>>>,
}
#[derive(Clone, Debug)]
pub(crate) enum CompletionEntry {
Match(StringMatch),
InlineCompletionHint(InlineCompletionMenuHint),
pub previewing_inline_completion: bool,
}
impl CompletionsMenu {
@ -208,6 +200,7 @@ impl CompletionsMenu {
scroll_handle: UniformListScrollHandle::new(),
resolve_completions: true,
last_rendered_range: RefCell::new(None).into(),
previewing_inline_completion: false,
}
}
@ -244,13 +237,11 @@ impl CompletionsMenu {
let entries = choices
.iter()
.enumerate()
.map(|(id, completion)| {
CompletionEntry::Match(StringMatch {
candidate_id: id,
score: 1.,
positions: vec![],
string: completion.clone(),
})
.map(|(id, completion)| StringMatch {
candidate_id: id,
score: 1.,
positions: vec![],
string: completion.clone(),
})
.collect::<Vec<_>>();
Self {
@ -266,6 +257,7 @@ impl CompletionsMenu {
resolve_completions: false,
show_completion_documentation: false,
last_rendered_range: RefCell::new(None).into(),
previewing_inline_completion: false,
}
}
@ -340,24 +332,6 @@ impl CompletionsMenu {
}
}
pub fn show_inline_completion_hint(&mut self, hint: InlineCompletionMenuHint) {
let hint = CompletionEntry::InlineCompletionHint(hint);
let mut entries = self.entries.borrow_mut();
match entries.first() {
Some(CompletionEntry::InlineCompletionHint { .. }) => {
entries[0] = hint;
}
_ => {
entries.insert(0, hint);
// When `y_flipped`, need to scroll to bring it into view.
if self.selected_item == 0 {
self.scroll_handle
.scroll_to_item(self.selected_item, ScrollStrategy::Top);
}
}
}
}
pub fn resolve_visible_completions(
&mut self,
provider: Option<&dyn CompletionProvider>,
@ -406,17 +380,15 @@ impl CompletionsMenu {
// This filtering doesn't happen if the completions are currently being updated.
let completions = self.completions.borrow();
let candidate_ids = entry_indices
.flat_map(|i| Self::entry_candidate_id(&entries[i]))
.map(|i| entries[i].candidate_id)
.filter(|i| completions[*i].documentation.is_none());
// Current selection is always resolved even if it already has documentation, to handle
// out-of-spec language servers that return more results later.
let candidate_ids = match Self::entry_candidate_id(&entries[self.selected_item]) {
None => candidate_ids.collect::<Vec<usize>>(),
Some(selected_candidate_id) => iter::once(selected_candidate_id)
.chain(candidate_ids.filter(|id| *id != selected_candidate_id))
.collect::<Vec<usize>>(),
};
let selected_candidate_id = entries[self.selected_item].candidate_id;
let candidate_ids = iter::once(selected_candidate_id)
.chain(candidate_ids.filter(|id| *id != selected_candidate_id))
.collect::<Vec<usize>>();
drop(entries);
if candidate_ids.is_empty() {
@ -438,19 +410,16 @@ impl CompletionsMenu {
.detach();
}
fn entry_candidate_id(entry: &CompletionEntry) -> Option<usize> {
match entry {
CompletionEntry::Match(entry) => Some(entry.candidate_id),
CompletionEntry::InlineCompletionHint { .. } => None,
}
pub fn is_empty(&self) -> bool {
self.entries.borrow().is_empty()
}
pub fn visible(&self) -> bool {
!self.entries.borrow().is_empty()
!self.is_empty() && !self.previewing_inline_completion
}
fn origin(&self, cursor_position: DisplayPoint) -> ContextMenuOrigin {
ContextMenuOrigin::EditorPoint(cursor_position)
fn origin(&self) -> ContextMenuOrigin {
ContextMenuOrigin::Cursor
}
fn render(
@ -468,23 +437,18 @@ impl CompletionsMenu {
.borrow()
.iter()
.enumerate()
.max_by_key(|(_, mat)| match mat {
CompletionEntry::Match(mat) => {
let completion = &completions[mat.candidate_id];
let documentation = &completion.documentation;
.max_by_key(|(_, mat)| {
let completion = &completions[mat.candidate_id];
let documentation = &completion.documentation;
let mut len = completion.label.text.chars().count();
if let Some(CompletionDocumentation::SingleLine(text)) = documentation {
if show_completion_documentation {
len += text.chars().count();
}
let mut len = completion.label.text.chars().count();
if let Some(CompletionDocumentation::SingleLine(text)) = documentation {
if show_completion_documentation {
len += text.chars().count();
}
}
len
}
CompletionEntry::InlineCompletionHint(hint) => {
"Zed AI / ".chars().count() + hint.label().chars().count()
}
len
})
.map(|(ix, _)| ix);
drop(completions);
@ -508,179 +472,83 @@ impl CompletionsMenu {
.enumerate()
.map(|(ix, mat)| {
let item_ix = start_ix + ix;
let buffer_font = theme::ThemeSettings::get_global(cx).buffer_font.clone();
let base_label = h_flex()
.gap_1()
.child(div().font(buffer_font.clone()).child("Zed AI"))
.child(div().px_0p5().child("/").opacity(0.2));
let completion = &completions_guard[mat.candidate_id];
let documentation = if show_completion_documentation {
&completion.documentation
} else {
&None
};
match mat {
CompletionEntry::Match(mat) => {
let candidate_id = mat.candidate_id;
let completion = &completions_guard[candidate_id];
let filter_start = completion.label.filter_range.start;
let highlights = gpui::combine_highlights(
mat.ranges().map(|range| {
(
filter_start + range.start..filter_start + range.end,
FontWeight::BOLD.into(),
)
}),
styled_runs_for_code_label(&completion.label, &style.syntax).map(
|(range, mut highlight)| {
// Ignore font weight for syntax highlighting, as we'll use it
// for fuzzy matches.
highlight.font_weight = None;
if completion.lsp_completion.deprecated.unwrap_or(false) {
highlight.strikethrough = Some(StrikethroughStyle {
thickness: 1.0.into(),
..Default::default()
});
highlight.color = Some(cx.theme().colors().text_muted);
}
let documentation = if show_completion_documentation {
&completion.documentation
} else {
&None
};
(range, highlight)
},
),
);
let filter_start = completion.label.filter_range.start;
let highlights = gpui::combine_highlights(
mat.ranges().map(|range| {
(
filter_start + range.start..filter_start + range.end,
FontWeight::BOLD.into(),
)
}),
styled_runs_for_code_label(&completion.label, &style.syntax)
.map(|(range, mut highlight)| {
// Ignore font weight for syntax highlighting, as we'll use it
// for fuzzy matches.
highlight.font_weight = None;
if completion.lsp_completion.deprecated.unwrap_or(false)
{
highlight.strikethrough =
Some(StrikethroughStyle {
thickness: 1.0.into(),
..Default::default()
});
highlight.color =
Some(cx.theme().colors().text_muted);
}
(range, highlight)
}),
);
let completion_label =
StyledText::new(completion.label.text.clone())
.with_highlights(&style.text, highlights);
let documentation_label =
if let Some(CompletionDocumentation::SingleLine(text)) =
documentation
{
if text.trim().is_empty() {
None
} else {
Some(
Label::new(text.clone())
.ml_4()
.size(LabelSize::Small)
.color(Color::Muted),
)
}
} else {
None
};
let color_swatch = completion
.color()
.map(|color| div().size_4().bg(color).rounded_sm());
div().min_w(px(220.)).max_w(px(540.)).child(
ListItem::new(mat.candidate_id)
.inset(true)
.toggle_state(item_ix == selected_item)
.on_click(cx.listener(move |editor, _event, window, cx| {
cx.stop_propagation();
if let Some(task) = editor.confirm_completion(
&ConfirmCompletion {
item_ix: Some(item_ix),
},
window,
cx,
) {
task.detach_and_log_err(cx)
}
}))
.start_slot::<Div>(color_swatch)
.child(h_flex().overflow_hidden().child(completion_label))
.end_slot::<Label>(documentation_label),
let completion_label = StyledText::new(completion.label.text.clone())
.with_highlights(&style.text, highlights);
let documentation_label = if let Some(
CompletionDocumentation::SingleLine(text),
) = documentation
{
if text.trim().is_empty() {
None
} else {
Some(
Label::new(text.clone())
.ml_4()
.size(LabelSize::Small)
.color(Color::Muted),
)
}
CompletionEntry::InlineCompletionHint(
hint @ InlineCompletionMenuHint::None,
) => div().min_w(px(250.)).max_w(px(500.)).child(
ListItem::new("inline-completion")
.inset(true)
.toggle_state(item_ix == selected_item)
.start_slot(Icon::new(IconName::ZedPredict))
.child(
base_label.child(
StyledText::new(hint.label())
.with_highlights(&style.text, None),
),
),
),
CompletionEntry::InlineCompletionHint(
hint @ InlineCompletionMenuHint::Loading,
) => div().min_w(px(250.)).max_w(px(500.)).child(
ListItem::new("inline-completion")
.inset(true)
.toggle_state(item_ix == selected_item)
.start_slot(Icon::new(IconName::ZedPredict))
.child(base_label.child({
let text_style = style.text.clone();
StyledText::new(hint.label())
.with_highlights(&text_style, None)
.with_animation(
"pulsating-label",
Animation::new(Duration::from_secs(1))
.repeat()
.with_easing(pulsating_between(0.4, 0.8)),
move |text, delta| {
let mut text_style = text_style.clone();
text_style.color =
text_style.color.opacity(delta);
text.with_highlights(&text_style, None)
},
)
})),
),
CompletionEntry::InlineCompletionHint(
hint @ InlineCompletionMenuHint::PendingTermsAcceptance,
) => div().min_w(px(250.)).max_w(px(500.)).child(
ListItem::new("inline-completion")
.inset(true)
.toggle_state(item_ix == selected_item)
.start_slot(Icon::new(IconName::ZedPredict))
.child(
base_label.child(
StyledText::new(hint.label())
.with_highlights(&style.text, None),
),
)
.on_click(cx.listener(move |editor, _event, window, cx| {
cx.stop_propagation();
editor.toggle_zed_predict_onboarding(window, cx);
})),
),
} else {
None
};
CompletionEntry::InlineCompletionHint(
hint @ InlineCompletionMenuHint::Loaded { .. },
) => div().min_w(px(250.)).max_w(px(500.)).child(
ListItem::new("inline-completion")
.inset(true)
.toggle_state(item_ix == selected_item)
.start_slot(Icon::new(IconName::ZedPredict))
.child(
base_label.child(
StyledText::new(hint.label())
.with_highlights(&style.text, None),
),
)
.on_click(cx.listener(move |editor, _event, window, cx| {
cx.stop_propagation();
editor.accept_inline_completion(
&AcceptInlineCompletion {},
window,
cx,
);
})),
),
}
let color_swatch = completion
.color()
.map(|color| div().size_4().bg(color).rounded_sm());
div().min_w(px(280.)).max_w(px(540.)).child(
ListItem::new(mat.candidate_id)
.inset(true)
.toggle_state(item_ix == selected_item)
.on_click(cx.listener(move |editor, _event, window, cx| {
cx.stop_propagation();
if let Some(task) = editor.confirm_completion(
&ConfirmCompletion {
item_ix: Some(item_ix),
},
window,
cx,
) {
task.detach_and_log_err(cx)
}
}))
.start_slot::<Div>(color_swatch)
.child(h_flex().overflow_hidden().child(completion_label))
.end_slot::<Label>(documentation_label),
)
})
.collect()
},
@ -706,45 +574,25 @@ impl CompletionsMenu {
return None;
}
let multiline_docs = match &self.entries.borrow()[self.selected_item] {
CompletionEntry::Match(mat) => {
match self.completions.borrow_mut()[mat.candidate_id]
.documentation
.as_ref()?
{
CompletionDocumentation::MultiLinePlainText(text) => {
div().child(SharedString::from(text.clone()))
}
CompletionDocumentation::MultiLineMarkdown(parsed)
if !parsed.text.is_empty() =>
{
div().child(render_parsed_markdown(
"completions_markdown",
parsed,
&style,
workspace,
cx,
))
}
CompletionDocumentation::MultiLineMarkdown(_) => return None,
CompletionDocumentation::SingleLine(_) => return None,
CompletionDocumentation::Undocumented => return None,
}
let mat = &self.entries.borrow()[self.selected_item];
let multiline_docs = match self.completions.borrow_mut()[mat.candidate_id]
.documentation
.as_ref()?
{
CompletionDocumentation::MultiLinePlainText(text) => {
div().child(SharedString::from(text.clone()))
}
CompletionEntry::InlineCompletionHint(InlineCompletionMenuHint::Loaded { text }) => {
match text {
InlineCompletionText::Edit(highlighted_edits) => div()
.mx_1()
.rounded_md()
.bg(cx.theme().colors().editor_background)
.child(
gpui::StyledText::new(highlighted_edits.text.clone())
.with_highlights(&style.text, highlighted_edits.highlights.clone()),
),
InlineCompletionText::Move(text) => div().child(text.clone()),
}
}
CompletionEntry::InlineCompletionHint(_) => return None,
CompletionDocumentation::MultiLineMarkdown(parsed) if !parsed.text.is_empty() => div()
.child(render_parsed_markdown(
"completions_markdown",
parsed,
&style,
workspace,
cx,
)),
CompletionDocumentation::MultiLineMarkdown(_) => return None,
CompletionDocumentation::SingleLine(_) => return None,
CompletionDocumentation::Undocumented => return None,
};
Some(
@ -763,11 +611,6 @@ impl CompletionsMenu {
}
pub async fn filter(&mut self, query: Option<&str>, executor: BackgroundExecutor) {
let inline_completion_was_selected = self.selected_item == 0
&& self.entries.borrow().first().map_or(false, |entry| {
matches!(entry, CompletionEntry::InlineCompletionHint(_))
});
let mut matches = if let Some(query) = query {
fuzzy::match_strings(
&self.match_candidates,
@ -861,25 +704,15 @@ impl CompletionsMenu {
}
drop(completions);
let mut entries = self.entries.borrow_mut();
let new_selection = if let Some(CompletionEntry::InlineCompletionHint(_)) = entries.first()
{
entries.truncate(1);
if inline_completion_was_selected || matches.is_empty() {
0
} else {
1
}
} else {
entries.truncate(0);
0
};
entries.extend(matches.into_iter().map(CompletionEntry::Match));
self.selected_item = new_selection;
// Scroll to 0 even if the LSP completion is the only one selected. This keeps the display
// consistent when y_flipped.
*self.entries.borrow_mut() = matches;
self.selected_item = 0;
// This keeps the display consistent when y_flipped.
self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top);
}
pub fn set_previewing_inline_completion(&mut self, value: bool) {
self.previewing_inline_completion = value;
}
}
#[derive(Clone)]
@ -1077,11 +910,11 @@ impl CodeActionsMenu {
!self.actions.is_empty()
}
fn origin(&self, cursor_position: DisplayPoint) -> ContextMenuOrigin {
fn origin(&self) -> ContextMenuOrigin {
if let Some(row) = self.deployed_from_indicator {
ContextMenuOrigin::GutterIndicator(row)
} else {
ContextMenuOrigin::EditorPoint(cursor_position)
ContextMenuOrigin::Cursor
}
}