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 max_width = plain::max_width_for_columns(
259 ReplSettings::get_global(cx).output_max_width_columns,
260 window,
261 cx,
262 );
263 let content = match self {
264 Self::Plain { content, .. } => Some(content.clone().into_any_element()),
265 Self::Markdown { content, .. } => Some(content.clone().into_any_element()),
266 Self::Stream { content, .. } => Some(content.clone().into_any_element()),
267 Self::Image { content, .. } => Some(content.clone().into_any_element()),
268 Self::Message(message) => Some(div().child(message.clone()).into_any_element()),
269 Self::Table { content, .. } => Some(content.clone().into_any_element()),
270 Self::Json { content, .. } => Some(content.clone().into_any_element()),
271 Self::ErrorOutput(error_view) => error_view.render(window, cx),
272 Self::ClearOutputWaitMarker => None,
273 };
274
275 let needs_horizontal_scroll = matches!(self, Self::Table { .. } | Self::Image { .. });
276
277 h_flex()
278 .id("output-content")
279 .w_full()
280 .when_some(max_width, |this, max_w| this.max_w(max_w))
281 .overflow_x_scroll()
282 .items_start()
283 .child(
284 div()
285 .when(!needs_horizontal_scroll, |el| {
286 el.flex_1().w_full().overflow_x_hidden()
287 })
288 .children(content),
289 )
290 .children(match self {
291 Self::Plain { content, .. } => {
292 Self::render_output_controls(content.clone(), workspace, window, cx)
293 }
294 Self::Markdown { content, .. } => {
295 Self::render_output_controls(content.clone(), workspace, window, cx)
296 }
297 Self::Stream { content, .. } => {
298 Self::render_output_controls(content.clone(), workspace, window, cx)
299 }
300 Self::Image { content, .. } => {
301 Self::render_output_controls(content.clone(), workspace, window, cx)
302 }
303 Self::Json { content, .. } => {
304 Self::render_output_controls(content.clone(), workspace, window, cx)
305 }
306 Self::ErrorOutput(err) => Some(
307 h_flex()
308 .pl_1()
309 .child({
310 let ename = err.ename.clone();
311 let evalue = err.evalue.clone();
312 let traceback = err.traceback.clone();
313 let traceback_text = traceback.read(cx).full_text();
314 let full_error = format!("{}: {}\n{}", ename, evalue, traceback_text);
315
316 CopyButton::new("copy-full-error", full_error)
317 .tooltip_label("Copy Full Error")
318 })
319 .child(
320 IconButton::new(
321 ElementId::Name("open-full-error-in-buffer-traceback".into()),
322 IconName::FileTextOutlined,
323 )
324 .style(ButtonStyle::Transparent)
325 .tooltip(Tooltip::text("Open Full Error in Buffer"))
326 .on_click({
327 let ename = err.ename.clone();
328 let evalue = err.evalue.clone();
329 let traceback = err.traceback.clone();
330 move |_, window, cx| {
331 if let Some(workspace) = workspace.upgrade() {
332 let traceback_text = traceback.read(cx).full_text();
333 let full_error =
334 format!("{}: {}\n{}", ename, evalue, traceback_text);
335 let buffer = cx.new(|cx| {
336 let mut buffer = Buffer::local(full_error, cx)
337 .with_language(language::PLAIN_TEXT.clone(), cx);
338 buffer
339 .set_capability(language::Capability::ReadOnly, cx);
340 buffer
341 });
342 let editor = Box::new(cx.new(|cx| {
343 let multibuffer = cx.new(|cx| {
344 let mut multi_buffer =
345 MultiBuffer::singleton(buffer.clone(), cx);
346 multi_buffer
347 .set_title("Full Error".to_string(), cx);
348 multi_buffer
349 });
350 Editor::for_multibuffer(multibuffer, None, window, cx)
351 }));
352 workspace.update(cx, |workspace, cx| {
353 workspace.add_item_to_active_pane(
354 editor, None, true, window, cx,
355 );
356 });
357 }
358 }
359 }),
360 )
361 .into_any_element(),
362 ),
363 Self::Message(_) => None,
364 Self::Table { content, .. } => {
365 Self::render_output_controls(content.clone(), workspace, window, cx)
366 }
367 Self::ClearOutputWaitMarker => None,
368 })
369 }
370
371 pub fn display_id(&self) -> Option<String> {
372 match self {
373 Output::Plain { display_id, .. } => display_id.clone(),
374 Output::Stream { .. } => None,
375 Output::Image { display_id, .. } => display_id.clone(),
376 Output::ErrorOutput(_) => None,
377 Output::Message(_) => None,
378 Output::Table { display_id, .. } => display_id.clone(),
379 Output::Markdown { display_id, .. } => display_id.clone(),
380 Output::Json { display_id, .. } => display_id.clone(),
381 Output::ClearOutputWaitMarker => None,
382 }
383 }
384
385 pub fn new(
386 data: &MimeBundle,
387 display_id: Option<String>,
388 window: &mut Window,
389 cx: &mut App,
390 ) -> Self {
391 match data.richest(rank_mime_type) {
392 Some(MimeType::Json(json_value)) => match JsonView::from_value(json_value.clone()) {
393 Ok(json_view) => Output::Json {
394 content: cx.new(|_| json_view),
395 display_id,
396 },
397 Err(_) => Output::Message("Failed to parse JSON".to_string()),
398 },
399 Some(MimeType::Plain(text)) => Output::Plain {
400 content: cx.new(|cx| TerminalOutput::from(text, window, cx)),
401 display_id,
402 },
403 Some(MimeType::Markdown(text)) => {
404 let content = cx.new(|cx| MarkdownView::from(text.clone(), cx));
405 Output::Markdown {
406 content,
407 display_id,
408 }
409 }
410 Some(MimeType::Png(data)) | Some(MimeType::Jpeg(data)) => match ImageView::from(data) {
411 Ok(view) => Output::Image {
412 content: cx.new(|_| view),
413 display_id,
414 },
415 Err(error) => Output::Message(format!("Failed to load image: {}", error)),
416 },
417 Some(MimeType::DataTable(data)) => Output::Table {
418 content: cx.new(|cx| TableView::new(data, window, cx)),
419 display_id,
420 },
421 // Any other media types are not supported
422 _ => Output::Message("Unsupported media type".to_string()),
423 }
424 }
425}
426
427#[derive(Default, Clone, Debug)]
428pub enum ExecutionStatus {
429 #[default]
430 Unknown,
431 ConnectingToKernel,
432 Queued,
433 Executing,
434 Finished,
435 ShuttingDown,
436 Shutdown,
437 KernelErrored(String),
438 Restarting,
439}
440
441pub struct ExecutionViewFinishedEmpty;
442pub struct ExecutionViewFinishedSmall(pub String);
443
444/// An ExecutionView shows the outputs of an execution.
445/// It can hold zero or more outputs, which the user
446/// sees as "the output" for a single execution.
447pub struct ExecutionView {
448 #[allow(unused)]
449 workspace: WeakEntity<Workspace>,
450 pub outputs: Vec<Output>,
451 pub status: ExecutionStatus,
452}
453
454impl EventEmitter<ExecutionViewFinishedEmpty> for ExecutionView {}
455impl EventEmitter<ExecutionViewFinishedSmall> for ExecutionView {}
456
457impl ExecutionView {
458 pub fn new(
459 status: ExecutionStatus,
460 workspace: WeakEntity<Workspace>,
461 _cx: &mut Context<Self>,
462 ) -> Self {
463 Self {
464 workspace,
465 outputs: Default::default(),
466 status,
467 }
468 }
469
470 /// Accept a Jupyter message belonging to this execution
471 pub fn push_message(
472 &mut self,
473 message: &JupyterMessageContent,
474 window: &mut Window,
475 cx: &mut Context<Self>,
476 ) {
477 let output: Output = match message {
478 JupyterMessageContent::ExecuteResult(result) => Output::new(
479 &result.data,
480 result.transient.as_ref().and_then(|t| t.display_id.clone()),
481 window,
482 cx,
483 ),
484 JupyterMessageContent::DisplayData(result) => Output::new(
485 &result.data,
486 result.transient.as_ref().and_then(|t| t.display_id.clone()),
487 window,
488 cx,
489 ),
490 JupyterMessageContent::StreamContent(result) => {
491 // Previous stream data will combine together, handling colors, carriage returns, etc
492 if let Some(new_terminal) = self.apply_terminal_text(&result.text, window, cx) {
493 new_terminal
494 } else {
495 return;
496 }
497 }
498 JupyterMessageContent::ErrorOutput(result) => {
499 let terminal =
500 cx.new(|cx| TerminalOutput::from(&result.traceback.join("\n"), window, cx));
501
502 Output::ErrorOutput(ErrorView {
503 ename: result.ename.clone(),
504 evalue: result.evalue.clone(),
505 traceback: terminal,
506 })
507 }
508 JupyterMessageContent::ExecuteReply(reply) => {
509 for payload in reply.payload.iter() {
510 if let runtimelib::Payload::Page { data, .. } = payload {
511 let output = Output::new(data, None, window, cx);
512 self.outputs.push(output);
513 }
514 }
515 cx.notify();
516 return;
517 }
518 JupyterMessageContent::ClearOutput(options) => {
519 if !options.wait {
520 self.outputs.clear();
521 cx.notify();
522 return;
523 }
524
525 // Create a marker to clear the output after we get in a new output
526 Output::ClearOutputWaitMarker
527 }
528 JupyterMessageContent::Status(status) => {
529 match status.execution_state {
530 ExecutionState::Busy => {
531 self.status = ExecutionStatus::Executing;
532 }
533 ExecutionState::Idle => {
534 self.status = ExecutionStatus::Finished;
535 if self.outputs.is_empty() {
536 cx.emit(ExecutionViewFinishedEmpty);
537 } else if ReplSettings::get_global(cx).inline_output {
538 if let Some(small_text) = self.get_small_inline_output(cx) {
539 cx.emit(ExecutionViewFinishedSmall(small_text));
540 }
541 }
542 }
543 ExecutionState::Unknown => self.status = ExecutionStatus::Unknown,
544 ExecutionState::Starting => self.status = ExecutionStatus::ConnectingToKernel,
545 ExecutionState::Restarting => self.status = ExecutionStatus::Restarting,
546 ExecutionState::Terminating => self.status = ExecutionStatus::ShuttingDown,
547 ExecutionState::AutoRestarting => self.status = ExecutionStatus::Restarting,
548 ExecutionState::Dead => self.status = ExecutionStatus::Shutdown,
549 ExecutionState::Other(_) => self.status = ExecutionStatus::Unknown,
550 }
551 cx.notify();
552 return;
553 }
554 _msg => {
555 return;
556 }
557 };
558
559 // Check for a clear output marker as the previous output, so we can clear it out
560 if let Some(output) = self.outputs.last()
561 && let Output::ClearOutputWaitMarker = output
562 {
563 self.outputs.clear();
564 }
565
566 self.outputs.push(output);
567
568 cx.notify();
569 }
570
571 pub fn update_display_data(
572 &mut self,
573 data: &MimeBundle,
574 display_id: &str,
575 window: &mut Window,
576 cx: &mut Context<Self>,
577 ) {
578 let mut any = false;
579
580 self.outputs.iter_mut().for_each(|output| {
581 if let Some(other_display_id) = output.display_id().as_ref()
582 && other_display_id == display_id
583 {
584 *output = Output::new(data, Some(display_id.to_owned()), window, cx);
585 any = true;
586 }
587 });
588
589 if any {
590 cx.notify();
591 }
592 }
593
594 /// Check if the output is a single small plain text that can be shown inline.
595 /// Returns the text if it's suitable for inline display (single line, short enough).
596 fn get_small_inline_output(&self, cx: &App) -> Option<String> {
597 // Only consider single outputs
598 if self.outputs.len() != 1 {
599 return None;
600 }
601
602 let output = self.outputs.first()?;
603
604 // Only Plain outputs can be inlined
605 let content = match output {
606 Output::Plain { content, .. } => content,
607 _ => return None,
608 };
609
610 let text = content.read(cx).full_text();
611 let trimmed = text.trim();
612
613 let max_length = ReplSettings::get_global(cx).inline_output_max_length;
614
615 // Must be a single line and within the configured max length
616 if trimmed.contains('\n') || trimmed.len() > max_length {
617 return None;
618 }
619
620 Some(trimmed.to_string())
621 }
622
623 fn apply_terminal_text(
624 &mut self,
625 text: &str,
626 window: &mut Window,
627 cx: &mut Context<Self>,
628 ) -> Option<Output> {
629 if let Some(last_output) = self.outputs.last_mut()
630 && let Output::Stream {
631 content: last_stream,
632 } = last_output
633 {
634 // Don't need to add a new output, we already have a terminal output
635 // and can just update the most recent terminal output
636 last_stream.update(cx, |last_stream, cx| {
637 last_stream.append_text(text, cx);
638 cx.notify();
639 });
640 return None;
641 }
642
643 Some(Output::Stream {
644 content: cx.new(|cx| TerminalOutput::from(text, window, cx)),
645 })
646 }
647}
648
649impl Render for ExecutionView {
650 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
651 let status = match &self.status {
652 ExecutionStatus::ConnectingToKernel => Label::new("Connecting to kernel...")
653 .color(Color::Muted)
654 .into_any_element(),
655 ExecutionStatus::Executing => h_flex()
656 .gap_2()
657 .child(
658 Icon::new(IconName::ArrowCircle)
659 .size(IconSize::Small)
660 .color(Color::Muted)
661 .with_rotate_animation(3),
662 )
663 .child(Label::new("Executing...").color(Color::Muted))
664 .into_any_element(),
665 ExecutionStatus::Finished => Icon::new(IconName::Check)
666 .size(IconSize::Small)
667 .into_any_element(),
668 ExecutionStatus::Unknown => Label::new("Unknown status")
669 .color(Color::Muted)
670 .into_any_element(),
671 ExecutionStatus::ShuttingDown => Label::new("Kernel shutting down...")
672 .color(Color::Muted)
673 .into_any_element(),
674 ExecutionStatus::Restarting => Label::new("Kernel restarting...")
675 .color(Color::Muted)
676 .into_any_element(),
677 ExecutionStatus::Shutdown => Label::new("Kernel shutdown")
678 .color(Color::Muted)
679 .into_any_element(),
680 ExecutionStatus::Queued => Label::new("Queued...")
681 .color(Color::Muted)
682 .into_any_element(),
683 ExecutionStatus::KernelErrored(error) => Label::new(format!("Kernel error: {}", error))
684 .color(Color::Error)
685 .into_any_element(),
686 };
687
688 if self.outputs.is_empty() {
689 return v_flex()
690 .min_h(window.line_height())
691 .justify_center()
692 .child(status)
693 .into_any_element();
694 }
695
696 div()
697 .w_full()
698 .children(
699 self.outputs
700 .iter()
701 .map(|output| output.render(self.workspace.clone(), window, cx)),
702 )
703 .children(match self.status {
704 ExecutionStatus::Executing => vec![status],
705 ExecutionStatus::Queued => vec![status],
706 _ => vec![],
707 })
708 .into_any_element()
709 }
710}