From 8b2829e1122094b6cd3fcce5e016f08cb5741281 Mon Sep 17 00:00:00 2001 From: Xiaobo Liu Date: Thu, 26 Feb 2026 23:18:25 +0800 Subject: [PATCH] language_selector: Auto-select current language when opening (#48475) --- Cargo.lock | 1 + crates/language_selector/Cargo.toml | 1 + .../src/language_selector.rs | 282 +++++++++++++++++- 3 files changed, 281 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 39826863f2f483b73475a5f325ee8bc3e5f70801..4a7097d4c1c6f2b9e56fb873b32b2da71e4d36ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9283,6 +9283,7 @@ dependencies = [ "open_path_prompt", "picker", "project", + "serde_json", "settings", "ui", "util", diff --git a/crates/language_selector/Cargo.toml b/crates/language_selector/Cargo.toml index 115509f512ae40f8f5ec9f8f588814cc4a3fa6af..1c236bba260e5825156005663b121dcd9ebc7b26 100644 --- a/crates/language_selector/Cargo.toml +++ b/crates/language_selector/Cargo.toml @@ -29,3 +29,4 @@ workspace.workspace = true [dev-dependencies] editor = { workspace = true, features = ["test-support"] } +serde_json.workspace = true diff --git a/crates/language_selector/src/language_selector.rs b/crates/language_selector/src/language_selector.rs index 4c8de90c14c556270386acad34b47961326b3f36..17a39d4979a1321a4b0e612bff228f186098babf 100644 --- a/crates/language_selector/src/language_selector.rs +++ b/crates/language_selector/src/language_selector.rs @@ -71,11 +71,16 @@ impl LanguageSelector { window: &mut Window, cx: &mut Context, ) -> Self { + let current_language_name = buffer + .read(cx) + .language() + .map(|language| language.name().as_ref().to_string()); let delegate = LanguageSelectorDelegate::new( cx.entity().downgrade(), buffer, project, language_registry, + current_language_name, ); let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); @@ -109,6 +114,7 @@ pub struct LanguageSelectorDelegate { candidates: Vec, matches: Vec, selected_index: usize, + current_language_candidate_index: Option, } impl LanguageSelectorDelegate { @@ -117,6 +123,7 @@ impl LanguageSelectorDelegate { buffer: Entity, project: Entity, language_registry: Arc, + current_language_name: Option, ) -> Self { let candidates = language_registry .language_names() @@ -132,6 +139,12 @@ impl LanguageSelectorDelegate { .map(|(candidate_id, name)| StringMatchCandidate::new(candidate_id, name.as_ref())) .collect::>(); + let current_language_candidate_index = current_language_name.as_ref().and_then(|name| { + candidates + .iter() + .position(|candidate| candidate.string == *name) + }); + Self { language_selector, buffer, @@ -139,7 +152,8 @@ impl LanguageSelectorDelegate { language_registry, candidates, matches: vec![], - selected_index: 0, + selected_index: current_language_candidate_index.unwrap_or(0), + current_language_candidate_index, } } @@ -239,8 +253,9 @@ impl PickerDelegate for LanguageSelectorDelegate { ) -> gpui::Task<()> { let background = cx.background_executor().clone(); let candidates = self.candidates.clone(); + let query_is_empty = query.is_empty(); cx.spawn_in(window, async move |this, cx| { - let matches = if query.is_empty() { + let matches = if query_is_empty { candidates .into_iter() .enumerate() @@ -264,12 +279,21 @@ impl PickerDelegate for LanguageSelectorDelegate { .await }; - this.update(cx, |this, cx| { + this.update_in(cx, |this, window, cx| { let delegate = &mut this.delegate; delegate.matches = matches; delegate.selected_index = delegate .selected_index .min(delegate.matches.len().saturating_sub(1)); + + if query_is_empty { + if let Some(index) = delegate + .current_language_candidate_index + .and_then(|ci| delegate.matches.iter().position(|m| m.candidate_id == ci)) + { + this.set_selected_index(index, None, false, window, cx); + } + } cx.notify(); }) .log_err(); @@ -295,3 +319,255 @@ impl PickerDelegate for LanguageSelectorDelegate { ) } } + +#[cfg(test)] +mod tests { + use super::*; + use editor::Editor; + use gpui::{TestAppContext, VisualTestContext}; + use language::{Language, LanguageConfig}; + use project::{Project, ProjectPath}; + use serde_json::json; + use std::sync::Arc; + use util::{path, rel_path::rel_path}; + use workspace::{AppState, MultiWorkspace, Workspace}; + + fn init_test(cx: &mut TestAppContext) -> Arc { + cx.update(|cx| { + let app_state = AppState::test(cx); + settings::init(cx); + super::init(cx); + editor::init(cx); + app_state + }) + } + + fn register_test_languages(project: &Entity, cx: &mut VisualTestContext) { + project.read_with(cx, |project, _| { + let language_registry = project.languages(); + language_registry.add(Arc::new(Language::new( + LanguageConfig { + name: "Rust".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + ..Default::default() + }, + None, + ))); + language_registry.add(Arc::new(Language::new( + LanguageConfig { + name: "TypeScript".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["ts".to_string()], + ..Default::default() + }, + ..Default::default() + }, + None, + ))); + }); + } + + async fn open_file_editor( + workspace: &Entity, + project: &Entity, + file_path: &str, + cx: &mut VisualTestContext, + ) -> Entity { + let worktree_id = project.update(cx, |project, cx| { + project + .worktrees(cx) + .next() + .expect("project should have a worktree") + .read(cx) + .id() + }); + let project_path = ProjectPath { + worktree_id, + path: rel_path(file_path).into(), + }; + let opened_item = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path(project_path, None, true, window, cx) + }) + .await + .expect("file should open"); + + cx.update(|_, cx| { + opened_item + .act_as::(cx) + .expect("opened item should be an editor") + }) + } + + async fn open_empty_editor( + workspace: &Entity, + project: &Entity, + cx: &mut VisualTestContext, + ) -> Entity { + let create_buffer = project.update(cx, |project, cx| project.create_buffer(None, true, cx)); + let buffer = create_buffer.await.expect("empty buffer should be created"); + let editor = cx.new_window_entity(|window, cx| { + Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx) + }); + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_center(Box::new(editor.clone()), window, cx); + }); + // Ensure the buffer has no language after the editor is created + buffer.update(cx, |buffer, cx| { + buffer.set_language(None, cx); + }); + editor + } + + async fn set_editor_language( + project: &Entity, + editor: &Entity, + language_name: &str, + cx: &mut VisualTestContext, + ) { + let language = project + .read_with(cx, |project, _| { + project.languages().language_for_name(language_name) + }) + .await + .expect("language should exist in registry"); + editor.update(cx, move |editor, cx| { + let (_, buffer, _) = editor + .active_excerpt(cx) + .expect("editor should have an active excerpt"); + buffer.update(cx, |buffer, cx| { + buffer.set_language(Some(language), cx); + }); + }); + } + + fn active_picker( + workspace: &Entity, + cx: &mut VisualTestContext, + ) -> Entity> { + workspace.update(cx, |workspace, cx| { + workspace + .active_modal::(cx) + .expect("language selector should be open") + .read(cx) + .picker + .clone() + }) + } + + fn open_selector( + workspace: &Entity, + cx: &mut VisualTestContext, + ) -> Entity> { + cx.dispatch_action(Toggle); + cx.run_until_parked(); + active_picker(workspace, cx) + } + + fn close_selector(workspace: &Entity, cx: &mut VisualTestContext) { + cx.dispatch_action(Toggle); + cx.run_until_parked(); + workspace.read_with(cx, |workspace, cx| { + assert!( + workspace.active_modal::(cx).is_none(), + "language selector should be closed" + ); + }); + } + + fn assert_selected_language_for_editor( + workspace: &Entity, + editor: &Entity, + expected_language_name: Option<&str>, + cx: &mut VisualTestContext, + ) { + workspace.update_in(cx, |workspace, window, cx| { + let was_activated = workspace.activate_item(editor, true, true, window, cx); + assert!( + was_activated, + "editor should be activated before opening the modal" + ); + }); + cx.run_until_parked(); + + let picker = open_selector(workspace, cx); + picker.read_with(cx, |picker, _| { + let selected_match = picker + .delegate + .matches + .get(picker.delegate.selected_index) + .expect("selected index should point to a match"); + let selected_candidate = picker + .delegate + .candidates + .get(selected_match.candidate_id) + .expect("selected match should map to a candidate"); + + if let Some(expected_language_name) = expected_language_name { + let current_language_candidate_index = picker + .delegate + .current_language_candidate_index + .expect("current language should map to a candidate"); + assert_eq!( + selected_match.candidate_id, + current_language_candidate_index + ); + assert_eq!(selected_candidate.string, expected_language_name); + } else { + assert!(picker.delegate.current_language_candidate_index.is_none()); + assert_eq!(picker.delegate.selected_index, 0); + } + }); + close_selector(workspace, cx); + } + + #[gpui::test] + async fn test_language_selector_selects_current_language_per_active_editor( + cx: &mut TestAppContext, + ) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree( + path!("/test"), + json!({ + "rust_file.rs": "fn main() {}\n", + "typescript_file.ts": "const value = 1;\n", + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()); + register_test_languages(&project, cx); + + let rust_editor = open_file_editor(&workspace, &project, "rust_file.rs", cx).await; + let typescript_editor = + open_file_editor(&workspace, &project, "typescript_file.ts", cx).await; + let empty_editor = open_empty_editor(&workspace, &project, cx).await; + + set_editor_language(&project, &rust_editor, "Rust", cx).await; + set_editor_language(&project, &typescript_editor, "TypeScript", cx).await; + cx.run_until_parked(); + + assert_selected_language_for_editor(&workspace, &rust_editor, Some("Rust"), cx); + assert_selected_language_for_editor(&workspace, &typescript_editor, Some("TypeScript"), cx); + // Ensure the empty editor's buffer has no language before asserting + let (_, buffer, _) = empty_editor.read_with(cx, |editor, cx| { + editor + .active_excerpt(cx) + .expect("editor should have an active excerpt") + }); + buffer.update(cx, |buffer, cx| { + buffer.set_language(None, cx); + }); + assert_selected_language_for_editor(&workspace, &empty_editor, None, cx); + } +}