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