repl: Improve kernelspec discoverability (#15886)

Kyle Kelley and Jason created

<img width="862" alt="image"
src="https://github.com/user-attachments/assets/ae8c479d-d9f9-4c46-bb1a-be411ab07876">

Release Notes:

- Added additional context about available to kernel sessions
- Fixed bug in kernelspec launch choosing first available kernel
matching the language rather than selected name

---------

Co-authored-by: Jason <jason@zed.dev>

Change summary

crates/repl/src/repl_editor.rs      |  6 +
crates/repl/src/repl_sessions_ui.rs | 89 ++++++++++++++++++++++++++----
crates/repl/src/repl_store.rs       | 32 ++++++----
3 files changed, 97 insertions(+), 30 deletions(-)

Detailed changes

crates/repl/src/repl_editor.rs 🔗

@@ -37,7 +37,7 @@ pub fn run(editor: WeakView<Editor>, move_down: bool, cx: &mut WindowContext) ->
 
         let kernel_specification = store.update(cx, |store, cx| {
             store
-                .kernelspec(&language, cx)
+                .kernelspec(language.code_fence_block_name().as_ref(), cx)
                 .with_context(|| format!("No kernel found for language: {}", language.name()))
         })?;
 
@@ -114,7 +114,9 @@ pub fn session(editor: WeakView<Editor>, cx: &mut AppContext) -> SessionSupport
     let Some(language) = get_language(editor, cx) else {
         return SessionSupport::Unsupported;
     };
-    let kernelspec = store.update(cx, |store, cx| store.kernelspec(&language, cx));
+    let kernelspec = store.update(cx, |store, cx| {
+        store.kernelspec(language.code_fence_block_name().as_ref(), cx)
+    });
 
     match kernelspec {
         Some(kernelspec) => SessionSupport::Inactive(Box::new(kernelspec)),

crates/repl/src/repl_sessions_ui.rs 🔗

@@ -1,17 +1,18 @@
+use collections::HashMap;
 use editor::Editor;
 use gpui::{
     actions, prelude::*, AnyElement, AppContext, EventEmitter, FocusHandle, FocusableView,
-    Subscription, View,
+    FontWeight, Subscription, View,
 };
-use ui::{prelude::*, ButtonLike, ElevationIndex, KeyBinding};
+use ui::{prelude::*, ButtonLike, ElevationIndex, KeyBinding, ListItem, Tooltip};
 use util::ResultExt as _;
 use workspace::item::ItemEvent;
 use workspace::WorkspaceId;
 use workspace::{item::Item, Workspace};
 
-use crate::components::KernelListItem;
 use crate::jupyter_settings::JupyterSettings;
 use crate::repl_store::ReplStore;
+use crate::KernelSpecification;
 
 actions!(
     repl,
@@ -216,13 +217,79 @@ impl Render for ReplSessionsPage {
                             .child(Label::new("Install Kernels"))
                             .on_click(move |_, cx| {
                                 cx.open_url(
-                                    "https://docs.jupyter.org/en/latest/install/kernels.html",
+                                    "https://zed.dev/docs/repl#language-specific-instructions",
                                 )
                             }),
                     ),
                 );
         }
 
+        let mut kernels_by_language: HashMap<String, Vec<KernelSpecification>> = HashMap::default();
+        for spec in kernel_specifications {
+            kernels_by_language
+                .entry(spec.kernelspec.language.clone())
+                .or_insert_with(Vec::new)
+                .push(spec);
+        }
+
+        let kernels_available = v_flex()
+            .child(Label::new("Kernels available").size(LabelSize::Large))
+            .gap_2()
+            .child(
+                h_flex()
+                    .child(Label::new(
+                        "Defaults indicated with a checkmark. Learn how to change your default kernel in the ",
+                    ))
+                    .child(
+                        ButtonLike::new("configure-kernels")
+                            .style(ButtonStyle::Filled)
+                            // .size(ButtonSize::Compact)
+                            .layer(ElevationIndex::Surface)
+                            .child(Label::new("REPL documentation"))
+                            .child(Icon::new(IconName::Link))
+                            .on_click(move |_, cx| {
+                                cx.open_url("https://zed.dev/docs/repl#changing-kernels")
+                            }),
+                    ),
+            )
+            .children(kernels_by_language.into_iter().map(|(language, specs)| {
+                let chosen_kernel = store.read(cx).kernelspec(&language, cx);
+
+                v_flex()
+                    .gap_1()
+                    .child(Label::new(language.clone()).weight(FontWeight::BOLD))
+                    .children(specs.into_iter().map(|spec| {
+                        let is_choice = if let Some(chosen_kernel) = &chosen_kernel {
+                            chosen_kernel.name.to_lowercase() == spec.name.to_lowercase()
+                                && chosen_kernel.path == spec.path
+                        } else {
+                            false
+                        };
+
+                        let path = SharedString::from(spec.path.to_string_lossy().to_string());
+
+                        ListItem::new(path.clone())
+                            .selectable(false)
+                            .tooltip({
+                                let path = path.clone();
+                                move |cx| Tooltip::text(path.clone(), cx)})
+                            .child(
+                                h_flex()
+                                    .gap_1()
+                                    .child(div().id(path.clone()).child(Label::new(spec.name.clone())))
+                                    .when(is_choice, |el| {
+
+                                        let language = language.clone();
+
+                                        el.child(
+
+                                        div().id("check").tooltip(move |cx| Tooltip::text(format!("Default Kernel for {language}"), cx))
+                                            .child(Icon::new(IconName::Check)))}),
+                            )
+
+                    }))
+            }));
+
         // When there are no sessions, show the command to run code in an editor
         if sessions.is_empty() {
             let instructions = "To run code in a Jupyter kernel, select some code and use the 'repl::Run' command.";
@@ -233,18 +300,12 @@ impl Render for ReplSessionsPage {
                         .child(Label::new(instructions))
                         .children(KeyBinding::for_action(&Run, cx)),
                 )
-                .child(Label::new("Kernels available").size(LabelSize::Large))
-                .children(kernel_specifications.into_iter().map(|spec| {
-                    KernelListItem::new(spec.clone()).child(
-                        h_flex()
-                            .gap_2()
-                            .child(Label::new(spec.name))
-                            .child(Label::new(spec.kernelspec.language).color(Color::Muted)),
-                    )
-                }));
+                .child(div().pt_3().child(kernels_available));
         }
 
-        ReplSessionsContainer::new("Jupyter Kernel Sessions").children(sessions)
+        ReplSessionsContainer::new("Jupyter Kernel Sessions")
+            .children(sessions)
+            .child(kernels_available)
     }
 }
 

