message_editor.rs

  1use anyhow::Result;
  2use channel::{ChannelId, ChannelMembership, ChannelStore, MessageParams};
  3use client::UserId;
  4use collections::HashMap;
  5use editor::{AnchorRangeExt, CompletionProvider, Editor, EditorElement, EditorStyle};
  6use fuzzy::StringMatchCandidate;
  7use gpui::{
  8    AsyncWindowContext, FocusableView, FontStyle, FontWeight, HighlightStyle, IntoElement, Model,
  9    Render, SharedString, Task, TextStyle, View, ViewContext, WeakView, WhiteSpace,
 10};
 11use language::{
 12    language_settings::SoftWrap, Anchor, Buffer, BufferSnapshot, CodeLabel, Completion,
 13    LanguageRegistry, LanguageServerId, ToOffset,
 14};
 15use lazy_static::lazy_static;
 16use parking_lot::RwLock;
 17use project::search::SearchQuery;
 18use settings::Settings;
 19use std::{sync::Arc, time::Duration};
 20use theme::ThemeSettings;
 21use ui::prelude::*;
 22
 23const MENTIONS_DEBOUNCE_INTERVAL: Duration = Duration::from_millis(50);
 24
 25lazy_static! {
 26    static ref MENTIONS_SEARCH: SearchQuery =
 27        SearchQuery::regex("@[-_\\w]+", false, false, false, Vec::new(), Vec::new()).unwrap();
 28}
 29
 30pub struct MessageEditor {
 31    pub editor: View<Editor>,
 32    channel_store: Model<ChannelStore>,
 33    users: HashMap<String, UserId>,
 34    mentions: Vec<UserId>,
 35    mentions_task: Option<Task<()>>,
 36    channel_id: Option<ChannelId>,
 37}
 38
 39struct MessageEditorCompletionProvider(WeakView<MessageEditor>);
 40
 41impl CompletionProvider for MessageEditorCompletionProvider {
 42    fn completions(
 43        &self,
 44        buffer: &Model<Buffer>,
 45        buffer_position: language::Anchor,
 46        cx: &mut ViewContext<Editor>,
 47    ) -> Task<anyhow::Result<Vec<language::Completion>>> {
 48        let Some(handle) = self.0.upgrade() else {
 49            return Task::ready(Ok(Vec::new()));
 50        };
 51        handle.update(cx, |message_editor, cx| {
 52            message_editor.completions(buffer, buffer_position, cx)
 53        })
 54    }
 55
 56    fn resolve_completions(
 57        &self,
 58        _completion_indices: Vec<usize>,
 59        _completions: Arc<RwLock<Box<[language::Completion]>>>,
 60        _cx: &mut ViewContext<Editor>,
 61    ) -> Task<anyhow::Result<bool>> {
 62        Task::ready(Ok(false))
 63    }
 64}
 65
 66impl MessageEditor {
 67    pub fn new(
 68        language_registry: Arc<LanguageRegistry>,
 69        channel_store: Model<ChannelStore>,
 70        editor: View<Editor>,
 71        cx: &mut ViewContext<Self>,
 72    ) -> Self {
 73        let this = cx.view().downgrade();
 74        editor.update(cx, |editor, cx| {
 75            editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
 76            editor.set_completion_provider(Box::new(MessageEditorCompletionProvider(this)));
 77        });
 78
 79        let buffer = editor
 80            .read(cx)
 81            .buffer()
 82            .read(cx)
 83            .as_singleton()
 84            .expect("message editor must be singleton");
 85
 86        cx.subscribe(&buffer, Self::on_buffer_event).detach();
 87
 88        let markdown = language_registry.language_for_name("Markdown");
 89        cx.spawn(|_, mut cx| async move {
 90            let markdown = markdown.await?;
 91            buffer.update(&mut cx, |buffer, cx| {
 92                buffer.set_language(Some(markdown), cx)
 93            })
 94        })
 95        .detach_and_log_err(cx);
 96
 97        Self {
 98            editor,
 99            channel_store,
100            users: HashMap::default(),
101            channel_id: None,
102            mentions: Vec::new(),
103            mentions_task: None,
104        }
105    }
106
107    pub fn set_channel(
108        &mut self,
109        channel_id: u64,
110        channel_name: Option<SharedString>,
111        cx: &mut ViewContext<Self>,
112    ) {
113        self.editor.update(cx, |editor, cx| {
114            if let Some(channel_name) = channel_name {
115                editor.set_placeholder_text(format!("Message #{}", channel_name), cx);
116            } else {
117                editor.set_placeholder_text(format!("Message Channel"), cx);
118            }
119        });
120        self.channel_id = Some(channel_id);
121        self.refresh_users(cx);
122    }
123
124    pub fn refresh_users(&mut self, cx: &mut ViewContext<Self>) {
125        if let Some(channel_id) = self.channel_id {
126            let members = self.channel_store.update(cx, |store, cx| {
127                store.get_channel_member_details(channel_id, cx)
128            });
129            cx.spawn(|this, mut cx| async move {
130                let members = members.await?;
131                this.update(&mut cx, |this, cx| this.set_members(members, cx))?;
132                anyhow::Ok(())
133            })
134            .detach_and_log_err(cx);
135        }
136    }
137
138    pub fn set_members(&mut self, members: Vec<ChannelMembership>, _: &mut ViewContext<Self>) {
139        self.users.clear();
140        self.users.extend(
141            members
142                .into_iter()
143                .map(|member| (member.user.github_login.clone(), member.user.id)),
144        );
145    }
146
147    pub fn take_message(&mut self, cx: &mut ViewContext<Self>) -> MessageParams {
148        self.editor.update(cx, |editor, cx| {
149            let highlights = editor.text_highlights::<Self>(cx);
150            let text = editor.text(cx);
151            let snapshot = editor.buffer().read(cx).snapshot(cx);
152            let mentions = if let Some((_, ranges)) = highlights {
153                ranges
154                    .iter()
155                    .map(|range| range.to_offset(&snapshot))
156                    .zip(self.mentions.iter().copied())
157                    .collect()
158            } else {
159                Vec::new()
160            };
161
162            editor.clear(cx);
163            self.mentions.clear();
164
165            MessageParams { text, mentions }
166        })
167    }
168
169    fn on_buffer_event(
170        &mut self,
171        buffer: Model<Buffer>,
172        event: &language::Event,
173        cx: &mut ViewContext<Self>,
174    ) {
175        if let language::Event::Reparsed | language::Event::Edited = event {
176            let buffer = buffer.read(cx).snapshot();
177            self.mentions_task = Some(cx.spawn(|this, cx| async move {
178                cx.background_executor()
179                    .timer(MENTIONS_DEBOUNCE_INTERVAL)
180                    .await;
181                Self::find_mentions(this, buffer, cx).await;
182            }));
183        }
184    }
185
186    fn completions(
187        &mut self,
188        buffer: &Model<Buffer>,
189        end_anchor: Anchor,
190        cx: &mut ViewContext<Self>,
191    ) -> Task<Result<Vec<Completion>>> {
192        let end_offset = end_anchor.to_offset(buffer.read(cx));
193
194        let Some(query) = buffer.update(cx, |buffer, _| {
195            let mut query = String::new();
196            for ch in buffer.reversed_chars_at(end_offset).take(100) {
197                if ch == '@' {
198                    return Some(query.chars().rev().collect::<String>());
199                }
200                if ch.is_whitespace() || !ch.is_ascii() {
201                    break;
202                }
203                query.push(ch);
204            }
205            return None;
206        }) else {
207            return Task::ready(Ok(vec![]));
208        };
209
210        let start_offset = end_offset - query.len();
211        let start_anchor = buffer.read(cx).anchor_before(start_offset);
212
213        let candidates = self
214            .users
215            .keys()
216            .map(|user| StringMatchCandidate {
217                id: 0,
218                string: user.clone(),
219                char_bag: user.chars().collect(),
220            })
221            .collect::<Vec<_>>();
222        cx.spawn(|_, cx| async move {
223            let matches = fuzzy::match_strings(
224                &candidates,
225                &query,
226                true,
227                10,
228                &Default::default(),
229                cx.background_executor().clone(),
230            )
231            .await;
232
233            Ok(matches
234                .into_iter()
235                .map(|mat| Completion {
236                    old_range: start_anchor..end_anchor,
237                    new_text: mat.string.clone(),
238                    label: CodeLabel {
239                        filter_range: 1..mat.string.len() + 1,
240                        text: format!("@{}", mat.string),
241                        runs: Vec::new(),
242                    },
243                    documentation: None,
244                    server_id: LanguageServerId(0), // TODO: Make this optional or something?
245                    lsp_completion: Default::default(), // TODO: Make this optional or something?
246                })
247                .collect())
248        })
249    }
250
251    async fn find_mentions(
252        this: WeakView<MessageEditor>,
253        buffer: BufferSnapshot,
254        mut cx: AsyncWindowContext,
255    ) {
256        let (buffer, ranges) = cx
257            .background_executor()
258            .spawn(async move {
259                let ranges = MENTIONS_SEARCH.search(&buffer, None).await;
260                (buffer, ranges)
261            })
262            .await;
263
264        this.update(&mut cx, |this, cx| {
265            let mut anchor_ranges = Vec::new();
266            let mut mentioned_user_ids = Vec::new();
267            let mut text = String::new();
268
269            this.editor.update(cx, |editor, cx| {
270                let multi_buffer = editor.buffer().read(cx).snapshot(cx);
271                for range in ranges {
272                    text.clear();
273                    text.extend(buffer.text_for_range(range.clone()));
274                    if let Some(username) = text.strip_prefix("@") {
275                        if let Some(user_id) = this.users.get(username) {
276                            let start = multi_buffer.anchor_after(range.start);
277                            let end = multi_buffer.anchor_after(range.end);
278
279                            mentioned_user_ids.push(*user_id);
280                            anchor_ranges.push(start..end);
281                        }
282                    }
283                }
284
285                editor.clear_highlights::<Self>(cx);
286                editor.highlight_text::<Self>(
287                    anchor_ranges,
288                    HighlightStyle {
289                        font_weight: Some(FontWeight::BOLD),
290                        ..Default::default()
291                    },
292                    cx,
293                )
294            });
295
296            this.mentions = mentioned_user_ids;
297            this.mentions_task.take();
298        })
299        .ok();
300    }
301
302    pub(crate) fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle {
303        self.editor.read(cx).focus_handle(cx)
304    }
305}
306
307impl Render for MessageEditor {
308    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
309        let settings = ThemeSettings::get_global(cx);
310        let text_style = TextStyle {
311            color: if self.editor.read(cx).read_only(cx) {
312                cx.theme().colors().text_disabled
313            } else {
314                cx.theme().colors().text
315            },
316            font_family: settings.ui_font.family.clone(),
317            font_features: settings.ui_font.features,
318            font_size: rems(0.875).into(),
319            font_weight: FontWeight::NORMAL,
320            font_style: FontStyle::Normal,
321            line_height: relative(1.3).into(),
322            background_color: None,
323            underline: None,
324            white_space: WhiteSpace::Normal,
325        };
326
327        div()
328            .w_full()
329            .px_2()
330            .py_1()
331            .bg(cx.theme().colors().editor_background)
332            .rounded_md()
333            .child(EditorElement::new(
334                &self.editor,
335                EditorStyle {
336                    local_player: cx.theme().players().local(),
337                    text: text_style,
338                    ..Default::default()
339                },
340            ))
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use client::{Client, User, UserStore};
348    use gpui::TestAppContext;
349    use language::{Language, LanguageConfig};
350    use rpc::proto;
351    use settings::SettingsStore;
352    use util::{http::FakeHttpClient, test::marked_text_ranges};
353
354    #[gpui::test]
355    async fn test_message_editor(cx: &mut TestAppContext) {
356        let language_registry = init_test(cx);
357
358        let (editor, cx) = cx.add_window_view(|cx| {
359            MessageEditor::new(
360                language_registry,
361                ChannelStore::global(cx),
362                cx.new_view(|cx| Editor::auto_height(4, cx)),
363                cx,
364            )
365        });
366        cx.executor().run_until_parked();
367
368        editor.update(cx, |editor, cx| {
369            editor.set_members(
370                vec![
371                    ChannelMembership {
372                        user: Arc::new(User {
373                            github_login: "a-b".into(),
374                            id: 101,
375                            avatar_uri: "avatar_a-b".into(),
376                        }),
377                        kind: proto::channel_member::Kind::Member,
378                        role: proto::ChannelRole::Member,
379                    },
380                    ChannelMembership {
381                        user: Arc::new(User {
382                            github_login: "C_D".into(),
383                            id: 102,
384                            avatar_uri: "avatar_C_D".into(),
385                        }),
386                        kind: proto::channel_member::Kind::Member,
387                        role: proto::ChannelRole::Member,
388                    },
389                ],
390                cx,
391            );
392
393            editor.editor.update(cx, |editor, cx| {
394                editor.set_text("Hello, @a-b! Have you met @C_D?", cx)
395            });
396        });
397
398        cx.executor().advance_clock(MENTIONS_DEBOUNCE_INTERVAL);
399
400        editor.update(cx, |editor, cx| {
401            let (text, ranges) = marked_text_ranges("Hello, «@a-b»! Have you met «@C_D»?", false);
402            assert_eq!(
403                editor.take_message(cx),
404                MessageParams {
405                    text,
406                    mentions: vec![(ranges[0].clone(), 101), (ranges[1].clone(), 102)],
407                }
408            );
409        });
410    }
411
412    fn init_test(cx: &mut TestAppContext) -> Arc<LanguageRegistry> {
413        cx.update(|cx| {
414            let settings = SettingsStore::test(cx);
415            cx.set_global(settings);
416
417            let http = FakeHttpClient::with_404_response();
418            let client = Client::new(http.clone(), cx);
419            let user_store = cx.new_model(|cx| UserStore::new(client.clone(), cx));
420            theme::init(theme::LoadThemes::JustBase, cx);
421            language::init(cx);
422            editor::init(cx);
423            client::init(&client, cx);
424            channel::init(&client, user_store, cx);
425        });
426
427        let language_registry = Arc::new(LanguageRegistry::test());
428        language_registry.add(Arc::new(Language::new(
429            LanguageConfig {
430                name: "Markdown".into(),
431                ..Default::default()
432            },
433            Some(tree_sitter_markdown::language()),
434        )));
435        language_registry
436    }
437}