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