Move `ContextMenu` out of editor.rs and rename `ContextMenu` to `CodeContextMenu` (#21766)

Mikayla Maki created

This is a no-functionality refactor of where the `ContextMenu` type is
defined. Just the type definition and implementation is up to almost
1,000 lines; so I've moved it to it's own file and renamed the type to
`CodeContextMenu`

Release Notes:

- N/A

Change summary

crates/editor/src/code_context_menus.rs | 895 ++++++++++++++++++++++++++
crates/editor/src/editor.rs             | 905 --------------------------
crates/editor/src/editor_tests.rs       |  30 
crates/editor/src/element.rs            |   9 
4 files changed, 941 insertions(+), 898 deletions(-)

Detailed changes

crates/editor/src/code_context_menus.rs 🔗

@@ -0,0 +1,895 @@
+use std::{cell::Cell, cmp::Reverse, ops::Range, sync::Arc};
+
+use fuzzy::{StringMatch, StringMatchCandidate};
+use gpui::{
+    div, px, uniform_list, AnyElement, BackgroundExecutor, Div, FontWeight, ListSizingBehavior,
+    Model, MouseButton, Pixels, ScrollStrategy, SharedString, StrikethroughStyle, StyledText,
+    UniformListScrollHandle, ViewContext, WeakView,
+};
+use language::Buffer;
+use language::{CodeLabel, Documentation};
+use lsp::LanguageServerId;
+use multi_buffer::{Anchor, ExcerptId};
+use ordered_float::OrderedFloat;
+use parking_lot::RwLock;
+use project::{CodeAction, Completion, TaskSourceKind};
+use task::ResolvedTask;
+use ui::{
+    h_flex, ActiveTheme as _, Color, FluentBuilder as _, InteractiveElement as _, IntoElement,
+    Label, LabelCommon as _, LabelSize, ListItem, ParentElement as _, Popover, Selectable as _,
+    StatefulInteractiveElement as _, Styled, StyledExt as _,
+};
+use util::ResultExt as _;
+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,
+};
+
+pub enum CodeContextMenu {
+    Completions(CompletionsMenu),
+    CodeActions(CodeActionsMenu),
+}
+
+impl CodeContextMenu {
+    pub fn select_first(
+        &mut self,
+        provider: Option<&dyn CompletionProvider>,
+        cx: &mut ViewContext<Editor>,
+    ) -> bool {
+        if self.visible() {
+            match self {
+                CodeContextMenu::Completions(menu) => menu.select_first(provider, cx),
+                CodeContextMenu::CodeActions(menu) => menu.select_first(cx),
+            }
+            true
+        } else {
+            false
+        }
+    }
+
+    pub fn select_prev(
+        &mut self,
+        provider: Option<&dyn CompletionProvider>,
+        cx: &mut ViewContext<Editor>,
+    ) -> bool {
+        if self.visible() {
+            match self {
+                CodeContextMenu::Completions(menu) => menu.select_prev(provider, cx),
+                CodeContextMenu::CodeActions(menu) => menu.select_prev(cx),
+            }
+            true
+        } else {
+            false
+        }
+    }
+
+    pub fn select_next(
+        &mut self,
+        provider: Option<&dyn CompletionProvider>,
+        cx: &mut ViewContext<Editor>,
+    ) -> bool {
+        if self.visible() {
+            match self {
+                CodeContextMenu::Completions(menu) => menu.select_next(provider, cx),
+                CodeContextMenu::CodeActions(menu) => menu.select_next(cx),
+            }
+            true
+        } else {
+            false
+        }
+    }
+
+    pub fn select_last(
+        &mut self,
+        provider: Option<&dyn CompletionProvider>,
+        cx: &mut ViewContext<Editor>,
+    ) -> bool {
+        if self.visible() {
+            match self {
+                CodeContextMenu::Completions(menu) => menu.select_last(provider, cx),
+                CodeContextMenu::CodeActions(menu) => menu.select_last(cx),
+            }
+            true
+        } else {
+            false
+        }
+    }
+
+    pub fn visible(&self) -> bool {
+        match self {
+            CodeContextMenu::Completions(menu) => menu.visible(),
+            CodeContextMenu::CodeActions(menu) => menu.visible(),
+        }
+    }
+
+    pub fn render(
+        &self,
+        cursor_position: DisplayPoint,
+        style: &EditorStyle,
+        max_height: Pixels,
+        workspace: Option<WeakView<Workspace>>,
+        cx: &mut ViewContext<Editor>,
+    ) -> (ContextMenuOrigin, AnyElement) {
+        match self {
+            CodeContextMenu::Completions(menu) => (
+                ContextMenuOrigin::EditorPoint(cursor_position),
+                menu.render(style, max_height, workspace, cx),
+            ),
+            CodeContextMenu::CodeActions(menu) => {
+                menu.render(cursor_position, style, max_height, cx)
+            }
+        }
+    }
+}
+
+pub enum ContextMenuOrigin {
+    EditorPoint(DisplayPoint),
+    GutterIndicator(DisplayRow),
+}
+
+#[derive(Clone, Debug)]
+pub struct CompletionsMenu {
+    pub id: CompletionId,
+    sort_completions: bool,
+    pub initial_position: Anchor,
+    pub buffer: Model<Buffer>,
+    pub completions: Arc<RwLock<Box<[Completion]>>>,
+    match_candidates: Arc<[StringMatchCandidate]>,
+    pub matches: Arc<[StringMatch]>,
+    pub selected_item: usize,
+    scroll_handle: UniformListScrollHandle,
+    resolve_completions: bool,
+    pub aside_was_displayed: Cell<bool>,
+    show_completion_documentation: bool,
+}
+
+impl CompletionsMenu {
+    pub fn new(
+        id: CompletionId,
+        sort_completions: bool,
+        show_completion_documentation: bool,
+        initial_position: Anchor,
+        buffer: Model<Buffer>,
+        completions: Box<[Completion]>,
+        aside_was_displayed: bool,
+    ) -> Self {
+        let match_candidates = completions
+            .iter()
+            .enumerate()
+            .map(|(id, completion)| {
+                StringMatchCandidate::new(
+                    id,
+                    completion.label.text[completion.label.filter_range.clone()].into(),
+                )
+            })
+            .collect();
+
+        Self {
+            id,
+            sort_completions,
+            initial_position,
+            buffer,
+            show_completion_documentation,
+            completions: Arc::new(RwLock::new(completions)),
+            match_candidates,
+            matches: Vec::new().into(),
+            selected_item: 0,
+            scroll_handle: UniformListScrollHandle::new(),
+            resolve_completions: true,
+            aside_was_displayed: Cell::new(aside_was_displayed),
+        }
+    }
+
+    pub fn new_snippet_choices(
+        id: CompletionId,
+        sort_completions: bool,
+        choices: &Vec<String>,
+        selection: Range<Anchor>,
+        buffer: Model<Buffer>,
+    ) -> Self {
+        let completions = choices
+            .iter()
+            .map(|choice| Completion {
+                old_range: selection.start.text_anchor..selection.end.text_anchor,
+                new_text: choice.to_string(),
+                label: CodeLabel {
+                    text: choice.to_string(),
+                    runs: Default::default(),
+                    filter_range: Default::default(),
+                },
+                server_id: LanguageServerId(usize::MAX),
+                documentation: None,
+                lsp_completion: Default::default(),
+                confirm: None,
+            })
+            .collect();
+
+        let match_candidates = choices
+            .iter()
+            .enumerate()
+            .map(|(id, completion)| StringMatchCandidate::new(id, completion.to_string()))
+            .collect();
+        let matches = choices
+            .iter()
+            .enumerate()
+            .map(|(id, completion)| StringMatch {
+                candidate_id: id,
+                score: 1.,
+                positions: vec![],
+                string: completion.clone(),
+            })
+            .collect();
+        Self {
+            id,
+            sort_completions,
+            initial_position: selection.start,
+            buffer,
+            completions: Arc::new(RwLock::new(completions)),
+            match_candidates,
+            matches,
+            selected_item: 0,
+            scroll_handle: UniformListScrollHandle::new(),
+            resolve_completions: false,
+            aside_was_displayed: Cell::new(false),
+            show_completion_documentation: false,
+        }
+    }
+
+    fn select_first(
+        &mut self,
+        provider: Option<&dyn CompletionProvider>,
+        cx: &mut ViewContext<Editor>,
+    ) {
+        self.selected_item = 0;
+        self.scroll_handle
+            .scroll_to_item(self.selected_item, ScrollStrategy::Top);
+        self.resolve_selected_completion(provider, cx);
+        cx.notify();
+    }
+
+    fn select_prev(
+        &mut self,
+        provider: Option<&dyn CompletionProvider>,
+        cx: &mut ViewContext<Editor>,
+    ) {
+        if self.selected_item > 0 {
+            self.selected_item -= 1;
+        } else {
+            self.selected_item = self.matches.len() - 1;
+        }
+        self.scroll_handle
+            .scroll_to_item(self.selected_item, ScrollStrategy::Top);
+        self.resolve_selected_completion(provider, cx);
+        cx.notify();
+    }
+
+    fn select_next(
+        &mut self,
+        provider: Option<&dyn CompletionProvider>,
+        cx: &mut ViewContext<Editor>,
+    ) {
+        if self.selected_item + 1 < self.matches.len() {
+            self.selected_item += 1;
+        } else {
+            self.selected_item = 0;
+        }
+        self.scroll_handle
+            .scroll_to_item(self.selected_item, ScrollStrategy::Top);
+        self.resolve_selected_completion(provider, cx);
+        cx.notify();
+    }
+
+    fn select_last(
+        &mut self,
+        provider: Option<&dyn CompletionProvider>,
+        cx: &mut ViewContext<Editor>,
+    ) {
+        self.selected_item = self.matches.len() - 1;
+        self.scroll_handle
+            .scroll_to_item(self.selected_item, ScrollStrategy::Top);
+        self.resolve_selected_completion(provider, cx);
+        cx.notify();
+    }
+
+    pub fn resolve_selected_completion(
+        &mut self,
+        provider: Option<&dyn CompletionProvider>,
+        cx: &mut ViewContext<Editor>,
+    ) {
+        if !self.resolve_completions {
+            return;
+        }
+        let Some(provider) = provider else {
+            return;
+        };
+
+        let completion_index = self.matches[self.selected_item].candidate_id;
+        let resolve_task = provider.resolve_completions(
+            self.buffer.clone(),
+            vec![completion_index],
+            self.completions.clone(),
+            cx,
+        );
+
+        cx.spawn(move |editor, mut cx| async move {
+            if let Some(true) = resolve_task.await.log_err() {
+                editor.update(&mut cx, |_, cx| cx.notify()).ok();
+            }
+        })
+        .detach();
+    }
+
+    fn visible(&self) -> bool {
+        !self.matches.is_empty()
+    }
+
+    fn render(
+        &self,
+        style: &EditorStyle,
+        max_height: Pixels,
+        workspace: Option<WeakView<Workspace>>,
+        cx: &mut ViewContext<Editor>,
+    ) -> AnyElement {
+        let show_completion_documentation = self.show_completion_documentation;
+        let widest_completion_ix = self
+            .matches
+            .iter()
+            .enumerate()
+            .max_by_key(|(_, mat)| {
+                let completions = self.completions.read();
+                let completion = &completions[mat.candidate_id];
+                let documentation = &completion.documentation;
+
+                let mut len = completion.label.text.chars().count();
+                if let Some(Documentation::SingleLine(text)) = documentation {
+                    if show_completion_documentation {
+                        len += text.chars().count();
+                    }
+                }
+
+                len
+            })
+            .map(|(ix, _)| ix);
+
+        let completions = self.completions.clone();
+        let matches = self.matches.clone();
+        let selected_item = self.selected_item;
+        let style = style.clone();
+
+        let multiline_docs = if show_completion_documentation {
+            let mat = &self.matches[selected_item];
+            match &self.completions.read()[mat.candidate_id].documentation {
+                Some(Documentation::MultiLinePlainText(text)) => {
+                    Some(div().child(SharedString::from(text.clone())))
+                }
+                Some(Documentation::MultiLineMarkdown(parsed)) if !parsed.text.is_empty() => {
+                    Some(div().child(render_parsed_markdown(
+                        "completions_markdown",
+                        parsed,
+                        &style,
+                        workspace,
+                        cx,
+                    )))
+                }
+                Some(Documentation::Undocumented) if self.aside_was_displayed.get() => {
+                    Some(div().child("No documentation"))
+                }
+                _ => None,
+            }
+        } else {
+            None
+        };
+
+        let aside_contents = if let Some(multiline_docs) = multiline_docs {
+            Some(multiline_docs)
+        } else if self.aside_was_displayed.get() {
+            Some(div().child("Fetching documentation..."))
+        } else {
+            None
+        };
+        self.aside_was_displayed.set(aside_contents.is_some());
+
+        let aside_contents = aside_contents.map(|div| {
+            div.id("multiline_docs")
+                .max_h(max_height)
+                .flex_1()
+                .px_1p5()
+                .py_1()
+                .min_w(px(260.))
+                .max_w(px(640.))
+                .w(px(500.))
+                .overflow_y_scroll()
+                .occlude()
+        });
+
+        let list = uniform_list(
+            cx.view().clone(),
+            "completions",
+            matches.len(),
+            move |_editor, range, cx| {
+                let start_ix = range.start;
+                let completions_guard = completions.read();
+
+                matches[range]
+                    .iter()
+                    .enumerate()
+                    .map(|(ix, mat)| {
+                        let item_ix = start_ix + ix;
+                        let candidate_id = mat.candidate_id;
+                        let completion = &completions_guard[candidate_id];
+
+                        let documentation = if show_completion_documentation {
+                            &completion.documentation
+                        } else {
+                            &None
+                        };
+
+                        let highlights = gpui::combine_highlights(
+                            mat.ranges().map(|range| (range, 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(Documentation::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)
+                                .selected(item_ix == selected_item)
+                                .on_click(cx.listener(move |editor, _event, cx| {
+                                    cx.stop_propagation();
+                                    if let Some(task) = editor.confirm_completion(
+                                        &ConfirmCompletion {
+                                            item_ix: Some(item_ix),
+                                        },
+                                        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()
+            },
+        )
+        .occlude()
+        .max_h(max_height)
+        .track_scroll(self.scroll_handle.clone())
+        .with_width_from_item(widest_completion_ix)
+        .with_sizing_behavior(ListSizingBehavior::Infer);
+
+        Popover::new()
+            .child(list)
+            .when_some(aside_contents, |popover, aside_contents| {
+                popover.aside(aside_contents)
+            })
+            .into_any_element()
+    }
+
+    pub async fn filter(&mut self, query: Option<&str>, executor: BackgroundExecutor) {
+        let mut matches = if let Some(query) = query {
+            fuzzy::match_strings(
+                &self.match_candidates,
+                query,
+                query.chars().any(|c| c.is_uppercase()),
+                100,
+                &Default::default(),
+                executor,
+            )
+            .await
+        } else {
+            self.match_candidates
+                .iter()
+                .enumerate()
+                .map(|(candidate_id, candidate)| StringMatch {
+                    candidate_id,
+                    score: Default::default(),
+                    positions: Default::default(),
+                    string: candidate.string.clone(),
+                })
+                .collect()
+        };
+
+        // Remove all candidates where the query's start does not match the start of any word in the candidate
+        if let Some(query) = query {
+            if let Some(query_start) = query.chars().next() {
+                matches.retain(|string_match| {
+                    split_words(&string_match.string).any(|word| {
+                        // Check that the first codepoint of the word as lowercase matches the first
+                        // codepoint of the query as lowercase
+                        word.chars()
+                            .flat_map(|codepoint| codepoint.to_lowercase())
+                            .zip(query_start.to_lowercase())
+                            .all(|(word_cp, query_cp)| word_cp == query_cp)
+                    })
+                });
+            }
+        }
+
+        let completions = self.completions.read();
+        if self.sort_completions {
+            matches.sort_unstable_by_key(|mat| {
+                // We do want to strike a balance here between what the language server tells us
+                // to sort by (the sort_text) and what are "obvious" good matches (i.e. when you type
+                // `Creat` and there is a local variable called `CreateComponent`).
+                // So what we do is: we bucket all matches into two buckets
+                // - Strong matches
+                // - Weak matches
+                // Strong matches are the ones with a high fuzzy-matcher score (the "obvious" matches)
+                // and the Weak matches are the rest.
+                //
+                // For the strong matches, we sort by our fuzzy-finder score first and for the weak
+                // matches, we prefer language-server sort_text first.
+                //
+                // The thinking behind that: we want to show strong matches first in order of relevance(fuzzy score).
+                // Rest of the matches(weak) can be sorted as language-server expects.
+
+                #[derive(PartialEq, Eq, PartialOrd, Ord)]
+                enum MatchScore<'a> {
+                    Strong {
+                        score: Reverse<OrderedFloat<f64>>,
+                        sort_text: Option<&'a str>,
+                        sort_key: (usize, &'a str),
+                    },
+                    Weak {
+                        sort_text: Option<&'a str>,
+                        score: Reverse<OrderedFloat<f64>>,
+                        sort_key: (usize, &'a str),
+                    },
+                }
+
+                let completion = &completions[mat.candidate_id];
+                let sort_key = completion.sort_key();
+                let sort_text = completion.lsp_completion.sort_text.as_deref();
+                let score = Reverse(OrderedFloat(mat.score));
+
+                if mat.score >= 0.2 {
+                    MatchScore::Strong {
+                        score,
+                        sort_text,
+                        sort_key,
+                    }
+                } else {
+                    MatchScore::Weak {
+                        sort_text,
+                        score,
+                        sort_key,
+                    }
+                }
+            });
+        }
+
+        for mat in &mut matches {
+            let completion = &completions[mat.candidate_id];
+            mat.string.clone_from(&completion.label.text);
+            for position in &mut mat.positions {
+                *position += completion.label.filter_range.start;
+            }
+        }
+        drop(completions);
+
+        self.matches = matches.into();
+        self.selected_item = 0;
+    }
+}
+
+#[derive(Clone)]
+pub struct AvailableCodeAction {
+    pub excerpt_id: ExcerptId,
+    pub action: CodeAction,
+    pub provider: Arc<dyn CodeActionProvider>,
+}
+
+#[derive(Clone)]
+pub struct CodeActionContents {
+    pub tasks: Option<Arc<ResolvedTasks>>,
+    pub actions: Option<Arc<[AvailableCodeAction]>>,
+}
+
+impl CodeActionContents {
+    fn len(&self) -> usize {
+        match (&self.tasks, &self.actions) {
+            (Some(tasks), Some(actions)) => actions.len() + tasks.templates.len(),
+            (Some(tasks), None) => tasks.templates.len(),
+            (None, Some(actions)) => actions.len(),
+            (None, None) => 0,
+        }
+    }
+
+    fn is_empty(&self) -> bool {
+        match (&self.tasks, &self.actions) {
+            (Some(tasks), Some(actions)) => actions.is_empty() && tasks.templates.is_empty(),
+            (Some(tasks), None) => tasks.templates.is_empty(),
+            (None, Some(actions)) => actions.is_empty(),
+            (None, None) => true,
+        }
+    }
+
+    fn iter(&self) -> impl Iterator<Item = CodeActionsItem> + '_ {
+        self.tasks
+            .iter()
+            .flat_map(|tasks| {
+                tasks
+                    .templates
+                    .iter()
+                    .map(|(kind, task)| CodeActionsItem::Task(kind.clone(), task.clone()))
+            })
+            .chain(self.actions.iter().flat_map(|actions| {
+                actions.iter().map(|available| CodeActionsItem::CodeAction {
+                    excerpt_id: available.excerpt_id,
+                    action: available.action.clone(),
+                    provider: available.provider.clone(),
+                })
+            }))
+    }
+
+    pub fn get(&self, index: usize) -> Option<CodeActionsItem> {
+        match (&self.tasks, &self.actions) {
+            (Some(tasks), Some(actions)) => {
+                if index < tasks.templates.len() {
+                    tasks
+                        .templates
+                        .get(index)
+                        .cloned()
+                        .map(|(kind, task)| CodeActionsItem::Task(kind, task))
+                } else {
+                    actions.get(index - tasks.templates.len()).map(|available| {
+                        CodeActionsItem::CodeAction {
+                            excerpt_id: available.excerpt_id,
+                            action: available.action.clone(),
+                            provider: available.provider.clone(),
+                        }
+                    })
+                }
+            }
+            (Some(tasks), None) => tasks
+                .templates
+                .get(index)
+                .cloned()
+                .map(|(kind, task)| CodeActionsItem::Task(kind, task)),
+            (None, Some(actions)) => {
+                actions
+                    .get(index)
+                    .map(|available| CodeActionsItem::CodeAction {
+                        excerpt_id: available.excerpt_id,
+                        action: available.action.clone(),
+                        provider: available.provider.clone(),
+                    })
+            }
+            (None, None) => None,
+        }
+    }
+}
+
+#[allow(clippy::large_enum_variant)]
+#[derive(Clone)]
+pub enum CodeActionsItem {
+    Task(TaskSourceKind, ResolvedTask),
+    CodeAction {
+        excerpt_id: ExcerptId,
+        action: CodeAction,
+        provider: Arc<dyn CodeActionProvider>,
+    },
+}
+
+impl CodeActionsItem {
+    fn as_task(&self) -> Option<&ResolvedTask> {
+        let Self::Task(_, task) = self else {
+            return None;
+        };
+        Some(task)
+    }
+
+    fn as_code_action(&self) -> Option<&CodeAction> {
+        let Self::CodeAction { action, .. } = self else {
+            return None;
+        };
+        Some(action)
+    }
+
+    pub fn label(&self) -> String {
+        match self {
+            Self::CodeAction { action, .. } => action.lsp_action.title.clone(),
+            Self::Task(_, task) => task.resolved_label.clone(),
+        }
+    }
+}
+
+pub struct CodeActionsMenu {
+    pub actions: CodeActionContents,
+    pub buffer: Model<Buffer>,
+    pub selected_item: usize,
+    pub scroll_handle: UniformListScrollHandle,
+    pub deployed_from_indicator: Option<DisplayRow>,
+}
+
+impl CodeActionsMenu {
+    fn select_first(&mut self, cx: &mut ViewContext<Editor>) {
+        self.selected_item = 0;
+        self.scroll_handle
+            .scroll_to_item(self.selected_item, ScrollStrategy::Top);
+        cx.notify()
+    }
+
+    fn select_prev(&mut self, cx: &mut ViewContext<Editor>) {
+        if self.selected_item > 0 {
+            self.selected_item -= 1;
+        } else {
+            self.selected_item = self.actions.len() - 1;
+        }
+        self.scroll_handle
+            .scroll_to_item(self.selected_item, ScrollStrategy::Top);
+        cx.notify();
+    }
+
+    fn select_next(&mut self, cx: &mut ViewContext<Editor>) {
+        if self.selected_item + 1 < self.actions.len() {
+            self.selected_item += 1;
+        } else {
+            self.selected_item = 0;
+        }
+        self.scroll_handle
+            .scroll_to_item(self.selected_item, ScrollStrategy::Top);
+        cx.notify();
+    }
+
+    fn select_last(&mut self, cx: &mut ViewContext<Editor>) {
+        self.selected_item = self.actions.len() - 1;
+        self.scroll_handle
+            .scroll_to_item(self.selected_item, ScrollStrategy::Top);
+        cx.notify()
+    }
+
+    fn visible(&self) -> bool {
+        !self.actions.is_empty()
+    }
+
+    fn render(
+        &self,
+        cursor_position: DisplayPoint,
+        _style: &EditorStyle,
+        max_height: Pixels,
+        cx: &mut ViewContext<Editor>,
+    ) -> (ContextMenuOrigin, AnyElement) {
+        let actions = self.actions.clone();
+        let selected_item = self.selected_item;
+        let element = uniform_list(
+            cx.view().clone(),
+            "code_actions_menu",
+            self.actions.len(),
+            move |_this, range, cx| {
+                actions
+                    .iter()
+                    .skip(range.start)
+                    .take(range.end - range.start)
+                    .enumerate()
+                    .map(|(ix, action)| {
+                        let item_ix = range.start + ix;
+                        let selected = selected_item == item_ix;
+                        let colors = cx.theme().colors();
+                        div()
+                            .px_1()
+                            .rounded_md()
+                            .text_color(colors.text)
+                            .when(selected, |style| {
+                                style
+                                    .bg(colors.element_active)
+                                    .text_color(colors.text_accent)
+                            })
+                            .hover(|style| {
+                                style
+                                    .bg(colors.element_hover)
+                                    .text_color(colors.text_accent)
+                            })
+                            .whitespace_nowrap()
+                            .when_some(action.as_code_action(), |this, action| {
+                                this.on_mouse_down(
+                                    MouseButton::Left,
+                                    cx.listener(move |editor, _, cx| {
+                                        cx.stop_propagation();
+                                        if let Some(task) = editor.confirm_code_action(
+                                            &ConfirmCodeAction {
+                                                item_ix: Some(item_ix),
+                                            },
+                                            cx,
+                                        ) {
+                                            task.detach_and_log_err(cx)
+                                        }
+                                    }),
+                                )
+                                // TASK: It would be good to make lsp_action.title a SharedString to avoid allocating here.
+                                .child(SharedString::from(
+                                    action.lsp_action.title.replace("\n", ""),
+                                ))
+                            })
+                            .when_some(action.as_task(), |this, task| {
+                                this.on_mouse_down(
+                                    MouseButton::Left,
+                                    cx.listener(move |editor, _, cx| {
+                                        cx.stop_propagation();
+                                        if let Some(task) = editor.confirm_code_action(
+                                            &ConfirmCodeAction {
+                                                item_ix: Some(item_ix),
+                                            },
+                                            cx,
+                                        ) {
+                                            task.detach_and_log_err(cx)
+                                        }
+                                    }),
+                                )
+                                .child(SharedString::from(task.resolved_label.replace("\n", "")))
+                            })
+                    })
+                    .collect()
+            },
+        )
+        .elevation_1(cx)
+        .p_1()
+        .max_h(max_height)
+        .occlude()
+        .track_scroll(self.scroll_handle.clone())
+        .with_width_from_item(
+            self.actions
+                .iter()
+                .enumerate()
+                .max_by_key(|(_, action)| match action {
+                    CodeActionsItem::Task(_, task) => task.resolved_label.chars().count(),
+                    CodeActionsItem::CodeAction { action, .. } => {
+                        action.lsp_action.title.chars().count()
+                    }
+                })
+                .map(|(ix, _)| ix),
+        )
+        .with_sizing_behavior(ListSizingBehavior::Infer)
+        .into_any_element();
+
+        let cursor_position = if let Some(row) = self.deployed_from_indicator {
+            ContextMenuOrigin::GutterIndicator(row)
+        } else {
+            ContextMenuOrigin::EditorPoint(cursor_position)
+        };
+
+        (cursor_position, element)
+    }
+}

crates/editor/src/editor.rs 🔗

@@ -16,6 +16,7 @@ pub mod actions;
 mod blame_entry_tooltip;
 mod blink_manager;
 mod clangd_ext;
+mod code_context_menus;
 pub mod display_map;
 mod editor_settings;
 mod editor_settings_controls;
@@ -68,17 +69,22 @@ pub use element::{
     CursorLayout, EditorElement, HighlightedRange, HighlightedRangeLine, PointForPosition,
 };
 use futures::{future, FutureExt};
-use fuzzy::{StringMatch, StringMatchCandidate};
+use fuzzy::StringMatchCandidate;
+
+use code_context_menus::{
+    AvailableCodeAction, CodeActionContents, CodeActionsItem, CodeActionsMenu, CodeContextMenu,
+    CompletionsMenu, ContextMenuOrigin,
+};
 use git::blame::GitBlame;
 use gpui::{
-    div, impl_actions, point, prelude::*, px, relative, size, uniform_list, Action, AnyElement,
-    AppContext, AsyncWindowContext, AvailableSpace, BackgroundExecutor, Bounds, ClipboardEntry,
-    ClipboardItem, Context, DispatchPhase, ElementId, EventEmitter, FocusHandle, FocusOutEvent,
-    FocusableView, FontId, FontWeight, Global, HighlightStyle, Hsla, InteractiveText, KeyContext,
-    ListSizingBehavior, Model, ModelContext, MouseButton, PaintQuad, ParentElement, Pixels, Render,
-    ScrollStrategy, SharedString, Size, StrikethroughStyle, Styled, StyledText, Subscription, Task,
-    TextStyle, TextStyleRefinement, UTF16Selection, UnderlineStyle, UniformListScrollHandle, View,
-    ViewContext, ViewInputHandler, VisualContext, WeakFocusHandle, WeakView, WindowContext,
+    div, impl_actions, point, prelude::*, px, relative, size, Action, AnyElement, AppContext,
+    AsyncWindowContext, AvailableSpace, Bounds, ClipboardEntry, ClipboardItem, Context,
+    DispatchPhase, ElementId, EventEmitter, FocusHandle, FocusOutEvent, FocusableView, FontId,
+    FontWeight, Global, HighlightStyle, Hsla, InteractiveText, KeyContext, Model, ModelContext,
+    MouseButton, PaintQuad, ParentElement, Pixels, Render, SharedString, Size, Styled, StyledText,
+    Subscription, Task, TextStyle, TextStyleRefinement, UTF16Selection, UnderlineStyle,
+    UniformListScrollHandle, View, ViewContext, ViewInputHandler, VisualContext, WeakFocusHandle,
+    WeakView, WindowContext,
 };
 use highlight_matching_bracket::refresh_matching_bracket_highlights;
 use hover_popover::{hide_hover, HoverState};
@@ -98,6 +104,7 @@ use language::{
 };
 use language::{point_to_lsp, BufferRow, CharClassifier, Runnable, RunnableRange};
 use linked_editing_ranges::refresh_linked_ranges;
+use mouse_context_menu::MouseContextMenu;
 pub use proposed_changes_editor::{
     ProposedChangeLocation, ProposedChangesEditor, ProposedChangesEditorToolbar,
 };
@@ -111,7 +118,7 @@ use lsp::{
     CompletionItemKind, CompletionTriggerKind, DiagnosticSeverity, InsertTextFormat,
     LanguageServerId, LanguageServerName,
 };
-use mouse_context_menu::MouseContextMenu;
+
 use movement::TextLayoutDetails;
 pub use multi_buffer::{
     Anchor, AnchorRangeExt, ExcerptId, ExcerptRange, MultiBuffer, MultiBufferSnapshot, ToOffset,
@@ -120,7 +127,6 @@ pub use multi_buffer::{
 use multi_buffer::{
     ExpandExcerptDirection, MultiBufferDiffHunk, MultiBufferPoint, MultiBufferRow, ToOffsetUtf16,
 };
-use ordered_float::OrderedFloat;
 use parking_lot::RwLock;
 use project::{
     lsp_store::{FormatTarget, FormatTrigger},
@@ -141,7 +147,7 @@ use snippet::Snippet;
 use std::{
     any::TypeId,
     borrow::Cow,
-    cell::{Cell, RefCell},
+    cell::RefCell,
     cmp::{self, Ordering, Reverse},
     mem,
     num::NonZeroU32,
@@ -160,7 +166,7 @@ use theme::{
 };
 use ui::{
     h_flex, prelude::*, ButtonSize, ButtonStyle, Disclosure, IconButton, IconName, IconSize,
-    ListItem, Popover, PopoverMenuHandle, Tooltip,
+    PopoverMenuHandle, Tooltip,
 };
 use util::{defer, maybe, post_inc, RangeExt, ResultExt, TryFutureExt};
 use workspace::item::{ItemHandle, PreviewTabsSettings};
@@ -586,7 +592,7 @@ pub struct Editor {
     scrollbar_marker_state: ScrollbarMarkerState,
     active_indent_guides_state: ActiveIndentGuidesState,
     nav_history: Option<ItemNavHistory>,
-    context_menu: RwLock<Option<ContextMenu>>,
+    context_menu: RwLock<Option<CodeContextMenu>>,
     mouse_context_menu: Option<MouseContextMenu>,
     hunk_controls_menu_handle: PopoverMenuHandle<ui::ContextMenu>,
     completion_tasks: Vec<(CompletionId, Task<Option<()>>)>,
@@ -893,866 +899,6 @@ struct RegisteredInlineCompletionProvider {
     _subscription: Subscription,
 }
 
-enum ContextMenu {
-    Completions(CompletionsMenu),
-    CodeActions(CodeActionsMenu),
-}
-
-impl ContextMenu {
-    fn select_first(
-        &mut self,
-        provider: Option<&dyn CompletionProvider>,
-        cx: &mut ViewContext<Editor>,
-    ) -> bool {
-        if self.visible() {
-            match self {
-                ContextMenu::Completions(menu) => menu.select_first(provider, cx),
-                ContextMenu::CodeActions(menu) => menu.select_first(cx),
-            }
-            true
-        } else {
-            false
-        }
-    }
-
-    fn select_prev(
-        &mut self,
-        provider: Option<&dyn CompletionProvider>,
-        cx: &mut ViewContext<Editor>,
-    ) -> bool {
-        if self.visible() {
-            match self {
-                ContextMenu::Completions(menu) => menu.select_prev(provider, cx),
-                ContextMenu::CodeActions(menu) => menu.select_prev(cx),
-            }
-            true
-        } else {
-            false
-        }
-    }
-
-    fn select_next(
-        &mut self,
-        provider: Option<&dyn CompletionProvider>,
-        cx: &mut ViewContext<Editor>,
-    ) -> bool {
-        if self.visible() {
-            match self {
-                ContextMenu::Completions(menu) => menu.select_next(provider, cx),
-                ContextMenu::CodeActions(menu) => menu.select_next(cx),
-            }
-            true
-        } else {
-            false
-        }
-    }
-
-    fn select_last(
-        &mut self,
-        provider: Option<&dyn CompletionProvider>,
-        cx: &mut ViewContext<Editor>,
-    ) -> bool {
-        if self.visible() {
-            match self {
-                ContextMenu::Completions(menu) => menu.select_last(provider, cx),
-                ContextMenu::CodeActions(menu) => menu.select_last(cx),
-            }
-            true
-        } else {
-            false
-        }
-    }
-
-    fn visible(&self) -> bool {
-        match self {
-            ContextMenu::Completions(menu) => menu.visible(),
-            ContextMenu::CodeActions(menu) => menu.visible(),
-        }
-    }
-
-    fn render(
-        &self,
-        cursor_position: DisplayPoint,
-        style: &EditorStyle,
-        max_height: Pixels,
-        workspace: Option<WeakView<Workspace>>,
-        cx: &mut ViewContext<Editor>,
-    ) -> (ContextMenuOrigin, AnyElement) {
-        match self {
-            ContextMenu::Completions(menu) => (
-                ContextMenuOrigin::EditorPoint(cursor_position),
-                menu.render(style, max_height, workspace, cx),
-            ),
-            ContextMenu::CodeActions(menu) => menu.render(cursor_position, style, max_height, cx),
-        }
-    }
-}
-
-enum ContextMenuOrigin {
-    EditorPoint(DisplayPoint),
-    GutterIndicator(DisplayRow),
-}
-
-#[derive(Clone, Debug)]
-struct CompletionsMenu {
-    id: CompletionId,
-    sort_completions: bool,
-    initial_position: Anchor,
-    buffer: Model<Buffer>,
-    completions: Arc<RwLock<Box<[Completion]>>>,
-    match_candidates: Arc<[StringMatchCandidate]>,
-    matches: Arc<[StringMatch]>,
-    selected_item: usize,
-    scroll_handle: UniformListScrollHandle,
-    resolve_completions: bool,
-    aside_was_displayed: Cell<bool>,
-    show_completion_documentation: bool,
-}
-
-impl CompletionsMenu {
-    fn new(
-        id: CompletionId,
-        sort_completions: bool,
-        show_completion_documentation: bool,
-        initial_position: Anchor,
-        buffer: Model<Buffer>,
-        completions: Box<[Completion]>,
-        aside_was_displayed: bool,
-    ) -> Self {
-        let match_candidates = completions
-            .iter()
-            .enumerate()
-            .map(|(id, completion)| {
-                StringMatchCandidate::new(
-                    id,
-                    completion.label.text[completion.label.filter_range.clone()].into(),
-                )
-            })
-            .collect();
-
-        Self {
-            id,
-            sort_completions,
-            initial_position,
-            buffer,
-            completions: Arc::new(RwLock::new(completions)),
-            match_candidates,
-            matches: Vec::new().into(),
-            selected_item: 0,
-            scroll_handle: UniformListScrollHandle::new(),
-            resolve_completions: true,
-            aside_was_displayed: Cell::new(aside_was_displayed),
-            show_completion_documentation: show_completion_documentation,
-        }
-    }
-
-    fn new_snippet_choices(
-        id: CompletionId,
-        sort_completions: bool,
-        choices: &Vec<String>,
-        selection: Range<Anchor>,
-        buffer: Model<Buffer>,
-    ) -> Self {
-        let completions = choices
-            .iter()
-            .map(|choice| Completion {
-                old_range: selection.start.text_anchor..selection.end.text_anchor,
-                new_text: choice.to_string(),
-                label: CodeLabel {
-                    text: choice.to_string(),
-                    runs: Default::default(),
-                    filter_range: Default::default(),
-                },
-                server_id: LanguageServerId(usize::MAX),
-                documentation: None,
-                lsp_completion: Default::default(),
-                confirm: None,
-            })
-            .collect();
-
-        let match_candidates = choices
-            .iter()
-            .enumerate()
-            .map(|(id, completion)| StringMatchCandidate::new(id, completion.to_string()))
-            .collect();
-        let matches = choices
-            .iter()
-            .enumerate()
-            .map(|(id, completion)| StringMatch {
-                candidate_id: id,
-                score: 1.,
-                positions: vec![],
-                string: completion.clone(),
-            })
-            .collect();
-        Self {
-            id,
-            sort_completions,
-            initial_position: selection.start,
-            buffer,
-            completions: Arc::new(RwLock::new(completions)),
-            match_candidates,
-            matches,
-            selected_item: 0,
-            scroll_handle: UniformListScrollHandle::new(),
-            resolve_completions: false,
-            aside_was_displayed: Cell::new(false),
-            show_completion_documentation: false,
-        }
-    }
-
-    fn select_first(
-        &mut self,
-        provider: Option<&dyn CompletionProvider>,
-        cx: &mut ViewContext<Editor>,
-    ) {
-        self.selected_item = 0;
-        self.scroll_handle
-            .scroll_to_item(self.selected_item, ScrollStrategy::Top);
-        self.resolve_selected_completion(provider, cx);
-        cx.notify();
-    }
-
-    fn select_prev(
-        &mut self,
-        provider: Option<&dyn CompletionProvider>,
-        cx: &mut ViewContext<Editor>,
-    ) {
-        if self.selected_item > 0 {
-            self.selected_item -= 1;
-        } else {
-            self.selected_item = self.matches.len() - 1;
-        }
-        self.scroll_handle
-            .scroll_to_item(self.selected_item, ScrollStrategy::Top);
-        self.resolve_selected_completion(provider, cx);
-        cx.notify();
-    }
-
-    fn select_next(
-        &mut self,
-        provider: Option<&dyn CompletionProvider>,
-        cx: &mut ViewContext<Editor>,
-    ) {
-        if self.selected_item + 1 < self.matches.len() {
-            self.selected_item += 1;
-        } else {
-            self.selected_item = 0;
-        }
-        self.scroll_handle
-            .scroll_to_item(self.selected_item, ScrollStrategy::Top);
-        self.resolve_selected_completion(provider, cx);
-        cx.notify();
-    }
-
-    fn select_last(
-        &mut self,
-        provider: Option<&dyn CompletionProvider>,
-        cx: &mut ViewContext<Editor>,
-    ) {
-        self.selected_item = self.matches.len() - 1;
-        self.scroll_handle
-            .scroll_to_item(self.selected_item, ScrollStrategy::Top);
-        self.resolve_selected_completion(provider, cx);
-        cx.notify();
-    }
-
-    fn resolve_selected_completion(
-        &mut self,
-        provider: Option<&dyn CompletionProvider>,
-        cx: &mut ViewContext<Editor>,
-    ) {
-        if !self.resolve_completions {
-            return;
-        }
-        let Some(provider) = provider else {
-            return;
-        };
-
-        let completion_index = self.matches[self.selected_item].candidate_id;
-        let resolve_task = provider.resolve_completions(
-            self.buffer.clone(),
-            vec![completion_index],
-            self.completions.clone(),
-            cx,
-        );
-
-        cx.spawn(move |editor, mut cx| async move {
-            if let Some(true) = resolve_task.await.log_err() {
-                editor.update(&mut cx, |_, cx| cx.notify()).ok();
-            }
-        })
-        .detach();
-    }
-
-    fn visible(&self) -> bool {
-        !self.matches.is_empty()
-    }
-
-    fn render(
-        &self,
-        style: &EditorStyle,
-        max_height: Pixels,
-        workspace: Option<WeakView<Workspace>>,
-        cx: &mut ViewContext<Editor>,
-    ) -> AnyElement {
-        let show_completion_documentation = self.show_completion_documentation;
-        let widest_completion_ix = self
-            .matches
-            .iter()
-            .enumerate()
-            .max_by_key(|(_, mat)| {
-                let completions = self.completions.read();
-                let completion = &completions[mat.candidate_id];
-                let documentation = &completion.documentation;
-
-                let mut len = completion.label.text.chars().count();
-                if let Some(Documentation::SingleLine(text)) = documentation {
-                    if show_completion_documentation {
-                        len += text.chars().count();
-                    }
-                }
-
-                len
-            })
-            .map(|(ix, _)| ix);
-
-        let completions = self.completions.clone();
-        let matches = self.matches.clone();
-        let selected_item = self.selected_item;
-        let style = style.clone();
-
-        let multiline_docs = if show_completion_documentation {
-            let mat = &self.matches[selected_item];
-            match &self.completions.read()[mat.candidate_id].documentation {
-                Some(Documentation::MultiLinePlainText(text)) => {
-                    Some(div().child(SharedString::from(text.clone())))
-                }
-                Some(Documentation::MultiLineMarkdown(parsed)) if !parsed.text.is_empty() => {
-                    Some(div().child(render_parsed_markdown(
-                        "completions_markdown",
-                        parsed,
-                        &style,
-                        workspace,
-                        cx,
-                    )))
-                }
-                Some(Documentation::Undocumented) if self.aside_was_displayed.get() => {
-                    Some(div().child("No documentation"))
-                }
-                _ => None,
-            }
-        } else {
-            None
-        };
-
-        let aside_contents = if let Some(multiline_docs) = multiline_docs {
-            Some(multiline_docs)
-        } else if self.aside_was_displayed.get() {
-            Some(div().child("Fetching documentation..."))
-        } else {
-            None
-        };
-        self.aside_was_displayed.set(aside_contents.is_some());
-
-        let aside_contents = aside_contents.map(|div| {
-            div.id("multiline_docs")
-                .max_h(max_height)
-                .flex_1()
-                .px_1p5()
-                .py_1()
-                .min_w(px(260.))
-                .max_w(px(640.))
-                .w(px(500.))
-                .overflow_y_scroll()
-                .occlude()
-        });
-
-        let list = uniform_list(
-            cx.view().clone(),
-            "completions",
-            matches.len(),
-            move |_editor, range, cx| {
-                let start_ix = range.start;
-                let completions_guard = completions.read();
-
-                matches[range]
-                    .iter()
-                    .enumerate()
-                    .map(|(ix, mat)| {
-                        let item_ix = start_ix + ix;
-                        let candidate_id = mat.candidate_id;
-                        let completion = &completions_guard[candidate_id];
-
-                        let documentation = if show_completion_documentation {
-                            &completion.documentation
-                        } else {
-                            &None
-                        };
-
-                        let highlights = gpui::combine_highlights(
-                            mat.ranges().map(|range| (range, 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(Documentation::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)
-                                .selected(item_ix == selected_item)
-                                .on_click(cx.listener(move |editor, _event, cx| {
-                                    cx.stop_propagation();
-                                    if let Some(task) = editor.confirm_completion(
-                                        &ConfirmCompletion {
-                                            item_ix: Some(item_ix),
-                                        },
-                                        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()
-            },
-        )
-        .occlude()
-        .max_h(max_height)
-        .track_scroll(self.scroll_handle.clone())
-        .with_width_from_item(widest_completion_ix)
-        .with_sizing_behavior(ListSizingBehavior::Infer);
-
-        Popover::new()
-            .child(list)
-            .when_some(aside_contents, |popover, aside_contents| {
-                popover.aside(aside_contents)
-            })
-            .into_any_element()
-    }
-
-    pub async fn filter(&mut self, query: Option<&str>, executor: BackgroundExecutor) {
-        let mut matches = if let Some(query) = query {
-            fuzzy::match_strings(
-                &self.match_candidates,
-                query,
-                query.chars().any(|c| c.is_uppercase()),
-                100,
-                &Default::default(),
-                executor,
-            )
-            .await
-        } else {
-            self.match_candidates
-                .iter()
-                .enumerate()
-                .map(|(candidate_id, candidate)| StringMatch {
-                    candidate_id,
-                    score: Default::default(),
-                    positions: Default::default(),
-                    string: candidate.string.clone(),
-                })
-                .collect()
-        };
-
-        // Remove all candidates where the query's start does not match the start of any word in the candidate
-        if let Some(query) = query {
-            if let Some(query_start) = query.chars().next() {
-                matches.retain(|string_match| {
-                    split_words(&string_match.string).any(|word| {
-                        // Check that the first codepoint of the word as lowercase matches the first
-                        // codepoint of the query as lowercase
-                        word.chars()
-                            .flat_map(|codepoint| codepoint.to_lowercase())
-                            .zip(query_start.to_lowercase())
-                            .all(|(word_cp, query_cp)| word_cp == query_cp)
-                    })
-                });
-            }
-        }
-
-        let completions = self.completions.read();
-        if self.sort_completions {
-            matches.sort_unstable_by_key(|mat| {
-                // We do want to strike a balance here between what the language server tells us
-                // to sort by (the sort_text) and what are "obvious" good matches (i.e. when you type
-                // `Creat` and there is a local variable called `CreateComponent`).
-                // So what we do is: we bucket all matches into two buckets
-                // - Strong matches
-                // - Weak matches
-                // Strong matches are the ones with a high fuzzy-matcher score (the "obvious" matches)
-                // and the Weak matches are the rest.
-                //
-                // For the strong matches, we sort by our fuzzy-finder score first and for the weak
-                // matches, we prefer language-server sort_text first.
-                //
-                // The thinking behind that: we want to show strong matches first in order of relevance(fuzzy score).
-                // Rest of the matches(weak) can be sorted as language-server expects.
-
-                #[derive(PartialEq, Eq, PartialOrd, Ord)]
-                enum MatchScore<'a> {
-                    Strong {
-                        score: Reverse<OrderedFloat<f64>>,
-                        sort_text: Option<&'a str>,
-                        sort_key: (usize, &'a str),
-                    },
-                    Weak {
-                        sort_text: Option<&'a str>,
-                        score: Reverse<OrderedFloat<f64>>,
-                        sort_key: (usize, &'a str),
-                    },
-                }
-
-                let completion = &completions[mat.candidate_id];
-                let sort_key = completion.sort_key();
-                let sort_text = completion.lsp_completion.sort_text.as_deref();
-                let score = Reverse(OrderedFloat(mat.score));
-
-                if mat.score >= 0.2 {
-                    MatchScore::Strong {
-                        score,
-                        sort_text,
-                        sort_key,
-                    }
-                } else {
-                    MatchScore::Weak {
-                        sort_text,
-                        score,
-                        sort_key,
-                    }
-                }
-            });
-        }
-
-        for mat in &mut matches {
-            let completion = &completions[mat.candidate_id];
-            mat.string.clone_from(&completion.label.text);
-            for position in &mut mat.positions {
-                *position += completion.label.filter_range.start;
-            }
-        }
-        drop(completions);
-
-        self.matches = matches.into();
-        self.selected_item = 0;
-    }
-}
-
-#[derive(Clone)]
-struct AvailableCodeAction {
-    excerpt_id: ExcerptId,
-    action: CodeAction,
-    provider: Arc<dyn CodeActionProvider>,
-}
-
-#[derive(Clone)]
-struct CodeActionContents {
-    tasks: Option<Arc<ResolvedTasks>>,
-    actions: Option<Arc<[AvailableCodeAction]>>,
-}
-
-impl CodeActionContents {
-    fn len(&self) -> usize {
-        match (&self.tasks, &self.actions) {
-            (Some(tasks), Some(actions)) => actions.len() + tasks.templates.len(),
-            (Some(tasks), None) => tasks.templates.len(),
-            (None, Some(actions)) => actions.len(),
-            (None, None) => 0,
-        }
-    }
-
-    fn is_empty(&self) -> bool {
-        match (&self.tasks, &self.actions) {
-            (Some(tasks), Some(actions)) => actions.is_empty() && tasks.templates.is_empty(),
-            (Some(tasks), None) => tasks.templates.is_empty(),
-            (None, Some(actions)) => actions.is_empty(),
-            (None, None) => true,
-        }
-    }
-
-    fn iter(&self) -> impl Iterator<Item = CodeActionsItem> + '_ {
-        self.tasks
-            .iter()
-            .flat_map(|tasks| {
-                tasks
-                    .templates
-                    .iter()
-                    .map(|(kind, task)| CodeActionsItem::Task(kind.clone(), task.clone()))
-            })
-            .chain(self.actions.iter().flat_map(|actions| {
-                actions.iter().map(|available| CodeActionsItem::CodeAction {
-                    excerpt_id: available.excerpt_id,
-                    action: available.action.clone(),
-                    provider: available.provider.clone(),
-                })
-            }))
-    }
-    fn get(&self, index: usize) -> Option<CodeActionsItem> {
-        match (&self.tasks, &self.actions) {
-            (Some(tasks), Some(actions)) => {
-                if index < tasks.templates.len() {
-                    tasks
-                        .templates
-                        .get(index)
-                        .cloned()
-                        .map(|(kind, task)| CodeActionsItem::Task(kind, task))
-                } else {
-                    actions.get(index - tasks.templates.len()).map(|available| {
-                        CodeActionsItem::CodeAction {
-                            excerpt_id: available.excerpt_id,
-                            action: available.action.clone(),
-                            provider: available.provider.clone(),
-                        }
-                    })
-                }
-            }
-            (Some(tasks), None) => tasks
-                .templates
-                .get(index)
-                .cloned()
-                .map(|(kind, task)| CodeActionsItem::Task(kind, task)),
-            (None, Some(actions)) => {
-                actions
-                    .get(index)
-                    .map(|available| CodeActionsItem::CodeAction {
-                        excerpt_id: available.excerpt_id,
-                        action: available.action.clone(),
-                        provider: available.provider.clone(),
-                    })
-            }
-            (None, None) => None,
-        }
-    }
-}
-
-#[allow(clippy::large_enum_variant)]
-#[derive(Clone)]
-enum CodeActionsItem {
-    Task(TaskSourceKind, ResolvedTask),
-    CodeAction {
-        excerpt_id: ExcerptId,
-        action: CodeAction,
-        provider: Arc<dyn CodeActionProvider>,
-    },
-}
-
-impl CodeActionsItem {
-    fn as_task(&self) -> Option<&ResolvedTask> {
-        let Self::Task(_, task) = self else {
-            return None;
-        };
-        Some(task)
-    }
-    fn as_code_action(&self) -> Option<&CodeAction> {
-        let Self::CodeAction { action, .. } = self else {
-            return None;
-        };
-        Some(action)
-    }
-    fn label(&self) -> String {
-        match self {
-            Self::CodeAction { action, .. } => action.lsp_action.title.clone(),
-            Self::Task(_, task) => task.resolved_label.clone(),
-        }
-    }
-}
-
-struct CodeActionsMenu {
-    actions: CodeActionContents,
-    buffer: Model<Buffer>,
-    selected_item: usize,
-    scroll_handle: UniformListScrollHandle,
-    deployed_from_indicator: Option<DisplayRow>,
-}
-
-impl CodeActionsMenu {
-    fn select_first(&mut self, cx: &mut ViewContext<Editor>) {
-        self.selected_item = 0;
-        self.scroll_handle
-            .scroll_to_item(self.selected_item, ScrollStrategy::Top);
-        cx.notify()
-    }
-
-    fn select_prev(&mut self, cx: &mut ViewContext<Editor>) {
-        if self.selected_item > 0 {
-            self.selected_item -= 1;
-        } else {
-            self.selected_item = self.actions.len() - 1;
-        }
-        self.scroll_handle
-            .scroll_to_item(self.selected_item, ScrollStrategy::Top);
-        cx.notify();
-    }
-
-    fn select_next(&mut self, cx: &mut ViewContext<Editor>) {
-        if self.selected_item + 1 < self.actions.len() {
-            self.selected_item += 1;
-        } else {
-            self.selected_item = 0;
-        }
-        self.scroll_handle
-            .scroll_to_item(self.selected_item, ScrollStrategy::Top);
-        cx.notify();
-    }
-
-    fn select_last(&mut self, cx: &mut ViewContext<Editor>) {
-        self.selected_item = self.actions.len() - 1;
-        self.scroll_handle
-            .scroll_to_item(self.selected_item, ScrollStrategy::Top);
-        cx.notify()
-    }
-
-    fn visible(&self) -> bool {
-        !self.actions.is_empty()
-    }
-
-    fn render(
-        &self,
-        cursor_position: DisplayPoint,
-        _style: &EditorStyle,
-        max_height: Pixels,
-        cx: &mut ViewContext<Editor>,
-    ) -> (ContextMenuOrigin, AnyElement) {
-        let actions = self.actions.clone();
-        let selected_item = self.selected_item;
-        let element = uniform_list(
-            cx.view().clone(),
-            "code_actions_menu",
-            self.actions.len(),
-            move |_this, range, cx| {
-                actions
-                    .iter()
-                    .skip(range.start)
-                    .take(range.end - range.start)
-                    .enumerate()
-                    .map(|(ix, action)| {
-                        let item_ix = range.start + ix;
-                        let selected = selected_item == item_ix;
-                        let colors = cx.theme().colors();
-                        div()
-                            .px_1()
-                            .rounded_md()
-                            .text_color(colors.text)
-                            .when(selected, |style| {
-                                style
-                                    .bg(colors.element_active)
-                                    .text_color(colors.text_accent)
-                            })
-                            .hover(|style| {
-                                style
-                                    .bg(colors.element_hover)
-                                    .text_color(colors.text_accent)
-                            })
-                            .whitespace_nowrap()
-                            .when_some(action.as_code_action(), |this, action| {
-                                this.on_mouse_down(
-                                    MouseButton::Left,
-                                    cx.listener(move |editor, _, cx| {
-                                        cx.stop_propagation();
-                                        if let Some(task) = editor.confirm_code_action(
-                                            &ConfirmCodeAction {
-                                                item_ix: Some(item_ix),
-                                            },
-                                            cx,
-                                        ) {
-                                            task.detach_and_log_err(cx)
-                                        }
-                                    }),
-                                )
-                                // TASK: It would be good to make lsp_action.title a SharedString to avoid allocating here.
-                                .child(SharedString::from(
-                                    action.lsp_action.title.replace("\n", ""),
-                                ))
-                            })
-                            .when_some(action.as_task(), |this, task| {
-                                this.on_mouse_down(
-                                    MouseButton::Left,
-                                    cx.listener(move |editor, _, cx| {
-                                        cx.stop_propagation();
-                                        if let Some(task) = editor.confirm_code_action(
-                                            &ConfirmCodeAction {
-                                                item_ix: Some(item_ix),
-                                            },
-                                            cx,
-                                        ) {
-                                            task.detach_and_log_err(cx)
-                                        }
-                                    }),
-                                )
-                                .child(SharedString::from(task.resolved_label.replace("\n", "")))
-                            })
-                    })
-                    .collect()
-            },
-        )
-        .elevation_1(cx)
-        .p_1()
-        .max_h(max_height)
-        .occlude()
-        .track_scroll(self.scroll_handle.clone())
-        .with_width_from_item(
-            self.actions
-                .iter()
-                .enumerate()
-                .max_by_key(|(_, action)| match action {
-                    CodeActionsItem::Task(_, task) => task.resolved_label.chars().count(),
-                    CodeActionsItem::CodeAction { action, .. } => {
-                        action.lsp_action.title.chars().count()
-                    }
-                })
-                .map(|(ix, _)| ix),
-        )
-        .with_sizing_behavior(ListSizingBehavior::Infer)
-        .into_any_element();
-
-        let cursor_position = if let Some(row) = self.deployed_from_indicator {
-            ContextMenuOrigin::GutterIndicator(row)
-        } else {
-            ContextMenuOrigin::EditorPoint(cursor_position)
-        };
-
-        (cursor_position, element)
-    }
-}
-
 #[derive(Debug)]
 struct ActiveDiagnosticGroup {
     primary_range: Range<Anchor>,
@@ -2210,11 +1356,11 @@ impl Editor {
         }
         if self.context_menu_visible() {
             match self.context_menu.read().as_ref() {
-                Some(ContextMenu::Completions(_)) => {
+                Some(CodeContextMenu::Completions(_)) => {
                     key_context.add("menu");
                     key_context.add("showing_completions")
                 }
-                Some(ContextMenu::CodeActions(_)) => {
+                Some(CodeContextMenu::CodeActions(_)) => {
                     key_context.add("menu");
                     key_context.add("showing_code_actions")
                 }
@@ -2688,7 +1834,7 @@ impl Editor {
             let new_cursor_position = self.selections.newest_anchor().head();
             let mut context_menu = self.context_menu.write();
             let completion_menu = match context_menu.as_ref() {
-                Some(ContextMenu::Completions(menu)) => Some(menu),
+                Some(CodeContextMenu::Completions(menu)) => Some(menu),
 
                 _ => {
                     *context_menu = None;
@@ -2714,7 +1860,8 @@ impl Editor {
 
                         this.update(&mut cx, |this, cx| {
                             let mut context_menu = this.context_menu.write();
-                            let Some(ContextMenu::Completions(menu)) = context_menu.as_ref() else {
+                            let Some(CodeContextMenu::Completions(menu)) = context_menu.as_ref()
+                            else {
                                 return;
                             };
 
@@ -2722,7 +1869,7 @@ impl Editor {
                                 return;
                             }
 
-                            *context_menu = Some(ContextMenu::Completions(completion_menu));
+                            *context_menu = Some(CodeContextMenu::Completions(completion_menu));
                             drop(context_menu);
                             cx.notify();
                         })

crates/editor/src/editor_tests.rs 🔗

@@ -9,8 +9,8 @@ use crate::{
 };
 use futures::StreamExt;
 use gpui::{
-    div, SemanticVersion, TestAppContext, UpdateGlobal, VisualTestContext, WindowBounds,
-    WindowOptions,
+    div, BackgroundExecutor, SemanticVersion, TestAppContext, UpdateGlobal, VisualTestContext,
+    WindowBounds, WindowOptions,
 };
 use indoc::indoc;
 use language::{
@@ -8443,7 +8443,7 @@ async fn test_completion_page_up_down_keys(cx: &mut gpui::TestAppContext) {
     cx.executor().run_until_parked();
 
     cx.update_editor(|editor, _| {
-        if let Some(ContextMenu::Completions(menu)) = editor.context_menu.read().as_ref() {
+        if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.read().as_ref() {
             assert_eq!(
                 menu.matches.iter().map(|m| &m.string).collect::<Vec<_>>(),
                 &["first", "last"]
@@ -8455,7 +8455,7 @@ async fn test_completion_page_up_down_keys(cx: &mut gpui::TestAppContext) {
 
     cx.update_editor(|editor, cx| {
         editor.move_page_down(&MovePageDown::default(), cx);
-        if let Some(ContextMenu::Completions(menu)) = editor.context_menu.read().as_ref() {
+        if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.read().as_ref() {
             assert!(
                 menu.selected_item == 1,
                 "expected PageDown to select the last item from the context menu"
@@ -8467,7 +8467,7 @@ async fn test_completion_page_up_down_keys(cx: &mut gpui::TestAppContext) {
 
     cx.update_editor(|editor, cx| {
         editor.move_page_up(&MovePageUp::default(), cx);
-        if let Some(ContextMenu::Completions(menu)) = editor.context_menu.read().as_ref() {
+        if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.read().as_ref() {
             assert!(
                 menu.selected_item == 0,
                 "expected PageUp to select the first item from the context menu"
@@ -8535,7 +8535,7 @@ async fn test_completion_sort(cx: &mut gpui::TestAppContext) {
     cx.executor().run_until_parked();
 
     cx.update_editor(|editor, _| {
-        if let Some(ContextMenu::Completions(menu)) = editor.context_menu.read().as_ref() {
+        if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.read().as_ref() {
             assert_eq!(
                 menu.matches.iter().map(|m| &m.string).collect::<Vec<_>>(),
                 &["r", "ret", "Range", "return"]
@@ -10666,12 +10666,12 @@ async fn test_completions_resolve_updates_labels(cx: &mut gpui::TestAppContext)
             .as_ref()
             .expect("Should have the context menu deployed");
         match context_menu {
-            ContextMenu::Completions(completions_menu) => {
+            CodeContextMenu::Completions(completions_menu) => {
                 let completions = completions_menu.completions.read();
                 assert_eq!(completions.len(), 1, "Should have one completion");
                 assert_eq!(completions.get(0).unwrap().label.text, "unresolved");
             }
-            ContextMenu::CodeActions(_) => panic!("Should show the completions menu"),
+            CodeContextMenu::CodeActions(_) => panic!("Should show the completions menu"),
         }
     });
 
@@ -10697,7 +10697,7 @@ async fn test_completions_resolve_updates_labels(cx: &mut gpui::TestAppContext)
             .as_ref()
             .expect("Should have the context menu deployed");
         match context_menu {
-            ContextMenu::Completions(completions_menu) => {
+            CodeContextMenu::Completions(completions_menu) => {
                 let completions = completions_menu.completions.read();
                 assert_eq!(completions.len(), 1, "Should have one completion");
                 assert_eq!(
@@ -10706,7 +10706,7 @@ async fn test_completions_resolve_updates_labels(cx: &mut gpui::TestAppContext)
                     "Should update the completion label after resolving"
                 );
             }
-            ContextMenu::CodeActions(_) => panic!("Should show the completions menu"),
+            CodeContextMenu::CodeActions(_) => panic!("Should show the completions menu"),
         }
     });
 }
@@ -10885,7 +10885,7 @@ async fn test_completions_default_resolve_data_handling(cx: &mut gpui::TestAppCo
     cx.update_editor(|editor, _| {
         let menu = editor.context_menu.read();
         match menu.as_ref().expect("should have the completions menu") {
-            ContextMenu::Completions(completions_menu) => {
+            CodeContextMenu::Completions(completions_menu) => {
                 assert_eq!(
                     completions_menu
                         .matches
@@ -10895,7 +10895,7 @@ async fn test_completions_default_resolve_data_handling(cx: &mut gpui::TestAppCo
                     vec!["Some(2)", "vec![2]"]
                 );
             }
-            ContextMenu::CodeActions(_) => panic!("Expected to have the completions menu"),
+            CodeContextMenu::CodeActions(_) => panic!("Expected to have the completions menu"),
         }
     });
     assert_eq!(
@@ -10988,7 +10988,7 @@ async fn test_completions_in_languages_with_extra_word_characters(cx: &mut gpui:
     cx.simulate_keystroke("-");
     cx.executor().run_until_parked();
     cx.update_editor(|editor, _| {
-        if let Some(ContextMenu::Completions(menu)) = editor.context_menu.read().as_ref() {
+        if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.read().as_ref() {
             assert_eq!(
                 menu.matches.iter().map(|m| &m.string).collect::<Vec<_>>(),
                 &["bg-red", "bg-blue", "bg-yellow"]
@@ -11001,7 +11001,7 @@ async fn test_completions_in_languages_with_extra_word_characters(cx: &mut gpui:
     cx.simulate_keystroke("l");
     cx.executor().run_until_parked();
     cx.update_editor(|editor, _| {
-        if let Some(ContextMenu::Completions(menu)) = editor.context_menu.read().as_ref() {
+        if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.read().as_ref() {
             assert_eq!(
                 menu.matches.iter().map(|m| &m.string).collect::<Vec<_>>(),
                 &["bg-blue", "bg-yellow"]
@@ -11017,7 +11017,7 @@ async fn test_completions_in_languages_with_extra_word_characters(cx: &mut gpui:
     cx.simulate_keystroke("l");
     cx.executor().run_until_parked();
     cx.update_editor(|editor, _| {
-        if let Some(ContextMenu::Completions(menu)) = editor.context_menu.read().as_ref() {
+        if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.read().as_ref() {
             assert_eq!(
                 menu.matches.iter().map(|m| &m.string).collect::<Vec<_>>(),
                 &["bg-yellow"]

crates/editor/src/element.rs 🔗

@@ -1,5 +1,6 @@
 use crate::{
     blame_entry_tooltip::{blame_entry_relative_timestamp, BlameEntryTooltip},
+    code_context_menus::CodeActionsMenu,
     display_map::{
         Block, BlockContext, BlockStyle, DisplaySnapshot, HighlightedChunk, ToDisplayPoint,
     },
@@ -16,8 +17,8 @@ use crate::{
     items::BufferSearchHighlights,
     mouse_context_menu::{self, MenuPosition, MouseContextMenu},
     scroll::scroll_amount::ScrollAmount,
-    BlockId, ChunkReplacement, CodeActionsMenu, CursorShape, CustomBlockId, DisplayPoint,
-    DisplayRow, DocumentHighlightRead, DocumentHighlightWrite, Editor, EditorMode, EditorSettings,
+    BlockId, ChunkReplacement, CursorShape, CustomBlockId, DisplayPoint, DisplayRow,
+    DocumentHighlightRead, DocumentHighlightWrite, Editor, EditorMode, EditorSettings,
     EditorSnapshot, EditorStyle, ExpandExcerpts, FocusedBlock, GutterDimensions, HalfPageDown,
     HalfPageUp, HandleInput, HoveredCursor, HoveredHunk, InlineCompletion, JumpData, LineDown,
     LineUp, OpenExcerpts, PageDown, PageUp, Point, RowExt, RowRangeExt, SelectPhase, Selection,
@@ -1680,7 +1681,7 @@ impl EditorElement {
     ) -> Vec<AnyElement> {
         self.editor.update(cx, |editor, cx| {
             let active_task_indicator_row =
-                if let Some(crate::ContextMenu::CodeActions(CodeActionsMenu {
+                if let Some(crate::CodeContextMenu::CodeActions(CodeActionsMenu {
                     deployed_from_indicator,
                     actions,
                     ..
@@ -1755,7 +1756,7 @@ impl EditorElement {
         let mut button = None;
         let row = newest_selection_head.row();
         self.editor.update(cx, |editor, cx| {
-            if let Some(crate::ContextMenu::CodeActions(CodeActionsMenu {
+            if let Some(crate::CodeContextMenu::CodeActions(CodeActionsMenu {
                 deployed_from_indicator,
                 ..
             })) = editor.context_menu.read().as_ref()