language_selector: Fix language selector query selection (#51581)

Xiaobo Liu created

Closes https://github.com/zed-industries/zed/issues/51576

Release Notes:

- Select first entry in the language selector when matching a query



https://github.com/user-attachments/assets/c824c024-d2f1-416e-a347-0eab7bc3ae0a

Signed-off-by: Xiaobo Liu <cppcoffee@gmail.com>

Change summary

crates/language_selector/src/language_selector.rs | 180 ++++++++++++----
1 file changed, 135 insertions(+), 45 deletions(-)

Detailed changes

crates/language_selector/src/language_selector.rs 🔗

@@ -280,20 +280,28 @@ impl PickerDelegate for LanguageSelectorDelegate {
             };
 
             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);
-                    }
+                if matches.is_empty() {
+                    this.delegate.matches = matches;
+                    this.delegate.selected_index = 0;
+                    cx.notify();
+                    return;
                 }
+
+                let selected_index = if query_is_empty {
+                    this.delegate
+                        .current_language_candidate_index
+                        .and_then(|current_language_candidate_index| {
+                            matches.iter().position(|mat| {
+                                mat.candidate_id == current_language_candidate_index
+                            })
+                        })
+                        .unwrap_or(0)
+                } else {
+                    0
+                };
+
+                this.delegate.matches = matches;
+                this.set_selected_index(selected_index, None, false, window, cx);
                 cx.notify();
             })
             .log_err();
@@ -345,28 +353,25 @@ mod tests {
     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()],
+            for (language_name, path_suffix) in [
+                ("C", "c"),
+                ("Go", "go"),
+                ("Ruby", "rb"),
+                ("Rust", "rs"),
+                ("TypeScript", "ts"),
+            ] {
+                language_registry.add(Arc::new(Language::new(
+                    LanguageConfig {
+                        name: language_name.into(),
+                        matcher: LanguageMatcher {
+                            path_suffixes: vec![path_suffix.to_string()],
+                            ..Default::default()
+                        },
                         ..Default::default()
                     },
-                    ..Default::default()
-                },
-                None,
-            )));
+                    None,
+                )));
+            }
         });
     }
 
@@ -406,6 +411,24 @@ mod tests {
         workspace: &Entity<Workspace>,
         project: &Entity<Project>,
         cx: &mut VisualTestContext,
+    ) -> Entity<Editor> {
+        let editor = open_new_buffer_editor(workspace, project, cx).await;
+        // Ensure the buffer has no language after the editor is created
+        let (_, buffer, _) = 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);
+        });
+        editor
+    }
+
+    async fn open_new_buffer_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");
@@ -415,10 +438,6 @@ mod tests {
         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
     }
 
@@ -559,15 +578,86 @@ mod tests {
 
         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")
+        assert_selected_language_for_editor(&workspace, &empty_editor, None, cx);
+    }
+
+    #[gpui::test]
+    async fn test_language_selector_selects_first_match_after_querying_new_buffer(
+        cx: &mut TestAppContext,
+    ) {
+        let app_state = init_test(cx);
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(path!("/test"), json!({}))
+            .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 editor = open_new_buffer_editor(&workspace, &project, cx).await;
+        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"
+            );
         });
-        buffer.update(cx, |buffer, cx| {
-            buffer.set_language(None, cx);
+        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");
+
+            assert_eq!(selected_candidate.string, "Plain Text");
+            assert!(
+                picker
+                    .delegate
+                    .current_language_candidate_index
+                    .is_some_and(|current_language_candidate_index| {
+                        current_language_candidate_index > 1
+                    }),
+                "test setup should place Plain Text after at least two earlier languages",
+            );
+        });
+
+        picker.update_in(cx, |picker, window, cx| {
+            picker.update_matches("ru".to_string(), window, cx)
+        });
+        cx.run_until_parked();
+
+        picker.read_with(cx, |picker, _| {
+            assert!(
+                picker.delegate.matches.len() > 1,
+                "query should return multiple matches"
+            );
+            assert_eq!(picker.delegate.selected_index, 0);
+
+            let first_match = picker
+                .delegate
+                .matches
+                .first()
+                .expect("query should produce at least one match");
+            let selected_match = picker
+                .delegate
+                .matches
+                .get(picker.delegate.selected_index)
+                .expect("selected index should point to a match");
+
+            assert_eq!(selected_match.candidate_id, first_match.candidate_id);
         });
-        assert_selected_language_for_editor(&workspace, &empty_editor, None, cx);
     }
 }