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_object)) => {
393 let json_value = serde_json::Value::Object(json_object.clone());
394 match JsonView::from_value(json_value) {
395 Ok(json_view) => Output::Json {
396 content: cx.new(|_| json_view),
397 display_id,
398 },
399 Err(_) => Output::Message("Failed to parse JSON".to_string()),
400 }
401 }
402 Some(MimeType::Plain(text)) => Output::Plain {
403 content: cx.new(|cx| TerminalOutput::from(text, window, cx)),
404 display_id,
405 },
406 Some(MimeType::Markdown(text)) => {
407 let content = cx.new(|cx| MarkdownView::from(text.clone(), cx));
408 Output::Markdown {
409 content,
410 display_id,
411 }
412 }
413 Some(MimeType::Png(data)) | Some(MimeType::Jpeg(data)) => match ImageView::from(data) {
414 Ok(view) => Output::Image {
415 content: cx.new(|_| view),
416 display_id,
417 },
418 Err(error) => Output::Message(format!("Failed to load image: {}", error)),
419 },
420 Some(MimeType::DataTable(data)) => Output::Table {
421 content: cx.new(|cx| TableView::new(data, window, cx)),
422 display_id,
423 },
424 // Any other media types are not supported
425 _ => Output::Message("Unsupported media type".to_string()),
426 }
427 }
428}
429
430#[derive(Default, Clone, Debug)]
431pub enum ExecutionStatus {
432 #[default]
433 Unknown,
434 ConnectingToKernel,
435 Queued,
436 Executing,
437 Finished,
438 ShuttingDown,
439 Shutdown,
440 KernelErrored(String),
441 Restarting,
442}
443
444pub struct ExecutionViewFinishedEmpty;
445pub struct ExecutionViewFinishedSmall(pub String);
446
447/// An ExecutionView shows the outputs of an execution.
448/// It can hold zero or more outputs, which the user
449/// sees as "the output" for a single execution.
450pub struct ExecutionView {
451 #[allow(unused)]
452 workspace: WeakEntity<Workspace>,
453 pub outputs: Vec<Output>,
454 pub status: ExecutionStatus,
455}
456
457impl EventEmitter<ExecutionViewFinishedEmpty> for ExecutionView {}
458impl EventEmitter<ExecutionViewFinishedSmall> for ExecutionView {}
459
460impl ExecutionView {
461 pub fn new(
462 status: ExecutionStatus,
463 workspace: WeakEntity<Workspace>,
464 _cx: &mut Context<Self>,
465 ) -> Self {
466 Self {
467 workspace,
468 outputs: Default::default(),
469 status,
470 }
471 }
472
473 /// Accept a Jupyter message belonging to this execution
474 pub fn push_message(
475 &mut self,
476 message: &JupyterMessageContent,
477 window: &mut Window,
478 cx: &mut Context<Self>,
479 ) {
480 let output: Output = match message {
481 JupyterMessageContent::ExecuteResult(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::DisplayData(result) => Output::new(
488 &result.data,
489 result.transient.as_ref().and_then(|t| t.display_id.clone()),
490 window,
491 cx,
492 ),
493 JupyterMessageContent::StreamContent(result) => {
494 // Previous stream data will combine together, handling colors, carriage returns, etc
495 if let Some(new_terminal) = self.apply_terminal_text(&result.text, window, cx) {
496 new_terminal
497 } else {
498 return;
499 }
500 }
501 JupyterMessageContent::ErrorOutput(result) => {
502 let terminal =
503 cx.new(|cx| TerminalOutput::from(&result.traceback.join("\n"), window, cx));
504
505 Output::ErrorOutput(ErrorView {
506 ename: result.ename.clone(),
507 evalue: result.evalue.clone(),
508 traceback: terminal,
509 })
510 }
511 JupyterMessageContent::ExecuteReply(reply) => {
512 for payload in reply.payload.iter() {
513 if let runtimelib::Payload::Page { data, .. } = payload {
514 let output = Output::new(data, None, window, cx);
515 self.outputs.push(output);
516 }
517 }
518 cx.notify();
519 return;
520 }
521 JupyterMessageContent::ClearOutput(options) => {
522 if !options.wait {
523 self.outputs.clear();
524 cx.notify();
525 return;
526 }
527
528 // Create a marker to clear the output after we get in a new output
529 Output::ClearOutputWaitMarker
530 }
531 JupyterMessageContent::Status(status) => {
532 match status.execution_state {
533 ExecutionState::Busy => {
534 self.status = ExecutionStatus::Executing;
535 }
536 ExecutionState::Idle => {
537 self.status = ExecutionStatus::Finished;
538 if self.outputs.is_empty() {
539 cx.emit(ExecutionViewFinishedEmpty);
540 } else if ReplSettings::get_global(cx).inline_output {
541 if let Some(small_text) = self.get_small_inline_output(cx) {
542 cx.emit(ExecutionViewFinishedSmall(small_text));
543 }
544 }
545 }
546 ExecutionState::Unknown => self.status = ExecutionStatus::Unknown,
547 ExecutionState::Starting => self.status = ExecutionStatus::ConnectingToKernel,
548 ExecutionState::Restarting => self.status = ExecutionStatus::Restarting,
549 ExecutionState::Terminating => self.status = ExecutionStatus::ShuttingDown,
550 ExecutionState::AutoRestarting => self.status = ExecutionStatus::Restarting,
551 ExecutionState::Dead => self.status = ExecutionStatus::Shutdown,
552 ExecutionState::Other(_) => self.status = ExecutionStatus::Unknown,
553 }
554 cx.notify();
555 return;
556 }
557 _msg => {
558 return;
559 }
560 };
561
562 // Check for a clear output marker as the previous output, so we can clear it out
563 if let Some(output) = self.outputs.last()
564 && let Output::ClearOutputWaitMarker = output
565 {
566 self.outputs.clear();
567 }
568
569 self.outputs.push(output);
570
571 cx.notify();
572 }
573
574 pub fn update_display_data(
575 &mut self,
576 data: &MimeBundle,
577 display_id: &str,
578 window: &mut Window,
579 cx: &mut Context<Self>,
580 ) {
581 let mut any = false;
582
583 self.outputs.iter_mut().for_each(|output| {
584 if let Some(other_display_id) = output.display_id().as_ref()
585 && other_display_id == display_id
586 {
587 *output = Output::new(data, Some(display_id.to_owned()), window, cx);
588 any = true;
589 }
590 });
591
592 if any {
593 cx.notify();
594 }
595 }
596
597 /// Check if the output is a single small plain text that can be shown inline.
598 /// Returns the text if it's suitable for inline display (single line, short enough).
599 fn get_small_inline_output(&self, cx: &App) -> Option<String> {
600 // Only consider single outputs
601 if self.outputs.len() != 1 {
602 return None;
603 }
604
605 let output = self.outputs.first()?;
606
607 // Only Plain outputs can be inlined
608 let content = match output {
609 Output::Plain { content, .. } => content,
610 _ => return None,
611 };
612
613 let text = content.read(cx).full_text();
614 let trimmed = text.trim();
615
616 let max_length = ReplSettings::get_global(cx).inline_output_max_length;
617
618 // Must be a single line and within the configured max length
619 if trimmed.contains('\n') || trimmed.len() > max_length {
620 return None;
621 }
622
623 Some(trimmed.to_string())
624 }
625
626 fn apply_terminal_text(
627 &mut self,
628 text: &str,
629 window: &mut Window,
630 cx: &mut Context<Self>,
631 ) -> Option<Output> {
632 if let Some(last_output) = self.outputs.last_mut()
633 && let Output::Stream {
634 content: last_stream,
635 } = last_output
636 {
637 // Don't need to add a new output, we already have a terminal output
638 // and can just update the most recent terminal output
639 last_stream.update(cx, |last_stream, cx| {
640 last_stream.append_text(text, cx);
641 cx.notify();
642 });
643 return None;
644 }
645
646 Some(Output::Stream {
647 content: cx.new(|cx| TerminalOutput::from(text, window, cx)),
648 })
649 }
650}
651
652impl Render for ExecutionView {
653 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
654 let status = match &self.status {
655 ExecutionStatus::ConnectingToKernel => Label::new("Connecting to kernel...")
656 .color(Color::Muted)
657 .into_any_element(),
658 ExecutionStatus::Executing => h_flex()
659 .gap_2()
660 .child(
661 Icon::new(IconName::ArrowCircle)
662 .size(IconSize::Small)
663 .color(Color::Muted)
664 .with_rotate_animation(3),
665 )
666 .child(Label::new("Executing...").color(Color::Muted))
667 .into_any_element(),
668 ExecutionStatus::Finished => Icon::new(IconName::Check)
669 .size(IconSize::Small)
670 .into_any_element(),
671 ExecutionStatus::Unknown => Label::new("Unknown status")
672 .color(Color::Muted)
673 .into_any_element(),
674 ExecutionStatus::ShuttingDown => Label::new("Kernel shutting down...")
675 .color(Color::Muted)
676 .into_any_element(),
677 ExecutionStatus::Restarting => Label::new("Kernel restarting...")
678 .color(Color::Muted)
679 .into_any_element(),
680 ExecutionStatus::Shutdown => Label::new("Kernel shutdown")
681 .color(Color::Muted)
682 .into_any_element(),
683 ExecutionStatus::Queued => Label::new("Queued...")
684 .color(Color::Muted)
685 .into_any_element(),
686 ExecutionStatus::KernelErrored(error) => Label::new(format!("Kernel error: {}", error))
687 .color(Color::Error)
688 .into_any_element(),
689 };
690
691 if self.outputs.is_empty() {
692 return v_flex()
693 .min_h(window.line_height())
694 .justify_center()
695 .child(status)
696 .into_any_element();
697 }
698
699 div()
700 .w_full()
701 .children(
702 self.outputs
703 .iter()
704 .map(|output| output.render(self.workspace.clone(), window, cx)),
705 )
706 .children(match self.status {
707 ExecutionStatus::Executing => vec![status],
708 ExecutionStatus::Queued => vec![status],
709 _ => vec![],
710 })
711 .into_any_element()
712 }
713}