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_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}