crates/repl/src/repl_store.rs 🔗

@@ -7,7 +7,6 @@ use command_palette_hooks::CommandPaletteFilter;
 use gpui::{
     prelude::*, AppContext, EntityId, Global, Model, ModelContext, Subscription, Task, View,
 };
-use language::Language;
 use project::Fs;
 use settings::{Settings, SettingsStore};
 
@@ -118,26 +117,31 @@ impl ReplStore {
         })
     }
 
-    pub fn kernelspec(
-        &self,
-        language: &Language,
-        cx: &mut ModelContext<Self>,
-    ) -> Option<KernelSpecification> {
+    pub fn kernelspec(&self, language_name: &str, cx: &AppContext) -> Option<KernelSpecification> {
         let settings = JupyterSettings::get_global(cx);
-        let language_name = language.code_fence_block_name();
-        let selected_kernel = settings.kernel_selections.get(language_name.as_ref());
+        let selected_kernel = settings.kernel_selections.get(language_name);
 
-        self.kernel_specifications
+        let found_by_name = self
+            .kernel_specifications
             .iter()
             .find(|runtime_specification| {
                 if let Some(selected) = selected_kernel {
                     // Top priority is the selected kernel
-                    runtime_specification.name.to_lowercase() == selected.to_lowercase()
-                } else {
-                    // Otherwise, we'll try to find a kernel that matches the language
-                    runtime_specification.kernelspec.language.to_lowercase()
-                        == language_name.to_lowercase()
+                    return runtime_specification.name.to_lowercase() == selected.to_lowercase();
                 }
+                return false;
+            })
+            .cloned();
+
+        if let Some(found_by_name) = found_by_name {
+            return Some(found_by_name);
+        }
+
+        self.kernel_specifications
+            .iter()
+            .find(|runtime_specification| {
+                runtime_specification.kernelspec.language.to_lowercase()
+                    == language_name.to_lowercase()
             })
             .cloned()
     }