1//! # REPL Output Module
2//!
3//! This module provides the core functionality for handling and displaying
4//! various types of output from Jupyter kernels.
5//!
6//! ## Key Components
7//!
8//! - `OutputContent`: An enum that encapsulates different types of output content.
9//! - `ExecutionView`: Manages the display of outputs for a single execution.
10//! - `ExecutionStatus`: Represents the current status of an execution.
11//!
12//! ## Output Types
13//!
14//! The module supports several output types, including:
15//! - Plain text
16//! - Markdown
17//! - Images (PNG and JPEG)
18//! - Tables
19//! - Error messages
20//!
21//! ## Clipboard Support
22//!
23//! Most output types implement the `SupportsClipboard` trait, allowing
24//! users to easily copy output content to the system clipboard.
25//!
26//! ## Rendering
27//!
28//! The module provides rendering capabilities for each output type,
29//! ensuring proper display within the REPL interface.
30//!
31//! ## Jupyter Integration
32//!
33//! This module is designed to work with Jupyter message protocols,
34//! interpreting and displaying various types of Jupyter output.
35
36use std::time::Duration;
37
38use editor::{Editor, MultiBuffer};
39use gpui::{
40 percentage, Animation, AnimationExt, AnyElement, ClipboardItem, Model, Render, Transformation,
41 View, WeakView,
42};
43use language::Buffer;
44use runtimelib::{ExecutionState, JupyterMessageContent, MimeBundle, MimeType};
45use ui::{div, prelude::*, v_flex, IntoElement, Styled, Tooltip, ViewContext};
46
47mod image;
48use image::ImageView;
49
50mod markdown;
51use markdown::MarkdownView;
52
53mod table;
54use table::TableView;
55
56pub mod plain;
57use plain::TerminalOutput;
58
59mod user_error;
60use user_error::ErrorView;
61use workspace::Workspace;
62
63/// When deciding what to render from a collection of mediatypes, we need to rank them in order of importance
64fn rank_mime_type(mimetype: &MimeType) -> usize {
65 match mimetype {
66 MimeType::DataTable(_) => 6,
67 MimeType::Png(_) => 4,
68 MimeType::Jpeg(_) => 3,
69 MimeType::Markdown(_) => 2,
70 MimeType::Plain(_) => 1,
71 // All other media types are not supported in Zed at this time
72 _ => 0,
73 }
74}
75
76pub(crate) trait OutputContent {
77 fn clipboard_content(&self, cx: &WindowContext) -> Option<ClipboardItem>;
78 fn has_clipboard_content(&self, _cx: &WindowContext) -> bool {
79 return false;
80 }
81 fn has_buffer_content(&self, _cx: &WindowContext) -> bool {
82 return false;
83 }
84 fn buffer_content(&mut self, _cx: &mut WindowContext) -> Option<Model<Buffer>> {
85 None
86 }
87}
88
89impl<V: OutputContent + 'static> OutputContent for View<V> {
90 fn clipboard_content(&self, cx: &WindowContext) -> Option<ClipboardItem> {
91 self.read(cx).clipboard_content(cx)
92 }
93
94 fn has_clipboard_content(&self, cx: &WindowContext) -> bool {
95 self.read(cx).has_clipboard_content(cx)
96 }
97
98 fn has_buffer_content(&self, cx: &WindowContext) -> bool {
99 self.read(cx).has_buffer_content(cx)
100 }
101
102 fn buffer_content(&mut self, cx: &mut WindowContext) -> Option<Model<Buffer>> {
103 self.update(cx, |item, cx| item.buffer_content(cx))
104 }
105}
106
107pub enum Output {
108 Plain {
109 content: View<TerminalOutput>,
110 display_id: Option<String>,
111 },
112 Stream {
113 content: View<TerminalOutput>,
114 },
115 Image {
116 content: View<ImageView>,
117 display_id: Option<String>,
118 },
119 ErrorOutput(ErrorView),
120 Message(String),
121 Table {
122 content: View<TableView>,
123 display_id: Option<String>,
124 },
125 Markdown {
126 content: View<MarkdownView>,
127 display_id: Option<String>,
128 },
129 ClearOutputWaitMarker,
130}
131
132impl Output {
133 fn render_output_controls<V: OutputContent + 'static>(
134 v: View<V>,
135 workspace: WeakView<Workspace>,
136 cx: &mut ViewContext<ExecutionView>,
137 ) -> Option<AnyElement> {
138 if !v.has_clipboard_content(cx) && !v.has_buffer_content(cx) {
139 return None;
140 }
141
142 Some(
143 h_flex()
144 .pl_1()
145 .when(v.has_clipboard_content(cx), |el| {
146 let v = v.clone();
147 el.child(
148 IconButton::new(ElementId::Name("copy-output".into()), IconName::Copy)
149 .style(ButtonStyle::Transparent)
150 .tooltip(move |cx| Tooltip::text("Copy Output", cx))
151 .on_click(cx.listener(move |_, _, cx| {
152 let clipboard_content = v.clipboard_content(cx);
153
154 if let Some(clipboard_content) = clipboard_content.as_ref() {
155 cx.write_to_clipboard(clipboard_content.clone());
156 }
157 })),
158 )
159 })
160 .when(v.has_buffer_content(cx), |el| {
161 let v = v.clone();
162 el.child(
163 IconButton::new(
164 ElementId::Name("open-in-buffer".into()),
165 IconName::FileText,
166 )
167 .style(ButtonStyle::Transparent)
168 .tooltip(move |cx| Tooltip::text("Open in Buffer", cx))
169 .on_click(cx.listener({
170 let workspace = workspace.clone();
171
172 move |_, _, cx| {
173 let buffer_content =
174 v.update(cx, |item, cx| item.buffer_content(cx));
175
176 if let Some(buffer_content) = buffer_content.as_ref() {
177 let buffer = buffer_content.clone();
178 let editor = Box::new(cx.new_view(|cx| {
179 let multibuffer = cx.new_model(|cx| {
180 let mut multi_buffer =
181 MultiBuffer::singleton(buffer.clone(), cx);
182
183 multi_buffer.set_title("REPL Output".to_string(), cx);
184 multi_buffer
185 });
186
187 let editor =
188 Editor::for_multibuffer(multibuffer, None, false, cx);
189
190 editor
191 }));
192 workspace
193 .update(cx, |workspace, cx| {
194 workspace
195 .add_item_to_active_pane(editor, None, true, cx);
196 })
197 .ok();
198 }
199 }
200 })),
201 )
202 })
203 .into_any_element(),
204 )
205 }
206
207 fn render(
208 &self,
209
210 workspace: WeakView<Workspace>,
211 cx: &mut ViewContext<ExecutionView>,
212 ) -> impl IntoElement {
213 let content = match self {
214 Self::Plain { content, .. } => Some(content.clone().into_any_element()),
215 Self::Markdown { content, .. } => Some(content.clone().into_any_element()),
216 Self::Stream { content, .. } => Some(content.clone().into_any_element()),
217 Self::Image { content, .. } => Some(content.clone().into_any_element()),
218 Self::Message(message) => Some(div().child(message.clone()).into_any_element()),
219 Self::Table { content, .. } => Some(content.clone().into_any_element()),
220 Self::ErrorOutput(error_view) => error_view.render(cx),
221 Self::ClearOutputWaitMarker => None,
222 };
223
224 h_flex()
225 .w_full()
226 .items_start()
227 .child(div().flex_1().children(content))
228 .children(match self {
229 Self::Plain { content, .. } => {
230 Self::render_output_controls(content.clone(), workspace.clone(), cx)
231 }
232 Self::Markdown { content, .. } => {
233 Self::render_output_controls(content.clone(), workspace.clone(), cx)
234 }
235 Self::Stream { content, .. } => {
236 Self::render_output_controls(content.clone(), workspace.clone(), cx)
237 }
238 Self::Image { content, .. } => {
239 Self::render_output_controls(content.clone(), workspace.clone(), cx)
240 }
241 Self::ErrorOutput(err) => {
242 Self::render_output_controls(err.traceback.clone(), workspace.clone(), cx)
243 }
244 Self::Message(_) => None,
245 Self::Table { content, .. } => {
246 Self::render_output_controls(content.clone(), workspace.clone(), cx)
247 }
248 Self::ClearOutputWaitMarker => None,
249 })
250 }
251
252 pub fn display_id(&self) -> Option<String> {
253 match self {
254 Output::Plain { display_id, .. } => display_id.clone(),
255 Output::Stream { .. } => None,
256 Output::Image { display_id, .. } => display_id.clone(),
257 Output::ErrorOutput(_) => None,
258 Output::Message(_) => None,
259 Output::Table { display_id, .. } => display_id.clone(),
260 Output::Markdown { display_id, .. } => display_id.clone(),
261 Output::ClearOutputWaitMarker => None,
262 }
263 }
264
265 pub fn new(data: &MimeBundle, display_id: Option<String>, cx: &mut WindowContext) -> Self {
266 match data.richest(rank_mime_type) {
267 Some(MimeType::Plain(text)) => Output::Plain {
268 content: cx.new_view(|cx| TerminalOutput::from(text, cx)),
269 display_id,
270 },
271 Some(MimeType::Markdown(text)) => {
272 let view = cx.new_view(|cx| MarkdownView::from(text.clone(), cx));
273 Output::Markdown {
274 content: view,
275 display_id,
276 }
277 }
278 Some(MimeType::Png(data)) | Some(MimeType::Jpeg(data)) => match ImageView::from(data) {
279 Ok(view) => Output::Image {
280 content: cx.new_view(|_| view),
281 display_id,
282 },
283 Err(error) => Output::Message(format!("Failed to load image: {}", error)),
284 },
285 Some(MimeType::DataTable(data)) => Output::Table {
286 content: cx.new_view(|cx| TableView::new(data, cx)),
287 display_id,
288 },
289 // Any other media types are not supported
290 _ => Output::Message("Unsupported media type".to_string()),
291 }
292 }
293}
294
295#[derive(Default, Clone, Debug)]
296pub enum ExecutionStatus {
297 #[default]
298 Unknown,
299 ConnectingToKernel,
300 Queued,
301 Executing,
302 Finished,
303 ShuttingDown,
304 Shutdown,
305 KernelErrored(String),
306 Restarting,
307}
308
309/// An ExecutionView shows the outputs of an execution.
310/// It can hold zero or more outputs, which the user
311/// sees as "the output" for a single execution.
312pub struct ExecutionView {
313 #[allow(unused)]
314 workspace: WeakView<Workspace>,
315 pub outputs: Vec<Output>,
316 pub status: ExecutionStatus,
317}
318
319impl ExecutionView {
320 pub fn new(
321 status: ExecutionStatus,
322 workspace: WeakView<Workspace>,
323 _cx: &mut ViewContext<Self>,
324 ) -> Self {
325 Self {
326 workspace,
327 outputs: Default::default(),
328 status,
329 }
330 }
331
332 /// Accept a Jupyter message belonging to this execution
333 pub fn push_message(&mut self, message: &JupyterMessageContent, cx: &mut ViewContext<Self>) {
334 let output: Output = match message {
335 JupyterMessageContent::ExecuteResult(result) => Output::new(
336 &result.data,
337 result.transient.as_ref().and_then(|t| t.display_id.clone()),
338 cx,
339 ),
340 JupyterMessageContent::DisplayData(result) => {
341 Output::new(&result.data, result.transient.display_id.clone(), cx)
342 }
343 JupyterMessageContent::StreamContent(result) => {
344 // Previous stream data will combine together, handling colors, carriage returns, etc
345 if let Some(new_terminal) = self.apply_terminal_text(&result.text, cx) {
346 new_terminal
347 } else {
348 return;
349 }
350 }
351 JupyterMessageContent::ErrorOutput(result) => {
352 let terminal =
353 cx.new_view(|cx| TerminalOutput::from(&result.traceback.join("\n"), cx));
354
355 Output::ErrorOutput(ErrorView {
356 ename: result.ename.clone(),
357 evalue: result.evalue.clone(),
358 traceback: terminal,
359 })
360 }
361 JupyterMessageContent::ExecuteReply(reply) => {
362 for payload in reply.payload.iter() {
363 match payload {
364 // Pager data comes in via `?` at the end of a statement in Python, used for showing documentation.
365 // Some UI will show this as a popup. For ease of implementation, it's included as an output here.
366 runtimelib::Payload::Page { data, .. } => {
367 let output = Output::new(data, None, cx);
368 self.outputs.push(output);
369 }
370
371 // There are other payloads that could be handled here, such as updating the input.
372 // Below are the other payloads that _could_ be handled, but are not required for Zed.
373
374 // Set next input adds text to the next cell. Not required to support.
375 // However, this could be implemented by adding text to the buffer.
376 // Trigger in python using `get_ipython().set_next_input("text")`
377 //
378 // runtimelib::Payload::SetNextInput { text, replace } => {},
379
380 // Not likely to be used in the context of Zed, where someone could just open the buffer themselves
381 // Python users can trigger this with the `%edit` magic command
382 // runtimelib::Payload::EditMagic { filename, line_number } => {},
383
384 // Ask the user if they want to exit the kernel. Not required to support.
385 // runtimelib::Payload::AskExit { keepkernel } => {},
386 _ => {}
387 }
388 }
389 cx.notify();
390 return;
391 }
392 JupyterMessageContent::ClearOutput(options) => {
393 if !options.wait {
394 self.outputs.clear();
395 cx.notify();
396 return;
397 }
398
399 // Create a marker to clear the output after we get in a new output
400 Output::ClearOutputWaitMarker
401 }
402 JupyterMessageContent::Status(status) => {
403 match status.execution_state {
404 ExecutionState::Busy => {
405 self.status = ExecutionStatus::Executing;
406 }
407 ExecutionState::Idle => self.status = ExecutionStatus::Finished,
408 }
409 cx.notify();
410 return;
411 }
412 _msg => {
413 return;
414 }
415 };
416
417 // Check for a clear output marker as the previous output, so we can clear it out
418 if let Some(output) = self.outputs.last() {
419 if let Output::ClearOutputWaitMarker = output {
420 self.outputs.clear();
421 }
422 }
423
424 self.outputs.push(output);
425
426 cx.notify();
427 }
428
429 pub fn update_display_data(
430 &mut self,
431 data: &MimeBundle,
432 display_id: &str,
433 cx: &mut ViewContext<Self>,
434 ) {
435 let mut any = false;
436
437 self.outputs.iter_mut().for_each(|output| {
438 if let Some(other_display_id) = output.display_id().as_ref() {
439 if other_display_id == display_id {
440 *output = Output::new(data, Some(display_id.to_owned()), cx);
441 any = true;
442 }
443 }
444 });
445
446 if any {
447 cx.notify();
448 }
449 }
450
451 fn apply_terminal_text(&mut self, text: &str, cx: &mut ViewContext<Self>) -> Option<Output> {
452 if let Some(last_output) = self.outputs.last_mut() {
453 match last_output {
454 Output::Stream {
455 content: last_stream,
456 } => {
457 // Don't need to add a new output, we already have a terminal output
458 // and can just update the most recent terminal output
459 last_stream.update(cx, |last_stream, cx| {
460 last_stream.append_text(text, cx);
461 cx.notify();
462 });
463 return None;
464 }
465 // A different output type is "in the way", so we need to create a new output,
466 // which is the same as having no prior stream/terminal text
467 _ => {}
468 }
469 }
470
471 Some(Output::Stream {
472 content: cx.new_view(|cx| TerminalOutput::from(text, cx)),
473 })
474 }
475}
476
477impl Render for ExecutionView {
478 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
479 let status = match &self.status {
480 ExecutionStatus::ConnectingToKernel => Label::new("Connecting to kernel...")
481 .color(Color::Muted)
482 .into_any_element(),
483 ExecutionStatus::Executing => h_flex()
484 .gap_2()
485 .child(
486 Icon::new(IconName::ArrowCircle)
487 .size(IconSize::Small)
488 .color(Color::Muted)
489 .with_animation(
490 "arrow-circle",
491 Animation::new(Duration::from_secs(3)).repeat(),
492 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
493 ),
494 )
495 .child(Label::new("Executing...").color(Color::Muted))
496 .into_any_element(),
497 ExecutionStatus::Finished => Icon::new(IconName::Check)
498 .size(IconSize::Small)
499 .into_any_element(),
500 ExecutionStatus::Unknown => Label::new("Unknown status")
501 .color(Color::Muted)
502 .into_any_element(),
503 ExecutionStatus::ShuttingDown => Label::new("Kernel shutting down...")
504 .color(Color::Muted)
505 .into_any_element(),
506 ExecutionStatus::Restarting => Label::new("Kernel restarting...")
507 .color(Color::Muted)
508 .into_any_element(),
509 ExecutionStatus::Shutdown => Label::new("Kernel shutdown")
510 .color(Color::Muted)
511 .into_any_element(),
512 ExecutionStatus::Queued => Label::new("Queued...")
513 .color(Color::Muted)
514 .into_any_element(),
515 ExecutionStatus::KernelErrored(error) => Label::new(format!("Kernel error: {}", error))
516 .color(Color::Error)
517 .into_any_element(),
518 };
519
520 if self.outputs.len() == 0 {
521 return v_flex()
522 .min_h(cx.line_height())
523 .justify_center()
524 .child(status)
525 .into_any_element();
526 }
527
528 div()
529 .w_full()
530 .children(
531 self.outputs
532 .iter()
533 .map(|output| output.render(self.workspace.clone(), cx)),
534 )
535 .children(match self.status {
536 ExecutionStatus::Executing => vec![status],
537 ExecutionStatus::Queued => vec![status],
538 _ => vec![],
539 })
540 .into_any_element()
541 }
542}