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 menu;
40use runtimelib::{ExecutionState, JupyterMessage, JupyterMessageContent, MimeBundle, MimeType};
41use ui::{CommonAnimationExt, CopyButton, IconButton, Tooltip, prelude::*};
42
43mod image;
44use image::ImageView;
45
46mod markdown;
47use markdown::MarkdownView;
48
49mod table;
50use table::TableView;
51
52mod json;
53use json::JsonView;
54
55mod html;
56
57pub mod plain;
58use plain::TerminalOutput;
59
60pub(crate) mod user_error;
61use user_error::ErrorView;
62use workspace::Workspace;
63
64use crate::repl_settings::ReplSettings;
65use settings::Settings;
66
67/// When deciding what to render from a collection of mediatypes, we need to rank them in order of importance
68fn rank_mime_type(mimetype: &MimeType) -> usize {
69 match mimetype {
70 MimeType::DataTable(_) => 7,
71 MimeType::Html(_) => 6,
72 MimeType::Json(_) => 5,
73 MimeType::Png(_) => 4,
74 MimeType::Jpeg(_) => 3,
75 MimeType::Markdown(_) => 2,
76 MimeType::Plain(_) => 1,
77 // All other media types are not supported in Zed at this time
78 _ => 0,
79 }
80}
81
82pub(crate) trait OutputContent {
83 fn clipboard_content(&self, window: &Window, cx: &App) -> Option<ClipboardItem>;
84 fn has_clipboard_content(&self, _window: &Window, _cx: &App) -> bool {
85 false
86 }
87 fn has_buffer_content(&self, _window: &Window, _cx: &App) -> bool {
88 false
89 }
90 fn buffer_content(&mut self, _window: &mut Window, _cx: &mut App) -> Option<Entity<Buffer>> {
91 None
92 }
93}
94
95impl<V: OutputContent + 'static> OutputContent for Entity<V> {
96 fn clipboard_content(&self, window: &Window, cx: &App) -> Option<ClipboardItem> {
97 self.read(cx).clipboard_content(window, cx)
98 }
99
100 fn has_clipboard_content(&self, window: &Window, cx: &App) -> bool {
101 self.read(cx).has_clipboard_content(window, cx)
102 }
103
104 fn has_buffer_content(&self, window: &Window, cx: &App) -> bool {
105 self.read(cx).has_buffer_content(window, cx)
106 }
107
108 fn buffer_content(&mut self, window: &mut Window, cx: &mut App) -> Option<Entity<Buffer>> {
109 self.update(cx, |item, cx| item.buffer_content(window, cx))
110 }
111}
112
113pub enum Output {
114 Plain {
115 content: Entity<TerminalOutput>,
116 display_id: Option<String>,
117 },
118 Stream {
119 content: Entity<TerminalOutput>,
120 },
121 Image {
122 content: Entity<ImageView>,
123 display_id: Option<String>,
124 },
125 ErrorOutput(ErrorView),
126 Message(String),
127 Table {
128 content: Entity<TableView>,
129 display_id: Option<String>,
130 },
131 Markdown {
132 content: Entity<MarkdownView>,
133 display_id: Option<String>,
134 },
135 Json {
136 content: Entity<JsonView>,
137 display_id: Option<String>,
138 },
139 ClearOutputWaitMarker,
140}
141
142impl Output {
143 pub fn to_nbformat(&self, cx: &App) -> Option<nbformat::v4::Output> {
144 match self {
145 Output::Stream { content } => {
146 let text = content.read(cx).full_text();
147 Some(nbformat::v4::Output::Stream {
148 name: "stdout".to_string(),
149 text: nbformat::v4::MultilineString(text),
150 })
151 }
152 Output::Plain { content, .. } => {
153 let text = content.read(cx).full_text();
154 let mut data = jupyter_protocol::media::Media::default();
155 data.content.push(jupyter_protocol::MediaType::Plain(text));
156 Some(nbformat::v4::Output::DisplayData(
157 nbformat::v4::DisplayData {
158 data,
159 metadata: serde_json::Map::new(),
160 },
161 ))
162 }
163 Output::ErrorOutput(error_view) => {
164 let traceback_text = error_view.traceback.read(cx).full_text();
165 let traceback_lines: Vec<String> =
166 traceback_text.lines().map(|s| s.to_string()).collect();
167 Some(nbformat::v4::Output::Error(nbformat::v4::ErrorOutput {
168 ename: error_view.ename.clone(),
169 evalue: error_view.evalue.clone(),
170 traceback: traceback_lines,
171 }))
172 }
173 Output::Image { .. }
174 | Output::Markdown { .. }
175 | Output::Table { .. }
176 | Output::Json { .. } => None,
177 Output::Message(_) => None,
178 Output::ClearOutputWaitMarker => None,
179 }
180 }
181}
182
183impl Output {
184 fn render_output_controls<V: OutputContent + 'static>(
185 v: Entity<V>,
186 workspace: WeakEntity<Workspace>,
187 window: &mut Window,
188 cx: &mut Context<ExecutionView>,
189 ) -> Option<AnyElement> {
190 if !v.has_clipboard_content(window, cx) && !v.has_buffer_content(window, cx) {
191 return None;
192 }
193
194 Some(
195 h_flex()
196 .pl_1()
197 .when(v.has_clipboard_content(window, cx), |el| {
198 let v = v.clone();
199 el.child(
200 IconButton::new(ElementId::Name("copy-output".into()), IconName::Copy)
201 .style(ButtonStyle::Transparent)
202 .tooltip(Tooltip::text("Copy Output"))
203 .on_click(move |_, window, cx| {
204 let clipboard_content = v.clipboard_content(window, cx);
205
206 if let Some(clipboard_content) = clipboard_content.as_ref() {
207 cx.write_to_clipboard(clipboard_content.clone());
208 }
209 }),
210 )
211 })
212 .when(v.has_buffer_content(window, cx), |el| {
213 let v = v.clone();
214 el.child(
215 IconButton::new(
216 ElementId::Name("open-in-buffer".into()),
217 IconName::FileTextOutlined,
218 )
219 .style(ButtonStyle::Transparent)
220 .tooltip(Tooltip::text("Open in Buffer"))
221 .on_click({
222 let workspace = workspace.clone();
223 move |_, window, cx| {
224 let buffer_content =
225 v.update(cx, |item, cx| item.buffer_content(window, cx));
226
227 if let Some(buffer_content) = buffer_content.as_ref() {
228 let buffer = buffer_content.clone();
229 let editor = Box::new(cx.new(|cx| {
230 let multibuffer = cx.new(|cx| {
231 let mut multi_buffer =
232 MultiBuffer::singleton(buffer.clone(), cx);
233
234 multi_buffer.set_title("REPL Output".to_string(), cx);
235 multi_buffer
236 });
237
238 Editor::for_multibuffer(multibuffer, None, window, cx)
239 }));
240 workspace
241 .update(cx, |workspace, cx| {
242 workspace.add_item_to_active_pane(
243 editor, None, true, window, cx,
244 );
245 })
246 .ok();
247 }
248 }
249 }),
250 )
251 })
252 .into_any_element(),
253 )
254 }
255
256 pub fn content(&self, window: &mut Window, cx: &mut App) -> Option<AnyElement> {
257 match self {
258 Self::Plain { content, .. } => Some(content.clone().into_any_element()),
259 Self::Markdown { content, .. } => Some(content.clone().into_any_element()),
260 Self::Stream { content, .. } => Some(content.clone().into_any_element()),
261 Self::Image { content, .. } => Some(content.clone().into_any_element()),
262 Self::Message(message) => Some(div().child(message.clone()).into_any_element()),
263 Self::Table { content, .. } => Some(content.clone().into_any_element()),
264 Self::Json { content, .. } => Some(content.clone().into_any_element()),
265 Self::ErrorOutput(error_view) => error_view.render(window, cx),
266 Self::ClearOutputWaitMarker => None,
267 }
268 }
269
270 pub fn render(
271 &self,
272 workspace: WeakEntity<Workspace>,
273 window: &mut Window,
274 cx: &mut Context<ExecutionView>,
275 ) -> impl IntoElement + use<> {
276 let max_width =
277 plain::max_width_for_columns(ReplSettings::get_global(cx).max_columns, window, cx);
278 let content = self.content(window, cx);
279
280 let needs_horizontal_scroll = matches!(self, Self::Table { .. });
281
282 h_flex()
283 .id("output-content")
284 .w_full()
285 .when_else(
286 needs_horizontal_scroll,
287 |this| this.overflow_x_scroll(),
288 |this| this.overflow_x_hidden(),
289 )
290 .items_start()
291 .child(
292 div()
293 .when(!needs_horizontal_scroll, |el| {
294 el.flex_1().w_full().overflow_x_hidden()
295 })
296 .when_some(max_width, |el, max_width| el.max_w(max_width))
297 .children(content),
298 )
299 .children(match self {
300 Self::Plain { content, .. } => {
301 Self::render_output_controls(content.clone(), workspace, window, cx)
302 }
303 Self::Markdown { content, .. } => {
304 Self::render_output_controls(content.clone(), workspace, window, cx)
305 }
306 Self::Stream { content, .. } => {
307 Self::render_output_controls(content.clone(), workspace, window, cx)
308 }
309 Self::Image { content, .. } => {
310 Self::render_output_controls(content.clone(), workspace, window, cx)
311 }
312 Self::Json { content, .. } => {
313 Self::render_output_controls(content.clone(), workspace, window, cx)
314 }
315 Self::ErrorOutput(err) => Some(
316 h_flex()
317 .pl_1()
318 .child({
319 let ename = err.ename.clone();
320 let evalue = err.evalue.clone();
321 let traceback = err.traceback.clone();
322 let traceback_text = traceback.read(cx).full_text();
323 let full_error = format!("{}: {}\n{}", ename, evalue, traceback_text);
324
325 CopyButton::new("copy-full-error", full_error)
326 .tooltip_label("Copy Full Error")
327 })
328 .child(
329 IconButton::new(
330 ElementId::Name("open-full-error-in-buffer-traceback".into()),
331 IconName::FileTextOutlined,
332 )
333 .style(ButtonStyle::Transparent)
334 .tooltip(Tooltip::text("Open Full Error in Buffer"))
335 .on_click({
336 let ename = err.ename.clone();
337 let evalue = err.evalue.clone();
338 let traceback = err.traceback.clone();
339 move |_, window, cx| {
340 if let Some(workspace) = workspace.upgrade() {
341 let traceback_text = traceback.read(cx).full_text();
342 let full_error =
343 format!("{}: {}\n{}", ename, evalue, traceback_text);
344 let buffer = cx.new(|cx| {
345 let mut buffer = Buffer::local(full_error, cx)
346 .with_language(language::PLAIN_TEXT.clone(), cx);
347 buffer
348 .set_capability(language::Capability::ReadOnly, cx);
349 buffer
350 });
351 let editor = Box::new(cx.new(|cx| {
352 let multibuffer = cx.new(|cx| {
353 let mut multi_buffer =
354 MultiBuffer::singleton(buffer.clone(), cx);
355 multi_buffer
356 .set_title("Full Error".to_string(), cx);
357 multi_buffer
358 });
359 Editor::for_multibuffer(multibuffer, None, window, cx)
360 }));
361 workspace.update(cx, |workspace, cx| {
362 workspace.add_item_to_active_pane(
363 editor, None, true, window, cx,
364 );
365 });
366 }
367 }
368 }),
369 )
370 .into_any_element(),
371 ),
372 Self::Message(_) => None,
373 Self::Table { content, .. } => {
374 Self::render_output_controls(content.clone(), workspace, window, cx)
375 }
376 Self::ClearOutputWaitMarker => None,
377 })
378 }
379
380 pub fn display_id(&self) -> Option<String> {
381 match self {
382 Output::Plain { display_id, .. } => display_id.clone(),
383 Output::Stream { .. } => None,
384 Output::Image { display_id, .. } => display_id.clone(),
385 Output::ErrorOutput(_) => None,
386 Output::Message(_) => None,
387 Output::Table { display_id, .. } => display_id.clone(),
388 Output::Markdown { display_id, .. } => display_id.clone(),
389 Output::Json { display_id, .. } => display_id.clone(),
390 Output::ClearOutputWaitMarker => None,
391 }
392 }
393
394 pub fn new(
395 data: &MimeBundle,
396 display_id: Option<String>,
397 window: &mut Window,
398 cx: &mut App,
399 ) -> Self {
400 match data.richest(rank_mime_type) {
401 Some(MimeType::Json(json_value)) => match JsonView::from_value(json_value.clone()) {
402 Ok(json_view) => Output::Json {
403 content: cx.new(|_| json_view),
404 display_id,
405 },
406 Err(_) => Output::Message("Failed to parse JSON".to_string()),
407 },
408 Some(MimeType::Plain(text)) => Output::Plain {
409 content: cx.new(|cx| TerminalOutput::from(text, window, cx)),
410 display_id,
411 },
412 Some(MimeType::Markdown(text)) => {
413 let content = cx.new(|cx| MarkdownView::from(text.clone(), cx));
414 Output::Markdown {
415 content,
416 display_id,
417 }
418 }
419 Some(MimeType::Png(data)) | Some(MimeType::Jpeg(data)) => match ImageView::from(data) {
420 Ok(view) => Output::Image {
421 content: cx.new(|_| view),
422 display_id,
423 },
424 Err(error) => Output::Message(format!("Failed to load image: {}", error)),
425 },
426 Some(MimeType::DataTable(data)) => Output::Table {
427 content: cx.new(|cx| TableView::new(data, window, cx)),
428 display_id,
429 },
430 Some(MimeType::Html(html_content)) => match html::html_to_markdown(html_content) {
431 Ok(markdown_text) => {
432 let content = cx.new(|cx| MarkdownView::from(markdown_text, cx));
433 Output::Markdown {
434 content,
435 display_id,
436 }
437 }
438 Err(_) => Output::Plain {
439 content: cx.new(|cx| TerminalOutput::from(html_content, window, cx)),
440 display_id,
441 },
442 },
443 // Any other media types are not supported
444 _ => Output::Message("Unsupported media type".to_string()),
445 }
446 }
447}
448
449#[derive(Default, Clone, Debug)]
450pub enum ExecutionStatus {
451 #[default]
452 Unknown,
453 ConnectingToKernel,
454 Queued,
455 Executing,
456 Finished,
457 ShuttingDown,
458 Shutdown,
459 KernelErrored(String),
460 Restarting,
461}
462
463pub struct ExecutionViewFinishedEmpty;
464pub struct ExecutionViewFinishedSmall(pub String);
465
466pub struct InputReplyEvent {
467 pub value: String,
468 pub parent_message: JupyterMessage,
469}
470
471struct PendingInput {
472 prompt: String,
473 password: bool,
474 editor: Entity<Editor>,
475 parent_message: JupyterMessage,
476}
477
478/// An ExecutionView shows the outputs of an execution.
479/// It can hold zero or more outputs, which the user
480/// sees as "the output" for a single execution.
481pub struct ExecutionView {
482 #[allow(unused)]
483 workspace: WeakEntity<Workspace>,
484 pub outputs: Vec<Output>,
485 pub status: ExecutionStatus,
486 pending_input: Option<PendingInput>,
487}
488
489impl EventEmitter<ExecutionViewFinishedEmpty> for ExecutionView {}
490impl EventEmitter<ExecutionViewFinishedSmall> for ExecutionView {}
491impl EventEmitter<InputReplyEvent> for ExecutionView {}
492
493impl ExecutionView {
494 pub fn new(
495 status: ExecutionStatus,
496 workspace: WeakEntity<Workspace>,
497 _cx: &mut Context<Self>,
498 ) -> Self {
499 Self {
500 workspace,
501 outputs: Default::default(),
502 status,
503 pending_input: None,
504 }
505 }
506
507 fn submit_input(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
508 if let Some(pending_input) = self.pending_input.take() {
509 let value = pending_input.editor.read(cx).text(cx);
510
511 let display_text = if pending_input.password {
512 format!("{}{}", pending_input.prompt, "*".repeat(value.len()))
513 } else {
514 format!("{}{}", pending_input.prompt, value)
515 };
516 self.outputs.push(Output::Message(display_text));
517
518 cx.emit(InputReplyEvent {
519 value,
520 parent_message: pending_input.parent_message,
521 });
522 cx.notify();
523 }
524 }
525
526 /// Handle an InputRequest message, storing the full message for replying
527 pub fn handle_input_request(
528 &mut self,
529 message: &JupyterMessage,
530 window: &mut Window,
531 cx: &mut Context<Self>,
532 ) {
533 if let JupyterMessageContent::InputRequest(input_request) = &message.content {
534 let prompt = input_request.prompt.clone();
535 let password = input_request.password;
536
537 let editor = cx.new(|cx| {
538 let mut editor = Editor::single_line(window, cx);
539 editor.set_placeholder_text("Type here and press Enter", window, cx);
540 if password {
541 editor.set_masked(true, cx);
542 }
543 editor
544 });
545
546 self.pending_input = Some(PendingInput {
547 prompt,
548 password,
549 editor,
550 parent_message: message.clone(),
551 });
552 cx.notify();
553 }
554 }
555
556 /// Accept a Jupyter message belonging to this execution
557 pub fn push_message(
558 &mut self,
559 message: &JupyterMessageContent,
560 window: &mut Window,
561 cx: &mut Context<Self>,
562 ) {
563 let output: Output = match message {
564 JupyterMessageContent::ExecuteResult(result) => Output::new(
565 &result.data,
566 result.transient.as_ref().and_then(|t| t.display_id.clone()),
567 window,
568 cx,
569 ),
570 JupyterMessageContent::DisplayData(result) => Output::new(
571 &result.data,
572 result.transient.as_ref().and_then(|t| t.display_id.clone()),
573 window,
574 cx,
575 ),
576 JupyterMessageContent::StreamContent(result) => {
577 // Previous stream data will combine together, handling colors, carriage returns, etc
578 if let Some(new_terminal) = self.apply_terminal_text(&result.text, window, cx) {
579 new_terminal
580 } else {
581 return;
582 }
583 }
584 JupyterMessageContent::ErrorOutput(result) => {
585 let terminal =
586 cx.new(|cx| TerminalOutput::from(&result.traceback.join("\n"), window, cx));
587
588 Output::ErrorOutput(ErrorView {
589 ename: result.ename.clone(),
590 evalue: result.evalue.clone(),
591 traceback: terminal,
592 })
593 }
594 JupyterMessageContent::ExecuteReply(reply) => {
595 for payload in reply.payload.iter() {
596 if let runtimelib::Payload::Page { data, .. } = payload {
597 let output = Output::new(data, None, window, cx);
598 self.outputs.push(output);
599 }
600 }
601 cx.notify();
602 return;
603 }
604 JupyterMessageContent::ClearOutput(options) => {
605 if !options.wait {
606 self.outputs.clear();
607 cx.notify();
608 return;
609 }
610
611 // Create a marker to clear the output after we get in a new output
612 Output::ClearOutputWaitMarker
613 }
614 JupyterMessageContent::InputRequest(_) => {
615 // InputRequest is handled by handle_input_request which needs the full message
616 return;
617 }
618 JupyterMessageContent::Status(status) => {
619 match status.execution_state {
620 ExecutionState::Busy => {
621 self.status = ExecutionStatus::Executing;
622 }
623 ExecutionState::Idle => {
624 self.status = ExecutionStatus::Finished;
625 self.pending_input = None;
626 if self.outputs.is_empty() {
627 cx.emit(ExecutionViewFinishedEmpty);
628 } else if ReplSettings::get_global(cx).inline_output {
629 if let Some(small_text) = self.get_small_inline_output(cx) {
630 cx.emit(ExecutionViewFinishedSmall(small_text));
631 }
632 }
633 }
634 ExecutionState::Unknown => self.status = ExecutionStatus::Unknown,
635 ExecutionState::Starting => self.status = ExecutionStatus::ConnectingToKernel,
636 ExecutionState::Restarting => self.status = ExecutionStatus::Restarting,
637 ExecutionState::Terminating => self.status = ExecutionStatus::ShuttingDown,
638 ExecutionState::AutoRestarting => self.status = ExecutionStatus::Restarting,
639 ExecutionState::Dead => self.status = ExecutionStatus::Shutdown,
640 ExecutionState::Other(_) => self.status = ExecutionStatus::Unknown,
641 }
642 cx.notify();
643 return;
644 }
645 _msg => {
646 return;
647 }
648 };
649
650 // Check for a clear output marker as the previous output, so we can clear it out
651 if let Some(output) = self.outputs.last()
652 && let Output::ClearOutputWaitMarker = output
653 {
654 self.outputs.clear();
655 }
656
657 self.outputs.push(output);
658
659 cx.notify();
660 }
661
662 pub fn update_display_data(
663 &mut self,
664 data: &MimeBundle,
665 display_id: &str,
666 window: &mut Window,
667 cx: &mut Context<Self>,
668 ) {
669 let mut any = false;
670
671 self.outputs.iter_mut().for_each(|output| {
672 if let Some(other_display_id) = output.display_id().as_ref()
673 && other_display_id == display_id
674 {
675 *output = Output::new(data, Some(display_id.to_owned()), window, cx);
676 any = true;
677 }
678 });
679
680 if any {
681 cx.notify();
682 }
683 }
684
685 /// Check if the output is a single small plain text that can be shown inline.
686 /// Returns the text if it's suitable for inline display (single line, short enough).
687 fn get_small_inline_output(&self, cx: &App) -> Option<String> {
688 // Only consider single outputs
689 if self.outputs.len() != 1 {
690 return None;
691 }
692
693 let output = self.outputs.first()?;
694
695 // Only Plain outputs can be inlined
696 let content = match output {
697 Output::Plain { content, .. } => content,
698 _ => return None,
699 };
700
701 let text = content.read(cx).full_text();
702 let trimmed = text.trim();
703
704 let max_length = ReplSettings::get_global(cx).inline_output_max_length;
705
706 // Must be a single line and within the configured max length
707 if trimmed.contains('\n') || trimmed.len() > max_length {
708 return None;
709 }
710
711 Some(trimmed.to_string())
712 }
713
714 fn apply_terminal_text(
715 &mut self,
716 text: &str,
717 window: &mut Window,
718 cx: &mut Context<Self>,
719 ) -> Option<Output> {
720 if let Some(last_output) = self.outputs.last_mut()
721 && let Output::Stream {
722 content: last_stream,
723 } = last_output
724 {
725 // Don't need to add a new output, we already have a terminal output
726 // and can just update the most recent terminal output
727 last_stream.update(cx, |last_stream, cx| {
728 last_stream.append_text(text, cx);
729 cx.notify();
730 });
731 return None;
732 }
733
734 Some(Output::Stream {
735 content: cx.new(|cx| TerminalOutput::from(text, window, cx)),
736 })
737 }
738}
739
740impl ExecutionView {
741 #[cfg(test)]
742 fn output_as_stream_text(&self, cx: &App) -> Option<String> {
743 self.outputs.iter().find_map(|output| {
744 if let Output::Stream { content } = output {
745 Some(content.read(cx).full_text())
746 } else {
747 None
748 }
749 })
750 }
751}
752
753impl Render for ExecutionView {
754 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
755 let status = match &self.status {
756 ExecutionStatus::ConnectingToKernel => Label::new("Connecting to kernel...")
757 .color(Color::Muted)
758 .into_any_element(),
759 ExecutionStatus::Executing => h_flex()
760 .gap_2()
761 .child(
762 Icon::new(IconName::ArrowCircle)
763 .size(IconSize::Small)
764 .color(Color::Muted)
765 .with_rotate_animation(3),
766 )
767 .child(Label::new("Executing...").color(Color::Muted))
768 .into_any_element(),
769 ExecutionStatus::Finished => Icon::new(IconName::Check)
770 .size(IconSize::Small)
771 .into_any_element(),
772 ExecutionStatus::Unknown => Label::new("Unknown status")
773 .color(Color::Muted)
774 .into_any_element(),
775 ExecutionStatus::ShuttingDown => Label::new("Kernel shutting down...")
776 .color(Color::Muted)
777 .into_any_element(),
778 ExecutionStatus::Restarting => Label::new("Kernel restarting...")
779 .color(Color::Muted)
780 .into_any_element(),
781 ExecutionStatus::Shutdown => Label::new("Kernel shutdown")
782 .color(Color::Muted)
783 .into_any_element(),
784 ExecutionStatus::Queued => Label::new("Queued...")
785 .color(Color::Muted)
786 .into_any_element(),
787 ExecutionStatus::KernelErrored(error) => Label::new(format!("Kernel error: {}", error))
788 .color(Color::Error)
789 .into_any_element(),
790 };
791
792 let pending_input_element = self.pending_input.as_ref().map(|pending_input| {
793 let prompt_label = if pending_input.prompt.is_empty() {
794 "Input:".to_string()
795 } else {
796 pending_input.prompt.clone()
797 };
798
799 div()
800 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
801 this.submit_input(window, cx);
802 }))
803 .w_full()
804 .child(
805 v_flex()
806 .gap_1()
807 .child(Label::new(prompt_label).color(Color::Muted))
808 .child(
809 div()
810 .px_2()
811 .py_1()
812 .border_1()
813 .border_color(cx.theme().colors().border)
814 .rounded_md()
815 .child(pending_input.editor.clone()),
816 ),
817 )
818 });
819
820 if self.outputs.is_empty() && pending_input_element.is_none() {
821 return v_flex()
822 .min_h(window.line_height())
823 .justify_center()
824 .child(status)
825 .into_any_element();
826 }
827
828 div()
829 .w_full()
830 .children(
831 self.outputs
832 .iter()
833 .map(|output| output.render(self.workspace.clone(), window, cx)),
834 )
835 .children(pending_input_element)
836 .children(match self.status {
837 ExecutionStatus::Executing => vec![status],
838 ExecutionStatus::Queued => vec![status],
839 _ => vec![],
840 })
841 .into_any_element()
842 }
843}
844
845#[cfg(test)]
846mod tests {
847 use super::*;
848 use gpui::TestAppContext;
849 use runtimelib::{
850 ClearOutput, ErrorOutput, ExecutionState, InputRequest, JupyterMessage,
851 JupyterMessageContent, MimeType, Status, Stdio, StreamContent,
852 };
853 use settings::SettingsStore;
854 use std::path::Path;
855 use std::sync::Arc;
856
857 #[test]
858 fn test_rank_mime_type_ordering() {
859 let data_table = MimeType::DataTable(Box::default());
860 let html = MimeType::Html(String::new());
861 let json = MimeType::Json(serde_json::json!({}));
862 let png = MimeType::Png(String::new());
863 let jpeg = MimeType::Jpeg(String::new());
864 let markdown = MimeType::Markdown(String::new());
865 let plain = MimeType::Plain(String::new());
866
867 assert_eq!(rank_mime_type(&data_table), 7);
868 assert_eq!(rank_mime_type(&html), 6);
869 assert_eq!(rank_mime_type(&json), 5);
870 assert_eq!(rank_mime_type(&png), 4);
871 assert_eq!(rank_mime_type(&jpeg), 3);
872 assert_eq!(rank_mime_type(&markdown), 2);
873 assert_eq!(rank_mime_type(&plain), 1);
874
875 assert!(rank_mime_type(&data_table) > rank_mime_type(&html));
876 assert!(rank_mime_type(&html) > rank_mime_type(&json));
877 assert!(rank_mime_type(&json) > rank_mime_type(&png));
878 assert!(rank_mime_type(&png) > rank_mime_type(&jpeg));
879 assert!(rank_mime_type(&jpeg) > rank_mime_type(&markdown));
880 assert!(rank_mime_type(&markdown) > rank_mime_type(&plain));
881 }
882
883 #[test]
884 fn test_rank_mime_type_unsupported_returns_zero() {
885 let svg = MimeType::Svg(String::new());
886 let latex = MimeType::Latex(String::new());
887
888 assert_eq!(rank_mime_type(&svg), 0);
889 assert_eq!(rank_mime_type(&latex), 0);
890 }
891
892 async fn init_test(
893 cx: &mut TestAppContext,
894 ) -> (gpui::VisualTestContext, WeakEntity<workspace::Workspace>) {
895 cx.update(|cx| {
896 let settings_store = SettingsStore::test(cx);
897 cx.set_global(settings_store);
898 theme::init(theme::LoadThemes::JustBase, cx);
899 });
900 let fs = project::FakeFs::new(cx.background_executor.clone());
901 let project = project::Project::test(fs, [] as [&Path; 0], cx).await;
902 let window =
903 cx.add_window(|window, cx| workspace::MultiWorkspace::test_new(project, window, cx));
904 let workspace = window
905 .read_with(cx, |mw, _| mw.workspace().clone())
906 .unwrap();
907 let weak_workspace = workspace.downgrade();
908 let visual_cx = gpui::VisualTestContext::from_window(window.into(), cx);
909 (visual_cx, weak_workspace)
910 }
911
912 fn create_execution_view(
913 cx: &mut gpui::VisualTestContext,
914 weak_workspace: WeakEntity<workspace::Workspace>,
915 ) -> Entity<ExecutionView> {
916 cx.update(|_window, cx| {
917 cx.new(|cx| ExecutionView::new(ExecutionStatus::Queued, weak_workspace, cx))
918 })
919 }
920
921 #[gpui::test]
922 async fn test_push_message_stream_content(cx: &mut TestAppContext) {
923 let (mut cx, workspace) = init_test(cx).await;
924 let execution_view = create_execution_view(&mut cx, workspace);
925
926 cx.update(|window, cx| {
927 execution_view.update(cx, |view, cx| {
928 let message = JupyterMessageContent::StreamContent(StreamContent {
929 name: Stdio::Stdout,
930 text: "hello world\n".to_string(),
931 });
932 view.push_message(&message, window, cx);
933 });
934 });
935
936 cx.update(|_, cx| {
937 let view = execution_view.read(cx);
938 assert_eq!(view.outputs.len(), 1);
939 assert!(matches!(view.outputs[0], Output::Stream { .. }));
940 let text = view.output_as_stream_text(cx);
941 assert!(text.is_some());
942 assert!(text.as_ref().is_some_and(|t| t.contains("hello world")));
943 });
944 }
945
946 #[gpui::test]
947 async fn test_push_message_stream_appends(cx: &mut TestAppContext) {
948 let (mut cx, workspace) = init_test(cx).await;
949 let execution_view = create_execution_view(&mut cx, workspace);
950
951 cx.update(|window, cx| {
952 execution_view.update(cx, |view, cx| {
953 let message1 = JupyterMessageContent::StreamContent(StreamContent {
954 name: Stdio::Stdout,
955 text: "first ".to_string(),
956 });
957 let message2 = JupyterMessageContent::StreamContent(StreamContent {
958 name: Stdio::Stdout,
959 text: "second".to_string(),
960 });
961 view.push_message(&message1, window, cx);
962 view.push_message(&message2, window, cx);
963 });
964 });
965
966 cx.update(|_, cx| {
967 let view = execution_view.read(cx);
968 assert_eq!(
969 view.outputs.len(),
970 1,
971 "consecutive streams should merge into one output"
972 );
973 let text = view.output_as_stream_text(cx);
974 assert!(text.as_ref().is_some_and(|t| t.contains("first ")));
975 assert!(text.as_ref().is_some_and(|t| t.contains("second")));
976 });
977 }
978
979 #[gpui::test]
980 async fn test_push_message_error_output(cx: &mut TestAppContext) {
981 let (mut cx, workspace) = init_test(cx).await;
982 let execution_view = create_execution_view(&mut cx, workspace);
983
984 cx.update(|window, cx| {
985 execution_view.update(cx, |view, cx| {
986 let message = JupyterMessageContent::ErrorOutput(ErrorOutput {
987 ename: "NameError".to_string(),
988 evalue: "name 'x' is not defined".to_string(),
989 traceback: vec![
990 "Traceback (most recent call last):".to_string(),
991 "NameError: name 'x' is not defined".to_string(),
992 ],
993 });
994 view.push_message(&message, window, cx);
995 });
996 });
997
998 cx.update(|_, cx| {
999 let view = execution_view.read(cx);
1000 assert_eq!(view.outputs.len(), 1);
1001 match &view.outputs[0] {
1002 Output::ErrorOutput(error_view) => {
1003 assert_eq!(error_view.ename, "NameError");
1004 assert_eq!(error_view.evalue, "name 'x' is not defined");
1005 }
1006 other => panic!(
1007 "expected ErrorOutput, got {:?}",
1008 std::mem::discriminant(other)
1009 ),
1010 }
1011 });
1012 }
1013
1014 #[gpui::test]
1015 async fn test_push_message_clear_output_immediate(cx: &mut TestAppContext) {
1016 let (mut cx, workspace) = init_test(cx).await;
1017 let execution_view = create_execution_view(&mut cx, workspace);
1018
1019 cx.update(|window, cx| {
1020 execution_view.update(cx, |view, cx| {
1021 let stream = JupyterMessageContent::StreamContent(StreamContent {
1022 name: Stdio::Stdout,
1023 text: "some output\n".to_string(),
1024 });
1025 view.push_message(&stream, window, cx);
1026 assert_eq!(view.outputs.len(), 1);
1027
1028 let clear = JupyterMessageContent::ClearOutput(ClearOutput { wait: false });
1029 view.push_message(&clear, window, cx);
1030 assert_eq!(
1031 view.outputs.len(),
1032 0,
1033 "immediate clear should remove all outputs"
1034 );
1035 });
1036 });
1037 }
1038
1039 #[gpui::test]
1040 async fn test_push_message_clear_output_deferred(cx: &mut TestAppContext) {
1041 let (mut cx, workspace) = init_test(cx).await;
1042 let execution_view = create_execution_view(&mut cx, workspace);
1043
1044 cx.update(|window, cx| {
1045 execution_view.update(cx, |view, cx| {
1046 let stream = JupyterMessageContent::StreamContent(StreamContent {
1047 name: Stdio::Stdout,
1048 text: "old output\n".to_string(),
1049 });
1050 view.push_message(&stream, window, cx);
1051 assert_eq!(view.outputs.len(), 1);
1052
1053 let clear = JupyterMessageContent::ClearOutput(ClearOutput { wait: true });
1054 view.push_message(&clear, window, cx);
1055 assert_eq!(view.outputs.len(), 2, "deferred clear adds a wait marker");
1056 assert!(matches!(view.outputs[1], Output::ClearOutputWaitMarker));
1057
1058 let new_stream = JupyterMessageContent::StreamContent(StreamContent {
1059 name: Stdio::Stdout,
1060 text: "new output\n".to_string(),
1061 });
1062 view.push_message(&new_stream, window, cx);
1063 assert_eq!(
1064 view.outputs.len(),
1065 1,
1066 "next output after wait marker should clear previous outputs"
1067 );
1068 });
1069 });
1070 }
1071
1072 #[gpui::test]
1073 async fn test_push_message_status_transitions(cx: &mut TestAppContext) {
1074 let (mut cx, workspace) = init_test(cx).await;
1075 let execution_view = create_execution_view(&mut cx, workspace);
1076
1077 cx.update(|window, cx| {
1078 execution_view.update(cx, |view, cx| {
1079 let busy = JupyterMessageContent::Status(Status {
1080 execution_state: ExecutionState::Busy,
1081 });
1082 view.push_message(&busy, window, cx);
1083 assert!(matches!(view.status, ExecutionStatus::Executing));
1084
1085 let idle = JupyterMessageContent::Status(Status {
1086 execution_state: ExecutionState::Idle,
1087 });
1088 view.push_message(&idle, window, cx);
1089 assert!(matches!(view.status, ExecutionStatus::Finished));
1090
1091 let starting = JupyterMessageContent::Status(Status {
1092 execution_state: ExecutionState::Starting,
1093 });
1094 view.push_message(&starting, window, cx);
1095 assert!(matches!(view.status, ExecutionStatus::ConnectingToKernel));
1096
1097 let dead = JupyterMessageContent::Status(Status {
1098 execution_state: ExecutionState::Dead,
1099 });
1100 view.push_message(&dead, window, cx);
1101 assert!(matches!(view.status, ExecutionStatus::Shutdown));
1102
1103 let restarting = JupyterMessageContent::Status(Status {
1104 execution_state: ExecutionState::Restarting,
1105 });
1106 view.push_message(&restarting, window, cx);
1107 assert!(matches!(view.status, ExecutionStatus::Restarting));
1108
1109 let terminating = JupyterMessageContent::Status(Status {
1110 execution_state: ExecutionState::Terminating,
1111 });
1112 view.push_message(&terminating, window, cx);
1113 assert!(matches!(view.status, ExecutionStatus::ShuttingDown));
1114 });
1115 });
1116 }
1117
1118 #[gpui::test]
1119 async fn test_push_message_status_idle_emits_finished_empty(cx: &mut TestAppContext) {
1120 let (mut cx, workspace) = init_test(cx).await;
1121 let execution_view = create_execution_view(&mut cx, workspace);
1122
1123 let emitted = Arc::new(std::sync::atomic::AtomicBool::new(false));
1124 let emitted_clone = emitted.clone();
1125
1126 cx.update(|_, cx| {
1127 cx.subscribe(
1128 &execution_view,
1129 move |_, _event: &ExecutionViewFinishedEmpty, _cx| {
1130 emitted_clone.store(true, std::sync::atomic::Ordering::SeqCst);
1131 },
1132 )
1133 .detach();
1134 });
1135
1136 cx.update(|window, cx| {
1137 execution_view.update(cx, |view, cx| {
1138 assert!(view.outputs.is_empty());
1139 let idle = JupyterMessageContent::Status(Status {
1140 execution_state: ExecutionState::Idle,
1141 });
1142 view.push_message(&idle, window, cx);
1143 });
1144 });
1145
1146 assert!(
1147 emitted.load(std::sync::atomic::Ordering::SeqCst),
1148 "should emit ExecutionViewFinishedEmpty when idle with no outputs"
1149 );
1150 }
1151
1152 #[gpui::test]
1153 async fn test_handle_input_request_creates_pending_input(cx: &mut TestAppContext) {
1154 let (mut cx, workspace) = init_test(cx).await;
1155 let execution_view = create_execution_view(&mut cx, workspace);
1156
1157 cx.update(|window, cx| {
1158 execution_view.update(cx, |view, cx| {
1159 assert!(view.pending_input.is_none());
1160
1161 let message = JupyterMessage::new(
1162 InputRequest {
1163 prompt: "Enter name: ".to_string(),
1164 password: false,
1165 },
1166 None,
1167 );
1168 view.handle_input_request(&message, window, cx);
1169 });
1170 });
1171
1172 cx.update(|_, cx| {
1173 let view = execution_view.read(cx);
1174 assert!(view.pending_input.is_some());
1175 let pending = view.pending_input.as_ref().unwrap();
1176 assert_eq!(pending.prompt, "Enter name: ");
1177 assert!(!pending.password);
1178 });
1179 }
1180
1181 #[gpui::test]
1182 async fn test_handle_input_request_with_password(cx: &mut TestAppContext) {
1183 let (mut cx, workspace) = init_test(cx).await;
1184 let execution_view = create_execution_view(&mut cx, workspace);
1185
1186 cx.update(|window, cx| {
1187 execution_view.update(cx, |view, cx| {
1188 let message = JupyterMessage::new(
1189 InputRequest {
1190 prompt: "Password: ".to_string(),
1191 password: true,
1192 },
1193 None,
1194 );
1195 view.handle_input_request(&message, window, cx);
1196 });
1197 });
1198
1199 cx.update(|_, cx| {
1200 let view = execution_view.read(cx);
1201 assert!(view.pending_input.is_some());
1202 let pending = view.pending_input.as_ref().unwrap();
1203 assert_eq!(pending.prompt, "Password: ");
1204 assert!(pending.password);
1205 });
1206 }
1207
1208 #[gpui::test]
1209 async fn test_submit_input_emits_reply_event(cx: &mut TestAppContext) {
1210 let (mut cx, workspace) = init_test(cx).await;
1211 let execution_view = create_execution_view(&mut cx, workspace);
1212
1213 let received_value = Arc::new(std::sync::Mutex::new(None::<String>));
1214 let received_clone = received_value.clone();
1215
1216 cx.update(|_, cx| {
1217 cx.subscribe(&execution_view, move |_, event: &InputReplyEvent, _cx| {
1218 *received_clone.lock().unwrap() = Some(event.value.clone());
1219 })
1220 .detach();
1221 });
1222
1223 cx.update(|window, cx| {
1224 execution_view.update(cx, |view, cx| {
1225 let message = JupyterMessage::new(
1226 InputRequest {
1227 prompt: "Name: ".to_string(),
1228 password: false,
1229 },
1230 None,
1231 );
1232 view.handle_input_request(&message, window, cx);
1233
1234 // Type into the editor
1235 if let Some(ref pending) = view.pending_input {
1236 pending.editor.update(cx, |editor, cx| {
1237 editor.set_text("test_user", window, cx);
1238 });
1239 }
1240
1241 view.submit_input(window, cx);
1242 });
1243 });
1244
1245 let value = received_value.lock().unwrap().clone();
1246 assert_eq!(value, Some("test_user".to_string()));
1247
1248 cx.update(|_, cx| {
1249 let view = execution_view.read(cx);
1250 assert!(
1251 view.pending_input.is_none(),
1252 "pending_input should be cleared after submit"
1253 );
1254 });
1255 }
1256
1257 #[gpui::test]
1258 async fn test_status_idle_clears_pending_input(cx: &mut TestAppContext) {
1259 let (mut cx, workspace) = init_test(cx).await;
1260 let execution_view = create_execution_view(&mut cx, workspace);
1261
1262 cx.update(|window, cx| {
1263 execution_view.update(cx, |view, cx| {
1264 let message = JupyterMessage::new(
1265 InputRequest {
1266 prompt: "Input: ".to_string(),
1267 password: false,
1268 },
1269 None,
1270 );
1271 view.handle_input_request(&message, window, cx);
1272 assert!(view.pending_input.is_some());
1273
1274 // Simulate kernel going idle (e.g., execution interrupted)
1275 let idle = JupyterMessageContent::Status(Status {
1276 execution_state: ExecutionState::Idle,
1277 });
1278 view.push_message(&idle, window, cx);
1279 });
1280 });
1281
1282 cx.update(|_, cx| {
1283 let view = execution_view.read(cx);
1284 assert!(
1285 view.pending_input.is_none(),
1286 "pending_input should be cleared when kernel goes idle"
1287 );
1288 });
1289 }
1290}