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}