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, Render, WeakEntity};
38use language::Buffer;
39use runtimelib::{ExecutionState, JupyterMessageContent, MimeBundle, MimeType};
40use ui::{
41 ButtonStyle, CommonAnimationExt, Context, IconButton, IconName, IntoElement, Styled, Tooltip,
42 Window, div, h_flex, prelude::*, v_flex,
43};
44
45mod image;
46use image::ImageView;
47
48mod markdown;
49use markdown::MarkdownView;
50
51mod table;
52use table::TableView;
53
54pub mod plain;
55use plain::TerminalOutput;
56
57pub(crate) mod user_error;
58use user_error::ErrorView;
59use workspace::Workspace;
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) => {
240 // Add buttons for the traceback section
241 Some(
242 h_flex()
243 .pl_1()
244 .child(
245 IconButton::new(
246 ElementId::Name("copy-full-error-traceback".into()),
247 IconName::Copy,
248 )
249 .style(ButtonStyle::Transparent)
250 .tooltip(Tooltip::text("Copy Full Error"))
251 .on_click({
252 let ename = err.ename.clone();
253 let evalue = err.evalue.clone();
254 let traceback = err.traceback.clone();
255 move |_, _window, cx| {
256 let traceback_text = traceback.read(cx).full_text();
257 let full_error =
258 format!("{}: {}\n{}", ename, evalue, traceback_text);
259 let clipboard_content =
260 ClipboardItem::new_string(full_error);
261 cx.write_to_clipboard(clipboard_content);
262 }
263 }),
264 )
265 .child(
266 IconButton::new(
267 ElementId::Name("open-full-error-in-buffer-traceback".into()),
268 IconName::FileTextOutlined,
269 )
270 .style(ButtonStyle::Transparent)
271 .tooltip(Tooltip::text("Open Full Error in Buffer"))
272 .on_click({
273 let ename = err.ename.clone();
274 let evalue = err.evalue.clone();
275 let traceback = err.traceback.clone();
276 move |_, window, cx| {
277 if let Some(workspace) = workspace.upgrade() {
278 let traceback_text = traceback.read(cx).full_text();
279 let full_error = format!(
280 "{}: {}\n{}",
281 ename, evalue, traceback_text
282 );
283 let buffer = cx.new(|cx| {
284 let mut buffer = Buffer::local(full_error, cx)
285 .with_language(
286 language::PLAIN_TEXT.clone(),
287 cx,
288 );
289 buffer.set_capability(
290 language::Capability::ReadOnly,
291 cx,
292 );
293 buffer
294 });
295 let editor = Box::new(cx.new(|cx| {
296 let multibuffer = cx.new(|cx| {
297 let mut multi_buffer =
298 MultiBuffer::singleton(buffer.clone(), cx);
299 multi_buffer
300 .set_title("Full Error".to_string(), cx);
301 multi_buffer
302 });
303 Editor::for_multibuffer(
304 multibuffer,
305 None,
306 window,
307 cx,
308 )
309 }));
310 workspace.update(cx, |workspace, cx| {
311 workspace.add_item_to_active_pane(
312 editor, None, true, window, cx,
313 );
314 });
315 }
316 }
317 }),
318 )
319 .into_any_element(),
320 )
321 }
322 Self::Message(_) => None,
323 Self::Table { content, .. } => {
324 Self::render_output_controls(content.clone(), workspace, window, cx)
325 }
326 Self::ClearOutputWaitMarker => None,
327 })
328 }
329
330 pub fn display_id(&self) -> Option<String> {
331 match self {
332 Output::Plain { display_id, .. } => display_id.clone(),
333 Output::Stream { .. } => None,
334 Output::Image { display_id, .. } => display_id.clone(),
335 Output::ErrorOutput(_) => None,
336 Output::Message(_) => None,
337 Output::Table { display_id, .. } => display_id.clone(),
338 Output::Markdown { display_id, .. } => display_id.clone(),
339 Output::ClearOutputWaitMarker => None,
340 }
341 }
342
343 pub fn new(
344 data: &MimeBundle,
345 display_id: Option<String>,
346 window: &mut Window,
347 cx: &mut App,
348 ) -> Self {
349 match data.richest(rank_mime_type) {
350 Some(MimeType::Plain(text)) => Output::Plain {
351 content: cx.new(|cx| TerminalOutput::from(text, window, cx)),
352 display_id,
353 },
354 Some(MimeType::Markdown(text)) => {
355 let content = cx.new(|cx| MarkdownView::from(text.clone(), cx));
356 Output::Markdown {
357 content,
358 display_id,
359 }
360 }
361 Some(MimeType::Png(data)) | Some(MimeType::Jpeg(data)) => match ImageView::from(data) {
362 Ok(view) => Output::Image {
363 content: cx.new(|_| view),
364 display_id,
365 },
366 Err(error) => Output::Message(format!("Failed to load image: {}", error)),
367 },
368 Some(MimeType::DataTable(data)) => Output::Table {
369 content: cx.new(|cx| TableView::new(data, window, cx)),
370 display_id,
371 },
372 // Any other media types are not supported
373 _ => Output::Message("Unsupported media type".to_string()),
374 }
375 }
376}
377
378#[derive(Default, Clone, Debug)]
379pub enum ExecutionStatus {
380 #[default]
381 Unknown,
382 ConnectingToKernel,
383 Queued,
384 Executing,
385 Finished,
386 ShuttingDown,
387 Shutdown,
388 KernelErrored(String),
389 Restarting,
390}
391
392/// An ExecutionView shows the outputs of an execution.
393/// It can hold zero or more outputs, which the user
394/// sees as "the output" for a single execution.
395pub struct ExecutionView {
396 #[allow(unused)]
397 workspace: WeakEntity<Workspace>,
398 pub outputs: Vec<Output>,
399 pub status: ExecutionStatus,
400}
401
402impl ExecutionView {
403 pub fn new(
404 status: ExecutionStatus,
405 workspace: WeakEntity<Workspace>,
406 _cx: &mut Context<Self>,
407 ) -> Self {
408 Self {
409 workspace,
410 outputs: Default::default(),
411 status,
412 }
413 }
414
415 /// Accept a Jupyter message belonging to this execution
416 pub fn push_message(
417 &mut self,
418 message: &JupyterMessageContent,
419 window: &mut Window,
420 cx: &mut Context<Self>,
421 ) {
422 let output: Output = match message {
423 JupyterMessageContent::ExecuteResult(result) => Output::new(
424 &result.data,
425 result.transient.as_ref().and_then(|t| t.display_id.clone()),
426 window,
427 cx,
428 ),
429 JupyterMessageContent::DisplayData(result) => Output::new(
430 &result.data,
431 result.transient.as_ref().and_then(|t| t.display_id.clone()),
432 window,
433 cx,
434 ),
435 JupyterMessageContent::StreamContent(result) => {
436 // Previous stream data will combine together, handling colors, carriage returns, etc
437 if let Some(new_terminal) = self.apply_terminal_text(&result.text, window, cx) {
438 new_terminal
439 } else {
440 return;
441 }
442 }
443 JupyterMessageContent::ErrorOutput(result) => {
444 let terminal =
445 cx.new(|cx| TerminalOutput::from(&result.traceback.join("\n"), window, cx));
446
447 Output::ErrorOutput(ErrorView {
448 ename: result.ename.clone(),
449 evalue: result.evalue.clone(),
450 traceback: terminal,
451 })
452 }
453 JupyterMessageContent::ExecuteReply(reply) => {
454 for payload in reply.payload.iter() {
455 if let runtimelib::Payload::Page { data, .. } = payload {
456 let output = Output::new(data, None, window, cx);
457 self.outputs.push(output);
458 }
459 }
460 cx.notify();
461 return;
462 }
463 JupyterMessageContent::ClearOutput(options) => {
464 if !options.wait {
465 self.outputs.clear();
466 cx.notify();
467 return;
468 }
469
470 // Create a marker to clear the output after we get in a new output
471 Output::ClearOutputWaitMarker
472 }
473 JupyterMessageContent::Status(status) => {
474 match status.execution_state {
475 ExecutionState::Busy => {
476 self.status = ExecutionStatus::Executing;
477 }
478 ExecutionState::Idle => self.status = ExecutionStatus::Finished,
479 }
480 cx.notify();
481 return;
482 }
483 _msg => {
484 return;
485 }
486 };
487
488 // Check for a clear output marker as the previous output, so we can clear it out
489 if let Some(output) = self.outputs.last()
490 && let Output::ClearOutputWaitMarker = output
491 {
492 self.outputs.clear();
493 }
494
495 self.outputs.push(output);
496
497 cx.notify();
498 }
499
500 pub fn update_display_data(
501 &mut self,
502 data: &MimeBundle,
503 display_id: &str,
504 window: &mut Window,
505 cx: &mut Context<Self>,
506 ) {
507 let mut any = false;
508
509 self.outputs.iter_mut().for_each(|output| {
510 if let Some(other_display_id) = output.display_id().as_ref()
511 && other_display_id == display_id
512 {
513 *output = Output::new(data, Some(display_id.to_owned()), window, cx);
514 any = true;
515 }
516 });
517
518 if any {
519 cx.notify();
520 }
521 }
522
523 fn apply_terminal_text(
524 &mut self,
525 text: &str,
526 window: &mut Window,
527 cx: &mut Context<Self>,
528 ) -> Option<Output> {
529 if let Some(last_output) = self.outputs.last_mut()
530 && let Output::Stream {
531 content: last_stream,
532 } = last_output
533 {
534 // Don't need to add a new output, we already have a terminal output
535 // and can just update the most recent terminal output
536 last_stream.update(cx, |last_stream, cx| {
537 last_stream.append_text(text, cx);
538 cx.notify();
539 });
540 return None;
541 }
542
543 Some(Output::Stream {
544 content: cx.new(|cx| TerminalOutput::from(text, window, cx)),
545 })
546 }
547}
548
549impl Render for ExecutionView {
550 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
551 let status = match &self.status {
552 ExecutionStatus::ConnectingToKernel => Label::new("Connecting to kernel...")
553 .color(Color::Muted)
554 .into_any_element(),
555 ExecutionStatus::Executing => h_flex()
556 .gap_2()
557 .child(
558 Icon::new(IconName::ArrowCircle)
559 .size(IconSize::Small)
560 .color(Color::Muted)
561 .with_rotate_animation(3),
562 )
563 .child(Label::new("Executing...").color(Color::Muted))
564 .into_any_element(),
565 ExecutionStatus::Finished => Icon::new(IconName::Check)
566 .size(IconSize::Small)
567 .into_any_element(),
568 ExecutionStatus::Unknown => Label::new("Unknown status")
569 .color(Color::Muted)
570 .into_any_element(),
571 ExecutionStatus::ShuttingDown => Label::new("Kernel shutting down...")
572 .color(Color::Muted)
573 .into_any_element(),
574 ExecutionStatus::Restarting => Label::new("Kernel restarting...")
575 .color(Color::Muted)
576 .into_any_element(),
577 ExecutionStatus::Shutdown => Label::new("Kernel shutdown")
578 .color(Color::Muted)
579 .into_any_element(),
580 ExecutionStatus::Queued => Label::new("Queued...")
581 .color(Color::Muted)
582 .into_any_element(),
583 ExecutionStatus::KernelErrored(error) => Label::new(format!("Kernel error: {}", error))
584 .color(Color::Error)
585 .into_any_element(),
586 };
587
588 if self.outputs.is_empty() {
589 return v_flex()
590 .min_h(window.line_height())
591 .justify_center()
592 .child(status)
593 .into_any_element();
594 }
595
596 div()
597 .w_full()
598 .children(
599 self.outputs
600 .iter()
601 .map(|output| output.render(self.workspace.clone(), window, cx)),
602 )
603 .children(match self.status {
604 ExecutionStatus::Executing => vec![status],
605 ExecutionStatus::Queued => vec![status],
606 _ => vec![],
607 })
608 .into_any_element()
609 }
610}