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//! - `Output`: Represents a single output item, which can be of various types.
9//! - `OutputContent`: An enum that encapsulates different types of output content.
10//! - `ExecutionView`: Manages the display of outputs for a single execution.
11//! - `ExecutionStatus`: Represents the current status of an execution.
12//!
13//! ## Output Types
14//!
15//! The module supports several output types, including:
16//! - Plain text
17//! - Markdown
18//! - Images (PNG and JPEG)
19//! - Tables
20//! - Error messages
21//!
22//! ## Clipboard Support
23//!
24//! Most output types implement the `SupportsClipboard` trait, allowing
25//! users to easily copy output content to the system clipboard.
26//!
27//! ## Rendering
28//!
29//! The module provides rendering capabilities for each output type,
30//! ensuring proper display within the REPL interface.
31//!
32//! ## Jupyter Integration
33//!
34//! This module is designed to work with Jupyter message protocols,
35//! interpreting and displaying various types of Jupyter output.
36
37use std::time::Duration;
38
39use gpui::{
40 percentage, Animation, AnimationExt, AnyElement, ClipboardItem, Render, Transformation, View,
41};
42use runtimelib::{ExecutionState, JupyterMessageContent, MimeBundle, MimeType};
43use ui::{div, prelude::*, v_flex, IntoElement, Styled, Tooltip, ViewContext};
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
57mod user_error;
58use user_error::ErrorView;
59
60/// When deciding what to render from a collection of mediatypes, we need to rank them in order of importance
61fn rank_mime_type(mimetype: &MimeType) -> usize {
62 match mimetype {
63 MimeType::DataTable(_) => 6,
64 MimeType::Png(_) => 4,
65 MimeType::Jpeg(_) => 3,
66 MimeType::Markdown(_) => 2,
67 MimeType::Plain(_) => 1,
68 // All other media types are not supported in Zed at this time
69 _ => 0,
70 }
71}
72
73pub(crate) trait SupportsClipboard {
74 fn clipboard_content(&self, cx: &WindowContext) -> Option<ClipboardItem>;
75 fn has_clipboard_content(&self, cx: &WindowContext) -> bool;
76}
77
78pub struct Output {
79 content: OutputContent,
80 display_id: Option<String>,
81}
82
83impl Output {
84 pub fn new(data: &MimeBundle, display_id: Option<String>, cx: &mut WindowContext) -> Self {
85 Self {
86 content: OutputContent::new(data, cx),
87 display_id,
88 }
89 }
90
91 pub fn from(content: OutputContent) -> Self {
92 Self {
93 content,
94 display_id: None,
95 }
96 }
97}
98
99impl SupportsClipboard for Output {
100 fn clipboard_content(&self, cx: &WindowContext) -> Option<ClipboardItem> {
101 match &self.content {
102 OutputContent::Plain(terminal) => terminal.clipboard_content(cx),
103 OutputContent::Stream(terminal) => terminal.clipboard_content(cx),
104 OutputContent::Image(image) => image.clipboard_content(cx),
105 OutputContent::ErrorOutput(error) => error.traceback.clipboard_content(cx),
106 OutputContent::Message(_) => None,
107 OutputContent::Table(table) => table.clipboard_content(cx),
108 OutputContent::Markdown(markdown) => markdown.read(cx).clipboard_content(cx),
109 OutputContent::ClearOutputWaitMarker => None,
110 }
111 }
112
113 fn has_clipboard_content(&self, cx: &WindowContext) -> bool {
114 match &self.content {
115 OutputContent::Plain(terminal) => terminal.has_clipboard_content(cx),
116 OutputContent::Stream(terminal) => terminal.has_clipboard_content(cx),
117 OutputContent::Image(image) => image.has_clipboard_content(cx),
118 OutputContent::ErrorOutput(error) => error.traceback.has_clipboard_content(cx),
119 OutputContent::Message(_) => false,
120 OutputContent::Table(table) => table.has_clipboard_content(cx),
121 OutputContent::Markdown(markdown) => markdown.read(cx).has_clipboard_content(cx),
122 OutputContent::ClearOutputWaitMarker => false,
123 }
124 }
125}
126
127pub enum OutputContent {
128 Plain(TerminalOutput),
129 Stream(TerminalOutput),
130 Image(ImageView),
131 ErrorOutput(ErrorView),
132 Message(String),
133 Table(TableView),
134 Markdown(View<MarkdownView>),
135 ClearOutputWaitMarker,
136}
137
138impl OutputContent {
139 fn render(&self, cx: &mut ViewContext<ExecutionView>) -> Option<AnyElement> {
140 let el = match self {
141 // Note: in typical frontends we would show the execute_result.execution_count
142 // Here we can just handle either
143 Self::Plain(stdio) => Some(stdio.render(cx)),
144 Self::Markdown(markdown) => Some(markdown.clone().into_any_element()),
145 Self::Stream(stdio) => Some(stdio.render(cx)),
146 Self::Image(image) => Some(image.render(cx)),
147 Self::Message(message) => Some(div().child(message.clone()).into_any_element()),
148 Self::Table(table) => Some(table.render(cx)),
149 Self::ErrorOutput(error_view) => error_view.render(cx),
150 Self::ClearOutputWaitMarker => None,
151 };
152
153 el
154 }
155
156 pub fn new(data: &MimeBundle, cx: &mut WindowContext) -> Self {
157 match data.richest(rank_mime_type) {
158 Some(MimeType::Plain(text)) => OutputContent::Plain(TerminalOutput::from(text, cx)),
159 Some(MimeType::Markdown(text)) => {
160 let view = cx.new_view(|cx| MarkdownView::from(text.clone(), cx));
161 OutputContent::Markdown(view)
162 }
163 Some(MimeType::Png(data)) | Some(MimeType::Jpeg(data)) => match ImageView::from(data) {
164 Ok(view) => OutputContent::Image(view),
165 Err(error) => OutputContent::Message(format!("Failed to load image: {}", error)),
166 },
167 Some(MimeType::DataTable(data)) => {
168 OutputContent::Table(TableView::new(data.clone(), cx))
169 }
170 // Any other media types are not supported
171 _ => OutputContent::Message("Unsupported media type".to_string()),
172 }
173 }
174}
175
176#[derive(Default, Clone, Debug)]
177pub enum ExecutionStatus {
178 #[default]
179 Unknown,
180 ConnectingToKernel,
181 Queued,
182 Executing,
183 Finished,
184 ShuttingDown,
185 Shutdown,
186 KernelErrored(String),
187 Restarting,
188}
189
190/// An ExecutionView shows the outputs of an execution.
191/// It can hold zero or more outputs, which the user
192/// sees as "the output" for a single execution.
193pub struct ExecutionView {
194 pub outputs: Vec<Output>,
195 pub status: ExecutionStatus,
196}
197
198impl ExecutionView {
199 pub fn new(status: ExecutionStatus, _cx: &mut ViewContext<Self>) -> Self {
200 Self {
201 outputs: Default::default(),
202 status,
203 }
204 }
205
206 /// Accept a Jupyter message belonging to this execution
207 pub fn push_message(&mut self, message: &JupyterMessageContent, cx: &mut ViewContext<Self>) {
208 let output: Output = match message {
209 JupyterMessageContent::ExecuteResult(result) => Output::new(
210 &result.data,
211 result.transient.as_ref().and_then(|t| t.display_id.clone()),
212 cx,
213 ),
214 JupyterMessageContent::DisplayData(result) => {
215 Output::new(&result.data, result.transient.display_id.clone(), cx)
216 }
217 JupyterMessageContent::StreamContent(result) => {
218 // Previous stream data will combine together, handling colors, carriage returns, etc
219 if let Some(new_terminal) = self.apply_terminal_text(&result.text, cx) {
220 Output::from(new_terminal)
221 } else {
222 return;
223 }
224 }
225 JupyterMessageContent::ErrorOutput(result) => {
226 let mut terminal = TerminalOutput::new(cx);
227 terminal.append_text(&result.traceback.join("\n"));
228
229 Output::from(OutputContent::ErrorOutput(ErrorView {
230 ename: result.ename.clone(),
231 evalue: result.evalue.clone(),
232 traceback: terminal,
233 }))
234 }
235 JupyterMessageContent::ExecuteReply(reply) => {
236 for payload in reply.payload.iter() {
237 match payload {
238 // Pager data comes in via `?` at the end of a statement in Python, used for showing documentation.
239 // Some UI will show this as a popup. For ease of implementation, it's included as an output here.
240 runtimelib::Payload::Page { data, .. } => {
241 let output = Output::new(data, None, cx);
242 self.outputs.push(output);
243 }
244
245 // There are other payloads that could be handled here, such as updating the input.
246 // Below are the other payloads that _could_ be handled, but are not required for Zed.
247
248 // Set next input adds text to the next cell. Not required to support.
249 // However, this could be implemented by adding text to the buffer.
250 // Trigger in python using `get_ipython().set_next_input("text")`
251 //
252 // runtimelib::Payload::SetNextInput { text, replace } => {},
253
254 // Not likely to be used in the context of Zed, where someone could just open the buffer themselves
255 // Python users can trigger this with the `%edit` magic command
256 // runtimelib::Payload::EditMagic { filename, line_number } => {},
257
258 // Ask the user if they want to exit the kernel. Not required to support.
259 // runtimelib::Payload::AskExit { keepkernel } => {},
260 _ => {}
261 }
262 }
263 cx.notify();
264 return;
265 }
266 JupyterMessageContent::ClearOutput(options) => {
267 if !options.wait {
268 self.outputs.clear();
269 cx.notify();
270 return;
271 }
272
273 // Create a marker to clear the output after we get in a new output
274 Output::from(OutputContent::ClearOutputWaitMarker)
275 }
276 JupyterMessageContent::Status(status) => {
277 match status.execution_state {
278 ExecutionState::Busy => {
279 self.status = ExecutionStatus::Executing;
280 }
281 ExecutionState::Idle => self.status = ExecutionStatus::Finished,
282 }
283 cx.notify();
284 return;
285 }
286 _msg => {
287 return;
288 }
289 };
290
291 // Check for a clear output marker as the previous output, so we can clear it out
292 if let Some(output) = self.outputs.last() {
293 if let OutputContent::ClearOutputWaitMarker = output.content {
294 self.outputs.clear();
295 }
296 }
297
298 self.outputs.push(output);
299
300 cx.notify();
301 }
302
303 pub fn update_display_data(
304 &mut self,
305 data: &MimeBundle,
306 display_id: &str,
307 cx: &mut ViewContext<Self>,
308 ) {
309 let mut any = false;
310
311 self.outputs.iter_mut().for_each(|output| {
312 if let Some(other_display_id) = output.display_id.as_ref() {
313 if other_display_id == display_id {
314 output.content = OutputContent::new(data, cx);
315 any = true;
316 }
317 }
318 });
319
320 if any {
321 cx.notify();
322 }
323 }
324
325 fn apply_terminal_text(
326 &mut self,
327 text: &str,
328 cx: &mut ViewContext<Self>,
329 ) -> Option<OutputContent> {
330 if let Some(last_output) = self.outputs.last_mut() {
331 match &mut last_output.content {
332 OutputContent::Stream(last_stream) => {
333 last_stream.append_text(text);
334 // Don't need to add a new output, we already have a terminal output
335 cx.notify();
336 return None;
337 }
338 // Edge case note: a clear output marker
339 OutputContent::ClearOutputWaitMarker => {
340 // Edge case note: a clear output marker is handled by the caller
341 // since we will return a new output at the end here as a new terminal output
342 }
343 // A different output type is "in the way", so we need to create a new output,
344 // which is the same as having no prior output
345 _ => {}
346 }
347 }
348
349 let mut new_terminal = TerminalOutput::new(cx);
350 new_terminal.append_text(text);
351 Some(OutputContent::Stream(new_terminal))
352 }
353}
354
355impl Render for ExecutionView {
356 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
357 let status = match &self.status {
358 ExecutionStatus::ConnectingToKernel => Label::new("Connecting to kernel...")
359 .color(Color::Muted)
360 .into_any_element(),
361 ExecutionStatus::Executing => h_flex()
362 .gap_2()
363 .child(
364 Icon::new(IconName::ArrowCircle)
365 .size(IconSize::Small)
366 .color(Color::Muted)
367 .with_animation(
368 "arrow-circle",
369 Animation::new(Duration::from_secs(3)).repeat(),
370 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
371 ),
372 )
373 .child(Label::new("Executing...").color(Color::Muted))
374 .into_any_element(),
375 ExecutionStatus::Finished => Icon::new(IconName::Check)
376 .size(IconSize::Small)
377 .into_any_element(),
378 ExecutionStatus::Unknown => Label::new("Unknown status")
379 .color(Color::Muted)
380 .into_any_element(),
381 ExecutionStatus::ShuttingDown => Label::new("Kernel shutting down...")
382 .color(Color::Muted)
383 .into_any_element(),
384 ExecutionStatus::Restarting => Label::new("Kernel restarting...")
385 .color(Color::Muted)
386 .into_any_element(),
387 ExecutionStatus::Shutdown => Label::new("Kernel shutdown")
388 .color(Color::Muted)
389 .into_any_element(),
390 ExecutionStatus::Queued => Label::new("Queued...")
391 .color(Color::Muted)
392 .into_any_element(),
393 ExecutionStatus::KernelErrored(error) => Label::new(format!("Kernel error: {}", error))
394 .color(Color::Error)
395 .into_any_element(),
396 };
397
398 if self.outputs.len() == 0 {
399 return v_flex()
400 .min_h(cx.line_height())
401 .justify_center()
402 .child(status)
403 .into_any_element();
404 }
405
406 div()
407 .w_full()
408 .children(self.outputs.iter().enumerate().map(|(index, output)| {
409 h_flex()
410 .w_full()
411 .items_start()
412 .child(
413 div().flex_1().child(
414 output
415 .content
416 .render(cx)
417 .unwrap_or_else(|| div().into_any_element()),
418 ),
419 )
420 .when(output.has_clipboard_content(cx), |el| {
421 let clipboard_content = output.clipboard_content(cx);
422
423 el.child(
424 div().pl_1().child(
425 IconButton::new(
426 ElementId::Name(format!("copy-output-{}", index).into()),
427 IconName::Copy,
428 )
429 .style(ButtonStyle::Transparent)
430 .tooltip(move |cx| Tooltip::text("Copy Output", cx))
431 .on_click(cx.listener(
432 move |_, _, cx| {
433 if let Some(clipboard_content) = clipboard_content.as_ref()
434 {
435 cx.write_to_clipboard(clipboard_content.clone());
436 // todo!(): let the user know that the content was copied
437 }
438 },
439 )),
440 ),
441 )
442 })
443 }))
444 .children(match self.status {
445 ExecutionStatus::Executing => vec![status],
446 ExecutionStatus::Queued => vec![status],
447 _ => vec![],
448 })
449 .into_any_element()
450 }
451}