1use anyhow::Result;
2use channel::{ChannelMembership, ChannelStore, MessageParams};
3use client::{ChannelId, UserId};
4use collections::{HashMap, HashSet};
5use editor::{AnchorRangeExt, CompletionProvider, Editor, EditorElement, EditorStyle};
6use fuzzy::{StringMatch, 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::{ops::Range, sync::Arc, time::Duration};
20use theme::ThemeSettings;
21use ui::{prelude::*, UiTextSize};
22
23use crate::panel_settings::MessageEditorSettings;
24
25const MENTIONS_DEBOUNCE_INTERVAL: Duration = Duration::from_millis(50);
26
27lazy_static! {
28 static ref MENTIONS_SEARCH: SearchQuery =
29 SearchQuery::regex("@[-_\\w]+", false, false, false, Vec::new(), Vec::new()).unwrap();
30}
31
32pub struct MessageEditor {
33 pub editor: View<Editor>,
34 channel_store: Model<ChannelStore>,
35 channel_members: HashMap<String, UserId>,
36 mentions: Vec<UserId>,
37 mentions_task: Option<Task<()>>,
38 channel_id: Option<ChannelId>,
39 reply_to_message_id: Option<u64>,
40}
41
42struct MessageEditorCompletionProvider(WeakView<MessageEditor>);
43
44impl CompletionProvider for MessageEditorCompletionProvider {
45 fn completions(
46 &self,
47 buffer: &Model<Buffer>,
48 buffer_position: language::Anchor,
49 cx: &mut ViewContext<Editor>,
50 ) -> Task<anyhow::Result<Vec<language::Completion>>> {
51 let Some(handle) = self.0.upgrade() else {
52 return Task::ready(Ok(Vec::new()));
53 };
54 handle.update(cx, |message_editor, cx| {
55 message_editor.completions(buffer, buffer_position, cx)
56 })
57 }
58
59 fn resolve_completions(
60 &self,
61 _completion_indices: Vec<usize>,
62 _completions: Arc<RwLock<Box<[language::Completion]>>>,
63 _cx: &mut ViewContext<Editor>,
64 ) -> Task<anyhow::Result<bool>> {
65 Task::ready(Ok(false))
66 }
67
68 fn apply_additional_edits_for_completion(
69 &self,
70 _buffer: Model<Buffer>,
71 _completion: Completion,
72 _push_to_history: bool,
73 _cx: &mut ViewContext<Editor>,
74 ) -> Task<Result<Option<language::Transaction>>> {
75 Task::ready(Ok(None))
76 }
77}
78
79impl MessageEditor {
80 pub fn new(
81 language_registry: Arc<LanguageRegistry>,
82 channel_store: Model<ChannelStore>,
83 editor: View<Editor>,
84 cx: &mut ViewContext<Self>,
85 ) -> Self {
86 let this = cx.view().downgrade();
87 editor.update(cx, |editor, cx| {
88 editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
89 editor.set_use_autoclose(false);
90 editor.set_completion_provider(Box::new(MessageEditorCompletionProvider(this)));
91 editor.set_auto_replace_emoji_shortcode(
92 MessageEditorSettings::get_global(cx)
93 .auto_replace_emoji_shortcode
94 .unwrap_or_default(),
95 );
96 });
97
98 let buffer = editor
99 .read(cx)
100 .buffer()
101 .read(cx)
102 .as_singleton()
103 .expect("message editor must be singleton");
104
105 cx.subscribe(&buffer, Self::on_buffer_event).detach();
106 cx.observe_global::<settings::SettingsStore>(|view, cx| {
107 view.editor.update(cx, |editor, cx| {
108 editor.set_auto_replace_emoji_shortcode(
109 MessageEditorSettings::get_global(cx)
110 .auto_replace_emoji_shortcode
111 .unwrap_or_default(),
112 )
113 })
114 })
115 .detach();
116
117 let markdown = language_registry.language_for_name("Markdown");
118 cx.spawn(|_, mut cx| async move {
119 let markdown = markdown.await?;
120 buffer.update(&mut cx, |buffer, cx| {
121 buffer.set_language(Some(markdown), cx)
122 })
123 })
124 .detach_and_log_err(cx);
125
126 Self {
127 editor,
128 channel_store,
129 channel_members: HashMap::default(),
130 channel_id: None,
131 mentions: Vec::new(),
132 mentions_task: None,
133 reply_to_message_id: None,
134 }
135 }
136
137 pub fn reply_to_message_id(&self) -> Option<u64> {
138 self.reply_to_message_id
139 }
140
141 pub fn set_reply_to_message_id(&mut self, reply_to_message_id: u64) {
142 self.reply_to_message_id = Some(reply_to_message_id);
143 }
144
145 pub fn clear_reply_to_message_id(&mut self) {
146 self.reply_to_message_id = None;
147 }
148
149 pub fn set_channel(
150 &mut self,
151 channel_id: ChannelId,
152 channel_name: Option<SharedString>,
153 cx: &mut ViewContext<Self>,
154 ) {
155 self.editor.update(cx, |editor, cx| {
156 if let Some(channel_name) = channel_name {
157 editor.set_placeholder_text(format!("Message #{channel_name}"), cx);
158 } else {
159 editor.set_placeholder_text("Message Channel", cx);
160 }
161 });
162 self.channel_id = Some(channel_id);
163 self.refresh_users(cx);
164 }
165
166 pub fn refresh_users(&mut self, cx: &mut ViewContext<Self>) {
167 if let Some(channel_id) = self.channel_id {
168 let members = self.channel_store.update(cx, |store, cx| {
169 store.get_channel_member_details(channel_id, cx)
170 });
171 cx.spawn(|this, mut cx| async move {
172 let members = members.await?;
173 this.update(&mut cx, |this, cx| this.set_members(members, cx))?;
174 anyhow::Ok(())
175 })
176 .detach_and_log_err(cx);
177 }
178 }
179
180 pub fn set_members(&mut self, members: Vec<ChannelMembership>, _: &mut ViewContext<Self>) {
181 self.channel_members.clear();
182 self.channel_members.extend(
183 members
184 .into_iter()
185 .map(|member| (member.user.github_login.clone(), member.user.id)),
186 );
187 }
188
189 pub fn take_message(&mut self, cx: &mut ViewContext<Self>) -> MessageParams {
190 self.editor.update(cx, |editor, cx| {
191 let highlights = editor.text_highlights::<Self>(cx);
192 let text = editor.text(cx);
193 let snapshot = editor.buffer().read(cx).snapshot(cx);
194 let mentions = if let Some((_, ranges)) = highlights {
195 ranges
196 .iter()
197 .map(|range| range.to_offset(&snapshot))
198 .zip(self.mentions.iter().copied())
199 .collect()
200 } else {
201 Vec::new()
202 };
203
204 editor.clear(cx);
205 self.mentions.clear();
206 let reply_to_message_id = std::mem::take(&mut self.reply_to_message_id);
207
208 MessageParams {
209 text,
210 mentions,
211 reply_to_message_id,
212 }
213 })
214 }
215
216 fn on_buffer_event(
217 &mut self,
218 buffer: Model<Buffer>,
219 event: &language::Event,
220 cx: &mut ViewContext<Self>,
221 ) {
222 if let language::Event::Reparsed | language::Event::Edited = event {
223 let buffer = buffer.read(cx).snapshot();
224 self.mentions_task = Some(cx.spawn(|this, cx| async move {
225 cx.background_executor()
226 .timer(MENTIONS_DEBOUNCE_INTERVAL)
227 .await;
228 Self::find_mentions(this, buffer, cx).await;
229 }));
230 }
231 }
232
233 fn completions(
234 &mut self,
235 buffer: &Model<Buffer>,
236 end_anchor: Anchor,
237 cx: &mut ViewContext<Self>,
238 ) -> Task<Result<Vec<Completion>>> {
239 if let Some((start_anchor, query, candidates)) =
240 self.collect_mention_candidates(buffer, end_anchor, cx)
241 {
242 if !candidates.is_empty() {
243 return cx.spawn(|_, cx| async move {
244 Ok(Self::resolve_completions_for_candidates(
245 &cx,
246 query.as_str(),
247 &candidates,
248 start_anchor..end_anchor,
249 Self::completion_for_mention,
250 )
251 .await)
252 });
253 }
254 }
255
256 if let Some((start_anchor, query, candidates)) =
257 self.collect_emoji_candidates(buffer, end_anchor, cx)
258 {
259 if !candidates.is_empty() {
260 return cx.spawn(|_, cx| async move {
261 Ok(Self::resolve_completions_for_candidates(
262 &cx,
263 query.as_str(),
264 candidates,
265 start_anchor..end_anchor,
266 Self::completion_for_emoji,
267 )
268 .await)
269 });
270 }
271 }
272
273 Task::ready(Ok(vec![]))
274 }
275
276 async fn resolve_completions_for_candidates(
277 cx: &AsyncWindowContext,
278 query: &str,
279 candidates: &[StringMatchCandidate],
280 range: Range<Anchor>,
281 completion_fn: impl Fn(&StringMatch) -> (String, CodeLabel),
282 ) -> Vec<Completion> {
283 let matches = fuzzy::match_strings(
284 &candidates,
285 &query,
286 true,
287 10,
288 &Default::default(),
289 cx.background_executor().clone(),
290 )
291 .await;
292
293 matches
294 .into_iter()
295 .map(|mat| {
296 let (new_text, label) = completion_fn(&mat);
297 Completion {
298 old_range: range.clone(),
299 new_text,
300 label,
301 documentation: None,
302 server_id: LanguageServerId(0), // TODO: Make this optional or something?
303 lsp_completion: Default::default(), // TODO: Make this optional or something?
304 }
305 })
306 .collect()
307 }
308
309 fn completion_for_mention(mat: &StringMatch) -> (String, CodeLabel) {
310 let label = CodeLabel {
311 filter_range: 1..mat.string.len() + 1,
312 text: format!("@{}", mat.string),
313 runs: Vec::new(),
314 };
315 (mat.string.clone(), label)
316 }
317
318 fn completion_for_emoji(mat: &StringMatch) -> (String, CodeLabel) {
319 let emoji = emojis::get_by_shortcode(&mat.string).unwrap();
320 let label = CodeLabel {
321 filter_range: 1..mat.string.len() + 1,
322 text: format!(":{}: {}", mat.string, emoji),
323 runs: Vec::new(),
324 };
325 (emoji.to_string(), label)
326 }
327
328 fn collect_mention_candidates(
329 &mut self,
330 buffer: &Model<Buffer>,
331 end_anchor: Anchor,
332 cx: &mut ViewContext<Self>,
333 ) -> Option<(Anchor, String, Vec<StringMatchCandidate>)> {
334 let end_offset = end_anchor.to_offset(buffer.read(cx));
335
336 let Some(query) = buffer.update(cx, |buffer, _| {
337 let mut query = String::new();
338 for ch in buffer.reversed_chars_at(end_offset).take(100) {
339 if ch == '@' {
340 return Some(query.chars().rev().collect::<String>());
341 }
342 if ch.is_whitespace() || !ch.is_ascii() {
343 break;
344 }
345 query.push(ch);
346 }
347 None
348 }) else {
349 return None;
350 };
351
352 let start_offset = end_offset - query.len();
353 let start_anchor = buffer.read(cx).anchor_before(start_offset);
354
355 let mut names = HashSet::default();
356 for (github_login, _) in self.channel_members.iter() {
357 names.insert(github_login.clone());
358 }
359 if let Some(channel_id) = self.channel_id {
360 for participant in self.channel_store.read(cx).channel_participants(channel_id) {
361 names.insert(participant.github_login.clone());
362 }
363 }
364
365 let candidates = names
366 .into_iter()
367 .map(|user| StringMatchCandidate {
368 id: 0,
369 string: user.clone(),
370 char_bag: user.chars().collect(),
371 })
372 .collect::<Vec<_>>();
373
374 Some((start_anchor, query, candidates))
375 }
376
377 fn collect_emoji_candidates(
378 &mut self,
379 buffer: &Model<Buffer>,
380 end_anchor: Anchor,
381 cx: &mut ViewContext<Self>,
382 ) -> Option<(Anchor, String, &'static [StringMatchCandidate])> {
383 lazy_static! {
384 static ref EMOJI_FUZZY_MATCH_CANDIDATES: Vec<StringMatchCandidate> = {
385 let emojis = emojis::iter()
386 .flat_map(|s| s.shortcodes())
387 .map(|emoji| StringMatchCandidate {
388 id: 0,
389 string: emoji.to_string(),
390 char_bag: emoji.chars().collect(),
391 })
392 .collect::<Vec<_>>();
393 emojis
394 };
395 }
396
397 let end_offset = end_anchor.to_offset(buffer.read(cx));
398
399 let Some(query) = buffer.update(cx, |buffer, _| {
400 let mut query = String::new();
401 for ch in buffer.reversed_chars_at(end_offset).take(100) {
402 if ch == ':' {
403 let next_char = buffer
404 .reversed_chars_at(end_offset - query.len() - 1)
405 .next();
406 // Ensure we are at the start of the message or that the previous character is a whitespace
407 if next_char.is_none() || next_char.unwrap().is_whitespace() {
408 return Some(query.chars().rev().collect::<String>());
409 }
410 break;
411 }
412 if ch.is_whitespace() || !ch.is_ascii() {
413 break;
414 }
415 query.push(ch);
416 }
417 None
418 }) else {
419 return None;
420 };
421
422 let start_offset = end_offset - query.len() - 1;
423 let start_anchor = buffer.read(cx).anchor_before(start_offset);
424
425 Some((start_anchor, query, &EMOJI_FUZZY_MATCH_CANDIDATES))
426 }
427
428 async fn find_mentions(
429 this: WeakView<MessageEditor>,
430 buffer: BufferSnapshot,
431 mut cx: AsyncWindowContext,
432 ) {
433 let (buffer, ranges) = cx
434 .background_executor()
435 .spawn(async move {
436 let ranges = MENTIONS_SEARCH.search(&buffer, None).await;
437 (buffer, ranges)
438 })
439 .await;
440
441 this.update(&mut cx, |this, cx| {
442 let mut anchor_ranges = Vec::new();
443 let mut mentioned_user_ids = Vec::new();
444 let mut text = String::new();
445
446 this.editor.update(cx, |editor, cx| {
447 let multi_buffer = editor.buffer().read(cx).snapshot(cx);
448 for range in ranges {
449 text.clear();
450 text.extend(buffer.text_for_range(range.clone()));
451 if let Some(username) = text.strip_prefix('@') {
452 if let Some(user_id) = this.channel_members.get(username) {
453 let start = multi_buffer.anchor_after(range.start);
454 let end = multi_buffer.anchor_after(range.end);
455
456 mentioned_user_ids.push(*user_id);
457 anchor_ranges.push(start..end);
458 }
459 }
460 }
461
462 editor.clear_highlights::<Self>(cx);
463 editor.highlight_text::<Self>(
464 anchor_ranges,
465 HighlightStyle {
466 font_weight: Some(FontWeight::BOLD),
467 ..Default::default()
468 },
469 cx,
470 )
471 });
472
473 this.mentions = mentioned_user_ids;
474 this.mentions_task.take();
475 })
476 .ok();
477 }
478
479 pub(crate) fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle {
480 self.editor.read(cx).focus_handle(cx)
481 }
482}
483
484impl Render for MessageEditor {
485 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
486 let settings = ThemeSettings::get_global(cx);
487 let text_style = TextStyle {
488 color: if self.editor.read(cx).read_only(cx) {
489 cx.theme().colors().text_disabled
490 } else {
491 cx.theme().colors().text
492 },
493 font_family: settings.ui_font.family.clone(),
494 font_features: settings.ui_font.features,
495 font_size: UiTextSize::Small.rems().into(),
496 font_weight: FontWeight::NORMAL,
497 font_style: FontStyle::Normal,
498 line_height: relative(1.3),
499 background_color: None,
500 underline: None,
501 strikethrough: None,
502 white_space: WhiteSpace::Normal,
503 };
504
505 div()
506 .w_full()
507 .px_2()
508 .py_1()
509 .bg(cx.theme().colors().editor_background)
510 .rounded_md()
511 .child(EditorElement::new(
512 &self.editor,
513 EditorStyle {
514 local_player: cx.theme().players().local(),
515 text: text_style,
516 ..Default::default()
517 },
518 ))
519 }
520}
521
522#[cfg(test)]
523mod tests {
524 use super::*;
525 use client::{Client, User, UserStore};
526 use clock::FakeSystemClock;
527 use gpui::TestAppContext;
528 use language::{Language, LanguageConfig};
529 use rpc::proto;
530 use settings::SettingsStore;
531 use util::{http::FakeHttpClient, test::marked_text_ranges};
532
533 #[gpui::test]
534 async fn test_message_editor(cx: &mut TestAppContext) {
535 let language_registry = init_test(cx);
536
537 let (editor, cx) = cx.add_window_view(|cx| {
538 MessageEditor::new(
539 language_registry,
540 ChannelStore::global(cx),
541 cx.new_view(|cx| Editor::auto_height(4, cx)),
542 cx,
543 )
544 });
545 cx.executor().run_until_parked();
546
547 editor.update(cx, |editor, cx| {
548 editor.set_members(
549 vec![
550 ChannelMembership {
551 user: Arc::new(User {
552 github_login: "a-b".into(),
553 id: 101,
554 avatar_uri: "avatar_a-b".into(),
555 }),
556 kind: proto::channel_member::Kind::Member,
557 role: proto::ChannelRole::Member,
558 },
559 ChannelMembership {
560 user: Arc::new(User {
561 github_login: "C_D".into(),
562 id: 102,
563 avatar_uri: "avatar_C_D".into(),
564 }),
565 kind: proto::channel_member::Kind::Member,
566 role: proto::ChannelRole::Member,
567 },
568 ],
569 cx,
570 );
571
572 editor.editor.update(cx, |editor, cx| {
573 editor.set_text("Hello, @a-b! Have you met @C_D?", cx)
574 });
575 });
576
577 cx.executor().advance_clock(MENTIONS_DEBOUNCE_INTERVAL);
578
579 editor.update(cx, |editor, cx| {
580 let (text, ranges) = marked_text_ranges("Hello, «@a-b»! Have you met «@C_D»?", false);
581 assert_eq!(
582 editor.take_message(cx),
583 MessageParams {
584 text,
585 mentions: vec![(ranges[0].clone(), 101), (ranges[1].clone(), 102)],
586 reply_to_message_id: None
587 }
588 );
589 });
590 }
591
592 fn init_test(cx: &mut TestAppContext) -> Arc<LanguageRegistry> {
593 cx.update(|cx| {
594 let settings = SettingsStore::test(cx);
595 cx.set_global(settings);
596
597 let clock = Arc::new(FakeSystemClock::default());
598 let http = FakeHttpClient::with_404_response();
599 let client = Client::new(clock, http.clone(), cx);
600 let user_store = cx.new_model(|cx| UserStore::new(client.clone(), cx));
601 theme::init(theme::LoadThemes::JustBase, cx);
602 language::init(cx);
603 editor::init(cx);
604 client::init(&client, cx);
605 channel::init(&client, user_store, cx);
606
607 MessageEditorSettings::register(cx);
608 });
609
610 let language_registry = Arc::new(LanguageRegistry::test());
611 language_registry.add(Arc::new(Language::new(
612 LanguageConfig {
613 name: "Markdown".into(),
614 ..Default::default()
615 },
616 Some(tree_sitter_markdown::language()),
617 )));
618 language_registry
619 }
620}