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 editor::{Editor, MultiBuffer};
37use gpui::{AnyElement, ClipboardItem, Entity, EventEmitter, Render, WeakEntity};
38use language::Buffer;
39use runtimelib::{ExecutionState, JupyterMessageContent, MimeBundle, MimeType};
40use ui::{CommonAnimationExt, CopyButton, IconButton, Tooltip, prelude::*};
41
42mod image;
43use image::ImageView;
44
45mod markdown;
46use markdown::MarkdownView;
47
48mod table;
49use table::TableView;
50
51mod json;
52use json::JsonView;
53
54pub mod plain;
55use plain::TerminalOutput;
56
57pub(crate) mod user_error;
58use user_error::ErrorView;
59use workspace::Workspace;
60
61use crate::repl_settings::ReplSettings;
62use settings::Settings;
63
64/// When deciding what to render from a collection of mediatypes, we need to rank them in order of importance
65fn rank_mime_type(mimetype: &MimeType) -> usize {
66 match mimetype {
67 MimeType::DataTable(_) => 6,
68 MimeType::Json(_) => 5,
69 MimeType::Png(_) => 4,
70 MimeType::Jpeg(_) => 3,
71 MimeType::Markdown(_) => 2,
72 MimeType::Plain(_) => 1,
73 // All other media types are not supported in Zed at this time
74 _ => 0,
75 }
76}
77
78pub(crate) trait OutputContent {
79 fn clipboard_content(&self, window: &Window, cx: &App) -> Option<ClipboardItem>;
80 fn has_clipboard_content(&self, _window: &Window, _cx: &App) -> bool {
81 false
82 }
83 fn has_buffer_content(&self, _window: &Window, _cx: &App) -> bool {
84 false
85 }
86 fn buffer_content(&mut self, _window: &mut Window, _cx: &mut App) -> Option<Entity<Buffer>> {
87 None
88 }
89}
90
91impl<V: OutputContent + 'static> OutputContent for Entity<V> {
92 fn clipboard_content(&self, window: &Window, cx: &App) -> Option<ClipboardItem> {
93 self.read(cx).clipboard_content(window, cx)
94 }
95
96 fn has_clipboard_content(&self, window: &Window, cx: &App) -> bool {
97 self.read(cx).has_clipboard_content(window, cx)
98 }
99
100 fn has_buffer_content(&self, window: &Window, cx: &App) -> bool {
101 self.read(cx).has_buffer_content(window, cx)
102 }
103
104 fn buffer_content(&mut self, window: &mut Window, cx: &mut App) -> Option<Entity<Buffer>> {
105 self.update(cx, |item, cx| item.buffer_content(window, cx))
106 }
107}
108
109pub enum Output {
110 Plain {
111 content: Entity<TerminalOutput>,
112 display_id: Option<String>,
113 },
114 Stream {
115 content: Entity<TerminalOutput>,
116 },
117 Image {
118 content: Entity<ImageView>,
119 display_id: Option<String>,
120 },
121 ErrorOutput(ErrorView),
122 Message(String),
123 Table {
124 content: Entity<TableView>,
125 display_id: Option<String>,
126 },
127 Markdown {
128 content: Entity<MarkdownView>,
129 display_id: Option<String>,
130 },
131 Json {
132 content: Entity<JsonView>,
133 display_id: Option<String>,
134 },
135 ClearOutputWaitMarker,
136}
137
138impl Output {
139 pub fn to_nbformat(&self, cx: &App) -> Option<nbformat::v4::Output> {
140 match self {
141 Output::Stream { content } => {
142 let text = content.read(cx).full_text();
143 Some(nbformat::v4::Output::Stream {
144 name: "stdout".to_string(),
145 text: nbformat::v4::MultilineString(text),
146 })
147 }
148 Output::Plain { content, .. } => {
149 let text = content.read(cx).full_text();
150 let mut data = jupyter_protocol::media::Media::default();
151 data.content.push(jupyter_protocol::MediaType::Plain(text));
152 Some(nbformat::v4::Output::DisplayData(
153 nbformat::v4::DisplayData {
154 data,
155 metadata: serde_json::Map::new(),
156 },
157 ))
158 }
159 Output::ErrorOutput(error_view) => {
160 let traceback_text = error_view.traceback.read(cx).full_text();
161 let traceback_lines: Vec<String> =
162 traceback_text.lines().map(|s| s.to_string()).collect();
163 Some(nbformat::v4::Output::Error(nbformat::v4::ErrorOutput {
164 ename: error_view.ename.clone(),
165 evalue: error_view.evalue.clone(),
166 traceback: traceback_lines,
167 }))
168 }
169 Output::Image { .. }
170 | Output::Markdown { .. }
171 | Output::Table { .. }
172 | Output::Json { .. } => None,
173 Output::Message(_) => None,
174 Output::ClearOutputWaitMarker => None,
175 }
176 }
177}
178
179impl Output {
180 fn render_output_controls<V: OutputContent + 'static>(
181 v: Entity<V>,
182 workspace: WeakEntity<Workspace>,
183 window: &mut Window,
184 cx: &mut Context<ExecutionView>,
185 ) -> Option<AnyElement> {
186 if !v.has_clipboard_content(window, cx) && !v.has_buffer_content(window, cx) {
187 return None;
188 }
189
190 Some(
191 h_flex()
192 .pl_1()
193 .when(v.has_clipboard_content(window, cx), |el| {
194 let v = v.clone();
195 el.child(
196 IconButton::new(ElementId::Name("copy-output".into()), IconName::Copy)
197 .style(ButtonStyle::Transparent)
198 .tooltip(Tooltip::text("Copy Output"))
199 .on_click(move |_, window, cx| {
200 let clipboard_content = v.clipboard_content(window, cx);
201
202 if let Some(clipboard_content) = clipboard_content.as_ref() {
203 cx.write_to_clipboard(clipboard_content.clone());
204 }
205 }),
206 )
207 })
208 .when(v.has_buffer_content(window, cx), |el| {
209 let v = v.clone();
210 el.child(
211 IconButton::new(
212 ElementId::Name("open-in-buffer".into()),
213 IconName::FileTextOutlined,
214 )
215 .style(ButtonStyle::Transparent)
216 .tooltip(Tooltip::text("Open in Buffer"))
217 .on_click({
218 let workspace = workspace.clone();
219 move |_, window, cx| {
220 let buffer_content =
221 v.update(cx, |item, cx| item.buffer_content(window, cx));
222
223 if let Some(buffer_content) = buffer_content.as_ref() {
224 let buffer = buffer_content.clone();
225 let editor = Box::new(cx.new(|cx| {
226 let multibuffer = cx.new(|cx| {
227 let mut multi_buffer =
228 MultiBuffer::singleton(buffer.clone(), cx);
229
230 multi_buffer.set_title("REPL Output".to_string(), cx);
231 multi_buffer
232 });
233
234 Editor::for_multibuffer(multibuffer, None, window, cx)
235 }));
236 workspace
237 .update(cx, |workspace, cx| {
238 workspace.add_item_to_active_pane(
239 editor, None, true, window, cx,
240 );
241 })
242 .ok();
243 }
244 }
245 }),
246 )
247 })
248 .into_any_element(),
249 )
250 }
251
252 pub fn render(
253 &self,
254 workspace: WeakEntity<Workspace>,
255 window: &mut Window,
256 cx: &mut Context<ExecutionView>,
257 ) -> impl IntoElement + use<> {
258 let content = match self {
259 Self::Plain { content, .. } => Some(content.clone().into_any_element()),
260 Self::Markdown { content, .. } => Some(content.clone().into_any_element()),
261 Self::Stream { content, .. } => Some(content.clone().into_any_element()),
262 Self::Image { content, .. } => Some(content.clone().into_any_element()),
263 Self::Message(message) => Some(div().child(message.clone()).into_any_element()),
264 Self::Table { content, .. } => Some(content.clone().into_any_element()),
265 Self::Json { content, .. } => Some(content.clone().into_any_element()),
266 Self::ErrorOutput(error_view) => error_view.render(window, cx),
267 Self::ClearOutputWaitMarker => None,
268 };
269
270 let needs_horizontal_scroll = matches!(self, Self::Table { .. } | Self::Image { .. });
271
272 h_flex()
273 .id("output-content")
274 .w_full()
275 .when(needs_horizontal_scroll, |el| el.overflow_x_scroll())
276 .items_start()
277 .child(
278 div()
279 .when(!needs_horizontal_scroll, |el| {
280 el.flex_1().w_full().overflow_x_hidden()
281 })
282 .children(content),
283 )
284 .children(match self {
285 Self::Plain { content, .. } => {
286 Self::render_output_controls(content.clone(), workspace, window, cx)
287 }
288 Self::Markdown { content, .. } => {
289 Self::render_output_controls(content.clone(), workspace, window, cx)
290 }
291 Self::Stream { content, .. } => {
292 Self::render_output_controls(content.clone(), workspace, window, cx)
293 }
294 Self::Image { content, .. } => {
295 Self::render_output_controls(content.clone(), workspace, window, cx)
296 }
297 Self::Json { content, .. } => {
298 Self::render_output_controls(content.clone(), workspace, window, cx)
299 }
300 Self::ErrorOutput(err) => Some(
301 h_flex()
302 .pl_1()
303 .child({
304 let ename = err.ename.clone();
305 let evalue = err.evalue.clone();
306 let traceback = err.traceback.clone();
307 let traceback_text = traceback.read(cx).full_text();
308 let full_error = format!("{}: {}\n{}", ename, evalue, traceback_text);
309
310 CopyButton::new("copy-full-error", full_error)
311 .tooltip_label("Copy Full Error")
312 })
313 .child(
314 IconButton::new(
315 ElementId::Name("open-full-error-in-buffer-traceback".into()),
316 IconName::FileTextOutlined,
317 )
318 .style(ButtonStyle::Transparent)
319 .tooltip(Tooltip::text("Open Full Error in Buffer"))
320 .on_click({
321 let ename = err.ename.clone();
322 let evalue = err.evalue.clone();
323 let traceback = err.traceback.clone();
324 move |_, window, cx| {
325 if let Some(workspace) = workspace.upgrade() {
326 let traceback_text = traceback.read(cx).full_text();
327 let full_error =
328 format!("{}: {}\n{}", ename, evalue, traceback_text);
329 let buffer = cx.new(|cx| {
330 let mut buffer = Buffer::local(full_error, cx)
331 .with_language(language::PLAIN_TEXT.clone(), cx);
332 buffer
333 .set_capability(language::Capability::ReadOnly, cx);
334 buffer
335 });
336 let editor = Box::new(cx.new(|cx| {
337 let multibuffer = cx.new(|cx| {
338 let mut multi_buffer =
339 MultiBuffer::singleton(buffer.clone(), cx);
340 multi_buffer
341 .set_title("Full Error".to_string(), cx);
342 multi_buffer
343 });
344 Editor::for_multibuffer(multibuffer, None, window, cx)
345 }));
346 workspace.update(cx, |workspace, cx| {
347 workspace.add_item_to_active_pane(
348 editor, None, true, window, cx,
349 );
350 });
351 }
352 }
353 }),
354 )
355 .into_any_element(),
356 ),
357 Self::Message(_) => None,
358 Self::Table { content, .. } => {
359 Self::render_output_controls(content.clone(), workspace, window, cx)
360 }
361 Self::ClearOutputWaitMarker => None,
362 })
363 }
364
365 pub fn display_id(&self) -> Option<String> {
366 match self {
367 Output::Plain { display_id, .. } => display_id.clone(),
368 Output::Stream { .. } => None,
369 Output::Image { display_id, .. } => display_id.clone(),
370 Output::ErrorOutput(_) => None,
371 Output::Message(_) => None,
372 Output::Table { display_id, .. } => display_id.clone(),
373 Output::Markdown { display_id, .. } => display_id.clone(),
374 Output::Json { display_id, .. } => display_id.clone(),
375 Output::ClearOutputWaitMarker => None,
376 }
377 }
378
379 pub fn new(
380 data: &MimeBundle,
381 display_id: Option<String>,
382 window: &mut Window,
383 cx: &mut App,
384 ) -> Self {
385 match data.richest(rank_mime_type) {
386 Some(MimeType::Json(json_object)) => {
387 let json_value = serde_json::Value::Object(json_object.clone());
388 match JsonView::from_value(json_value) {
389 Ok(json_view) => Output::Json {
390 content: cx.new(|_| json_view),
391 display_id,
392 },
393 Err(_) => Output::Message("Failed to parse JSON".to_string()),
394 }
395 }
396 Some(MimeType::Plain(text)) => Output::Plain {
397 content: cx.new(|cx| TerminalOutput::from(text, window, cx)),
398 display_id,
399 },
400 Some(MimeType::Markdown(text)) => {
401 let content = cx.new(|cx| MarkdownView::from(text.clone(), cx));
402 Output::Markdown {
403 content,
404 display_id,
405 }
406 }
407 Some(MimeType::Png(data)) | Some(MimeType::Jpeg(data)) => match ImageView::from(data) {
408 Ok(view) => Output::Image {
409 content: cx.new(|_| view),
410 display_id,
411 },
412 Err(error) => Output::Message(format!("Failed to load image: {}", error)),
413 },
414 Some(MimeType::DataTable(data)) => Output::Table {
415 content: cx.new(|cx| TableView::new(data, window, cx)),
416 display_id,
417 },
418 // Any other media types are not supported
419 _ => Output::Message("Unsupported media type".to_string()),
420 }
421 }
422}
423
424#[derive(Default, Clone, Debug)]
425pub enum ExecutionStatus {
426 #[default]
427 Unknown,
428 ConnectingToKernel,
429 Queued,
430 Executing,
431 Finished,
432 ShuttingDown,
433 Shutdown,
434 KernelErrored(String),
435 Restarting,
436}
437
438pub struct ExecutionViewFinishedEmpty;
439pub struct ExecutionViewFinishedSmall(pub String);
440
441/// An ExecutionView shows the outputs of an execution.
442/// It can hold zero or more outputs, which the user
443/// sees as "the output" for a single execution.
444pub struct ExecutionView {
445 #[allow(unused)]
446 workspace: WeakEntity<Workspace>,
447 pub outputs: Vec<Output>,
448 pub status: ExecutionStatus,
449}
450
451impl EventEmitter<ExecutionViewFinishedEmpty> for ExecutionView {}
452impl EventEmitter<ExecutionViewFinishedSmall> for ExecutionView {}
453
454impl ExecutionView {
455 pub fn new(
456 status: ExecutionStatus,
457 workspace: WeakEntity<Workspace>,
458 _cx: &mut Context<Self>,
459 ) -> Self {
460 Self {
461 workspace,
462 outputs: Default::default(),
463 status,
464 }
465 }
466
467 /// Accept a Jupyter message belonging to this execution
468 pub fn push_message(
469 &mut self,
470 message: &JupyterMessageContent,
471 window: &mut Window,
472 cx: &mut Context<Self>,
473 ) {
474 let output: Output = match message {
475 JupyterMessageContent::ExecuteResult(result) => Output::new(
476 &result.data,
477 result.transient.as_ref().and_then(|t| t.display_id.clone()),
478 window,
479 cx,
480 ),
481 JupyterMessageContent::DisplayData(result) => Output::new(
482 &result.data,
483 result.transient.as_ref().and_then(|t| t.display_id.clone()),
484 window,
485 cx,
486 ),
487 JupyterMessageContent::StreamContent(result) => {
488 // Previous stream data will combine together, handling colors, carriage returns, etc
489 if let Some(new_terminal) = self.apply_terminal_text(&result.text, window, cx) {
490 new_terminal
491 } else {
492 return;
493 }
494 }
495 JupyterMessageContent::ErrorOutput(result) => {
496 let terminal =
497 cx.new(|cx| TerminalOutput::from(&result.traceback.join("\n"), window, cx));
498
499 Output::ErrorOutput(ErrorView {
500 ename: result.ename.clone(),
501 evalue: result.evalue.clone(),
502 traceback: terminal,
503 })
504 }
505 JupyterMessageContent::ExecuteReply(reply) => {
506 for payload in reply.payload.iter() {
507 if let runtimelib::Payload::Page { data, .. } = payload {
508 let output = Output::new(data, None, window, cx);
509 self.outputs.push(output);
510 }
511 }
512 cx.notify();
513 return;
514 }
515 JupyterMessageContent::ClearOutput(options) => {
516 if !options.wait {
517 self.outputs.clear();
518 cx.notify();
519 return;
520 }
521
522 // Create a marker to clear the output after we get in a new output
523 Output::ClearOutputWaitMarker
524 }
525 JupyterMessageContent::Status(status) => {
526 match status.execution_state {
527 ExecutionState::Busy => {
528 self.status = ExecutionStatus::Executing;
529 }
530 ExecutionState::Idle => {
531 self.status = ExecutionStatus::Finished;
532 if self.outputs.is_empty() {
533 cx.emit(ExecutionViewFinishedEmpty);
534 } else if ReplSettings::get_global(cx).inline_output {
535 if let Some(small_text) = self.get_small_inline_output(cx) {
536 cx.emit(ExecutionViewFinishedSmall(small_text));
537 }
538 }
539 }
540 ExecutionState::Unknown => self.status = ExecutionStatus::Unknown,
541 ExecutionState::Starting => self.status = ExecutionStatus::ConnectingToKernel,
542 ExecutionState::Restarting => self.status = ExecutionStatus::Restarting,
543 ExecutionState::Terminating => self.status = ExecutionStatus::ShuttingDown,
544 ExecutionState::AutoRestarting => self.status = ExecutionStatus::Restarting,
545 ExecutionState::Dead => self.status = ExecutionStatus::Shutdown,
546 ExecutionState::Other(_) => self.status = ExecutionStatus::Unknown,
547 }
548 cx.notify();
549 return;
550 }
551 _msg => {
552 return;
553 }
554 };
555
556 // Check for a clear output marker as the previous output, so we can clear it out
557 if let Some(output) = self.outputs.last()
558 && let Output::ClearOutputWaitMarker = output
559 {
560 self.outputs.clear();
561 }
562
563 self.outputs.push(output);
564
565 cx.notify();
566 }
567
568 pub fn update_display_data(
569 &mut self,
570 data: &MimeBundle,
571 display_id: &str,
572 window: &mut Window,
573 cx: &mut Context<Self>,
574 ) {
575 let mut any = false;
576
577 self.outputs.iter_mut().for_each(|output| {
578 if let Some(other_display_id) = output.display_id().as_ref()
579 && other_display_id == display_id
580 {
581 *output = Output::new(data, Some(display_id.to_owned()), window, cx);
582 any = true;
583 }
584 });
585
586 if any {
587 cx.notify();
588 }
589 }
590
591 /// Check if the output is a single small plain text that can be shown inline.
592 /// Returns the text if it's suitable for inline display (single line, short enough).
593 fn get_small_inline_output(&self, cx: &App) -> Option<String> {
594 // Only consider single outputs
595 if self.outputs.len() != 1 {
596 return None;
597 }
598
599 let output = self.outputs.first()?;
600
601 // Only Plain outputs can be inlined
602 let content = match output {
603 Output::Plain { content, .. } => content,
604 _ => return None,
605 };
606
607 let text = content.read(cx).full_text();
608 let trimmed = text.trim();
609
610 let max_length = ReplSettings::get_global(cx).inline_output_max_length;
611
612 // Must be a single line and within the configured max length
613 if trimmed.contains('\n') || trimmed.len() > max_length {
614 return None;
615 }
616
617 Some(trimmed.to_string())
618 }
619
620 fn apply_terminal_text(
621 &mut self,
622 text: &str,
623 window: &mut Window,
624 cx: &mut Context<Self>,
625 ) -> Option<Output> {
626 if let Some(last_output) = self.outputs.last_mut()
627 && let Output::Stream {
628 content: last_stream,
629 } = last_output
630 {
631 // Don't need to add a new output, we already have a terminal output
632 // and can just update the most recent terminal output
633 last_stream.update(cx, |last_stream, cx| {
634 last_stream.append_text(text, cx);
635 cx.notify();
636 });
637 return None;
638 }
639
640 Some(Output::Stream {
641 content: cx.new(|cx| TerminalOutput::from(text, window, cx)),
642 })
643 }
644}
645
646impl Render for ExecutionView {
647 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
648 let status = match &self.status {
649 ExecutionStatus::ConnectingToKernel => Label::new("Connecting to kernel...")
650 .color(Color::Muted)
651 .into_any_element(),
652 ExecutionStatus::Executing => h_flex()
653 .gap_2()
654 .child(
655 Icon::new(IconName::ArrowCircle)
656 .size(IconSize::Small)
657 .color(Color::Muted)
658 .with_rotate_animation(3),
659 )
660 .child(Label::new("Executing...").color(Color::Muted))
661 .into_any_element(),
662 ExecutionStatus::Finished => Icon::new(IconName::Check)
663 .size(IconSize::Small)
664 .into_any_element(),
665 ExecutionStatus::Unknown => Label::new("Unknown status")
666 .color(Color::Muted)
667 .into_any_element(),
668 ExecutionStatus::ShuttingDown => Label::new("Kernel shutting down...")
669 .color(Color::Muted)
670 .into_any_element(),
671 ExecutionStatus::Restarting => Label::new("Kernel restarting...")
672 .color(Color::Muted)
673 .into_any_element(),
674 ExecutionStatus::Shutdown => Label::new("Kernel shutdown")
675 .color(Color::Muted)
676 .into_any_element(),
677 ExecutionStatus::Queued => Label::new("Queued...")
678 .color(Color::Muted)
679 .into_any_element(),
680 ExecutionStatus::KernelErrored(error) => Label::new(format!("Kernel error: {}", error))
681 .color(Color::Error)
682 .into_any_element(),
683 };
684
685 if self.outputs.is_empty() {
686 return v_flex()
687 .min_h(window.line_height())
688 .justify_center()
689 .child(status)
690 .into_any_element();
691 }
692
693 div()
694 .w_full()
695 .children(
696 self.outputs
697 .iter()
698 .map(|output| output.render(self.workspace.clone(), window, cx)),
699 )
700 .children(match self.status {
701 ExecutionStatus::Executing => vec![status],
702 ExecutionStatus::Queued => vec![status],
703 _ => vec![],
704 })
705 .into_any_element()
706 }
707}