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 std::time::Duration;
37
38use editor::Editor;
39use gpui::{
40 percentage, Animation, AnimationExt, AnyElement, ClipboardItem, Model, Render, Transformation,
41 View, WeakView,
42};
43use language::Buffer;
44use runtimelib::{ExecutionState, JupyterMessageContent, MimeBundle, MimeType};
45use ui::{div, prelude::*, v_flex, IntoElement, Styled, Tooltip, ViewContext};
46
47mod image;
48use image::ImageView;
49
50mod markdown;
51use markdown::MarkdownView;
52
53mod table;
54use table::TableView;
55
56pub mod plain;
57use plain::TerminalOutput;
58
59mod user_error;
60use user_error::ErrorView;
61use workspace::Workspace;
62
63/// When deciding what to render from a collection of mediatypes, we need to rank them in order of importance
64fn rank_mime_type(mimetype: &MimeType) -> usize {
65 match mimetype {
66 MimeType::DataTable(_) => 6,
67 MimeType::Png(_) => 4,
68 MimeType::Jpeg(_) => 3,
69 MimeType::Markdown(_) => 2,
70 MimeType::Plain(_) => 1,
71 // All other media types are not supported in Zed at this time
72 _ => 0,
73 }
74}
75
76pub(crate) trait OutputContent {
77 fn clipboard_content(&self, cx: &WindowContext) -> Option<ClipboardItem>;
78 fn has_clipboard_content(&self, _cx: &WindowContext) -> bool {
79 return false;
80 }
81 fn has_buffer_content(&self, _cx: &WindowContext) -> bool {
82 return false;
83 }
84 fn buffer_content(&mut self, _cx: &mut WindowContext) -> Option<Model<Buffer>> {
85 None
86 }
87}
88
89impl<V: OutputContent + 'static> OutputContent for View<V> {
90 fn clipboard_content(&self, cx: &WindowContext) -> Option<ClipboardItem> {
91 self.read(cx).clipboard_content(cx)
92 }
93
94 fn has_clipboard_content(&self, cx: &WindowContext) -> bool {
95 self.read(cx).has_clipboard_content(cx)
96 }
97
98 fn has_buffer_content(&self, cx: &WindowContext) -> bool {
99 self.read(cx).has_buffer_content(cx)
100 }
101
102 fn buffer_content(&mut self, cx: &mut WindowContext) -> Option<Model<Buffer>> {
103 self.update(cx, |item, cx| item.buffer_content(cx))
104 }
105}
106
107pub enum Output {
108 Plain {
109 content: View<TerminalOutput>,
110 display_id: Option<String>,
111 },
112 Stream {
113 content: View<TerminalOutput>,
114 },
115 Image {
116 content: View<ImageView>,
117 display_id: Option<String>,
118 },
119 ErrorOutput(ErrorView),
120 Message(String),
121 Table {
122 content: View<TableView>,
123 display_id: Option<String>,
124 },
125 Markdown {
126 content: View<MarkdownView>,
127 display_id: Option<String>,
128 },
129 ClearOutputWaitMarker,
130}
131
132impl Output {
133 fn render_output_controls<V: OutputContent + 'static>(
134 v: View<V>,
135 workspace: WeakView<Workspace>,
136 cx: &mut ViewContext<ExecutionView>,
137 ) -> Option<AnyElement> {
138 if !v.has_clipboard_content(cx) && !v.has_buffer_content(cx) {
139 return None;
140 }
141
142 Some(
143 h_flex()
144 .pl_1()
145 .when(v.has_clipboard_content(cx), |el| {
146 let v = v.clone();
147 el.child(
148 IconButton::new(ElementId::Name("copy-output".into()), IconName::Copy)
149 .style(ButtonStyle::Transparent)
150 .tooltip(move |cx| Tooltip::text("Copy Output", cx))
151 .on_click(cx.listener(move |_, _, cx| {
152 let clipboard_content = v.clipboard_content(cx);
153
154 if let Some(clipboard_content) = clipboard_content.as_ref() {
155 cx.write_to_clipboard(clipboard_content.clone());
156 }
157 })),
158 )
159 })
160 .when(v.has_buffer_content(cx), |el| {
161 let v = v.clone();
162 el.child(
163 IconButton::new(
164 ElementId::Name("open-in-buffer".into()),
165 IconName::FileText,
166 )
167 .style(ButtonStyle::Transparent)
168 .tooltip(move |cx| Tooltip::text("Open in Buffer", cx))
169 .on_click(cx.listener({
170 let workspace = workspace.clone();
171
172 move |_, _, cx| {
173 let buffer_content =
174 v.update(cx, |item, cx| item.buffer_content(cx));
175
176 if let Some(buffer_content) = buffer_content.as_ref() {
177 let buffer = buffer_content.clone();
178 let editor = Box::new(cx.new_view(|cx| {
179 Editor::for_buffer(buffer.clone(), None, cx)
180 }));
181 workspace
182 .update(cx, |workspace, cx| {
183 workspace
184 .add_item_to_active_pane(editor, None, true, cx);
185 })
186 .ok();
187 }
188 }
189 })),
190 )
191 })
192 .into_any_element(),
193 )
194 }
195
196 fn render(
197 &self,
198 workspace: WeakView<Workspace>,
199 cx: &mut ViewContext<ExecutionView>,
200 ) -> impl IntoElement {
201 let content = match self {
202 Self::Plain { content, .. } => Some(content.clone().into_any_element()),
203 Self::Markdown { content, .. } => Some(content.clone().into_any_element()),
204 Self::Stream { content, .. } => Some(content.clone().into_any_element()),
205 Self::Image { content, .. } => Some(content.clone().into_any_element()),
206 Self::Message(message) => Some(div().child(message.clone()).into_any_element()),
207 Self::Table { content, .. } => Some(content.clone().into_any_element()),
208 Self::ErrorOutput(error_view) => error_view.render(cx),
209 Self::ClearOutputWaitMarker => None,
210 };
211
212 h_flex()
213 .w_full()
214 .items_start()
215 .child(div().flex_1().children(content))
216 .children(match self {
217 Self::Plain { content, .. } => {
218 Self::render_output_controls(content.clone(), workspace.clone(), cx)
219 }
220 Self::Markdown { content, .. } => {
221 Self::render_output_controls(content.clone(), workspace.clone(), cx)
222 }
223 Self::Stream { content, .. } => {
224 Self::render_output_controls(content.clone(), workspace.clone(), cx)
225 }
226 Self::Image { content, .. } => {
227 Self::render_output_controls(content.clone(), workspace.clone(), cx)
228 }
229 Self::ErrorOutput(err) => {
230 Self::render_output_controls(err.traceback.clone(), workspace.clone(), cx)
231 }
232 Self::Message(_) => None,
233 Self::Table { content, .. } => {
234 Self::render_output_controls(content.clone(), workspace.clone(), cx)
235 }
236 Self::ClearOutputWaitMarker => None,
237 })
238 }
239
240 pub fn display_id(&self) -> Option<String> {
241 match self {
242 Output::Plain { display_id, .. } => display_id.clone(),
243 Output::Stream { .. } => None,
244 Output::Image { display_id, .. } => display_id.clone(),
245 Output::ErrorOutput(_) => None,
246 Output::Message(_) => None,
247 Output::Table { display_id, .. } => display_id.clone(),
248 Output::Markdown { display_id, .. } => display_id.clone(),
249 Output::ClearOutputWaitMarker => None,
250 }
251 }
252
253 pub fn new(data: &MimeBundle, display_id: Option<String>, cx: &mut WindowContext) -> Self {
254 match data.richest(rank_mime_type) {
255 Some(MimeType::Plain(text)) => Output::Plain {
256 content: cx.new_view(|cx| TerminalOutput::from(text, cx)),
257 display_id,
258 },
259 Some(MimeType::Markdown(text)) => {
260 let view = cx.new_view(|cx| MarkdownView::from(text.clone(), cx));
261 Output::Markdown {
262 content: view,
263 display_id,
264 }
265 }
266 Some(MimeType::Png(data)) | Some(MimeType::Jpeg(data)) => match ImageView::from(data) {
267 Ok(view) => Output::Image {
268 content: cx.new_view(|_| view),
269 display_id,
270 },
271 Err(error) => Output::Message(format!("Failed to load image: {}", error)),
272 },
273 Some(MimeType::DataTable(data)) => Output::Table {
274 content: cx.new_view(|cx| TableView::new(data, cx)),
275 display_id,
276 },
277 // Any other media types are not supported
278 _ => Output::Message("Unsupported media type".to_string()),
279 }
280 }
281}
282
283#[derive(Default, Clone, Debug)]
284pub enum ExecutionStatus {
285 #[default]
286 Unknown,
287 ConnectingToKernel,
288 Queued,
289 Executing,
290 Finished,
291 ShuttingDown,
292 Shutdown,
293 KernelErrored(String),
294 Restarting,
295}
296
297/// An ExecutionView shows the outputs of an execution.
298/// It can hold zero or more outputs, which the user
299/// sees as "the output" for a single execution.
300pub struct ExecutionView {
301 #[allow(unused)]
302 workspace: WeakView<Workspace>,
303 pub outputs: Vec<Output>,
304 pub status: ExecutionStatus,
305}
306
307impl ExecutionView {
308 pub fn new(
309 status: ExecutionStatus,
310 workspace: WeakView<Workspace>,
311 _cx: &mut ViewContext<Self>,
312 ) -> Self {
313 Self {
314 workspace,
315 outputs: Default::default(),
316 status,
317 }
318 }
319
320 /// Accept a Jupyter message belonging to this execution
321 pub fn push_message(&mut self, message: &JupyterMessageContent, cx: &mut ViewContext<Self>) {
322 let output: Output = match message {
323 JupyterMessageContent::ExecuteResult(result) => Output::new(
324 &result.data,
325 result.transient.as_ref().and_then(|t| t.display_id.clone()),
326 cx,
327 ),
328 JupyterMessageContent::DisplayData(result) => {
329 Output::new(&result.data, result.transient.display_id.clone(), cx)
330 }
331 JupyterMessageContent::StreamContent(result) => {
332 // Previous stream data will combine together, handling colors, carriage returns, etc
333 if let Some(new_terminal) = self.apply_terminal_text(&result.text, cx) {
334 new_terminal
335 } else {
336 return;
337 }
338 }
339 JupyterMessageContent::ErrorOutput(result) => {
340 let terminal =
341 cx.new_view(|cx| TerminalOutput::from(&result.traceback.join("\n"), cx));
342
343 Output::ErrorOutput(ErrorView {
344 ename: result.ename.clone(),
345 evalue: result.evalue.clone(),
346 traceback: terminal,
347 })
348 }
349 JupyterMessageContent::ExecuteReply(reply) => {
350 for payload in reply.payload.iter() {
351 match payload {
352 // Pager data comes in via `?` at the end of a statement in Python, used for showing documentation.
353 // Some UI will show this as a popup. For ease of implementation, it's included as an output here.
354 runtimelib::Payload::Page { data, .. } => {
355 let output = Output::new(data, None, cx);
356 self.outputs.push(output);
357 }
358
359 // There are other payloads that could be handled here, such as updating the input.
360 // Below are the other payloads that _could_ be handled, but are not required for Zed.
361
362 // Set next input adds text to the next cell. Not required to support.
363 // However, this could be implemented by adding text to the buffer.
364 // Trigger in python using `get_ipython().set_next_input("text")`
365 //
366 // runtimelib::Payload::SetNextInput { text, replace } => {},
367
368 // Not likely to be used in the context of Zed, where someone could just open the buffer themselves
369 // Python users can trigger this with the `%edit` magic command
370 // runtimelib::Payload::EditMagic { filename, line_number } => {},
371
372 // Ask the user if they want to exit the kernel. Not required to support.
373 // runtimelib::Payload::AskExit { keepkernel } => {},
374 _ => {}
375 }
376 }
377 cx.notify();
378 return;
379 }
380 JupyterMessageContent::ClearOutput(options) => {
381 if !options.wait {
382 self.outputs.clear();
383 cx.notify();
384 return;
385 }
386
387 // Create a marker to clear the output after we get in a new output
388 Output::ClearOutputWaitMarker
389 }
390 JupyterMessageContent::Status(status) => {
391 match status.execution_state {
392 ExecutionState::Busy => {
393 self.status = ExecutionStatus::Executing;
394 }
395 ExecutionState::Idle => self.status = ExecutionStatus::Finished,
396 }
397 cx.notify();
398 return;
399 }
400 _msg => {
401 return;
402 }
403 };
404
405 // Check for a clear output marker as the previous output, so we can clear it out
406 if let Some(output) = self.outputs.last() {
407 if let Output::ClearOutputWaitMarker = output {
408 self.outputs.clear();
409 }
410 }
411
412 self.outputs.push(output);
413
414 cx.notify();
415 }
416
417 pub fn update_display_data(
418 &mut self,
419 data: &MimeBundle,
420 display_id: &str,
421 cx: &mut ViewContext<Self>,
422 ) {
423 let mut any = false;
424
425 self.outputs.iter_mut().for_each(|output| {
426 if let Some(other_display_id) = output.display_id().as_ref() {
427 if other_display_id == display_id {
428 *output = Output::new(data, Some(display_id.to_owned()), cx);
429 any = true;
430 }
431 }
432 });
433
434 if any {
435 cx.notify();
436 }
437 }
438
439 fn apply_terminal_text(&mut self, text: &str, cx: &mut ViewContext<Self>) -> Option<Output> {
440 if let Some(last_output) = self.outputs.last_mut() {
441 match last_output {
442 Output::Stream {
443 content: last_stream,
444 } => {
445 // Don't need to add a new output, we already have a terminal output
446 // and can just update the most recent terminal output
447 last_stream.update(cx, |last_stream, cx| {
448 last_stream.append_text(text, cx);
449 cx.notify();
450 });
451 return None;
452 }
453 // A different output type is "in the way", so we need to create a new output,
454 // which is the same as having no prior stream/terminal text
455 _ => {}
456 }
457 }
458
459 Some(Output::Stream {
460 content: cx.new_view(|cx| TerminalOutput::from(text, cx)),
461 })
462 }
463}
464
465impl Render for ExecutionView {
466 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
467 let status = match &self.status {
468 ExecutionStatus::ConnectingToKernel => Label::new("Connecting to kernel...")
469 .color(Color::Muted)
470 .into_any_element(),
471 ExecutionStatus::Executing => h_flex()
472 .gap_2()
473 .child(
474 Icon::new(IconName::ArrowCircle)
475 .size(IconSize::Small)
476 .color(Color::Muted)
477 .with_animation(
478 "arrow-circle",
479 Animation::new(Duration::from_secs(3)).repeat(),
480 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
481 ),
482 )
483 .child(Label::new("Executing...").color(Color::Muted))
484 .into_any_element(),
485 ExecutionStatus::Finished => Icon::new(IconName::Check)
486 .size(IconSize::Small)
487 .into_any_element(),
488 ExecutionStatus::Unknown => Label::new("Unknown status")
489 .color(Color::Muted)
490 .into_any_element(),
491 ExecutionStatus::ShuttingDown => Label::new("Kernel shutting down...")
492 .color(Color::Muted)
493 .into_any_element(),
494 ExecutionStatus::Restarting => Label::new("Kernel restarting...")
495 .color(Color::Muted)
496 .into_any_element(),
497 ExecutionStatus::Shutdown => Label::new("Kernel shutdown")
498 .color(Color::Muted)
499 .into_any_element(),
500 ExecutionStatus::Queued => Label::new("Queued...")
501 .color(Color::Muted)
502 .into_any_element(),
503 ExecutionStatus::KernelErrored(error) => Label::new(format!("Kernel error: {}", error))
504 .color(Color::Error)
505 .into_any_element(),
506 };
507
508 if self.outputs.len() == 0 {
509 return v_flex()
510 .min_h(cx.line_height())
511 .justify_center()
512 .child(status)
513 .into_any_element();
514 }
515
516 div()
517 .w_full()
518 .children(
519 self.outputs
520 .iter()
521 .map(|output| output.render(self.workspace.clone(), cx)),
522 )
523 .children(match self.status {
524 ExecutionStatus::Executing => vec![status],
525 ExecutionStatus::Queued => vec![status],
526 _ => vec![],
527 })
528 .into_any_element()
529 }
530}