1mod active_buffer_language;
2
3pub use active_buffer_language::ActiveBufferLanguage;
4use anyhow::Context as _;
5use editor::Editor;
6use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
7use gpui::{
8 App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, ParentElement,
9 Render, Styled, WeakEntity, Window, actions,
10};
11use language::{Buffer, LanguageMatcher, LanguageName, LanguageRegistry};
12use open_path_prompt::file_finder_settings::FileFinderSettings;
13use picker::{Picker, PickerDelegate};
14use project::Project;
15use settings::Settings;
16use std::{ops::Not as _, path::Path, sync::Arc};
17use ui::{HighlightedLabel, ListItem, ListItemSpacing, prelude::*};
18use util::ResultExt;
19use workspace::{ModalView, Workspace};
20
21actions!(
22 language_selector,
23 [
24 /// Toggles the language selector modal.
25 Toggle
26 ]
27);
28
29pub fn init(cx: &mut App) {
30 cx.observe_new(LanguageSelector::register).detach();
31}
32
33pub struct LanguageSelector {
34 picker: Entity<Picker<LanguageSelectorDelegate>>,
35}
36
37impl LanguageSelector {
38 fn register(
39 workspace: &mut Workspace,
40 _window: Option<&mut Window>,
41 _: &mut Context<Workspace>,
42 ) {
43 workspace.register_action(move |workspace, _: &Toggle, window, cx| {
44 Self::toggle(workspace, window, cx);
45 });
46 }
47
48 fn toggle(
49 workspace: &mut Workspace,
50 window: &mut Window,
51 cx: &mut Context<Workspace>,
52 ) -> Option<()> {
53 let registry = workspace.app_state().languages.clone();
54 let buffer = workspace
55 .active_item(cx)?
56 .act_as::<Editor>(cx)?
57 .read(cx)
58 .active_buffer(cx)?;
59 let project = workspace.project().clone();
60
61 workspace.toggle_modal(window, cx, move |window, cx| {
62 LanguageSelector::new(buffer, project, registry, window, cx)
63 });
64 Some(())
65 }
66
67 fn new(
68 buffer: Entity<Buffer>,
69 project: Entity<Project>,
70 language_registry: Arc<LanguageRegistry>,
71 window: &mut Window,
72 cx: &mut Context<Self>,
73 ) -> Self {
74 let current_language_name = buffer
75 .read(cx)
76 .language()
77 .map(|language| language.name().as_ref().to_string());
78 let delegate = LanguageSelectorDelegate::new(
79 cx.entity().downgrade(),
80 buffer,
81 project,
82 language_registry,
83 current_language_name,
84 );
85
86 let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
87 Self { picker }
88 }
89}
90
91impl Render for LanguageSelector {
92 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
93 v_flex()
94 .key_context("LanguageSelector")
95 .w(rems(34.))
96 .child(self.picker.clone())
97 }
98}
99
100impl Focusable for LanguageSelector {
101 fn focus_handle(&self, cx: &App) -> FocusHandle {
102 self.picker.focus_handle(cx)
103 }
104}
105
106impl EventEmitter<DismissEvent> for LanguageSelector {}
107impl ModalView for LanguageSelector {}
108
109pub struct LanguageSelectorDelegate {
110 language_selector: WeakEntity<LanguageSelector>,
111 buffer: Entity<Buffer>,
112 project: Entity<Project>,
113 language_registry: Arc<LanguageRegistry>,
114 candidates: Vec<StringMatchCandidate>,
115 matches: Vec<StringMatch>,
116 selected_index: usize,
117 current_language_candidate_index: Option<usize>,
118}
119
120impl LanguageSelectorDelegate {
121 fn new(
122 language_selector: WeakEntity<LanguageSelector>,
123 buffer: Entity<Buffer>,
124 project: Entity<Project>,
125 language_registry: Arc<LanguageRegistry>,
126 current_language_name: Option<String>,
127 ) -> Self {
128 let candidates = language_registry
129 .language_names()
130 .into_iter()
131 .filter_map(|name| {
132 language_registry
133 .available_language_for_name(name.as_ref())?
134 .hidden()
135 .not()
136 .then_some(name)
137 })
138 .enumerate()
139 .map(|(candidate_id, name)| StringMatchCandidate::new(candidate_id, name.as_ref()))
140 .collect::<Vec<_>>();
141
142 let current_language_candidate_index = current_language_name.as_ref().and_then(|name| {
143 candidates
144 .iter()
145 .position(|candidate| candidate.string == *name)
146 });
147
148 Self {
149 language_selector,
150 buffer,
151 project,
152 language_registry,
153 candidates,
154 matches: vec![],
155 selected_index: current_language_candidate_index.unwrap_or(0),
156 current_language_candidate_index,
157 }
158 }
159
160 fn language_data_for_match(&self, mat: &StringMatch, cx: &App) -> (String, Option<Icon>) {
161 let mut label = mat.string.clone();
162 let buffer_language = self.buffer.read(cx).language();
163 let need_icon = FileFinderSettings::get_global(cx).file_icons;
164
165 if let Some(buffer_language) = buffer_language
166 .filter(|buffer_language| buffer_language.name().as_ref() == mat.string.as_str())
167 {
168 label.push_str(" (current)");
169 let icon = need_icon
170 .then(|| self.language_icon(&buffer_language.config().matcher, cx))
171 .flatten();
172 (label, icon)
173 } else {
174 let icon = need_icon
175 .then(|| {
176 let language_name = LanguageName::new(mat.string.as_str());
177 self.language_registry
178 .available_language_for_name(language_name.as_ref())
179 .and_then(|available_language| {
180 self.language_icon(available_language.matcher(), cx)
181 })
182 })
183 .flatten();
184 (label, icon)
185 }
186 }
187
188 fn language_icon(&self, matcher: &LanguageMatcher, cx: &App) -> Option<Icon> {
189 matcher
190 .path_suffixes
191 .iter()
192 .find_map(|extension| file_icons::FileIcons::get_icon(Path::new(extension), cx))
193 .map(Icon::from_path)
194 .map(|icon| icon.color(Color::Muted))
195 }
196}
197
198impl PickerDelegate for LanguageSelectorDelegate {
199 type ListItem = ListItem;
200
201 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
202 "Select a language…".into()
203 }
204
205 fn match_count(&self) -> usize {
206 self.matches.len()
207 }
208
209 fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
210 if let Some(mat) = self.matches.get(self.selected_index) {
211 let language_name = &self.candidates[mat.candidate_id].string;
212 let language = self.language_registry.language_for_name(language_name);
213 let project = self.project.downgrade();
214 let buffer = self.buffer.downgrade();
215 cx.spawn_in(window, async move |_, cx| {
216 let language = language.await?;
217 let project = project.upgrade().context("project was dropped")?;
218 let buffer = buffer.upgrade().context("buffer was dropped")?;
219 project.update(cx, |project, cx| {
220 project.set_language_for_buffer(&buffer, language, cx);
221 });
222 anyhow::Ok(())
223 })
224 .detach_and_log_err(cx);
225 }
226 self.dismissed(window, cx);
227 }
228
229 fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
230 self.language_selector
231 .update(cx, |_, cx| cx.emit(DismissEvent))
232 .log_err();
233 }
234
235 fn selected_index(&self) -> usize {
236 self.selected_index
237 }
238
239 fn set_selected_index(
240 &mut self,
241 ix: usize,
242 _window: &mut Window,
243 _: &mut Context<Picker<Self>>,
244 ) {
245 self.selected_index = ix;
246 }
247
248 fn update_matches(
249 &mut self,
250 query: String,
251 window: &mut Window,
252 cx: &mut Context<Picker<Self>>,
253 ) -> gpui::Task<()> {
254 let background = cx.background_executor().clone();
255 let candidates = self.candidates.clone();
256 let query_is_empty = query.is_empty();
257 cx.spawn_in(window, async move |this, cx| {
258 let matches = if query_is_empty {
259 candidates
260 .into_iter()
261 .enumerate()
262 .map(|(index, candidate)| StringMatch {
263 candidate_id: index,
264 string: candidate.string,
265 positions: Vec::new(),
266 score: 0.0,
267 })
268 .collect()
269 } else {
270 match_strings(
271 &candidates,
272 &query,
273 false,
274 true,
275 100,
276 &Default::default(),
277 background,
278 )
279 .await
280 };
281
282 this.update_in(cx, |this, window, cx| {
283 if matches.is_empty() {
284 this.delegate.matches = matches;
285 this.delegate.selected_index = 0;
286 cx.notify();
287 return;
288 }
289
290 let selected_index = if query_is_empty {
291 this.delegate
292 .current_language_candidate_index
293 .and_then(|current_language_candidate_index| {
294 matches.iter().position(|mat| {
295 mat.candidate_id == current_language_candidate_index
296 })
297 })
298 .unwrap_or(0)
299 } else {
300 0
301 };
302
303 this.delegate.matches = matches;
304 this.set_selected_index(selected_index, None, false, window, cx);
305 cx.notify();
306 })
307 .log_err();
308 })
309 }
310
311 fn render_match(
312 &self,
313 ix: usize,
314 selected: bool,
315 _: &mut Window,
316 cx: &mut Context<Picker<Self>>,
317 ) -> Option<Self::ListItem> {
318 let mat = &self.matches.get(ix)?;
319 let (label, language_icon) = self.language_data_for_match(mat, cx);
320 Some(
321 ListItem::new(ix)
322 .inset(true)
323 .spacing(ListItemSpacing::Sparse)
324 .toggle_state(selected)
325 .start_slot::<Icon>(language_icon)
326 .child(HighlightedLabel::new(label, mat.positions.clone())),
327 )
328 }
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334 use editor::Editor;
335 use gpui::{TestAppContext, VisualTestContext};
336 use language::{Language, LanguageConfig};
337 use project::{Project, ProjectPath};
338 use serde_json::json;
339 use std::sync::Arc;
340 use util::{path, rel_path::rel_path};
341 use workspace::{AppState, MultiWorkspace, Workspace};
342
343 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
344 cx.update(|cx| {
345 let app_state = AppState::test(cx);
346 settings::init(cx);
347 super::init(cx);
348 editor::init(cx);
349 app_state
350 })
351 }
352
353 fn register_test_languages(project: &Entity<Project>, cx: &mut VisualTestContext) {
354 project.read_with(cx, |project, _| {
355 let language_registry = project.languages();
356 for (language_name, path_suffix) in [
357 ("C", "c"),
358 ("Go", "go"),
359 ("Ruby", "rb"),
360 ("Rust", "rs"),
361 ("TypeScript", "ts"),
362 ] {
363 language_registry.add(Arc::new(Language::new(
364 LanguageConfig {
365 name: language_name.into(),
366 matcher: LanguageMatcher {
367 path_suffixes: vec![path_suffix.to_string()],
368 ..Default::default()
369 },
370 ..Default::default()
371 },
372 None,
373 )));
374 }
375 });
376 }
377
378 async fn open_file_editor(
379 workspace: &Entity<Workspace>,
380 project: &Entity<Project>,
381 file_path: &str,
382 cx: &mut VisualTestContext,
383 ) -> Entity<Editor> {
384 let worktree_id = project.update(cx, |project, cx| {
385 project
386 .worktrees(cx)
387 .next()
388 .expect("project should have a worktree")
389 .read(cx)
390 .id()
391 });
392 let project_path = ProjectPath {
393 worktree_id,
394 path: rel_path(file_path).into(),
395 };
396 let opened_item = workspace
397 .update_in(cx, |workspace, window, cx| {
398 workspace.open_path(project_path, None, true, window, cx)
399 })
400 .await
401 .expect("file should open");
402
403 cx.update(|_, cx| {
404 opened_item
405 .act_as::<Editor>(cx)
406 .expect("opened item should be an editor")
407 })
408 }
409
410 async fn open_empty_editor(
411 workspace: &Entity<Workspace>,
412 project: &Entity<Project>,
413 cx: &mut VisualTestContext,
414 ) -> Entity<Editor> {
415 let editor = open_new_buffer_editor(workspace, project, cx).await;
416 // Ensure the buffer has no language after the editor is created
417 let buffer = editor.read_with(cx, |editor, cx| {
418 editor
419 .active_buffer(cx)
420 .expect("editor should have an active buffer")
421 });
422 buffer.update(cx, |buffer, cx| {
423 buffer.set_language(None, cx);
424 });
425 editor
426 }
427
428 async fn open_new_buffer_editor(
429 workspace: &Entity<Workspace>,
430 project: &Entity<Project>,
431 cx: &mut VisualTestContext,
432 ) -> Entity<Editor> {
433 let create_buffer = project.update(cx, |project, cx| project.create_buffer(None, true, cx));
434 let buffer = create_buffer.await.expect("empty buffer should be created");
435 let editor = cx.new_window_entity(|window, cx| {
436 Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx)
437 });
438 workspace.update_in(cx, |workspace, window, cx| {
439 workspace.add_item_to_center(Box::new(editor.clone()), window, cx);
440 });
441 editor
442 }
443
444 async fn set_editor_language(
445 project: &Entity<Project>,
446 editor: &Entity<Editor>,
447 language_name: &str,
448 cx: &mut VisualTestContext,
449 ) {
450 let language = project
451 .read_with(cx, |project, _| {
452 project.languages().language_for_name(language_name)
453 })
454 .await
455 .expect("language should exist in registry");
456 editor.update(cx, move |editor, cx| {
457 let buffer = editor
458 .active_buffer(cx)
459 .expect("editor should have an active excerpt");
460 buffer.update(cx, |buffer, cx| {
461 buffer.set_language(Some(language), cx);
462 });
463 });
464 }
465
466 fn active_picker(
467 workspace: &Entity<Workspace>,
468 cx: &mut VisualTestContext,
469 ) -> Entity<Picker<LanguageSelectorDelegate>> {
470 workspace.update(cx, |workspace, cx| {
471 workspace
472 .active_modal::<LanguageSelector>(cx)
473 .expect("language selector should be open")
474 .read(cx)
475 .picker
476 .clone()
477 })
478 }
479
480 fn open_selector(
481 workspace: &Entity<Workspace>,
482 cx: &mut VisualTestContext,
483 ) -> Entity<Picker<LanguageSelectorDelegate>> {
484 cx.dispatch_action(Toggle);
485 cx.run_until_parked();
486 active_picker(workspace, cx)
487 }
488
489 fn close_selector(workspace: &Entity<Workspace>, cx: &mut VisualTestContext) {
490 cx.dispatch_action(Toggle);
491 cx.run_until_parked();
492 workspace.read_with(cx, |workspace, cx| {
493 assert!(
494 workspace.active_modal::<LanguageSelector>(cx).is_none(),
495 "language selector should be closed"
496 );
497 });
498 }
499
500 fn assert_selected_language_for_editor(
501 workspace: &Entity<Workspace>,
502 editor: &Entity<Editor>,
503 expected_language_name: Option<&str>,
504 cx: &mut VisualTestContext,
505 ) {
506 workspace.update_in(cx, |workspace, window, cx| {
507 let was_activated = workspace.activate_item(editor, true, true, window, cx);
508 assert!(
509 was_activated,
510 "editor should be activated before opening the modal"
511 );
512 });
513 cx.run_until_parked();
514
515 let picker = open_selector(workspace, cx);
516 picker.read_with(cx, |picker, _| {
517 let selected_match = picker
518 .delegate
519 .matches
520 .get(picker.delegate.selected_index)
521 .expect("selected index should point to a match");
522 let selected_candidate = picker
523 .delegate
524 .candidates
525 .get(selected_match.candidate_id)
526 .expect("selected match should map to a candidate");
527
528 if let Some(expected_language_name) = expected_language_name {
529 let current_language_candidate_index = picker
530 .delegate
531 .current_language_candidate_index
532 .expect("current language should map to a candidate");
533 assert_eq!(
534 selected_match.candidate_id,
535 current_language_candidate_index
536 );
537 assert_eq!(selected_candidate.string, expected_language_name);
538 } else {
539 assert!(picker.delegate.current_language_candidate_index.is_none());
540 assert_eq!(picker.delegate.selected_index, 0);
541 }
542 });
543 close_selector(workspace, cx);
544 }
545
546 #[gpui::test]
547 async fn test_language_selector_selects_current_language_per_active_editor(
548 cx: &mut TestAppContext,
549 ) {
550 let app_state = init_test(cx);
551 app_state
552 .fs
553 .as_fake()
554 .insert_tree(
555 path!("/test"),
556 json!({
557 "rust_file.rs": "fn main() {}\n",
558 "typescript_file.ts": "const value = 1;\n",
559 }),
560 )
561 .await;
562
563 let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await;
564 let (multi_workspace, cx) =
565 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
566 let workspace =
567 multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone());
568 register_test_languages(&project, cx);
569
570 let rust_editor = open_file_editor(&workspace, &project, "rust_file.rs", cx).await;
571 let typescript_editor =
572 open_file_editor(&workspace, &project, "typescript_file.ts", cx).await;
573 let empty_editor = open_empty_editor(&workspace, &project, cx).await;
574
575 set_editor_language(&project, &rust_editor, "Rust", cx).await;
576 set_editor_language(&project, &typescript_editor, "TypeScript", cx).await;
577 cx.run_until_parked();
578
579 assert_selected_language_for_editor(&workspace, &rust_editor, Some("Rust"), cx);
580 assert_selected_language_for_editor(&workspace, &typescript_editor, Some("TypeScript"), cx);
581 // Ensure the empty editor's buffer has no language before asserting
582 let buffer = empty_editor.read_with(cx, |editor, cx| {
583 editor
584 .active_buffer(cx)
585 .expect("editor should have an active excerpt")
586 });
587 buffer.update(cx, |buffer, cx| {
588 buffer.set_language(None, cx);
589 });
590 assert_selected_language_for_editor(&workspace, &empty_editor, None, cx);
591 }
592
593 #[gpui::test]
594 async fn test_language_selector_selects_first_match_after_querying_new_buffer(
595 cx: &mut TestAppContext,
596 ) {
597 let app_state = init_test(cx);
598 app_state
599 .fs
600 .as_fake()
601 .insert_tree(path!("/test"), json!({}))
602 .await;
603
604 let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await;
605 let (multi_workspace, cx) =
606 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
607 let workspace =
608 multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone());
609 register_test_languages(&project, cx);
610
611 let editor = open_new_buffer_editor(&workspace, &project, cx).await;
612 workspace.update_in(cx, |workspace, window, cx| {
613 let was_activated = workspace.activate_item(&editor, true, true, window, cx);
614 assert!(
615 was_activated,
616 "editor should be activated before opening the modal"
617 );
618 });
619 cx.run_until_parked();
620
621 let picker = open_selector(&workspace, cx);
622 picker.read_with(cx, |picker, _| {
623 let selected_match = picker
624 .delegate
625 .matches
626 .get(picker.delegate.selected_index)
627 .expect("selected index should point to a match");
628 let selected_candidate = picker
629 .delegate
630 .candidates
631 .get(selected_match.candidate_id)
632 .expect("selected match should map to a candidate");
633
634 assert_eq!(selected_candidate.string, "Plain Text");
635 assert!(
636 picker
637 .delegate
638 .current_language_candidate_index
639 .is_some_and(|current_language_candidate_index| {
640 current_language_candidate_index > 1
641 }),
642 "test setup should place Plain Text after at least two earlier languages",
643 );
644 });
645
646 picker.update_in(cx, |picker, window, cx| {
647 picker.update_matches("ru".to_string(), window, cx)
648 });
649 cx.run_until_parked();
650
651 picker.read_with(cx, |picker, _| {
652 assert!(
653 picker.delegate.matches.len() > 1,
654 "query should return multiple matches"
655 );
656 assert_eq!(picker.delegate.selected_index, 0);
657
658 let first_match = picker
659 .delegate
660 .matches
661 .first()
662 .expect("query should produce at least one match");
663 let selected_match = picker
664 .delegate
665 .matches
666 .get(picker.delegate.selected_index)
667 .expect("selected index should point to a match");
668
669 assert_eq!(selected_match.candidate_id, first_match.candidate_id);
670 });
671 }
672}