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