repl_menu.rs

  1use std::time::Duration;
  2
  3use gpui::{percentage, Animation, AnimationExt, AnyElement, Transformation, View};
  4use picker::Picker;
  5use repl::{
  6    components::{KernelPickerDelegate, KernelSelector},
  7    worktree_id_for_editor, ExecutionState, JupyterSettings, Kernel, KernelSpecification,
  8    KernelStatus, Session, SessionSupport,
  9};
 10use ui::{
 11    prelude::*, ButtonLike, ContextMenu, IconWithIndicator, Indicator, IntoElement, PopoverMenu,
 12    PopoverMenuHandle, Tooltip,
 13};
 14
 15use gpui::ElementId;
 16use util::ResultExt;
 17
 18use crate::QuickActionBar;
 19
 20const ZED_REPL_DOCUMENTATION: &str = "https://zed.dev/docs/repl";
 21
 22struct ReplMenuState {
 23    tooltip: SharedString,
 24    icon: IconName,
 25    icon_color: Color,
 26    icon_is_animating: bool,
 27    popover_disabled: bool,
 28    indicator: Option<Indicator>,
 29
 30    status: KernelStatus,
 31    kernel_name: SharedString,
 32    kernel_language: SharedString,
 33}
 34
 35impl QuickActionBar {
 36    pub fn render_repl_menu(&self, cx: &mut ViewContext<Self>) -> Option<AnyElement> {
 37        if !JupyterSettings::enabled(cx) {
 38            return None;
 39        }
 40
 41        let editor = self.active_editor()?;
 42
 43        let is_local_project = editor
 44            .read(cx)
 45            .workspace()
 46            .map(|workspace| workspace.read(cx).project().read(cx).is_local())
 47            .unwrap_or(false);
 48
 49        if !is_local_project {
 50            return None;
 51        }
 52
 53        let has_nonempty_selection = {
 54            editor.update(cx, |this, cx| {
 55                this.selections
 56                    .count()
 57                    .ne(&0)
 58                    .then(|| {
 59                        let latest = this.selections.newest_display(cx);
 60                        !latest.is_empty()
 61                    })
 62                    .unwrap_or_default()
 63            })
 64        };
 65
 66        let session = repl::session(editor.downgrade(), cx);
 67        let session = match session {
 68            SessionSupport::ActiveSession(session) => session,
 69            SessionSupport::Inactive(spec) => {
 70                return self.render_repl_launch_menu(spec, cx);
 71            }
 72            SessionSupport::RequiresSetup(language) => {
 73                return self.render_repl_setup(&language.0, cx);
 74            }
 75            SessionSupport::Unsupported => return None,
 76        };
 77
 78        let menu_state = session_state(session.clone(), cx);
 79
 80        let id = "repl-menu".to_string();
 81
 82        let element_id = |suffix| ElementId::Name(format!("{}-{}", id, suffix).into());
 83
 84        let editor = editor.downgrade();
 85        let dropdown_menu = PopoverMenu::new(element_id("menu"))
 86            .menu(move |cx| {
 87                let editor = editor.clone();
 88                let session = session.clone();
 89                ContextMenu::build(cx, move |menu, cx| {
 90                    let menu_state = session_state(session, cx);
 91                    let status = menu_state.status;
 92                    let editor = editor.clone();
 93
 94                    menu.map(|menu| {
 95                        if status.is_connected() {
 96                            let status = status.clone();
 97                            menu.custom_row(move |_cx| {
 98                                h_flex()
 99                                    .child(
100                                        Label::new(format!(
101                                            "kernel: {} ({})",
102                                            menu_state.kernel_name.clone(),
103                                            menu_state.kernel_language.clone()
104                                        ))
105                                        .size(LabelSize::Small)
106                                        .color(Color::Muted),
107                                    )
108                                    .into_any_element()
109                            })
110                            .custom_row(move |_cx| {
111                                h_flex()
112                                    .child(
113                                        Label::new(status.clone().to_string())
114                                            .size(LabelSize::Small)
115                                            .color(Color::Muted),
116                                    )
117                                    .into_any_element()
118                            })
119                        } else {
120                            let status = status.clone();
121                            menu.custom_row(move |_cx| {
122                                h_flex()
123                                    .child(
124                                        Label::new(format!("{}...", status.clone().to_string()))
125                                            .size(LabelSize::Small)
126                                            .color(Color::Muted),
127                                    )
128                                    .into_any_element()
129                            })
130                        }
131                    })
132                    .separator()
133                    .custom_entry(
134                        move |_cx| {
135                            Label::new(if has_nonempty_selection {
136                                "Run Selection"
137                            } else {
138                                "Run Line"
139                            })
140                            .into_any_element()
141                        },
142                        {
143                            let editor = editor.clone();
144                            move |cx| {
145                                repl::run(editor.clone(), true, cx).log_err();
146                            }
147                        },
148                    )
149                    .custom_entry(
150                        move |_cx| {
151                            Label::new("Interrupt")
152                                .size(LabelSize::Small)
153                                .color(Color::Error)
154                                .into_any_element()
155                        },
156                        {
157                            let editor = editor.clone();
158                            move |cx| {
159                                repl::interrupt(editor.clone(), cx);
160                            }
161                        },
162                    )
163                    .custom_entry(
164                        move |_cx| {
165                            Label::new("Clear Outputs")
166                                .size(LabelSize::Small)
167                                .color(Color::Muted)
168                                .into_any_element()
169                        },
170                        {
171                            let editor = editor.clone();
172                            move |cx| {
173                                repl::clear_outputs(editor.clone(), cx);
174                            }
175                        },
176                    )
177                    .separator()
178                    .custom_entry(
179                        move |_cx| {
180                            Label::new("Shut Down Kernel")
181                                .size(LabelSize::Small)
182                                .color(Color::Error)
183                                .into_any_element()
184                        },
185                        {
186                            let editor = editor.clone();
187                            move |cx| {
188                                repl::shutdown(editor.clone(), cx);
189                            }
190                        },
191                    )
192                    .custom_entry(
193                        move |_cx| {
194                            Label::new("Restart Kernel")
195                                .size(LabelSize::Small)
196                                .color(Color::Error)
197                                .into_any_element()
198                        },
199                        {
200                            let editor = editor.clone();
201                            move |cx| {
202                                repl::restart(editor.clone(), cx);
203                            }
204                        },
205                    )
206                    .separator()
207                    .action("View Sessions", Box::new(repl::Sessions))
208                    // TODO: Add shut down all kernels action
209                    // .action("Shut Down all Kernels", Box::new(gpui::NoAction))
210                })
211                .into()
212            })
213            .trigger(
214                ButtonLike::new_rounded_right(element_id("dropdown"))
215                    .child(
216                        Icon::new(IconName::ChevronDownSmall)
217                            .size(IconSize::XSmall)
218                            .color(Color::Muted),
219                    )
220                    .tooltip(move |cx| Tooltip::text("REPL Menu", cx))
221                    .width(rems(1.).into())
222                    .disabled(menu_state.popover_disabled),
223            );
224
225        let button = ButtonLike::new_rounded_left("toggle_repl_icon")
226            .child(if menu_state.icon_is_animating {
227                Icon::new(menu_state.icon)
228                    .color(menu_state.icon_color)
229                    .with_animation(
230                        "arrow-circle",
231                        Animation::new(Duration::from_secs(5)).repeat(),
232                        |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
233                    )
234                    .into_any_element()
235            } else {
236                IconWithIndicator::new(
237                    Icon::new(IconName::ReplNeutral).color(menu_state.icon_color),
238                    menu_state.indicator,
239                )
240                .indicator_border_color(Some(cx.theme().colors().toolbar_background))
241                .into_any_element()
242            })
243            .size(ButtonSize::Compact)
244            .style(ButtonStyle::Subtle)
245            .tooltip(move |cx| Tooltip::text(menu_state.tooltip.clone(), cx))
246            .on_click(|_, cx| cx.dispatch_action(Box::new(repl::Run {})))
247            .into_any_element();
248
249        Some(
250            h_flex()
251                .child(self.render_kernel_selector(cx))
252                .child(button)
253                .child(dropdown_menu)
254                .into_any_element(),
255        )
256    }
257    pub fn render_repl_launch_menu(
258        &self,
259        kernel_specification: KernelSpecification,
260        cx: &mut ViewContext<Self>,
261    ) -> Option<AnyElement> {
262        let tooltip: SharedString =
263            SharedString::from(format!("Start REPL for {}", kernel_specification.name()));
264
265        Some(
266            h_flex()
267                .child(self.render_kernel_selector(cx))
268                .child(
269                    IconButton::new("toggle_repl_icon", IconName::ReplNeutral)
270                        .size(ButtonSize::Compact)
271                        .icon_color(Color::Muted)
272                        .style(ButtonStyle::Subtle)
273                        .tooltip(move |cx| Tooltip::text(tooltip.clone(), cx))
274                        .on_click(|_, cx| cx.dispatch_action(Box::new(repl::Run {}))),
275                )
276                .into_any_element(),
277        )
278    }
279
280    pub fn render_kernel_selector(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
281        let editor = if let Some(editor) = self.active_editor() {
282            editor
283        } else {
284            return div().into_any_element();
285        };
286
287        let Some(worktree_id) = worktree_id_for_editor(editor.downgrade(), cx) else {
288            return div().into_any_element();
289        };
290
291        let session = repl::session(editor.downgrade(), cx);
292
293        let current_kernelspec = match session {
294            SessionSupport::ActiveSession(view) => Some(view.read(cx).kernel_specification.clone()),
295            SessionSupport::Inactive(kernel_specification) => Some(kernel_specification),
296            SessionSupport::RequiresSetup(_language_name) => None,
297            SessionSupport::Unsupported => None,
298        };
299
300        let current_kernel_name = current_kernelspec.as_ref().map(|spec| spec.name());
301
302        let menu_handle: PopoverMenuHandle<Picker<KernelPickerDelegate>> =
303            PopoverMenuHandle::default();
304        KernelSelector::new(
305            {
306                Box::new(move |kernelspec, cx| {
307                    repl::assign_kernelspec(kernelspec, editor.downgrade(), cx).ok();
308                })
309            },
310            worktree_id,
311            ButtonLike::new("kernel-selector")
312                .style(ButtonStyle::Subtle)
313                .child(
314                    h_flex()
315                        .w_full()
316                        .gap_0p5()
317                        .child(
318                            div()
319                                .overflow_x_hidden()
320                                .flex_grow()
321                                .whitespace_nowrap()
322                                .child(
323                                    Label::new(if let Some(name) = current_kernel_name {
324                                        name
325                                    } else {
326                                        SharedString::from("Select Kernel")
327                                    })
328                                    .size(LabelSize::Small)
329                                    .color(if current_kernelspec.is_some() {
330                                        Color::Default
331                                    } else {
332                                        Color::Placeholder
333                                    })
334                                    .into_any_element(),
335                                ),
336                        )
337                        .child(
338                            Icon::new(IconName::ChevronDown)
339                                .color(Color::Muted)
340                                .size(IconSize::XSmall),
341                        ),
342                )
343                .tooltip(move |cx| Tooltip::text("Select Kernel", cx)),
344        )
345        .with_handle(menu_handle.clone())
346        .into_any_element()
347    }
348
349    pub fn render_repl_setup(
350        &self,
351        language: &str,
352        cx: &mut ViewContext<Self>,
353    ) -> Option<AnyElement> {
354        let tooltip: SharedString = SharedString::from(format!("Setup Zed REPL for {}", language));
355        Some(
356            h_flex()
357                .child(self.render_kernel_selector(cx))
358                .child(
359                    IconButton::new("toggle_repl_icon", IconName::ReplNeutral)
360                        .size(ButtonSize::Compact)
361                        .icon_color(Color::Muted)
362                        .style(ButtonStyle::Subtle)
363                        .tooltip(move |cx| Tooltip::text(tooltip.clone(), cx))
364                        .on_click(|_, cx| {
365                            cx.open_url(&format!("{}#installation", ZED_REPL_DOCUMENTATION))
366                        }),
367                )
368                .into_any_element(),
369        )
370    }
371}
372
373fn session_state(session: View<Session>, cx: &WindowContext) -> ReplMenuState {
374    let session = session.read(cx);
375
376    let kernel_name = session.kernel_specification.name();
377    let kernel_language: SharedString = session.kernel_specification.language();
378
379    let fill_fields = || {
380        ReplMenuState {
381            tooltip: "Nothing running".into(),
382            icon: IconName::ReplNeutral,
383            icon_color: Color::Default,
384            icon_is_animating: false,
385            popover_disabled: false,
386            indicator: None,
387            kernel_name: kernel_name.clone(),
388            kernel_language: kernel_language.clone(),
389            // todo!(): Technically not shutdown, but indeterminate
390            status: KernelStatus::Shutdown,
391            // current_delta: Duration::default(),
392        }
393    };
394
395    match &session.kernel {
396        Kernel::Restarting => ReplMenuState {
397            tooltip: format!("Restarting {}", kernel_name).into(),
398            icon_is_animating: true,
399            popover_disabled: true,
400            icon_color: Color::Muted,
401            indicator: Some(Indicator::dot().color(Color::Muted)),
402            status: session.kernel.status(),
403            ..fill_fields()
404        },
405        Kernel::RunningKernel(kernel) => match &kernel.execution_state() {
406            ExecutionState::Idle => ReplMenuState {
407                tooltip: format!("Run code on {} ({})", kernel_name, kernel_language).into(),
408                indicator: Some(Indicator::dot().color(Color::Success)),
409                status: session.kernel.status(),
410                ..fill_fields()
411            },
412            ExecutionState::Busy => ReplMenuState {
413                tooltip: format!("Interrupt {} ({})", kernel_name, kernel_language).into(),
414                icon_is_animating: true,
415                popover_disabled: false,
416                indicator: None,
417                status: session.kernel.status(),
418                ..fill_fields()
419            },
420        },
421        Kernel::StartingKernel(_) => ReplMenuState {
422            tooltip: format!("{} is starting", kernel_name).into(),
423            icon_is_animating: true,
424            popover_disabled: true,
425            icon_color: Color::Muted,
426            indicator: Some(Indicator::dot().color(Color::Muted)),
427            status: session.kernel.status(),
428            ..fill_fields()
429        },
430        Kernel::ErroredLaunch(e) => ReplMenuState {
431            tooltip: format!("Error with kernel {}: {}", kernel_name, e).into(),
432            popover_disabled: false,
433            indicator: Some(Indicator::dot().color(Color::Error)),
434            status: session.kernel.status(),
435            ..fill_fields()
436        },
437        Kernel::ShuttingDown => ReplMenuState {
438            tooltip: format!("{} is shutting down", kernel_name).into(),
439            popover_disabled: true,
440            icon_color: Color::Muted,
441            indicator: Some(Indicator::dot().color(Color::Muted)),
442            status: session.kernel.status(),
443            ..fill_fields()
444        },
445        Kernel::Shutdown => ReplMenuState {
446            tooltip: "Nothing running".into(),
447            icon: IconName::ReplNeutral,
448            icon_color: Color::Default,
449            icon_is_animating: false,
450            popover_disabled: false,
451            indicator: None,
452            status: KernelStatus::Shutdown,
453            ..fill_fields()
454        },
455    }
456}