Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T03:36:33.960Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

language_selector.rs

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