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, MultiBuffer};
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
59pub(crate) mod 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 false
80 }
81 fn has_buffer_content(&self, _cx: &WindowContext) -> bool {
82 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 let multibuffer = cx.new_model(|cx| {
180 let mut multi_buffer =
181 MultiBuffer::singleton(buffer.clone(), cx);
182
183 multi_buffer.set_title("REPL Output".to_string(), cx);
184 multi_buffer
185 });
186
187 Editor::for_multibuffer(multibuffer, None, false, cx)
188 }));
189 workspace
190 .update(cx, |workspace, cx| {
191 workspace
192 .add_item_to_active_pane(editor, None, true, cx);
193 })
194 .ok();
195 }
196 }
197 })),
198 )
199 })
200 .into_any_element(),
201 )
202 }
203
204 pub fn render(
205 &self,
206
207 workspace: WeakView<Workspace>,
208 cx: &mut ViewContext<ExecutionView>,
209 ) -> impl IntoElement {
210 let content = match self {
211 Self::Plain { content, .. } => Some(content.clone().into_any_element()),
212 Self::Markdown { content, .. } => Some(content.clone().into_any_element()),
213 Self::Stream { content, .. } => Some(content.clone().into_any_element()),
214 Self::Image { content, .. } => Some(content.clone().into_any_element()),
215 Self::Message(message) => Some(div().child(message.clone()).into_any_element()),
216 Self::Table { content, .. } => Some(content.clone().into_any_element()),
217 Self::ErrorOutput(error_view) => error_view.render(cx),
218 Self::ClearOutputWaitMarker => None,
219 };
220
221 h_flex()
222 .w_full()
223 .items_start()
224 .child(div().flex_1().children(content))
225 .children(match self {
226 Self::Plain { content, .. } => {
227 Self::render_output_controls(content.clone(), workspace.clone(), cx)
228 }
229 Self::Markdown { content, .. } => {
230 Self::render_output_controls(content.clone(), workspace.clone(), cx)
231 }
232 Self::Stream { content, .. } => {
233 Self::render_output_controls(content.clone(), workspace.clone(), cx)
234 }
235 Self::Image { content, .. } => {
236 Self::render_output_controls(content.clone(), workspace.clone(), cx)
237 }
238 Self::ErrorOutput(err) => {
239 Self::render_output_controls(err.traceback.clone(), workspace.clone(), cx)
240 }
241 Self::Message(_) => None,
242 Self::Table { content, .. } => {
243 Self::render_output_controls(content.clone(), workspace.clone(), cx)
244 }
245 Self::ClearOutputWaitMarker => None,
246 })
247 }
248
249 pub fn display_id(&self) -> Option<String> {
250 match self {
251 Output::Plain { display_id, .. } => display_id.clone(),
252 Output::Stream { .. } => None,
253 Output::Image { display_id, .. } => display_id.clone(),
254 Output::ErrorOutput(_) => None,
255 Output::Message(_) => None,
256 Output::Table { display_id, .. } => display_id.clone(),
257 Output::Markdown { display_id, .. } => display_id.clone(),
258 Output::ClearOutputWaitMarker => None,
259 }
260 }
261
262 pub fn new(data: &MimeBundle, display_id: Option<String>, cx: &mut WindowContext) -> Self {
263 match data.richest(rank_mime_type) {
264 Some(MimeType::Plain(text)) => Output::Plain {
265 content: cx.new_view(|cx| TerminalOutput::from(text, cx)),
266 display_id,
267 },
268 Some(MimeType::Markdown(text)) => {
269 let view = cx.new_view(|cx| MarkdownView::from(text.clone(), cx));
270 Output::Markdown {
271 content: view,
272 display_id,
273 }
274 }
275 Some(MimeType::Png(data)) | Some(MimeType::Jpeg(data)) => match ImageView::from(data) {
276 Ok(view) => Output::Image {
277 content: cx.new_view(|_| view),
278 display_id,
279 },
280 Err(error) => Output::Message(format!("Failed to load image: {}", error)),
281 },
282 Some(MimeType::DataTable(data)) => Output::Table {
283 content: cx.new_view(|cx| TableView::new(data, cx)),
284 display_id,
285 },
286 // Any other media types are not supported
287 _ => Output::Message("Unsupported media type".to_string()),
288 }
289 }
290}
291
292#[derive(Default, Clone, Debug)]
293pub enum ExecutionStatus {
294 #[default]
295 Unknown,
296 ConnectingToKernel,
297 Queued,
298 Executing,
299 Finished,
300 ShuttingDown,
301 Shutdown,
302 KernelErrored(String),
303 Restarting,
304}
305
306/// An ExecutionView shows the outputs of an execution.
307/// It can hold zero or more outputs, which the user
308/// sees as "the output" for a single execution.
309pub struct ExecutionView {
310 #[allow(unused)]
311 workspace: WeakView<Workspace>,
312 pub outputs: Vec<Output>,
313 pub status: ExecutionStatus,
314}
315
316impl ExecutionView {
317 pub fn new(
318 status: ExecutionStatus,
319 workspace: WeakView<Workspace>,
320 _cx: &mut ViewContext<Self>,
321 ) -> Self {
322 Self {
323 workspace,
324 outputs: Default::default(),
325 status,
326 }
327 }
328
329 /// Accept a Jupyter message belonging to this execution
330 pub fn push_message(&mut self, message: &JupyterMessageContent, cx: &mut ViewContext<Self>) {
331 let output: Output = match message {
332 JupyterMessageContent::ExecuteResult(result) => Output::new(
333 &result.data,
334 result.transient.as_ref().and_then(|t| t.display_id.clone()),
335 cx,
336 ),
337 JupyterMessageContent::DisplayData(result) => Output::new(
338 &result.data,
339 result.transient.as_ref().and_then(|t| t.display_id.clone()),
340 cx,
341 ),
342 JupyterMessageContent::StreamContent(result) => {
343 // Previous stream data will combine together, handling colors, carriage returns, etc
344 if let Some(new_terminal) = self.apply_terminal_text(&result.text, cx) {
345 new_terminal
346 } else {
347 return;
348 }
349 }
350 JupyterMessageContent::ErrorOutput(result) => {
351 let terminal =
352 cx.new_view(|cx| TerminalOutput::from(&result.traceback.join("\n"), cx));
353
354 Output::ErrorOutput(ErrorView {
355 ename: result.ename.clone(),
356 evalue: result.evalue.clone(),
357 traceback: terminal,
358 })
359 }
360 JupyterMessageContent::ExecuteReply(reply) => {
361 for payload in reply.payload.iter() {
362 if let runtimelib::Payload::Page { data, .. } = payload {
363 let output = Output::new(data, None, cx);
364 self.outputs.push(output);
365 }
366 }
367 cx.notify();
368 return;
369 }
370 JupyterMessageContent::ClearOutput(options) => {
371 if !options.wait {
372 self.outputs.clear();
373 cx.notify();
374 return;
375 }
376
377 // Create a marker to clear the output after we get in a new output
378 Output::ClearOutputWaitMarker
379 }
380 JupyterMessageContent::Status(status) => {
381 match status.execution_state {
382 ExecutionState::Busy => {
383 self.status = ExecutionStatus::Executing;
384 }
385 ExecutionState::Idle => self.status = ExecutionStatus::Finished,
386 }
387 cx.notify();
388 return;
389 }
390 _msg => {
391 return;
392 }
393 };
394
395 // Check for a clear output marker as the previous output, so we can clear it out
396 if let Some(output) = self.outputs.last() {
397 if let Output::ClearOutputWaitMarker = output {
398 self.outputs.clear();
399 }
400 }
401
402 self.outputs.push(output);
403
404 cx.notify();
405 }
406
407 pub fn update_display_data(
408 &mut self,
409 data: &MimeBundle,
410 display_id: &str,
411 cx: &mut ViewContext<Self>,
412 ) {
413 let mut any = false;
414
415 self.outputs.iter_mut().for_each(|output| {
416 if let Some(other_display_id) = output.display_id().as_ref() {
417 if other_display_id == display_id {
418 *output = Output::new(data, Some(display_id.to_owned()), cx);
419 any = true;
420 }
421 }
422 });
423
424 if any {
425 cx.notify();
426 }
427 }
428
429 fn apply_terminal_text(&mut self, text: &str, cx: &mut ViewContext<Self>) -> Option<Output> {
430 if let Some(last_output) = self.outputs.last_mut() {
431 if let Output::Stream {
432 content: last_stream,
433 } = last_output
434 {
435 // Don't need to add a new output, we already have a terminal output
436 // and can just update the most recent terminal output
437 last_stream.update(cx, |last_stream, cx| {
438 last_stream.append_text(text, cx);
439 cx.notify();
440 });
441 return None;
442 }
443 }
444
445 Some(Output::Stream {
446 content: cx.new_view(|cx| TerminalOutput::from(text, cx)),
447 })
448 }
449}
450
451impl Render for ExecutionView {
452 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
453 let status = match &self.status {
454 ExecutionStatus::ConnectingToKernel => Label::new("Connecting to kernel...")
455 .color(Color::Muted)
456 .into_any_element(),
457 ExecutionStatus::Executing => h_flex()
458 .gap_2()
459 .child(
460 Icon::new(IconName::ArrowCircle)
461 .size(IconSize::Small)
462 .color(Color::Muted)
463 .with_animation(
464 "arrow-circle",
465 Animation::new(Duration::from_secs(3)).repeat(),
466 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
467 ),
468 )
469 .child(Label::new("Executing...").color(Color::Muted))
470 .into_any_element(),
471 ExecutionStatus::Finished => Icon::new(IconName::Check)
472 .size(IconSize::Small)
473 .into_any_element(),
474 ExecutionStatus::Unknown => Label::new("Unknown status")
475 .color(Color::Muted)
476 .into_any_element(),
477 ExecutionStatus::ShuttingDown => Label::new("Kernel shutting down...")
478 .color(Color::Muted)
479 .into_any_element(),
480 ExecutionStatus::Restarting => Label::new("Kernel restarting...")
481 .color(Color::Muted)
482 .into_any_element(),
483 ExecutionStatus::Shutdown => Label::new("Kernel shutdown")
484 .color(Color::Muted)
485 .into_any_element(),
486 ExecutionStatus::Queued => Label::new("Queued...")
487 .color(Color::Muted)
488 .into_any_element(),
489 ExecutionStatus::KernelErrored(error) => Label::new(format!("Kernel error: {}", error))
490 .color(Color::Error)
491 .into_any_element(),
492 };
493
494 if self.outputs.is_empty() {
495 return v_flex()
496 .min_h(cx.line_height())
497 .justify_center()
498 .child(status)
499 .into_any_element();
500 }
501
502 div()
503 .w_full()
504 .children(
505 self.outputs
506 .iter()
507 .map(|output| output.render(self.workspace.clone(), cx)),
508 )
509 .children(match self.status {
510 ExecutionStatus::Executing => vec![status],
511 ExecutionStatus::Queued => vec![status],
512 _ => vec![],
513 })
514 .into_any_element()
515 }
516}