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