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