Start work on autocomplete for chat mentions

Max Brunsfeld , Conrad , Nathan , and Marshall created

Co-authored-by: Conrad <conrad@zed.dev>
Co-authored-by: Nathan <nathan@zed.dev>
Co-authored-by: Marshall <marshall@zed.dev>

Change summary

Cargo.lock                                        |   1 
crates/collab_ui/Cargo.toml                       |   1 
crates/collab_ui/src/chat_panel/message_editor.rs | 110 ++++++++++++++++
crates/editor/src/editor.rs                       |  67 ++++++++--
crates/editor/src/element.rs                      |   6 
crates/language/src/language.rs                   |   2 
6 files changed, 165 insertions(+), 22 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -1548,6 +1548,7 @@ dependencies = [
  "log",
  "menu",
  "notifications",
+ "parking_lot 0.11.2",
  "picker",
  "postage",
  "pretty_assertions",

crates/collab_ui/Cargo.toml 🔗

@@ -60,6 +60,7 @@ anyhow.workspace = true
 futures.workspace = true
 lazy_static.workspace = true
 log.workspace = true
+parking_lot.workspace = true
 schemars.workspace = true
 postage.workspace = true
 serde.workspace = true

crates/collab_ui/src/chat_panel/message_editor.rs 🔗

@@ -1,17 +1,22 @@
-use std::{sync::Arc, time::Duration};
-
+use anyhow::Result;
 use channel::{ChannelId, ChannelMembership, ChannelStore, MessageParams};
 use client::UserId;
 use collections::HashMap;
-use editor::{AnchorRangeExt, Editor, EditorElement, EditorStyle};
+use editor::{AnchorRangeExt, CompletionProvider, Editor, EditorElement, EditorStyle};
+use fuzzy::StringMatchCandidate;
 use gpui::{
     AsyncWindowContext, FocusableView, FontStyle, FontWeight, HighlightStyle, IntoElement, Model,
     Render, SharedString, Task, TextStyle, View, ViewContext, WeakView, WhiteSpace,
 };
-use language::{language_settings::SoftWrap, Buffer, BufferSnapshot, LanguageRegistry};
+use language::{
+    language_settings::SoftWrap, Anchor, Buffer, BufferSnapshot, CodeLabel, Completion,
+    LanguageRegistry, LanguageServerId, ToOffset,
+};
 use lazy_static::lazy_static;
+use parking_lot::RwLock;
 use project::search::SearchQuery;
 use settings::Settings;
+use std::{sync::Arc, time::Duration};
 use theme::ThemeSettings;
 use ui::prelude::*;
 
@@ -31,6 +36,33 @@ pub struct MessageEditor {
     channel_id: Option<ChannelId>,
 }
 
+struct MessageEditorCompletionProvider(WeakView<MessageEditor>);
+
+impl CompletionProvider for MessageEditorCompletionProvider {
+    fn completions(
+        &self,
+        buffer: &Model<Buffer>,
+        buffer_position: language::Anchor,
+        cx: &mut ViewContext<Editor>,
+    ) -> Task<anyhow::Result<Vec<language::Completion>>> {
+        let Some(handle) = self.0.upgrade() else {
+            return Task::ready(Ok(Vec::new()));
+        };
+        handle.update(cx, |message_editor, cx| {
+            message_editor.completions(buffer, buffer_position, cx)
+        })
+    }
+
+    fn resolve_completions(
+        &self,
+        _completion_indices: Vec<usize>,
+        _completions: Arc<RwLock<Box<[language::Completion]>>>,
+        _cx: &mut ViewContext<Editor>,
+    ) -> Task<anyhow::Result<bool>> {
+        Task::ready(Ok(false))
+    }
+}
+
 impl MessageEditor {
     pub fn new(
         language_registry: Arc<LanguageRegistry>,
@@ -38,8 +70,10 @@ impl MessageEditor {
         editor: View<Editor>,
         cx: &mut ViewContext<Self>,
     ) -> Self {
+        let this = cx.view().downgrade();
         editor.update(cx, |editor, cx| {
             editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
+            editor.set_completion_provider(Box::new(MessageEditorCompletionProvider(this)));
         });
 
         let buffer = editor
@@ -149,6 +183,71 @@ impl MessageEditor {
         }
     }
 
+    fn completions(
+        &mut self,
+        buffer: &Model<Buffer>,
+        end_anchor: Anchor,
+        cx: &mut ViewContext<Self>,
+    ) -> Task<Result<Vec<Completion>>> {
+        let end_offset = end_anchor.to_offset(buffer.read(cx));
+
+        let Some(query) = buffer.update(cx, |buffer, _| {
+            let mut query = String::new();
+            for ch in buffer.reversed_chars_at(end_offset).take(100) {
+                if ch == '@' {
+                    return Some(query.chars().rev().collect::<String>());
+                }
+                if ch.is_whitespace() || !ch.is_ascii() {
+                    break;
+                }
+                query.push(ch);
+            }
+            return None;
+        }) else {
+            return Task::ready(Ok(vec![]));
+        };
+
+        let start_offset = end_offset - query.len();
+        let start_anchor = buffer.read(cx).anchor_before(start_offset);
+
+        let candidates = self
+            .users
+            .keys()
+            .map(|user| StringMatchCandidate {
+                id: 0,
+                string: user.clone(),
+                char_bag: user.chars().collect(),
+            })
+            .collect::<Vec<_>>();
+        cx.spawn(|_, cx| async move {
+            let matches = fuzzy::match_strings(
+                &candidates,
+                &query,
+                true,
+                10,
+                &Default::default(),
+                cx.background_executor().clone(),
+            )
+            .await;
+
+            Ok(matches
+                .into_iter()
+                .map(|mat| Completion {
+                    old_range: start_anchor..end_anchor,
+                    new_text: mat.string.clone(),
+                    label: CodeLabel {
+                        filter_range: 1..mat.string.len() + 1,
+                        text: format!("@{}", mat.string),
+                        runs: Vec::new(),
+                    },
+                    server_id: LanguageServerId(0), // TODO: Make this optional or something?
+                    documentation: None,
+                    lsp_completion: Default::default(), // TODO: Make this optional or something?
+                })
+                .collect())
+        })
+    }
+
     async fn find_mentions(
         this: WeakView<MessageEditor>,
         buffer: BufferSnapshot,
@@ -227,6 +326,7 @@ impl Render for MessageEditor {
 
         div()
             .w_full()
+            .h(px(500.))
             .px_2()
             .py_1()
             .bg(cx.theme().colors().editor_background)
@@ -260,7 +360,7 @@ mod tests {
             MessageEditor::new(
                 language_registry,
                 ChannelStore::global(cx),
-                cx.new_view(|cx| Editor::auto_height(4, cx)),
+                cx.new_view(|cx| Editor::auto_height(25, cx)),
                 cx,
             )
         });

crates/editor/src/editor.rs 🔗

@@ -364,6 +364,7 @@ pub struct Editor {
     active_diagnostics: Option<ActiveDiagnosticGroup>,
     soft_wrap_mode_override: Option<language_settings::SoftWrap>,
     project: Option<Model<Project>>,
+    completion_provider: Option<Box<dyn CompletionProvider>>,
     collaboration_hub: Option<Box<dyn CollaborationHub>>,
     blink_manager: Model<BlinkManager>,
     show_cursor_names: bool,
@@ -730,17 +731,15 @@ impl CompletionsMenu {
             return None;
         }
 
-        let Some(project) = editor.project.clone() else {
+        let Some(provider) = editor.completion_provider.as_ref() else {
             return None;
         };
 
-        let resolve_task = project.update(cx, |project, cx| {
-            project.resolve_completions(
-                self.matches.iter().map(|m| m.candidate_id).collect(),
-                self.completions.clone(),
-                cx,
-            )
-        });
+        let resolve_task = provider.resolve_completions(
+            self.matches.iter().map(|m| m.candidate_id).collect(),
+            self.completions.clone(),
+            cx,
+        );
 
         return Some(cx.spawn(move |this, mut cx| async move {
             if let Some(true) = resolve_task.await.log_err() {
@@ -1381,6 +1380,7 @@ impl Editor {
             ime_transaction: Default::default(),
             active_diagnostics: None,
             soft_wrap_mode_override,
+            completion_provider: project.clone().map(|project| Box::new(project) as _),
             collaboration_hub: project.clone().map(|project| Box::new(project) as _),
             project,
             blink_manager: blink_manager.clone(),
@@ -1613,6 +1613,10 @@ impl Editor {
         self.collaboration_hub = Some(hub);
     }
 
+    pub fn set_completion_provider(&mut self, hub: Box<dyn CompletionProvider>) {
+        self.completion_provider = Some(hub);
+    }
+
     pub fn placeholder_text(&self) -> Option<&str> {
         self.placeholder_text.as_deref()
     }
@@ -3059,9 +3063,7 @@ impl Editor {
             return;
         }
 
-        let project = if let Some(project) = self.project.clone() {
-            project
-        } else {
+        let Some(provider) = self.completion_provider.as_ref() else {
             return;
         };
 
@@ -3077,9 +3079,7 @@ impl Editor {
         };
 
         let query = Self::completion_query(&self.buffer.read(cx).read(cx), position.clone());
-        let completions = project.update(cx, |project, cx| {
-            project.completions(&buffer, buffer_position, cx)
-        });
+        let completions = provider.completions(&buffer, buffer_position, cx);
 
         let id = post_inc(&mut self.next_completion_id);
         let task = cx.spawn(|this, mut cx| {
@@ -8904,6 +8904,45 @@ impl CollaborationHub for Model<Project> {
     }
 }
 
+pub trait CompletionProvider {
+    fn completions(
+        &self,
+        buffer: &Model<Buffer>,
+        buffer_position: text::Anchor,
+        cx: &mut ViewContext<Editor>,
+    ) -> Task<Result<Vec<Completion>>>;
+    fn resolve_completions(
+        &self,
+        completion_indices: Vec<usize>,
+        completions: Arc<RwLock<Box<[Completion]>>>,
+        cx: &mut ViewContext<Editor>,
+    ) -> Task<Result<bool>>;
+}
+
+impl CompletionProvider for Model<Project> {
+    fn completions(
+        &self,
+        buffer: &Model<Buffer>,
+        buffer_position: text::Anchor,
+        cx: &mut ViewContext<Editor>,
+    ) -> Task<Result<Vec<Completion>>> {
+        self.update(cx, |project, cx| {
+            project.completions(&buffer, buffer_position, cx)
+        })
+    }
+
+    fn resolve_completions(
+        &self,
+        completion_indices: Vec<usize>,
+        completions: Arc<RwLock<Box<[Completion]>>>,
+        cx: &mut ViewContext<Editor>,
+    ) -> Task<Result<bool>> {
+        self.update(cx, |project, cx| {
+            project.resolve_completions(completion_indices, completions, cx)
+        })
+    }
+}
+
 fn inlay_hint_settings(
     location: Anchor,
     snapshot: &MultiBufferSnapshot,

crates/editor/src/element.rs 🔗

@@ -1177,9 +1177,9 @@ impl EditorElement {
                 list_origin.x = (cx.viewport_size().width - list_width).max(Pixels::ZERO);
             }
 
-            if list_origin.y + list_height > text_bounds.lower_right().y {
-                list_origin.y -= layout.position_map.line_height + list_height;
-            }
+            // if list_origin.y + list_height > text_bounds.lower_right().y {
+            //     list_origin.y -= layout.position_map.line_height + list_height;
+            // }
 
             cx.break_content_mask(|cx| context_menu.draw(list_origin, available_space, cx));
         }

crates/language/src/language.rs 🔗

@@ -380,7 +380,9 @@ pub trait LspAdapter: 'static + Send + Sync {
 #[derive(Clone, Debug, PartialEq, Eq)]
 pub struct CodeLabel {
     pub text: String,
+    /// Determines the syntax highlighting for the label
     pub runs: Vec<(Range<usize>, HighlightId)>,
+    /// Which part of the label participates
     pub filter_range: Range<usize>,
 }