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