repl: Render `application/json` media types from Jupyter kernels (#47905)

Kyle Kelley created

Closes #47757

Component of https://github.com/zed-industries/zed/issues/9778

<img width="799" height="913" alt="image"
src="https://github.com/user-attachments/assets/ac496987-bf03-4ea4-99f0-d327e57e7b20"
/>

Release Notes:

- Added JSON output support to REPL

Change summary

crates/repl/src/notebook/cell.rs |   3 
crates/repl/src/outputs.rs       |  31 +++
crates/repl/src/outputs/json.rs  | 255 ++++++++++++++++++++++++++++++++++
3 files changed, 287 insertions(+), 2 deletions(-)

Detailed changes

crates/repl/src/notebook/cell.rs 🔗

@@ -1191,6 +1191,9 @@ impl Render for CodeCell {
                                                     Output::Table { content, .. } => {
                                                         Some(content.clone().into_any_element())
                                                     }
+                                                    Output::Json { content, .. } => {
+                                                        Some(content.clone().into_any_element())
+                                                    }
                                                     Output::ErrorOutput(error_view) => {
                                                         error_view.render(window, cx)
                                                     }

crates/repl/src/outputs.rs 🔗

@@ -48,6 +48,9 @@ use markdown::MarkdownView;
 mod table;
 use table::TableView;
 
+mod json;
+use json::JsonView;
+
 pub mod plain;
 use plain::TerminalOutput;
 
@@ -62,6 +65,7 @@ use settings::Settings;
 fn rank_mime_type(mimetype: &MimeType) -> usize {
     match mimetype {
         MimeType::DataTable(_) => 6,
+        MimeType::Json(_) => 5,
         MimeType::Png(_) => 4,
         MimeType::Jpeg(_) => 3,
         MimeType::Markdown(_) => 2,
@@ -124,6 +128,10 @@ pub enum Output {
         content: Entity<MarkdownView>,
         display_id: Option<String>,
     },
+    Json {
+        content: Entity<JsonView>,
+        display_id: Option<String>,
+    },
     ClearOutputWaitMarker,
 }
 
@@ -158,8 +166,12 @@ impl Output {
                     traceback: traceback_lines,
                 }))
             }
-            Output::Message(_) | Output::ClearOutputWaitMarker => None,
-            Output::Image { .. } | Output::Table { .. } | Output::Markdown { .. } => None,
+            Output::Image { .. }
+            | Output::Markdown { .. }
+            | Output::Table { .. }
+            | Output::Json { .. } => None,
+            Output::Message(_) => None,
+            Output::ClearOutputWaitMarker => None,
         }
     }
 }
@@ -250,6 +262,7 @@ impl Output {
             Self::Image { content, .. } => Some(content.clone().into_any_element()),
             Self::Message(message) => Some(div().child(message.clone()).into_any_element()),
             Self::Table { content, .. } => Some(content.clone().into_any_element()),
+            Self::Json { content, .. } => Some(content.clone().into_any_element()),
             Self::ErrorOutput(error_view) => error_view.render(window, cx),
             Self::ClearOutputWaitMarker => None,
         };
