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