Cargo.lock 🔗
@@ -9283,6 +9283,7 @@ dependencies = [
"open_path_prompt",
"picker",
"project",
+ "serde_json",
"settings",
"ui",
"util",
Xiaobo Liu created
Cargo.lock | 1
crates/language_selector/Cargo.toml | 1
crates/language_selector/src/language_selector.rs | 282 ++++++++++++++++
3 files changed, 281 insertions(+), 3 deletions(-)
@@ -9283,6 +9283,7 @@ dependencies = [
"open_path_prompt",
"picker",
"project",
+ "serde_json",
"settings",
"ui",
"util",
@@ -29,3 +29,4 @@ workspace.workspace = true
[dev-dependencies]
editor = { workspace = true, features = ["test-support"] }
+serde_json.workspace = true
@@ -71,11 +71,16 @@ impl LanguageSelector {
window: &mut Window,
cx: &mut Context<Self>,
) -> 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<StringMatchCandidate>,
matches: Vec<StringMatch>,
selected_index: usize,
+ current_language_candidate_index: Option<usize>,
}
impl LanguageSelectorDelegate {
@@ -117,6 +123,7 @@ impl LanguageSelectorDelegate {
buffer: Entity<Buffer>,
project: Entity<Project>,
language_registry: Arc<LanguageRegistry>,
+ current_language_name: Option<String>,
) -> 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::<Vec<_>>();
+ 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<AppState> {
+ 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<Project>, 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<Workspace>,
+ project: &Entity<Project>,
+ file_path: &str,
+ cx: &mut VisualTestContext,
+ ) -> Entity<Editor> {
+ 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::<Editor>(cx)
+ .expect("opened item should be an editor")
+ })
+ }
+
+ async fn open_empty_editor(
+ workspace: &Entity<Workspace>,
+ project: &Entity<Project>,
+ cx: &mut VisualTestContext,
+ ) -> Entity<Editor> {
+ 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<Project>,
+ editor: &Entity<Editor>,
+ 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<Workspace>,
+ cx: &mut VisualTestContext,
+ ) -> Entity<Picker<LanguageSelectorDelegate>> {
+ workspace.update(cx, |workspace, cx| {
+ workspace
+ .active_modal::<LanguageSelector>(cx)
+ .expect("language selector should be open")
+ .read(cx)
+ .picker
+ .clone()
+ })
+ }
+
+ fn open_selector(
+ workspace: &Entity<Workspace>,
+ cx: &mut VisualTestContext,
+ ) -> Entity<Picker<LanguageSelectorDelegate>> {
+ cx.dispatch_action(Toggle);
+ cx.run_until_parked();
+ active_picker(workspace, cx)
+ }
+
+ fn close_selector(workspace: &Entity<Workspace>, cx: &mut VisualTestContext) {
+ cx.dispatch_action(Toggle);
+ cx.run_until_parked();
+ workspace.read_with(cx, |workspace, cx| {
+ assert!(
+ workspace.active_modal::<LanguageSelector>(cx).is_none(),
+ "language selector should be closed"
+ );
+ });
+ }
+
+ fn assert_selected_language_for_editor(
+ workspace: &Entity<Workspace>,
+ editor: &Entity<Editor>,
+ 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);
+ }
+}