@@ -281,6 +294,9 @@ impl Output {
                 Self::Image { content, .. } => {
                     Self::render_output_controls(content.clone(), workspace, window, cx)
                 }
+                Self::Json { content, .. } => {
+                    Self::render_output_controls(content.clone(), workspace, window, cx)
+                }
                 Self::ErrorOutput(err) => Some(
                     h_flex()
                         .pl_1()
@@ -355,6 +371,7 @@ impl Output {
             Output::Message(_) => None,
             Output::Table { display_id, .. } => display_id.clone(),
             Output::Markdown { display_id, .. } => display_id.clone(),
+            Output::Json { display_id, .. } => display_id.clone(),
             Output::ClearOutputWaitMarker => None,
         }
     }
@@ -366,6 +383,16 @@ impl Output {
         cx: &mut App,
     ) -> Self {
         match data.richest(rank_mime_type) {
+            Some(MimeType::Json(json_object)) => {
+                let json_value = serde_json::Value::Object(json_object.clone());
+                match JsonView::from_value(json_value) {
+                    Ok(json_view) => Output::Json {
+                        content: cx.new(|_| json_view),
+                        display_id,
+                    },
+                    Err(_) => Output::Message("Failed to parse JSON".to_string()),
+                }
+            }
             Some(MimeType::Plain(text)) => Output::Plain {
                 content: cx.new(|cx| TerminalOutput::from(text, window, cx)),
                 display_id,

crates/repl/src/outputs/json.rs 🔗

@@ -0,0 +1,255 @@
+//! # JSON Output for REPL
+//!
+//! This module provides an interactive JSON viewer for displaying JSON data in the REPL.
+//! It supports collapsible/expandable tree views for objects and arrays, with syntax
+//! highlighting for different value types.
+
+use std::collections::HashMap;
+use std::collections::hash_map::DefaultHasher;
+use std::hash::{Hash, Hasher};
+
+use gpui::{App, ClipboardItem, Context, Entity, Window, div, prelude::*};
+use language::Buffer;
+use serde_json::Value;
+use ui::{Disclosure, prelude::*};
+
+use crate::outputs::OutputContent;
+
+pub struct JsonView {
+    root: Value,
+    expanded_paths: HashMap<String, bool>,
+}
+
+impl JsonView {
+    pub fn from_value(value: Value) -> anyhow::Result<Self> {
+        let mut expanded_paths = HashMap::new();
+        expanded_paths.insert("root".to_string(), true);
+
+        Ok(Self {
+            root: value,
+            expanded_paths,
+        })
+    }
+
+    fn toggle_path(&mut self, path: &str, cx: &mut Context<Self>) {
+        let current = self.expanded_paths.get(path).copied().unwrap_or(false);
+        self.expanded_paths.insert(path.to_string(), !current);
+        cx.notify();
+    }
+
+    fn is_expanded(&self, path: &str) -> bool {
+        self.expanded_paths.get(path).copied().unwrap_or(false)
+    }
+
+    fn path_hash(path: &str) -> u64 {
+        let mut hasher = DefaultHasher::new();
+        path.hash(&mut hasher);
+        hasher.finish()
+    }
+
+    fn render_value(
+        &self,
+        path: String,
+        key: Option<&str>,
+        value: &Value,
+        depth: usize,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> AnyElement {
+        let indent = depth * 12;
+
+        match value {
+            Value::Object(map) if map.is_empty() => {
+                self.render_line(path, key, "{}", depth, Color::Muted, window, cx)
+            }
+            Value::Object(map) => {
+                let is_expanded = self.is_expanded(&path);
+                let preview = if is_expanded {
+                    String::new()
+                } else {
+                    format!("{{ {} fields }}", map.len())
+                };
+
+                v_flex()
+                    .child(
+                        h_flex()
+                            .gap_1()
+                            .pl(px(indent as f32))
+                            .cursor_pointer()
+                            .on_mouse_down(
+                                gpui::MouseButton::Left,
+                                cx.listener({
+                                    let path = path.clone();
+                                    move |this, _, _, cx| {
+                                        this.toggle_path(&path, cx);
+                                    }
+                                }),
+                            )
+                            .child(Disclosure::new(
+                                ("json-disclosure", Self::path_hash(&path)),
+                                is_expanded,
+                            ))
+                            .child(
+                                h_flex()
+                                    .gap_1()
+                                    .when_some(key, |this, k| {
+                                        this.child(
+                                            Label::new(format!("{}: ", k)).color(Color::Accent),
+                                        )
+                                    })
+                                    .when(!is_expanded, |this| {
+                                        this.child(Label::new("{").color(Color::Muted))
+                                            .child(
+                                                Label::new(format!(" {} ", preview))
+                                                    .color(Color::Muted),
+                                            )
+                                            .child(Label::new("}").color(Color::Muted))
+                                    }),
+                            ),
+                    )
+                    .when(is_expanded, |this| {
+                        this.children(
+                            map.iter()
+                                .map(|(k, v)| {
+                                    let child_path = format!("{}.{}", path, k);
+                                    self.render_value(child_path, Some(k), v, depth + 1, window, cx)
+                                })
+                                .collect::<Vec<_>>(),
+                        )
+                    })
+                    .into_any_element()
+            }
+            Value::Array(arr) if arr.is_empty() => {
+                self.render_line(path, key, "[]", depth, Color::Muted, window, cx)
+            }
+            Value::Array(arr) => {
+                let is_expanded = self.is_expanded(&path);
+                let preview = if is_expanded {
+                    String::new()
+                } else {
+                    format!("[ {} items ]", arr.len())
+                };
+
+                v_flex()
+                    .child(
+                        h_flex()
+                            .gap_1()
+                            .pl(px(indent as f32))
+                            .cursor_pointer()
+                            .on_mouse_down(
+                                gpui::MouseButton::Left,
+                                cx.listener({
+                                    let path = path.clone();
+                                    move |this, _, _, cx| {
+                                        this.toggle_path(&path, cx);
+                                    }
+                                }),
+                            )
+                            .child(Disclosure::new(
+                                ("json-disclosure", Self::path_hash(&path)),
+                                is_expanded,
+                            ))
+                            .child(
+                                h_flex()
+                                    .gap_1()
+                                    .when_some(key, |this, k| {
+                                        this.child(
+                                            Label::new(format!("{}: ", k)).color(Color::Accent),
+                                        )
+                                    })
+                                    .when(!is_expanded, |this| {
+                                        this.child(Label::new("[").color(Color::Muted))
+                                            .child(
+                                                Label::new(format!(" {} ", preview))
+                                                    .color(Color::Muted),
+                                            )
+                                            .child(Label::new("]").color(Color::Muted))
+                                    }),
+                            ),
+                    )
+                    .when(is_expanded, |this| {
+                        this.children(
+                            arr.iter()
+                                .enumerate()
+                                .map(|(i, v)| {
+                                    let child_path = format!("{}[{}]", path, i);
+                                    self.render_value(child_path, None, v, depth + 1, window, cx)
+                                })
+                                .collect::<Vec<_>>(),
+                        )
+                    })
+                    .into_any_element()
+            }
+            Value::String(s) => {
+                let display = format!("\"{}\"", s);
+                self.render_line(path, key, &display, depth, Color::Success, window, cx)
+            }
+            Value::Number(n) => {
+                let display = n.to_string();
+                self.render_line(path, key, &display, depth, Color::Modified, window, cx)
+            }
+            Value::Bool(b) => {
+                let display = b.to_string();
+                self.render_line(path, key, &display, depth, Color::Info, window, cx)
+            }
+            Value::Null => self.render_line(path, key, "null", depth, Color::Disabled, window, cx),
+        }
+    }
+
+    fn render_line(
+        &self,
+        _path: String,
+        key: Option<&str>,
+        value: &str,
+        depth: usize,
+        color: Color,
+        _window: &mut Window,
+        _cx: &mut Context<Self>,
+    ) -> AnyElement {
+        let indent = depth * 16;
+
+        h_flex()
+            .pl(px(indent as f32))
+            .gap_1()
+            .when_some(key, |this, k| {
+                this.child(Label::new(format!("{}: ", k)).color(Color::Accent))
+            })
+            .child(Label::new(value.to_string()).color(color))
+            .into_any_element()
+    }
+}
+
+impl Render for JsonView {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let root_clone = self.root.clone();
+        let root_element = self.render_value("root".to_string(), None, &root_clone, 0, window, cx);
+        div().w_full().child(root_element)
+    }
+}
+
+impl OutputContent for JsonView {
+    fn clipboard_content(&self, _window: &Window, _cx: &App) -> Option<ClipboardItem> {
+        serde_json::to_string_pretty(&self.root)
+            .ok()
+            .map(ClipboardItem::new_string)
+    }
+
+    fn has_clipboard_content(&self, _window: &Window, _cx: &App) -> bool {
+        true
+    }
+
+    fn has_buffer_content(&self, _window: &Window, _cx: &App) -> bool {
+        true
+    }
+
+    fn buffer_content(&mut self, _window: &mut Window, cx: &mut App) -> Option<Entity<Buffer>> {
+        let json_text = serde_json::to_string_pretty(&self.root).ok()?;
+        let buffer = cx.new(|cx| {
+            let mut buffer =
+                Buffer::local(json_text, cx).with_language(language::PLAIN_TEXT.clone(), cx);
+            buffer.set_capability(language::Capability::ReadOnly, cx);
+            buffer
+        });
+        Some(buffer)
+    }
+}