From 3723a2560aa45dd68e6270622d30776a1b9928d0 Mon Sep 17 00:00:00 2001 From: Kyle Kelley Date: Fri, 30 Jan 2026 09:09:45 -0800 Subject: [PATCH] repl: Render `application/json` media types from Jupyter kernels (#47905) Closes #47757 Component of https://github.com/zed-industries/zed/issues/9778 image Release Notes: - Added JSON output support to REPL --- 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(-) create mode 100644 crates/repl/src/outputs/json.rs diff --git a/crates/repl/src/notebook/cell.rs b/crates/repl/src/notebook/cell.rs index a62c6cd4b7baedff929442e93f90a9c8e3f736a8..625b2a8746c0f84fbf1d7a0f4dede9deb0de1451 100644 --- a/crates/repl/src/notebook/cell.rs +++ b/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) } diff --git a/crates/repl/src/outputs.rs b/crates/repl/src/outputs.rs index d1942a67d8ceaafd2ad207397a6677682e2e1e05..53448cc8ae1915582dd2d72b5475af0bb48ea42e 100644 --- a/crates/repl/src/outputs.rs +++ b/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, display_id: Option, }, + Json { + content: Entity, + display_id: Option, + }, 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, diff --git a/crates/repl/src/outputs/json.rs b/crates/repl/src/outputs/json.rs new file mode 100644 index 0000000000000000000000000000000000000000..c4add05e56eaec814f543a45a38cffeab2577b10 --- /dev/null +++ b/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, +} + +impl JsonView { + pub fn from_value(value: Value) -> anyhow::Result { + 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) { + 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, + ) -> 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::>(), + ) + }) + .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::>(), + ) + }) + .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, + ) -> 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) -> 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 { + 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> { + 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) + } +}