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