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}