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