Skip to main content

gpui_kit/structured/
json_view.rs

1//! A structured view of a value a host parsed somewhere else.
2//!
3//! # Why there is a value type here
4//!
5//! This crate takes no serialization dependency, for the same reason
6//! [`SplitLayout`](crate::layout::SplitLayout) converts to plain records
7//! instead of deriving `Serialize`: a product-neutral component must not
8//! decide which parsing crate an application depends on. [`JsonValue`] is the
9//! smallest shape that expresses a JSON document faithfully, and a host
10//! converts into it from whatever it already parses with.
11//!
12//! Two choices in it are deliberate.
13//!
14//! - A number is carried as the text the document contained. A `f64` cannot
15//!   hold every integer a JSON document can write, and it cannot tell `1.10`
16//!   from `1.1`; this crate also formats no numbers (`docs/coverage.md`
17//!   records that gap), so re-rendering one would be inventing digits.
18//! - An object is a `Vec` of pairs rather than a map. JSON documents have an
19//!   order and may repeat a key; a map would silently reorder the first and
20//!   drop the second, and a viewer that quietly edits its document is the
21//!   thing this component exists not to be.
22//!
23//! # Absent, null, and empty are three facts
24//!
25//! A key that is not in the document produces no row at all. A key whose value
26//! is `null` produces a row reading `null`. A key holding an empty object
27//! produces a row reading `{}` that discloses nothing. All three are different
28//! statements about the document, and a viewer that renders them alike is
29//! lying about one of them.
30//!
31//! # Withheld is not absent
32//!
33//! A caller that must not show a subtree replaces it with
34//! [`JsonValue::Redacted`], which carries a description of the shape and never
35//! the value. The secret therefore never reaches this component, so no
36//! rendering path, no snapshot, and no export can leak it — and the row still
37//! exists, marked `withheld`, because a secret drawn as an absence tells the
38//! reader the document does not contain it.
39//!
40//! # Virtualization
41//!
42//! The view lays out only the rows its viewport holds, over the same
43//! `uniform_list` primitive [`List`](crate::data::List) and
44//! [`DataGrid`](crate::data::DataGrid) use, so the same rule applies: only
45//! rendered rows publish semantic nodes, and the container carries how many
46//! rows are currently disclosed. It does not build on
47//! [`Tree`](crate::data::Tree), which draws one label per node and has no slot
48//! for a second column; a key and a typed value drawn as one string would lose
49//! the distinction between the name of a thing and what it holds.
50
51use std::cell::RefCell;
52use std::collections::HashMap;
53use std::f32::consts::FRAC_PI_2;
54use std::ops::Range;
55use std::rc::Rc;
56
57use gpui::{
58    AnyElement, App, Global, InteractiveElement, IntoElement, ListSizingBehavior, ParentElement,
59    RenderOnce, ScrollStrategy, SharedString, StatefulInteractiveElement, Styled, Transformation,
60    UniformListScrollHandle, Window, div, prelude::FluentBuilder, px, radians, uniform_list,
61};
62use gpui_kit_assets::{Icon, icon};
63use gpui_kit_semantics::{NodeSpec, Role, Semantic};
64use gpui_kit_theme::{ActiveTheme, ControlSize, Space, TextTone, Theme, TypeScale};
65use unicode_segmentation::UnicodeSegmentation;
66
67use crate::foundation::{Disableable, FocusRing, Ident, Pressable, Sizable, StyledExt, text};
68use crate::strings::{ActiveStrings, StringKey};
69
70type ToggleHandler = Rc<dyn Fn(SharedString, bool, &mut Window, &mut App)>;
71type SelectHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
72
73/// A JSON value, plus the one thing JSON cannot say: that a subtree is being
74/// withheld on purpose.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum JsonValue {
77    Null,
78    Bool(bool),
79    /// The number exactly as the document wrote it.
80    Number(SharedString),
81    String(SharedString),
82    Array(Vec<JsonValue>),
83    /// Members in document order. A repeated key is kept, not collapsed.
84    Object(Vec<(SharedString, JsonValue)>),
85    /// Present, and not shown. Carries a description of the shape and no part
86    /// of the value.
87    Redacted(SharedString),
88}
89
90impl JsonValue {
91    pub fn number(text: impl Into<SharedString>) -> Self {
92        Self::Number(text.into())
93    }
94
95    pub fn string(text: impl Into<SharedString>) -> Self {
96        Self::String(text.into())
97    }
98
99    pub fn array(items: impl IntoIterator<Item = JsonValue>) -> Self {
100        Self::Array(items.into_iter().collect())
101    }
102
103    pub fn object(members: impl IntoIterator<Item = (impl Into<SharedString>, JsonValue)>) -> Self {
104        Self::Object(
105            members
106                .into_iter()
107                .map(|(key, value)| (key.into(), value))
108                .collect(),
109        )
110    }
111
112    /// Withholds a subtree, described by a shape the caller wrote.
113    pub fn redacted(shape: impl Into<SharedString>) -> Self {
114        Self::Redacted(shape.into())
115    }
116
117    /// Measures a value and keeps only the measurement.
118    ///
119    /// The value is consumed here and never stored, which is what makes this
120    /// safe to call with the secret itself: what comes back holds a sentence
121    /// about the shape and nothing of the content.
122    pub fn redacted_from(value: &JsonValue, cx: &App) -> Self {
123        let strings = cx.strings();
124        let shape = match value {
125            JsonValue::String(text) => strings.format(
126                StringKey::DescriptionCharacters,
127                &[&text.graphemes(true).count().to_string()],
128            ),
129            JsonValue::Object(members) => {
130                strings.format(StringKey::JsonShapeEntries, &[&members.len().to_string()])
131            }
132            JsonValue::Array(items) => {
133                strings.format(StringKey::JsonShapeItems, &[&items.len().to_string()])
134            }
135            _ => strings.text(StringKey::JsonShapeValue),
136        };
137        Self::Redacted(shape)
138    }
139
140    pub fn kind(&self) -> ValueKind {
141        match self {
142            Self::Null => ValueKind::Null,
143            Self::Bool(_) => ValueKind::Bool,
144            Self::Number(_) => ValueKind::Number,
145            Self::String(_) => ValueKind::String,
146            Self::Array(_) => ValueKind::Array,
147            Self::Object(_) => ValueKind::Object,
148            Self::Redacted(_) => ValueKind::Redacted,
149        }
150    }
151
152    /// How many rows this value discloses when it is opened. A scalar
153    /// discloses nothing; so does an empty container, which is why an empty
154    /// object never grows a chevron that would open onto nothing.
155    fn member_count(&self) -> usize {
156        match self {
157            Self::Array(items) => items.len(),
158            Self::Object(members) => members.len(),
159            _ => 0,
160        }
161    }
162}
163
164/// What kind of thing a row holds, which is the one fact its node publishes.
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub enum ValueKind {
167    Null,
168    Bool,
169    Number,
170    String,
171    Array,
172    Object,
173    Redacted,
174}
175
176impl ValueKind {
177    /// The published name. `null` and `withheld` are states rather than
178    /// values, and an empty container says so, because "object" and "an object
179    /// with nothing in it" are different answers to the same question.
180    fn published(self, value: &JsonValue) -> SharedString {
181        match value {
182            JsonValue::Null => SharedString::new_static("null"),
183            JsonValue::Bool(true) => SharedString::new_static("true"),
184            JsonValue::Bool(false) => SharedString::new_static("false"),
185            JsonValue::Number(text) => text.clone(),
186            JsonValue::String(text) => text.clone(),
187            JsonValue::Array(items) if items.is_empty() => SharedString::new_static("empty array"),
188            JsonValue::Array(_) => SharedString::new_static("array"),
189            JsonValue::Object(members) if members.is_empty() => {
190                SharedString::new_static("empty object")
191            }
192            JsonValue::Object(_) => SharedString::new_static("object"),
193            // The shape is not published: a snapshot carries that the value
194            // was withheld and nothing that describes it.
195            JsonValue::Redacted(_) => SharedString::new_static("withheld"),
196        }
197    }
198}
199
200/// One row as it is drawn: what the keyboard can reach this frame.
201#[derive(Debug, Clone)]
202struct Line {
203    /// The row's identity within the document: a slash-joined path of keys
204    /// and array indices, escaped the way a JSON pointer token is. It is
205    /// business identity, not list position: the path of a member does not
206    /// change when a sibling above it is removed.
207    path: SharedString,
208    /// The key, or the index within an array.
209    label: SharedString,
210    kind: ValueKind,
211    /// What is drawn to the right of the key.
212    shown: SharedString,
213    /// A redacted row's shape, drawn beside the mark and published nowhere.
214    shape: Option<SharedString>,
215    published: SharedString,
216    level: u32,
217    open: bool,
218    has_members: bool,
219    parent: Option<SharedString>,
220    first_member: Option<SharedString>,
221}
222
223/// Escapes one path token the way RFC 6901 does, so a key containing a slash
224/// cannot be read back as two levels of nesting.
225fn escape(token: &str) -> String {
226    token.replace('~', "~0").replace('/', "~1")
227}
228
229fn join(parent: &str, token: &str) -> SharedString {
230    if parent.is_empty() {
231        SharedString::from(escape(token))
232    } else {
233        SharedString::from(format!("{parent}/{}", escape(token)))
234    }
235}
236
237/// What is drawn to the right of a key.
238///
239/// The container marks and the three JSON literals are syntax rather than
240/// prose: a reader pastes them back into a document, so they are not in the
241/// string catalogue and are not translated.
242fn shown_text(value: &JsonValue) -> SharedString {
243    match value {
244        JsonValue::Null => SharedString::new_static("null"),
245        JsonValue::Bool(true) => SharedString::new_static("true"),
246        JsonValue::Bool(false) => SharedString::new_static("false"),
247        JsonValue::Number(text) => text.clone(),
248        JsonValue::String(text) => SharedString::from(format!("\"{text}\"")),
249        JsonValue::Array(items) if items.is_empty() => SharedString::new_static("[]"),
250        JsonValue::Array(_) => SharedString::new_static("[…]"),
251        JsonValue::Object(members) if members.is_empty() => SharedString::new_static("{}"),
252        JsonValue::Object(_) => SharedString::new_static("{…}"),
253        JsonValue::Redacted(_) => SharedString::new_static("••••••••"),
254    }
255}
256
257fn flatten(
258    value: &JsonValue,
259    path: SharedString,
260    label: SharedString,
261    level: u32,
262    parent: Option<&SharedString>,
263    expanded: &[SharedString],
264    out: &mut Vec<Line>,
265) {
266    let has_members = value.member_count() > 0;
267    let open = has_members && expanded.contains(&path);
268    let first_member = match value {
269        JsonValue::Object(members) => members
270            .first()
271            .map(|(key, _)| join(path.as_ref(), key.as_ref())),
272        JsonValue::Array(items) if !items.is_empty() => Some(join(path.as_ref(), "0")),
273        _ => None,
274    };
275    out.push(Line {
276        path: path.clone(),
277        label,
278        kind: value.kind(),
279        shown: shown_text(value),
280        shape: match value {
281            JsonValue::Redacted(shape) => Some(shape.clone()),
282            _ => None,
283        },
284        published: value.kind().published(value),
285        level,
286        open,
287        has_members,
288        parent: parent.cloned(),
289        first_member,
290    });
291    if !open {
292        return;
293    }
294    match value {
295        JsonValue::Object(members) => {
296            for (key, member) in members {
297                flatten(
298                    member,
299                    join(path.as_ref(), key.as_ref()),
300                    key.clone(),
301                    level + 1,
302                    Some(&path),
303                    expanded,
304                    out,
305                );
306            }
307        }
308        JsonValue::Array(items) => {
309            for (index, item) in items.iter().enumerate() {
310                let token = index.to_string();
311                flatten(
312                    item,
313                    join(path.as_ref(), &token),
314                    SharedString::from(token),
315                    level + 1,
316                    Some(&path),
317                    expanded,
318                    out,
319                );
320            }
321        }
322        _ => {}
323    }
324}
325
326/// A collapsible view of a structured value.
327#[derive(IntoElement)]
328pub struct JsonView {
329    ident: Ident,
330    value: JsonValue,
331    root_label: Option<SharedString>,
332    expanded: Vec<SharedString>,
333    selected: Option<SharedString>,
334    visible_rows: Option<usize>,
335    row_height: Option<f32>,
336    size: ControlSize,
337    disabled: bool,
338    on_toggle: Option<ToggleHandler>,
339    on_select: Option<SelectHandler>,
340}
341
342impl std::fmt::Debug for JsonView {
343    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
344        formatter
345            .debug_struct("JsonView")
346            .field("ident", &self.ident)
347            .field("kind", &self.value.kind())
348            .field("expanded", &self.expanded)
349            .field("selected", &self.selected)
350            .field("disabled", &self.disabled)
351            .finish()
352    }
353}
354
355impl JsonView {
356    pub fn new(ident: impl Into<Ident>, value: JsonValue) -> Self {
357        Self {
358            ident: ident.into(),
359            value,
360            root_label: None,
361            expanded: Vec::new(),
362            selected: None,
363            visible_rows: None,
364            row_height: None,
365            size: ControlSize::Md,
366            disabled: false,
367            on_toggle: None,
368            on_select: None,
369        }
370    }
371
372    /// What the single row of a document that is one scalar is called. A
373    /// document that is an object or an array names its own members and never
374    /// uses this.
375    pub fn root_label(mut self, label: impl Into<SharedString>) -> Self {
376        self.root_label = Some(label.into());
377        self
378    }
379
380    /// The paths whose members are disclosed. Everything else is shut, and
381    /// nothing under a shut path is laid out or published.
382    pub fn expanded(mut self, paths: impl IntoIterator<Item = SharedString>) -> Self {
383        self.expanded = paths.into_iter().collect();
384        self
385    }
386
387    pub fn expanded_paths<S: AsRef<str>>(mut self, paths: &[S]) -> Self {
388        self.expanded = paths
389            .iter()
390            .map(|path| SharedString::from(path.as_ref().to_string()))
391            .collect();
392        self
393    }
394
395    pub fn selected(mut self, path: impl Into<SharedString>) -> Self {
396        self.selected = Some(path.into());
397        self
398    }
399
400    /// Bounds the viewport, which is what lets the view skip the rows it does
401    /// not show. Without it the view sizes itself to its content and every
402    /// disclosed row is laid out.
403    pub fn visible_rows(mut self, rows: usize) -> Self {
404        self.visible_rows = Some(rows);
405        self
406    }
407
408    pub fn row_height(mut self, height: f32) -> Self {
409        self.row_height = Some(height);
410        self
411    }
412
413    /// Reports a path and the disclosure state it should take. The view opens
414    /// nothing itself.
415    pub fn on_toggle(
416        mut self,
417        handler: impl Fn(SharedString, bool, &mut Window, &mut App) + 'static,
418    ) -> Self {
419        self.on_toggle = Some(Rc::new(handler));
420        self
421    }
422
423    pub fn on_select(
424        mut self,
425        handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
426    ) -> Self {
427        self.on_select = Some(Rc::new(handler));
428        self
429    }
430
431    /// The rows this view would disclose, as paths, in the order the keyboard
432    /// walks them. A caller uses it to expand everything without knowing the
433    /// shape of the document.
434    pub fn disclosed_paths(&self, cx: &App) -> Vec<SharedString> {
435        self.lines(cx)
436            .into_iter()
437            .map(|line| line.path)
438            .collect::<Vec<_>>()
439    }
440
441    fn lines(&self, cx: &App) -> Vec<Line> {
442        let mut lines = Vec::new();
443        match &self.value {
444            JsonValue::Object(members) => {
445                for (key, member) in members {
446                    flatten(
447                        member,
448                        join("", key.as_ref()),
449                        key.clone(),
450                        1,
451                        None,
452                        &self.expanded,
453                        &mut lines,
454                    );
455                }
456            }
457            JsonValue::Array(items) => {
458                for (index, item) in items.iter().enumerate() {
459                    let token = index.to_string();
460                    flatten(
461                        item,
462                        join("", &token),
463                        SharedString::from(token),
464                        1,
465                        None,
466                        &self.expanded,
467                        &mut lines,
468                    );
469                }
470            }
471            scalar => {
472                let label = self
473                    .root_label
474                    .clone()
475                    .unwrap_or_else(|| cx.strings().text(StringKey::JsonRootValue));
476                flatten(
477                    scalar,
478                    SharedString::default(),
479                    label,
480                    1,
481                    None,
482                    &self.expanded,
483                    &mut lines,
484                );
485            }
486        }
487        lines
488    }
489
490    /// The semantic id of one row.
491    ///
492    /// A document that is one scalar has no path, so its single row is named
493    /// `value`. No key can collide with it: a document with keys renders its
494    /// members instead and never produces that row.
495    fn row_ident(&self, path: &SharedString) -> Ident {
496        if path.is_empty() {
497            self.ident.child("value")
498        } else {
499            self.ident.child(path.as_ref())
500        }
501    }
502}
503
504impl Disableable for JsonView {
505    fn disabled(mut self, disabled: bool) -> Self {
506        self.disabled = disabled;
507        self
508    }
509}
510
511impl Sizable for JsonView {
512    fn control_size(mut self, size: ControlSize) -> Self {
513        self.size = size;
514        self
515    }
516}
517
518/// What a keystroke asks for.
519enum Move {
520    Select(SharedString),
521    Toggle(SharedString, bool),
522}
523
524/// The same movement a tree has, over the rows a frame disclosed. Right opens
525/// a shut value or descends into an open one; left shuts an open value or
526/// climbs to the key that holds it.
527fn keystroke_move(key: &str, lines: &[Line], selected: Option<&SharedString>) -> Option<Move> {
528    let at = lines
529        .iter()
530        .position(|line| Some(&line.path) == selected)
531        .filter(|_| selected.is_some());
532    match key {
533        "up" | "down" => {
534            let next = match (key, at) {
535                ("down", Some(at)) => at + 1,
536                ("down", None) => 0,
537                ("up", Some(at)) => at.checked_sub(1)?,
538                _ => lines.len().checked_sub(1)?,
539            };
540            lines.get(next).map(|line| Move::Select(line.path.clone()))
541        }
542        "home" => lines.first().map(|line| Move::Select(line.path.clone())),
543        "end" => lines.last().map(|line| Move::Select(line.path.clone())),
544        "right" => {
545            let line = lines.get(at?)?;
546            if line.has_members && !line.open {
547                Some(Move::Toggle(line.path.clone(), true))
548            } else {
549                line.first_member
550                    .clone()
551                    .filter(|_| line.open)
552                    .map(Move::Select)
553            }
554        }
555        "left" => {
556            let line = lines.get(at?)?;
557            if line.has_members && line.open {
558                Some(Move::Toggle(line.path.clone(), false))
559            } else {
560                line.parent.clone().map(Move::Select)
561            }
562        }
563        _ => None,
564    }
565}
566
567impl RenderOnce for JsonView {
568    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
569        let theme = cx.theme().clone();
570        let metrics = theme.control.get(self.size);
571        let row_height = self.row_height.unwrap_or(metrics.height);
572        let lines = Rc::new(self.lines(cx));
573        let count = lines.len();
574        let scroll = scroll_handle(&self.ident, cx);
575        let view = Rc::new(self);
576
577        let owner = Rc::clone(&view);
578        let source = Rc::clone(&lines);
579        let row_theme = theme.clone();
580        let rows = uniform_list(
581            view.ident.child("rows").element_id(),
582            count,
583            move |range: Range<usize>, window, cx| {
584                range
585                    .filter_map(|index| source.get(index).cloned())
586                    .map(|line| {
587                        row_element(
588                            &owner,
589                            &row_theme,
590                            row_height,
591                            metrics.icon_size,
592                            &line,
593                            window,
594                            cx,
595                        )
596                    })
597                    .collect::<Vec<_>>()
598            },
599        )
600        .track_scroll(&scroll)
601        .w_full()
602        .with_sizing_behavior(if view.visible_rows.is_some() {
603            ListSizingBehavior::Auto
604        } else {
605            ListSizingBehavior::Infer
606        })
607        .when_some(view.visible_rows, |element, rows| {
608            element.h(px(row_height * rows as f32))
609        });
610
611        let mut container = div()
612            .id(view.ident.element_id())
613            .column()
614            .w_full()
615            .font_family(theme.typography.mono.clone())
616            .child(rows);
617
618        if !view.disabled && (view.on_select.is_some() || view.on_toggle.is_some()) {
619            let selected = view.selected.clone();
620            let select = view.on_select.clone();
621            let toggle = view.on_toggle.clone();
622            let lines = Rc::clone(&lines);
623            let scroll = scroll.clone();
624            container = container.on_key_down(move |event, window, cx| {
625                let Some(next) =
626                    keystroke_move(event.keystroke.key.as_str(), &lines, selected.as_ref())
627                else {
628                    return;
629                };
630                match next {
631                    Move::Select(path) => {
632                        if Some(&path) == selected.as_ref() {
633                            return;
634                        }
635                        // The view scrolls to what it reported, so a row the
636                        // caller is told about is one the reader can see.
637                        if let Some(index) = lines.iter().position(|line| line.path == path) {
638                            scroll.scroll_to_item(index, ScrollStrategy::Nearest);
639                            window.refresh();
640                        }
641                        let Some(handler) = select.as_ref() else {
642                            return;
643                        };
644                        handler(path, window, cx);
645                    }
646                    Move::Toggle(path, open) => {
647                        let Some(handler) = toggle.as_ref() else {
648                            return;
649                        };
650                        handler(path, open, window, cx);
651                    }
652                }
653                cx.stop_propagation();
654            });
655        }
656
657        container.semantic_in(
658            cx,
659            NodeSpec::new(view.ident.semantic_id(), Role::Tree).value(count.to_string()),
660        )
661    }
662}
663
664fn row_element(
665    view: &JsonView,
666    theme: &Theme,
667    height: f32,
668    icon_size: f32,
669    line: &Line,
670    _window: &mut Window,
671    cx: &mut App,
672) -> AnyElement {
673    let ident = view.row_ident(&line.path);
674    let selected = view.selected.as_ref() == Some(&line.path);
675    let selectable = !view.disabled && view.on_select.is_some();
676    let toggleable = !view.disabled && line.has_members && view.on_toggle.is_some();
677    let value_color = match line.kind {
678        // A withheld or missing value recedes; anything the document actually
679        // states is drawn at full strength. Neither invents a status colour.
680        ValueKind::Null | ValueKind::Redacted => theme.colors.text_faint,
681        _ => theme.colors.text,
682    };
683
684    let chevron = line.has_members.then(|| {
685        let toggle = ident.child("toggle");
686        let mut glyph = div()
687            .id(toggle.element_id())
688            .row()
689            .flex_none()
690            .size(px(icon_size))
691            .child(
692                icon(Icon::AltArrowRight)
693                    .size(px(icon_size))
694                    .text_color(theme.colors.text_muted)
695                    .when(line.open, |glyph| {
696                        glyph.with_transformation(Transformation::rotate(radians(FRAC_PI_2)))
697                    }),
698            )
699            .when(toggleable, |element| {
700                element
701                    .cursor_pointer()
702                    .tab_index(0)
703                    .pressable(cx)
704                    .focus_ring(theme)
705            });
706
707        if let (true, Some(handler)) = (toggleable, view.on_toggle.clone()) {
708            let path = line.path.clone();
709            let open = line.open;
710            glyph = glyph.on_click(move |_, window, cx| {
711                handler(path.clone(), !open, window, cx);
712                // A disclosure is not a selection, so the row beneath must not
713                // also report one.
714                cx.stop_propagation();
715            });
716        }
717
718        glyph.semantic_in(
719            cx,
720            NodeSpec::new(toggle.semantic_id(), Role::Button)
721                .parent(ident.semantic_id())
722                .text(line.label.clone())
723                .expanded(line.open)
724                .disabled(!toggleable),
725        )
726    });
727
728    let mut row = div()
729        .id(ident.element_id())
730        .row()
731        .w_full()
732        .h(px(height))
733        .pr(px(theme.space(Space::Sm)))
734        .pl(px(theme.space(Space::Sm)
735            + line.level.saturating_sub(1) as f32
736                * theme.space(Space::Md)))
737        .gap(px(theme.space(Space::Xs)))
738        .when(selected, |element| element.bg(theme.colors.selected))
739        .when(view.disabled, |element| {
740            element.opacity(theme.opacity.disabled)
741        })
742        .when(selectable, |element| {
743            element
744                .cursor_pointer()
745                .tab_index(0)
746                .pressable(cx)
747                .when(!selected, |element| {
748                    element.hover(|style| style.bg(theme.colors.hover.opacity(0.3)))
749                })
750                .focus_ring(theme)
751        })
752        .children(chevron)
753        // A row with nothing to disclose still lines up with its siblings, so
754        // the indent reads as depth rather than as decoration.
755        .when(!line.has_members, |element| {
756            element.child(div().flex_none().size(px(icon_size)))
757        })
758        .child(
759            text(theme, TypeScale::Code, line.label.clone())
760                .flex_none()
761                .text_tone(theme, TextTone::Muted),
762        )
763        .child(
764            text(theme, TypeScale::Code, line.shown.clone())
765                .flex_1()
766                .overflow_hidden()
767                .text_color(value_color),
768        );
769
770    // The mark says the value was kept back; the shape says how much was kept
771    // back. Neither is the value, and neither reaches the semantic tree.
772    if let Some(shape) = line.shape.clone() {
773        row = row.child(
774            div()
775                .flex_none()
776                .row()
777                .gap(px(theme.space(Space::Xs)))
778                .child(
779                    text(
780                        theme,
781                        TypeScale::Caption,
782                        cx.strings().text(StringKey::JsonWithheld),
783                    )
784                    .text_tone(theme, TextTone::Muted),
785                )
786                .child(text(theme, TypeScale::Code, shape).text_tone(theme, TextTone::Faint)),
787        );
788    }
789
790    if let (true, Some(handler)) = (selectable, view.on_select.clone()) {
791        let path = line.path.clone();
792        row = row.on_click(move |_, window, cx| handler(path.clone(), window, cx));
793    }
794
795    let mut spec = NodeSpec::new(ident.semantic_id(), Role::TreeItem)
796        .parent(match &line.parent {
797            Some(parent) => view.row_ident(parent).semantic_id(),
798            None => view.ident.semantic_id(),
799        })
800        .text(line.label.clone())
801        .value(line.published.clone())
802        .selected(selected)
803        .disabled(view.disabled)
804        .level(line.level);
805    // Only a row with something under it claims a disclosure state. An empty
806    // object reporting `expanded: false` would read as one that is merely shut.
807    if line.has_members {
808        spec = spec.expanded(line.open);
809    }
810
811    row.semantic_in(cx, spec).into_any_element()
812}
813
814#[derive(Default)]
815struct ScrollHandles(RefCell<HashMap<SharedString, UniformListScrollHandle>>);
816
817impl Global for ScrollHandles {}
818
819/// Where a view is scrolled, kept across the frames a `RenderOnce` builder is
820/// rebuilt in, keyed by the identity the caller gave it.
821fn scroll_handle(ident: &Ident, cx: &mut App) -> UniformListScrollHandle {
822    if !cx.has_global::<ScrollHandles>() {
823        cx.set_global(ScrollHandles::default());
824    }
825    let mut handles = cx.global::<ScrollHandles>().0.borrow_mut();
826    handles.entry(ident.semantic_id()).or_default().clone()
827}
828
829#[cfg(test)]
830mod tests {
831    use super::*;
832
833    fn document() -> JsonValue {
834        JsonValue::object([
835            ("name", JsonValue::string("run")),
836            ("retries", JsonValue::number("3")),
837            ("cursor", JsonValue::Null),
838            ("labels", JsonValue::object(Vec::<(&str, JsonValue)>::new())),
839            (
840                "steps",
841                JsonValue::array([JsonValue::string("plan"), JsonValue::string("apply")]),
842            ),
843        ])
844    }
845
846    fn lines(expanded: &[&str]) -> Vec<Line> {
847        let expanded: Vec<SharedString> = expanded
848            .iter()
849            .map(|path| SharedString::from(path.to_string()))
850            .collect();
851        let mut out = Vec::new();
852        let JsonValue::Object(members) = document() else {
853            unreachable!()
854        };
855        for (key, member) in &members {
856            flatten(
857                member,
858                join("", key.as_ref()),
859                key.clone(),
860                1,
861                None,
862                &expanded,
863                &mut out,
864            );
865        }
866        out
867    }
868
869    #[test]
870    fn a_shut_value_discloses_nothing() {
871        let shut = lines(&[]);
872        let paths: Vec<&str> = shut.iter().map(|line| line.path.as_ref()).collect();
873        assert_eq!(
874            paths,
875            vec!["name", "retries", "cursor", "labels", "steps"],
876            "a shut array must not lay out its items"
877        );
878    }
879
880    #[test]
881    fn an_empty_object_offers_no_disclosure() {
882        let labels = lines(&[]);
883        let empty = labels
884            .iter()
885            .find(|line| line.path.as_ref() == "labels")
886            .expect("present");
887        assert!(!empty.has_members);
888        assert_eq!(empty.published.as_ref(), "empty object");
889    }
890
891    #[test]
892    fn a_key_containing_a_slash_stays_one_level() {
893        let value = JsonValue::object([("a/b", JsonValue::object([("c", JsonValue::Bool(true))]))]);
894        let JsonValue::Object(members) = &value else {
895            unreachable!()
896        };
897        let mut out = Vec::new();
898        flatten(
899            &members[0].1,
900            join("", members[0].0.as_ref()),
901            members[0].0.clone(),
902            1,
903            None,
904            &[SharedString::from("a~1b")],
905            &mut out,
906        );
907        assert_eq!(out[0].path.as_ref(), "a~1b");
908        assert_eq!(out[1].path.as_ref(), "a~1b/c");
909    }
910
911    #[test]
912    fn right_opens_a_shut_value_and_then_descends() {
913        let shut = lines(&[]);
914        let steps = SharedString::from("steps");
915        match keystroke_move("right", &shut, Some(&steps)) {
916            Some(Move::Toggle(path, next)) => {
917                assert_eq!(path.as_ref(), "steps");
918                assert!(next);
919            }
920            _ => panic!("right must open a shut value"),
921        }
922        let open = lines(&["steps"]);
923        match keystroke_move("right", &open, Some(&steps)) {
924            Some(Move::Select(path)) => assert_eq!(path.as_ref(), "steps/0"),
925            _ => panic!("right must descend into an open value"),
926        }
927    }
928
929    #[test]
930    fn a_move_stops_at_the_ends() {
931        let shut = lines(&[]);
932        let last = SharedString::from("steps");
933        assert!(keystroke_move("down", &shut, Some(&last)).is_none());
934        let first = SharedString::from("name");
935        assert!(keystroke_move("up", &shut, Some(&first)).is_none());
936    }
937}