message_editor.rs

  1use anyhow::Result;
  2use channel::{ChannelId, ChannelMembership, ChannelStore, MessageParams};
  3use client::UserId;
  4use collections::{HashMap, HashSet};
  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    channel_members: 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            channel_members: 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.channel_members.clear();
151        self.channel_members.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 mut names = HashSet::default();
225        for (github_login, _) in self.channel_members.iter() {
226            names.insert(github_login.clone());
227        }
228        if let Some(channel_id) = self.channel_id {
229            for participant in self.channel_store.read(cx).channel_participants(channel_id) {
230                names.insert(participant.github_login.clone());
231            }
232        }
233
234        let candidates = names
235            .into_iter()
236            .map(|user| StringMatchCandidate {
237                id: 0,
238                string: user.clone(),
239                char_bag: user.chars().collect(),
240            })
241            .collect::<Vec<_>>();
242        cx.spawn(|_, cx| async move {
243            let matches = fuzzy::match_strings(
244                &candidates,
245                &query,
246                true,
247                10,
248                &Default::default(),
249                cx.background_executor().clone(),
250            )
251            .await;
252
253            Ok(matches
254                .into_iter()
255                .map(|mat| Completion {
256                    old_range: start_anchor..end_anchor,
257                    new_text: mat.string.clone(),
258                    label: CodeLabel {
259                        filter_range: 1..mat.string.len() + 1,
260                        text: format!("@{}", mat.string),
261                        runs: Vec::new(),
262                    },
263                    documentation: None,
264                    server_id: LanguageServerId(0), // TODO: Make this optional or something?
265                    lsp_completion: Default::default(), // TODO: Make this optional or something?
266                })
267                .collect())
268        })
269    }
270
271    async fn find_mentions(
272        this: WeakView<MessageEditor>,
273        buffer: BufferSnapshot,
274        mut cx: AsyncWindowContext,
275    ) {
276        let (buffer, ranges) = cx
277            .background_executor()
278            .spawn(async move {
279                let ranges = MENTIONS_SEARCH.search(&buffer, None).await;
280                (buffer, ranges)
281            })
282            .await;
283
284        this.update(&mut cx, |this, cx| {
285            let mut anchor_ranges = Vec::new();
286            let mut mentioned_user_ids = Vec::new();
287            let mut text = String::new();
288
289            this.editor.update(cx, |editor, cx| {
290                let multi_buffer = editor.buffer().read(cx).snapshot(cx);
291                for range in ranges {
292                    text.clear();
293                    text.extend(buffer.text_for_range(range.clone()));
294                    if let Some(username) = text.strip_prefix("@") {
295                        if let Some(user_id) = this.channel_members.get(username) {
296                            let start = multi_buffer.anchor_after(range.start);
297                            let end = multi_buffer.anchor_after(range.end);
298
299                            mentioned_user_ids.push(*user_id);
300                            anchor_ranges.push(start..end);
301                        }
302                    }
303                }
304
305                editor.clear_highlights::<Self>(cx);
306                editor.highlight_text::<Self>(
307                    anchor_ranges,
308                    HighlightStyle {
309                        font_weight: Some(FontWeight::BOLD),
310                        ..Default::default()
311                    },
312                    cx,
313                )
314            });
315
316            this.mentions = mentioned_user_ids;
317            this.mentions_task.take();
318        })
319        .ok();
320    }
321
322    pub(crate) fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle {
323        self.editor.read(cx).focus_handle(cx)
324    }
325}
326
327impl Render for MessageEditor {
328    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
329        let settings = ThemeSettings::get_global(cx);
330        let text_style = TextStyle {
331            color: if self.editor.read(cx).read_only(cx) {
332                cx.theme().colors().text_disabled
333            } else {
334                cx.theme().colors().text
335            },
336            font_family: settings.ui_font.family.clone(),
337            font_features: settings.ui_font.features,
338            font_size: UiTextSize::Small.rems().into(),
339            font_weight: FontWeight::NORMAL,
340            font_style: FontStyle::Normal,
341            line_height: relative(1.3).into(),
342            background_color: None,
343            underline: None,
344            white_space: WhiteSpace::Normal,
345        };
346
347        div()
348            .w_full()
349            .px_2()
350            .py_1()
351            .bg(cx.theme().colors().editor_background)
352            .rounded_md()
353            .child(EditorElement::new(
354                &self.editor,
355                EditorStyle {
356                    local_player: cx.theme().players().local(),
357                    text: text_style,
358                    ..Default::default()
359                },
360            ))
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367    use client::{Client, User, UserStore};
368    use gpui::TestAppContext;
369    use language::{Language, LanguageConfig};
370    use rpc::proto;
371    use settings::SettingsStore;
372    use util::{http::FakeHttpClient, test::marked_text_ranges};
373
374    #[gpui::test]
375    async fn test_message_editor(cx: &mut TestAppContext) {
376        let language_registry = init_test(cx);
377
378        let (editor, cx) = cx.add_window_view(|cx| {
379            MessageEditor::new(
380                language_registry,
381                ChannelStore::global(cx),
382                cx.new_view(|cx| Editor::auto_height(4, cx)),
383                cx,
384            )
385        });
386        cx.executor().run_until_parked();
387
388        editor.update(cx, |editor, cx| {
389            editor.set_members(
390                vec![
391                    ChannelMembership {
392                        user: Arc::new(User {
393                            github_login: "a-b".into(),
394                            id: 101,
395                            avatar_uri: "avatar_a-b".into(),
396                        }),
397                        kind: proto::channel_member::Kind::Member,
398                        role: proto::ChannelRole::Member,
399                    },
400                    ChannelMembership {
401                        user: Arc::new(User {
402                            github_login: "C_D".into(),
403                            id: 102,
404                            avatar_uri: "avatar_C_D".into(),
405                        }),
406                        kind: proto::channel_member::Kind::Member,
407                        role: proto::ChannelRole::Member,
408                    },
409                ],
410                cx,
411            );
412
413            editor.editor.update(cx, |editor, cx| {
414                editor.set_text("Hello, @a-b! Have you met @C_D?", cx)
415            });
416        });
417
418        cx.executor().advance_clock(MENTIONS_DEBOUNCE_INTERVAL);
419
420        editor.update(cx, |editor, cx| {
421            let (text, ranges) = marked_text_ranges("Hello, «@a-b»! Have you met «@C_D»?", false);
422            assert_eq!(
423                editor.take_message(cx),
424                MessageParams {
425                    text,
426                    mentions: vec![(ranges[0].clone(), 101), (ranges[1].clone(), 102)],
427                }
428            );
429        });
430    }
431
432    fn init_test(cx: &mut TestAppContext) -> Arc<LanguageRegistry> {
433        cx.update(|cx| {
434            let settings = SettingsStore::test(cx);
435            cx.set_global(settings);
436
437            let http = FakeHttpClient::with_404_response();
438            let client = Client::new(http.clone(), cx);
439            let user_store = cx.new_model(|cx| UserStore::new(client.clone(), cx));
440            theme::init(theme::LoadThemes::JustBase, cx);
441            language::init(cx);
442            editor::init(cx);
443            client::init(&client, cx);
444            channel::init(&client, user_store, cx);
445        });
446
447        let language_registry = Arc::new(LanguageRegistry::test());
448        language_registry.add(Arc::new(Language::new(
449            LanguageConfig {
450                name: "Markdown".into(),
451                ..Default::default()
452            },
453            Some(tree_sitter_markdown::language()),
454        )));
455        language_registry
456    }
457}