language_selector.rs

  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}