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_excerpt(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 let delegate = &mut this.delegate;
284 delegate.matches = matches;
285 delegate.selected_index = delegate
286 .selected_index
287 .min(delegate.matches.len().saturating_sub(1));
288
289 if query_is_empty {
290 if let Some(index) = delegate
291 .current_language_candidate_index
292 .and_then(|ci| delegate.matches.iter().position(|m| m.candidate_id == ci))
293 {
294 this.set_selected_index(index, None, false, window, cx);
295 }
296 }
297 cx.notify();
298 })
299 .log_err();
300 })
301 }
302
303 fn render_match(
304 &self,
305 ix: usize,
306 selected: bool,
307 _: &mut Window,
308 cx: &mut Context<Picker<Self>>,
309 ) -> Option<Self::ListItem> {
310 let mat = &self.matches.get(ix)?;
311 let (label, language_icon) = self.language_data_for_match(mat, cx);
312 Some(
313 ListItem::new(ix)
314 .inset(true)
315 .spacing(ListItemSpacing::Sparse)
316 .toggle_state(selected)
317 .start_slot::<Icon>(language_icon)
318 .child(HighlightedLabel::new(label, mat.positions.clone())),
319 )
320 }
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326 use editor::Editor;
327 use gpui::{TestAppContext, VisualTestContext};
328 use language::{Language, LanguageConfig};
329 use project::{Project, ProjectPath};
330 use serde_json::json;
331 use std::sync::Arc;
332 use util::{path, rel_path::rel_path};
333 use workspace::{AppState, MultiWorkspace, Workspace};
334
335 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
336 cx.update(|cx| {
337 let app_state = AppState::test(cx);
338 settings::init(cx);
339 super::init(cx);
340 editor::init(cx);
341 app_state
342 })
343 }
344
345 fn register_test_languages(project: &Entity<Project>, cx: &mut VisualTestContext) {
346 project.read_with(cx, |project, _| {
347 let language_registry = project.languages();
348 language_registry.add(Arc::new(Language::new(
349 LanguageConfig {
350 name: "Rust".into(),
351 matcher: LanguageMatcher {
352 path_suffixes: vec!["rs".to_string()],
353 ..Default::default()
354 },
355 ..Default::default()
356 },
357 None,
358 )));
359 language_registry.add(Arc::new(Language::new(
360 LanguageConfig {
361 name: "TypeScript".into(),
362 matcher: LanguageMatcher {
363 path_suffixes: vec!["ts".to_string()],
364 ..Default::default()
365 },
366 ..Default::default()
367 },
368 None,
369 )));
370 });
371 }
372
373 async fn open_file_editor(
374 workspace: &Entity<Workspace>,
375 project: &Entity<Project>,
376 file_path: &str,
377 cx: &mut VisualTestContext,
378 ) -> Entity<Editor> {
379 let worktree_id = project.update(cx, |project, cx| {
380 project
381 .worktrees(cx)
382 .next()
383 .expect("project should have a worktree")
384 .read(cx)
385 .id()
386 });
387 let project_path = ProjectPath {
388 worktree_id,
389 path: rel_path(file_path).into(),
390 };
391 let opened_item = workspace
392 .update_in(cx, |workspace, window, cx| {
393 workspace.open_path(project_path, None, true, window, cx)
394 })
395 .await
396 .expect("file should open");
397
398 cx.update(|_, cx| {
399 opened_item
400 .act_as::<Editor>(cx)
401 .expect("opened item should be an editor")
402 })
403 }
404
405 async fn open_empty_editor(
406 workspace: &Entity<Workspace>,
407 project: &Entity<Project>,
408 cx: &mut VisualTestContext,
409 ) -> Entity<Editor> {
410 let create_buffer = project.update(cx, |project, cx| project.create_buffer(None, true, cx));
411 let buffer = create_buffer.await.expect("empty buffer should be created");
412 let editor = cx.new_window_entity(|window, cx| {
413 Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx)
414 });
415 workspace.update_in(cx, |workspace, window, cx| {
416 workspace.add_item_to_center(Box::new(editor.clone()), window, cx);
417 });
418 // Ensure the buffer has no language after the editor is created
419 buffer.update(cx, |buffer, cx| {
420 buffer.set_language(None, cx);
421 });
422 editor
423 }
424
425 async fn set_editor_language(
426 project: &Entity<Project>,
427 editor: &Entity<Editor>,
428 language_name: &str,
429 cx: &mut VisualTestContext,
430 ) {
431 let language = project
432 .read_with(cx, |project, _| {
433 project.languages().language_for_name(language_name)
434 })
435 .await
436 .expect("language should exist in registry");
437 editor.update(cx, move |editor, cx| {
438 let (_, buffer, _) = editor
439 .active_excerpt(cx)
440 .expect("editor should have an active excerpt");
441 buffer.update(cx, |buffer, cx| {
442 buffer.set_language(Some(language), cx);
443 });
444 });
445 }
446
447 fn active_picker(
448 workspace: &Entity<Workspace>,
449 cx: &mut VisualTestContext,
450 ) -> Entity<Picker<LanguageSelectorDelegate>> {
451 workspace.update(cx, |workspace, cx| {
452 workspace
453 .active_modal::<LanguageSelector>(cx)
454 .expect("language selector should be open")
455 .read(cx)
456 .picker
457 .clone()
458 })
459 }
460
461 fn open_selector(
462 workspace: &Entity<Workspace>,
463 cx: &mut VisualTestContext,
464 ) -> Entity<Picker<LanguageSelectorDelegate>> {
465 cx.dispatch_action(Toggle);
466 cx.run_until_parked();
467 active_picker(workspace, cx)
468 }
469
470 fn close_selector(workspace: &Entity<Workspace>, cx: &mut VisualTestContext) {
471 cx.dispatch_action(Toggle);
472 cx.run_until_parked();
473 workspace.read_with(cx, |workspace, cx| {
474 assert!(
475 workspace.active_modal::<LanguageSelector>(cx).is_none(),
476 "language selector should be closed"
477 );
478 });
479 }
480
481 fn assert_selected_language_for_editor(
482 workspace: &Entity<Workspace>,
483 editor: &Entity<Editor>,
484 expected_language_name: Option<&str>,
485 cx: &mut VisualTestContext,
486 ) {
487 workspace.update_in(cx, |workspace, window, cx| {
488 let was_activated = workspace.activate_item(editor, true, true, window, cx);
489 assert!(
490 was_activated,
491 "editor should be activated before opening the modal"
492 );
493 });
494 cx.run_until_parked();
495
496 let picker = open_selector(workspace, cx);
497 picker.read_with(cx, |picker, _| {
498 let selected_match = picker
499 .delegate
500 .matches
501 .get(picker.delegate.selected_index)
502 .expect("selected index should point to a match");
503 let selected_candidate = picker
504 .delegate
505 .candidates
506 .get(selected_match.candidate_id)
507 .expect("selected match should map to a candidate");
508
509 if let Some(expected_language_name) = expected_language_name {
510 let current_language_candidate_index = picker
511 .delegate
512 .current_language_candidate_index
513 .expect("current language should map to a candidate");
514 assert_eq!(
515 selected_match.candidate_id,
516 current_language_candidate_index
517 );
518 assert_eq!(selected_candidate.string, expected_language_name);
519 } else {
520 assert!(picker.delegate.current_language_candidate_index.is_none());
521 assert_eq!(picker.delegate.selected_index, 0);
522 }
523 });
524 close_selector(workspace, cx);
525 }
526
527 #[gpui::test]
528 async fn test_language_selector_selects_current_language_per_active_editor(
529 cx: &mut TestAppContext,
530 ) {
531 let app_state = init_test(cx);
532 app_state
533 .fs
534 .as_fake()
535 .insert_tree(
536 path!("/test"),
537 json!({
538 "rust_file.rs": "fn main() {}\n",
539 "typescript_file.ts": "const value = 1;\n",
540 }),
541 )
542 .await;
543
544 let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await;
545 let (multi_workspace, cx) =
546 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
547 let workspace =
548 multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone());
549 register_test_languages(&project, cx);
550
551 let rust_editor = open_file_editor(&workspace, &project, "rust_file.rs", cx).await;
552 let typescript_editor =
553 open_file_editor(&workspace, &project, "typescript_file.ts", cx).await;
554 let empty_editor = open_empty_editor(&workspace, &project, cx).await;
555
556 set_editor_language(&project, &rust_editor, "Rust", cx).await;
557 set_editor_language(&project, &typescript_editor, "TypeScript", cx).await;
558 cx.run_until_parked();
559
560 assert_selected_language_for_editor(&workspace, &rust_editor, Some("Rust"), cx);
561 assert_selected_language_for_editor(&workspace, &typescript_editor, Some("TypeScript"), cx);
562 // Ensure the empty editor's buffer has no language before asserting
563 let (_, buffer, _) = empty_editor.read_with(cx, |editor, cx| {
564 editor
565 .active_excerpt(cx)
566 .expect("editor should have an active excerpt")
567 });
568 buffer.update(cx, |buffer, cx| {
569 buffer.set_language(None, cx);
570 });
571 assert_selected_language_for_editor(&workspace, &empty_editor, None, cx);
572 }
573}