runtime_panel.rs

  1use crate::{
  2    jupyter_settings::{JupyterDockPosition, JupyterSettings},
  3    kernels::{kernel_specifications, KernelSpecification},
  4    session::{Session, SessionEvent},
  5};
  6use anyhow::{Context as _, Result};
  7use collections::HashMap;
  8use editor::{Anchor, Editor, RangeToAnchorExt};
  9use gpui::{
 10    actions, prelude::*, AppContext, AsyncWindowContext, Entity, EntityId, EventEmitter,
 11    FocusHandle, FocusOutEvent, FocusableView, Subscription, Task, View, WeakView,
 12};
 13use language::Point;
 14use project::Fs;
 15use settings::{Settings as _, SettingsStore};
 16use std::{ops::Range, sync::Arc};
 17use ui::{prelude::*, ButtonLike, ElevationIndex, KeyBinding};
 18use workspace::{
 19    dock::{Panel, PanelEvent},
 20    Workspace,
 21};
 22
 23actions!(repl, [Run, ToggleFocus, ClearOutputs]);
 24
 25pub fn init(cx: &mut AppContext) {
 26    cx.observe_new_views(
 27        |workspace: &mut Workspace, _cx: &mut ViewContext<Workspace>| {
 28            workspace
 29                .register_action(|workspace, _: &ToggleFocus, cx| {
 30                    workspace.toggle_panel_focus::<RuntimePanel>(cx);
 31                })
 32                .register_action(run)
 33                .register_action(clear_outputs);
 34        },
 35    )
 36    .detach();
 37}
 38
 39pub struct RuntimePanel {
 40    fs: Arc<dyn Fs>,
 41    enabled: bool,
 42    focus_handle: FocusHandle,
 43    width: Option<Pixels>,
 44    sessions: HashMap<EntityId, View<Session>>,
 45    kernel_specifications: Vec<KernelSpecification>,
 46    _subscriptions: Vec<Subscription>,
 47}
 48
 49impl RuntimePanel {
 50    pub fn load(
 51        workspace: WeakView<Workspace>,
 52        cx: AsyncWindowContext,
 53    ) -> Task<Result<View<Self>>> {
 54        cx.spawn(|mut cx| async move {
 55            let view = workspace.update(&mut cx, |workspace, cx| {
 56                cx.new_view::<Self>(|cx| {
 57                    let focus_handle = cx.focus_handle();
 58
 59                    let fs = workspace.app_state().fs.clone();
 60
 61                    let subscriptions = vec![
 62                        cx.on_focus_in(&focus_handle, Self::focus_in),
 63                        cx.on_focus_out(&focus_handle, Self::focus_out),
 64                        cx.observe_global::<SettingsStore>(move |this, cx| {
 65                            let settings = JupyterSettings::get_global(cx);
 66                            this.set_enabled(settings.enabled, cx);
 67                        }),
 68                    ];
 69
 70                    let enabled = JupyterSettings::get_global(cx).enabled;
 71
 72                    Self {
 73                        fs,
 74                        width: None,
 75                        focus_handle,
 76                        kernel_specifications: Vec::new(),
 77                        sessions: Default::default(),
 78                        _subscriptions: subscriptions,
 79                        enabled,
 80                    }
 81                })
 82            })?;
 83
 84            view.update(&mut cx, |this, cx| this.refresh_kernelspecs(cx))?
 85                .await?;
 86
 87            Ok(view)
 88        })
 89    }
 90
 91    fn set_enabled(&mut self, enabled: bool, cx: &mut ViewContext<Self>) {
 92        if self.enabled != enabled {
 93            self.enabled = enabled;
 94            cx.notify();
 95        }
 96    }
 97
 98    fn focus_in(&mut self, cx: &mut ViewContext<Self>) {
 99        cx.notify();
100    }
101
102    fn focus_out(&mut self, _event: FocusOutEvent, cx: &mut ViewContext<Self>) {
103        cx.notify();
104    }
105
106    // Gets the active selection in the editor or the current line
107    fn selection(&self, editor: View<Editor>, cx: &mut ViewContext<Self>) -> Range<Anchor> {
108        let editor = editor.read(cx);
109        let selection = editor.selections.newest::<usize>(cx);
110        let multi_buffer_snapshot = editor.buffer().read(cx).snapshot(cx);
111
112        let range = if selection.is_empty() {
113            let cursor = selection.head();
114
115            let line_start = multi_buffer_snapshot.offset_to_point(cursor).row;
116            let mut start_offset = multi_buffer_snapshot.point_to_offset(Point::new(line_start, 0));
117
118            // Iterate backwards to find the start of the line
119            while start_offset > 0 {
120                let ch = multi_buffer_snapshot
121                    .chars_at(start_offset - 1)
122                    .next()
123                    .unwrap_or('\0');
124                if ch == '\n' {
125                    break;
126                }
127                start_offset -= 1;
128            }
129
130            let mut end_offset = cursor;
131
132            // Iterate forwards to find the end of the line
133            while end_offset < multi_buffer_snapshot.len() {
134                let ch = multi_buffer_snapshot
135                    .chars_at(end_offset)
136                    .next()
137                    .unwrap_or('\0');
138                if ch == '\n' {
139                    break;
140                }
141                end_offset += 1;
142            }
143
144            // Create a range from the start to the end of the line
145            start_offset..end_offset
146        } else {
147            selection.range()
148        };
149
150        range.to_anchors(&multi_buffer_snapshot)
151    }
152
153    pub fn snippet(
154        &self,
155        editor: View<Editor>,
156        cx: &mut ViewContext<Self>,
157    ) -> Option<(String, Arc<str>, Range<Anchor>)> {
158        let buffer = editor.read(cx).buffer().read(cx).snapshot(cx);
159        let anchor_range = self.selection(editor, cx);
160
161        let selected_text = buffer
162            .text_for_range(anchor_range.clone())
163            .collect::<String>();
164
165        let start_language = buffer.language_at(anchor_range.start);
166        let end_language = buffer.language_at(anchor_range.end);
167
168        let language_name = if start_language == end_language {
169            start_language
170                .map(|language| language.code_fence_block_name())
171                .filter(|lang| **lang != *"markdown")?
172        } else {
173            // If the selection spans multiple languages, don't run it
174            return None;
175        };
176
177        Some((selected_text, language_name, anchor_range))
178    }
179
180    pub fn refresh_kernelspecs(&mut self, cx: &mut ViewContext<Self>) -> Task<anyhow::Result<()>> {
181        let kernel_specifications = kernel_specifications(self.fs.clone());
182        cx.spawn(|this, mut cx| async move {
183            let kernel_specifications = kernel_specifications.await?;
184
185            this.update(&mut cx, |this, cx| {
186                this.kernel_specifications = kernel_specifications;
187                cx.notify();
188            })
189        })
190    }
191
192    pub fn kernelspec(
193        &self,
194        language_name: &str,
195        cx: &mut ViewContext<Self>,
196    ) -> Option<KernelSpecification> {
197        let settings = JupyterSettings::get_global(cx);
198        let selected_kernel = settings.kernel_selections.get(language_name);
199
200        self.kernel_specifications
201            .iter()
202            .find(|runtime_specification| {
203                if let Some(selected) = selected_kernel {
204                    // Top priority is the selected kernel
205                    runtime_specification.name.to_lowercase() == selected.to_lowercase()
206                } else {
207                    // Otherwise, we'll try to find a kernel that matches the language
208                    runtime_specification.kernelspec.language.to_lowercase()
209                        == language_name.to_lowercase()
210                }
211            })
212            .cloned()
213    }
214
215    pub fn run(
216        &mut self,
217        editor: View<Editor>,
218        fs: Arc<dyn Fs>,
219        cx: &mut ViewContext<Self>,
220    ) -> anyhow::Result<()> {
221        if !self.enabled {
222            return Ok(());
223        }
224
225        let (selected_text, language_name, anchor_range) = match self.snippet(editor.clone(), cx) {
226            Some(snippet) => snippet,
227            None => return Ok(()),
228        };
229
230        let entity_id = editor.entity_id();
231
232        let kernel_specification = self
233            .kernelspec(&language_name, cx)
234            .with_context(|| format!("No kernel found for language: {language_name}"))?;
235
236        let session = self.sessions.entry(entity_id).or_insert_with(|| {
237            let view = cx.new_view(|cx| Session::new(editor, fs.clone(), kernel_specification, cx));
238            cx.notify();
239
240            let subscription = cx.subscribe(
241                &view,
242                |panel: &mut RuntimePanel, _session: View<Session>, event: &SessionEvent, _cx| {
243                    match event {
244                        SessionEvent::Shutdown(shutdown_event) => {
245                            panel.sessions.remove(&shutdown_event.entity_id());
246                        }
247                    }
248                    //
249                },
250            );
251
252            subscription.detach();
253
254            view
255        });
256
257        session.update(cx, |session, cx| {
258            session.execute(&selected_text, anchor_range, cx);
259        });
260
261        anyhow::Ok(())
262    }
263
264    pub fn clear_outputs(&mut self, editor: View<Editor>, cx: &mut ViewContext<Self>) {
265        let entity_id = editor.entity_id();
266        if let Some(session) = self.sessions.get_mut(&entity_id) {
267            session.update(cx, |session, cx| {
268                session.clear_outputs(cx);
269            });
270            cx.notify();
271        }
272    }
273}
274
275pub fn run(workspace: &mut Workspace, _: &Run, cx: &mut ViewContext<Workspace>) {
276    let settings = JupyterSettings::get_global(cx);
277    if !settings.enabled {
278        return;
279    }
280
281    let editor = workspace
282        .active_item(cx)
283        .and_then(|item| item.act_as::<Editor>(cx));
284
285    if let (Some(editor), Some(runtime_panel)) = (editor, workspace.panel::<RuntimePanel>(cx)) {
286        runtime_panel.update(cx, |runtime_panel, cx| {
287            runtime_panel
288                .run(editor, workspace.app_state().fs.clone(), cx)
289                .ok();
290        });
291    }
292}
293
294pub fn clear_outputs(workspace: &mut Workspace, _: &ClearOutputs, cx: &mut ViewContext<Workspace>) {
295    let settings = JupyterSettings::get_global(cx);
296    if !settings.enabled {
297        return;
298    }
299
300    let editor = workspace
301        .active_item(cx)
302        .and_then(|item| item.act_as::<Editor>(cx));
303
304    if let (Some(editor), Some(runtime_panel)) = (editor, workspace.panel::<RuntimePanel>(cx)) {
305        runtime_panel.update(cx, |runtime_panel, cx| {
306            runtime_panel.clear_outputs(editor, cx);
307        });
308    }
309}
310
311impl Panel for RuntimePanel {
312    fn persistent_name() -> &'static str {
313        "RuntimePanel"
314    }
315
316    fn position(&self, cx: &ui::WindowContext) -> workspace::dock::DockPosition {
317        match JupyterSettings::get_global(cx).dock {
318            JupyterDockPosition::Left => workspace::dock::DockPosition::Left,
319            JupyterDockPosition::Right => workspace::dock::DockPosition::Right,
320            JupyterDockPosition::Bottom => workspace::dock::DockPosition::Bottom,
321        }
322    }
323
324    fn position_is_valid(&self, _position: workspace::dock::DockPosition) -> bool {
325        true
326    }
327
328    fn set_position(
329        &mut self,
330        position: workspace::dock::DockPosition,
331        cx: &mut ViewContext<Self>,
332    ) {
333        settings::update_settings_file::<JupyterSettings>(self.fs.clone(), cx, move |settings| {
334            let dock = match position {
335                workspace::dock::DockPosition::Left => JupyterDockPosition::Left,
336                workspace::dock::DockPosition::Right => JupyterDockPosition::Right,
337                workspace::dock::DockPosition::Bottom => JupyterDockPosition::Bottom,
338            };
339            settings.set_dock(dock);
340        })
341    }
342
343    fn size(&self, cx: &ui::WindowContext) -> Pixels {
344        let settings = JupyterSettings::get_global(cx);
345
346        self.width.unwrap_or(settings.default_width)
347    }
348
349    fn set_size(&mut self, size: Option<ui::Pixels>, _cx: &mut ViewContext<Self>) {
350        self.width = size;
351    }
352
353    fn icon(&self, _cx: &ui::WindowContext) -> Option<ui::IconName> {
354        if !self.enabled {
355            return None;
356        }
357
358        Some(IconName::Code)
359    }
360
361    fn icon_tooltip(&self, _cx: &ui::WindowContext) -> Option<&'static str> {
362        Some("Runtime Panel")
363    }
364
365    fn toggle_action(&self) -> Box<dyn gpui::Action> {
366        Box::new(ToggleFocus)
367    }
368}
369
370impl EventEmitter<PanelEvent> for RuntimePanel {}
371
372impl FocusableView for RuntimePanel {
373    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
374        self.focus_handle.clone()
375    }
376}
377
378impl Render for RuntimePanel {
379    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
380        // When there are no kernel specifications, show a link to the Zed docs explaining how to
381        // install kernels. It can be assumed they don't have a running kernel if we have no
382        // specifications.
383        if self.kernel_specifications.is_empty() {
384            return v_flex()
385                .p_4()
386                .size_full()
387                .gap_2()
388                        .child(Label::new("No Jupyter Kernels Available").size(LabelSize::Large))
389                        .child(
390                            Label::new("To start interactively running code in your editor, you need to install and configure Jupyter kernels.")
391                                .size(LabelSize::Default),
392                        )
393                        .child(
394                            h_flex().w_full().p_4().justify_center().gap_2().child(
395                                ButtonLike::new("install-kernels")
396                                    .style(ButtonStyle::Filled)
397                                    .size(ButtonSize::Large)
398                                    .layer(ElevationIndex::ModalSurface)
399                                    .child(Label::new("Install Kernels"))
400                                    .on_click(move |_, cx| {
401                                        cx.open_url(
402                                        "https://docs.jupyter.org/en/latest/install/kernels.html",
403                                    )
404                                    }),
405                            ),
406                        )
407                .into_any_element();
408        }
409
410        // When there are no sessions, show the command to run code in an editor
411        if self.sessions.is_empty() {
412            return v_flex()
413                .p_4()
414                .size_full()
415                .gap_2()
416                .child(Label::new("No Jupyter Kernel Sessions").size(LabelSize::Large))
417                .child(
418                    v_flex().child(
419                        Label::new("To run code in a Jupyter kernel, select some code and use the 'repl::Run' command.")
420                            .size(LabelSize::Default)
421                    )
422                    .children(
423                            KeyBinding::for_action(&Run, cx)
424                            .map(|binding|
425                                binding.into_any_element()
426                            )
427                    )
428                )
429
430                .into_any_element();
431        }
432
433        v_flex()
434            .p_4()
435            .child(Label::new("Jupyter Kernel Sessions").size(LabelSize::Large))
436            .children(
437                self.sessions
438                    .values()
439                    .map(|session| session.clone().into_any_element()),
440            )
441            .into_any_element()
442    }
443}