//! # 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) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_json_view_from_value_root_expanded() { let view = JsonView::from_value(serde_json::json!({"key": "value"})).unwrap(); assert!( view.is_expanded("root"), "root should be expanded by default" ); } #[test] fn test_json_view_is_expanded_unknown_path() { let view = JsonView::from_value(serde_json::json!({"key": "value"})).unwrap(); assert!( !view.is_expanded("root.key"), "non-root paths should not be expanded by default" ); assert!( !view.is_expanded("nonexistent"), "unknown paths should not be expanded" ); } #[gpui::test] fn test_json_view_toggle_path(cx: &mut gpui::App) { let view = cx.new(|_cx| JsonView::from_value(serde_json::json!({"nested": {"a": 1}})).unwrap()); view.update(cx, |view, cx| { assert!(!view.is_expanded("root.nested")); view.toggle_path("root.nested", cx); assert!(view.is_expanded("root.nested")); view.toggle_path("root.nested", cx); assert!(!view.is_expanded("root.nested")); }); } }