Skip to repository content677 lines · 23.2 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:36:33.960Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
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, 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