use std::cell::RefCell;
use std::collections::HashMap;
use std::f32::consts::FRAC_PI_2;
use std::ops::Range;
use std::rc::Rc;
use gpui::{
AnyElement, App, Global, InteractiveElement, IntoElement, ListSizingBehavior, ParentElement,
RenderOnce, ScrollStrategy, SharedString, StatefulInteractiveElement, Styled, Transformation,
UniformListScrollHandle, Window, div, prelude::FluentBuilder, px, radians, uniform_list,
};
use gpui_kit_assets::{Icon, icon};
use gpui_kit_semantics::{NodeSpec, Role, Semantic};
use gpui_kit_theme::{ActiveTheme, ControlSize, Space, TextTone, Theme, TypeScale};
use unicode_segmentation::UnicodeSegmentation;
use crate::foundation::{Disableable, FocusRing, Ident, Pressable, Sizable, StyledExt, text};
use crate::strings::{ActiveStrings, StringKey};
type ToggleHandler = Rc<dyn Fn(SharedString, bool, &mut Window, &mut App)>;
type SelectHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum JsonValue {
Null,
Bool(bool),
Number(SharedString),
String(SharedString),
Array(Vec<JsonValue>),
Object(Vec<(SharedString, JsonValue)>),
Redacted(SharedString),
}
impl JsonValue {
pub fn number(text: impl Into<SharedString>) -> Self {
Self::Number(text.into())
}
pub fn string(text: impl Into<SharedString>) -> Self {
Self::String(text.into())
}
pub fn array(items: impl IntoIterator<Item = JsonValue>) -> Self {
Self::Array(items.into_iter().collect())
}
pub fn object(members: impl IntoIterator<Item = (impl Into<SharedString>, JsonValue)>) -> Self {
Self::Object(
members
.into_iter()
.map(|(key, value)| (key.into(), value))
.collect(),
)
}
pub fn redacted(shape: impl Into<SharedString>) -> Self {
Self::Redacted(shape.into())
}
pub fn redacted_from(value: &JsonValue, cx: &App) -> Self {
let strings = cx.strings();
let shape = match value {
JsonValue::String(text) => strings.format(
StringKey::DescriptionCharacters,
&[&text.graphemes(true).count().to_string()],
),
JsonValue::Object(members) => {
strings.format(StringKey::JsonShapeEntries, &[&members.len().to_string()])
}
JsonValue::Array(items) => {
strings.format(StringKey::JsonShapeItems, &[&items.len().to_string()])
}
_ => strings.text(StringKey::JsonShapeValue),
};
Self::Redacted(shape)
}
pub fn kind(&self) -> ValueKind {
match self {
Self::Null => ValueKind::Null,
Self::Bool(_) => ValueKind::Bool,
Self::Number(_) => ValueKind::Number,
Self::String(_) => ValueKind::String,
Self::Array(_) => ValueKind::Array,
Self::Object(_) => ValueKind::Object,
Self::Redacted(_) => ValueKind::Redacted,
}
}
fn member_count(&self) -> usize {
match self {
Self::Array(items) => items.len(),
Self::Object(members) => members.len(),
_ => 0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValueKind {
Null,
Bool,
Number,
String,
Array,
Object,
Redacted,
}
impl ValueKind {
fn published(self, value: &JsonValue) -> SharedString {
match value {
JsonValue::Null => SharedString::new_static("null"),
JsonValue::Bool(true) => SharedString::new_static("true"),
JsonValue::Bool(false) => SharedString::new_static("false"),
JsonValue::Number(text) => text.clone(),
JsonValue::String(text) => text.clone(),
JsonValue::Array(items) if items.is_empty() => SharedString::new_static("empty array"),
JsonValue::Array(_) => SharedString::new_static("array"),
JsonValue::Object(members) if members.is_empty() => {
SharedString::new_static("empty object")
}
JsonValue::Object(_) => SharedString::new_static("object"),
JsonValue::Redacted(_) => SharedString::new_static("withheld"),
}
}
}
#[derive(Debug, Clone)]
struct Line {
path: SharedString,
label: SharedString,
kind: ValueKind,
shown: SharedString,
shape: Option<SharedString>,
published: SharedString,
level: u32,
open: bool,
has_members: bool,
parent: Option<SharedString>,
first_member: Option<SharedString>,
}
fn escape(token: &str) -> String {
token.replace('~', "~0").replace('/', "~1")
}
fn join(parent: &str, token: &str) -> SharedString {
if parent.is_empty() {
SharedString::from(escape(token))
} else {
SharedString::from(format!("{parent}/{}", escape(token)))
}
}
fn shown_text(value: &JsonValue) -> SharedString {
match value {
JsonValue::Null => SharedString::new_static("null"),
JsonValue::Bool(true) => SharedString::new_static("true"),
JsonValue::Bool(false) => SharedString::new_static("false"),
JsonValue::Number(text) => text.clone(),
JsonValue::String(text) => SharedString::from(format!("\"{text}\"")),
JsonValue::Array(items) if items.is_empty() => SharedString::new_static("[]"),
JsonValue::Array(_) => SharedString::new_static("[…]"),
JsonValue::Object(members) if members.is_empty() => SharedString::new_static("{}"),
JsonValue::Object(_) => SharedString::new_static("{…}"),
JsonValue::Redacted(_) => SharedString::new_static("••••••••"),
}
}
fn flatten(
value: &JsonValue,
path: SharedString,
label: SharedString,
level: u32,
parent: Option<&SharedString>,
expanded: &[SharedString],
out: &mut Vec<Line>,
) {
let has_members = value.member_count() > 0;
let open = has_members && expanded.contains(&path);
let first_member = match value {
JsonValue::Object(members) => members
.first()
.map(|(key, _)| join(path.as_ref(), key.as_ref())),
JsonValue::Array(items) if !items.is_empty() => Some(join(path.as_ref(), "0")),
_ => None,
};
out.push(Line {
path: path.clone(),
label,
kind: value.kind(),
shown: shown_text(value),
shape: match value {
JsonValue::Redacted(shape) => Some(shape.clone()),
_ => None,
},
published: value.kind().published(value),
level,
open,
has_members,
parent: parent.cloned(),
first_member,
});
if !open {
return;
}
match value {
JsonValue::Object(members) => {
for (key, member) in members {
flatten(
member,
join(path.as_ref(), key.as_ref()),
key.clone(),
level + 1,
Some(&path),
expanded,
out,
);
}
}
JsonValue::Array(items) => {
for (index, item) in items.iter().enumerate() {
let token = index.to_string();
flatten(
item,
join(path.as_ref(), &token),
SharedString::from(token),
level + 1,
Some(&path),
expanded,
out,
);
}
}
_ => {}
}
}
#[derive(IntoElement)]
pub struct JsonView {
ident: Ident,
value: JsonValue,
root_label: Option<SharedString>,
expanded: Vec<SharedString>,
selected: Option<SharedString>,
visible_rows: Option<usize>,
row_height: Option<f32>,
size: ControlSize,
disabled: bool,
on_toggle: Option<ToggleHandler>,
on_select: Option<SelectHandler>,
}
impl std::fmt::Debug for JsonView {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("JsonView")
.field("ident", &self.ident)
.field("kind", &self.value.kind())
.field("expanded", &self.expanded)
.field("selected", &self.selected)
.field("disabled", &self.disabled)
.finish()
}
}
impl JsonView {
pub fn new(ident: impl Into<Ident>, value: JsonValue) -> Self {
Self {
ident: ident.into(),
value,
root_label: None,
expanded: Vec::new(),
selected: None,
visible_rows: None,
row_height: None,
size: ControlSize::Md,
disabled: false,
on_toggle: None,
on_select: None,
}
}
pub fn root_label(mut self, label: impl Into<SharedString>) -> Self {
self.root_label = Some(label.into());
self
}
pub fn expanded(mut self, paths: impl IntoIterator<Item = SharedString>) -> Self {
self.expanded = paths.into_iter().collect();
self
}
pub fn expanded_paths<S: AsRef<str>>(mut self, paths: &[S]) -> Self {
self.expanded = paths
.iter()
.map(|path| SharedString::from(path.as_ref().to_string()))
.collect();
self
}
pub fn selected(mut self, path: impl Into<SharedString>) -> Self {
self.selected = Some(path.into());
self
}
pub fn visible_rows(mut self, rows: usize) -> Self {
self.visible_rows = Some(rows);
self
}
pub fn row_height(mut self, height: f32) -> Self {
self.row_height = Some(height);
self
}
pub fn on_toggle(
mut self,
handler: impl Fn(SharedString, bool, &mut Window, &mut App) + 'static,
) -> Self {
self.on_toggle = Some(Rc::new(handler));
self
}
pub fn on_select(
mut self,
handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_select = Some(Rc::new(handler));
self
}
pub fn disclosed_paths(&self, cx: &App) -> Vec<SharedString> {
self.lines(cx)
.into_iter()
.map(|line| line.path)
.collect::<Vec<_>>()
}
fn lines(&self, cx: &App) -> Vec<Line> {
let mut lines = Vec::new();
match &self.value {
JsonValue::Object(members) => {
for (key, member) in members {
flatten(
member,
join("", key.as_ref()),
key.clone(),
1,
None,
&self.expanded,
&mut lines,
);
}
}
JsonValue::Array(items) => {
for (index, item) in items.iter().enumerate() {
let token = index.to_string();
flatten(
item,
join("", &token),
SharedString::from(token),
1,
None,
&self.expanded,
&mut lines,
);
}
}
scalar => {
let label = self
.root_label
.clone()
.unwrap_or_else(|| cx.strings().text(StringKey::JsonRootValue));
flatten(
scalar,
SharedString::default(),
label,
1,
None,
&self.expanded,
&mut lines,
);
}
}
lines
}
fn row_ident(&self, path: &SharedString) -> Ident {
if path.is_empty() {
self.ident.child("value")
} else {
self.ident.child(path.as_ref())
}
}
}
impl Disableable for JsonView {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl Sizable for JsonView {
fn control_size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
}
enum Move {
Select(SharedString),
Toggle(SharedString, bool),
}
fn keystroke_move(key: &str, lines: &[Line], selected: Option<&SharedString>) -> Option<Move> {
let at = lines
.iter()
.position(|line| Some(&line.path) == selected)
.filter(|_| selected.is_some());
match key {
"up" | "down" => {
let next = match (key, at) {
("down", Some(at)) => at + 1,
("down", None) => 0,
("up", Some(at)) => at.checked_sub(1)?,
_ => lines.len().checked_sub(1)?,
};
lines.get(next).map(|line| Move::Select(line.path.clone()))
}
"home" => lines.first().map(|line| Move::Select(line.path.clone())),
"end" => lines.last().map(|line| Move::Select(line.path.clone())),
"right" => {
let line = lines.get(at?)?;
if line.has_members && !line.open {
Some(Move::Toggle(line.path.clone(), true))
} else {
line.first_member
.clone()
.filter(|_| line.open)
.map(Move::Select)
}
}
"left" => {
let line = lines.get(at?)?;
if line.has_members && line.open {
Some(Move::Toggle(line.path.clone(), false))
} else {
line.parent.clone().map(Move::Select)
}
}
_ => None,
}
}
impl RenderOnce for JsonView {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme().clone();
let metrics = theme.control.get(self.size);
let row_height = self.row_height.unwrap_or(metrics.height);
let lines = Rc::new(self.lines(cx));
let count = lines.len();
let scroll = scroll_handle(&self.ident, cx);
let view = Rc::new(self);
let owner = Rc::clone(&view);
let source = Rc::clone(&lines);
let row_theme = theme.clone();
let rows = uniform_list(
view.ident.child("rows").element_id(),
count,
move |range: Range<usize>, window, cx| {
range
.filter_map(|index| source.get(index).cloned())
.map(|line| {
row_element(
&owner,
&row_theme,
row_height,
metrics.icon_size,
&line,
window,
cx,
)
})
.collect::<Vec<_>>()
},
)
.track_scroll(&scroll)
.w_full()
.with_sizing_behavior(if view.visible_rows.is_some() {
ListSizingBehavior::Auto
} else {
ListSizingBehavior::Infer
})
.when_some(view.visible_rows, |element, rows| {
element.h(px(row_height * rows as f32))
});
let mut container = div()
.id(view.ident.element_id())
.column()
.w_full()
.font_family(theme.typography.mono.clone())
.child(rows);
if !view.disabled && (view.on_select.is_some() || view.on_toggle.is_some()) {
let selected = view.selected.clone();
let select = view.on_select.clone();
let toggle = view.on_toggle.clone();
let lines = Rc::clone(&lines);
let scroll = scroll.clone();
container = container.on_key_down(move |event, window, cx| {
let Some(next) =
keystroke_move(event.keystroke.key.as_str(), &lines, selected.as_ref())
else {
return;
};
match next {
Move::Select(path) => {
if Some(&path) == selected.as_ref() {
return;
}
if let Some(index) = lines.iter().position(|line| line.path == path) {
scroll.scroll_to_item(index, ScrollStrategy::Nearest);
window.refresh();
}
let Some(handler) = select.as_ref() else {
return;
};
handler(path, window, cx);
}
Move::Toggle(path, open) => {
let Some(handler) = toggle.as_ref() else {
return;
};
handler(path, open, window, cx);
}
}
cx.stop_propagation();
});
}
container.semantic_in(
cx,
NodeSpec::new(view.ident.semantic_id(), Role::Tree).value(count.to_string()),
)
}
}
fn row_element(
view: &JsonView,
theme: &Theme,
height: f32,
icon_size: f32,
line: &Line,
_window: &mut Window,
cx: &mut App,
) -> AnyElement {
let ident = view.row_ident(&line.path);
let selected = view.selected.as_ref() == Some(&line.path);
let selectable = !view.disabled && view.on_select.is_some();
let toggleable = !view.disabled && line.has_members && view.on_toggle.is_some();
let value_color = match line.kind {
ValueKind::Null | ValueKind::Redacted => theme.colors.text_faint,
_ => theme.colors.text,
};
let chevron = line.has_members.then(|| {
let toggle = ident.child("toggle");
let mut glyph = div()
.id(toggle.element_id())
.row()
.flex_none()
.size(px(icon_size))
.child(
icon(Icon::AltArrowRight)
.size(px(icon_size))
.text_color(theme.colors.text_muted)
.when(line.open, |glyph| {
glyph.with_transformation(Transformation::rotate(radians(FRAC_PI_2)))
}),
)
.when(toggleable, |element| {
element
.cursor_pointer()
.tab_index(0)
.pressable(cx)
.focus_ring(theme)
});
if let (true, Some(handler)) = (toggleable, view.on_toggle.clone()) {
let path = line.path.clone();
let open = line.open;
glyph = glyph.on_click(move |_, window, cx| {
handler(path.clone(), !open, window, cx);
cx.stop_propagation();
});
}
glyph.semantic_in(
cx,
NodeSpec::new(toggle.semantic_id(), Role::Button)
.parent(ident.semantic_id())
.text(line.label.clone())
.expanded(line.open)
.disabled(!toggleable),
)
});
let mut row = div()
.id(ident.element_id())
.row()
.w_full()
.h(px(height))
.pr(px(theme.space(Space::Sm)))
.pl(px(theme.space(Space::Sm)
+ line.level.saturating_sub(1) as f32
* theme.space(Space::Md)))
.gap(px(theme.space(Space::Xs)))
.when(selected, |element| element.bg(theme.colors.selected))
.when(view.disabled, |element| {
element.opacity(theme.opacity.disabled)
})
.when(selectable, |element| {
element
.cursor_pointer()
.tab_index(0)
.pressable(cx)
.when(!selected, |element| {
element.hover(|style| style.bg(theme.colors.hover.opacity(0.3)))
})
.focus_ring(theme)
})
.children(chevron)
.when(!line.has_members, |element| {
element.child(div().flex_none().size(px(icon_size)))
})
.child(
text(theme, TypeScale::Code, line.label.clone())
.flex_none()
.text_tone(theme, TextTone::Muted),
)
.child(
text(theme, TypeScale::Code, line.shown.clone())
.flex_1()
.overflow_hidden()
.text_color(value_color),
);
if let Some(shape) = line.shape.clone() {
row = row.child(
div()
.flex_none()
.row()
.gap(px(theme.space(Space::Xs)))
.child(
text(
theme,
TypeScale::Caption,
cx.strings().text(StringKey::JsonWithheld),
)
.text_tone(theme, TextTone::Muted),
)
.child(text(theme, TypeScale::Code, shape).text_tone(theme, TextTone::Faint)),
);
}
if let (true, Some(handler)) = (selectable, view.on_select.clone()) {
let path = line.path.clone();
row = row.on_click(move |_, window, cx| handler(path.clone(), window, cx));
}
let mut spec = NodeSpec::new(ident.semantic_id(), Role::TreeItem)
.parent(match &line.parent {
Some(parent) => view.row_ident(parent).semantic_id(),
None => view.ident.semantic_id(),
})
.text(line.label.clone())
.value(line.published.clone())
.selected(selected)
.disabled(view.disabled)
.level(line.level);
if line.has_members {
spec = spec.expanded(line.open);
}
row.semantic_in(cx, spec).into_any_element()
}
#[derive(Default)]
struct ScrollHandles(RefCell<HashMap<SharedString, UniformListScrollHandle>>);
impl Global for ScrollHandles {}
fn scroll_handle(ident: &Ident, cx: &mut App) -> UniformListScrollHandle {
if !cx.has_global::<ScrollHandles>() {
cx.set_global(ScrollHandles::default());
}
let mut handles = cx.global::<ScrollHandles>().0.borrow_mut();
handles.entry(ident.semantic_id()).or_default().clone()
}
#[cfg(test)]
mod tests {
use super::*;
fn document() -> JsonValue {
JsonValue::object([
("name", JsonValue::string("run")),
("retries", JsonValue::number("3")),
("cursor", JsonValue::Null),
("labels", JsonValue::object(Vec::<(&str, JsonValue)>::new())),
(
"steps",
JsonValue::array([JsonValue::string("plan"), JsonValue::string("apply")]),
),
])
}
fn lines(expanded: &[&str]) -> Vec<Line> {
let expanded: Vec<SharedString> = expanded
.iter()
.map(|path| SharedString::from(path.to_string()))
.collect();
let mut out = Vec::new();
let JsonValue::Object(members) = document() else {
unreachable!()
};
for (key, member) in &members {
flatten(
member,
join("", key.as_ref()),
key.clone(),
1,
None,
&expanded,
&mut out,
);
}
out
}
#[test]
fn a_shut_value_discloses_nothing() {
let shut = lines(&[]);
let paths: Vec<&str> = shut.iter().map(|line| line.path.as_ref()).collect();
assert_eq!(
paths,
vec!["name", "retries", "cursor", "labels", "steps"],
"a shut array must not lay out its items"
);
}
#[test]
fn an_empty_object_offers_no_disclosure() {
let labels = lines(&[]);
let empty = labels
.iter()
.find(|line| line.path.as_ref() == "labels")
.expect("present");
assert!(!empty.has_members);
assert_eq!(empty.published.as_ref(), "empty object");
}
#[test]
fn a_key_containing_a_slash_stays_one_level() {
let value = JsonValue::object([("a/b", JsonValue::object([("c", JsonValue::Bool(true))]))]);
let JsonValue::Object(members) = &value else {
unreachable!()
};
let mut out = Vec::new();
flatten(
&members[0].1,
join("", members[0].0.as_ref()),
members[0].0.clone(),
1,
None,
&[SharedString::from("a~1b")],
&mut out,
);
assert_eq!(out[0].path.as_ref(), "a~1b");
assert_eq!(out[1].path.as_ref(), "a~1b/c");
}
#[test]
fn right_opens_a_shut_value_and_then_descends() {
let shut = lines(&[]);
let steps = SharedString::from("steps");
match keystroke_move("right", &shut, Some(&steps)) {
Some(Move::Toggle(path, next)) => {
assert_eq!(path.as_ref(), "steps");
assert!(next);
}
_ => panic!("right must open a shut value"),
}
let open = lines(&["steps"]);
match keystroke_move("right", &open, Some(&steps)) {
Some(Move::Select(path)) => assert_eq!(path.as_ref(), "steps/0"),
_ => panic!("right must descend into an open value"),
}
}
#[test]
fn a_move_stops_at_the_ends() {
let shut = lines(&[]);
let last = SharedString::from("steps");
assert!(keystroke_move("down", &shut, Some(&last)).is_none());
let first = SharedString::from("name");
assert!(keystroke_move("up", &shut, Some(&first)).is_none());
}
}