diff --git a/Cargo.toml b/Cargo.toml
index b2578d6..9b3c4c8 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -31,7 +31,7 @@ qrcode = { version = "0.14", default-features = false }
regex = "1"
rust-embed = "8.12"
serde = { version = "1", features = ["derive"] }
-serde_json = "1"
+serde_json = { version = "1", features = ["preserve_order"] }
similar = { version = "3.2", default-features = false, features = ["std", "text", "inline", "unicode"] }
smallvec = "1"
unicode-segmentation = "1"
diff --git a/examples/gallery/pages/devtools/formats.rs b/examples/gallery/pages/devtools/formats.rs
new file mode 100644
index 0000000..8ec07c5
--- /dev/null
+++ b/examples/gallery/pages/devtools/formats.rs
@@ -0,0 +1,83 @@
+use ely_gpui_component::{
+ devtools::{JsonEditor, JsonViewer, XmlViewer, YamlViewer},
+ editor::CodeEditor,
+ forms::TextInput,
+};
+use gpui::{App, IntoElement, ParentElement, Styled, Window, div, px};
+
+use crate::ui::section;
+
+const ORDER: &str = r#"{
+ "_id": "66f1c2a9e4b0",
+ "customer": { "name": "Ada Lovelace", "email": "ada@example.com" },
+ "items": [
+ { "sku": "LAMP-01", "quantity": 1, "price": 89.0 },
+ { "sku": "BULB-E27", "quantity": 4, "price": 6.5 }
+ ],
+ "paid": true,
+ "shipped_at": null,
+ "tags": ["gift", "express"]
+}"#;
+
+const DEPLOYMENT: &str = "apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: web
+ labels:
+ app: web
+spec:
+ replicas: 3
+ template:
+ spec:
+ containers:
+ - name: web
+ image: registry.example.com/web:1.8.2
+ ports:
+ - containerPort: 8080
+";
+
+const FEED: &str = r#"
+
+
+ Release notes
+ -
+ Faster queries
+ Fri, 25 Sep 2026 09:00:00 GMT
+
+ -
+ Dark mode
+
+
+"#;
+
+pub fn documents(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
+ let search = window.use_keyed_state("devtools-json-search", cx, |window, cx| {
+ TextInput::new(window, cx).placeholder("Find a key or a value")
+ });
+ let editor = window.use_keyed_state("devtools-json-editor", cx, |window, cx| {
+ CodeEditor::new(
+ r#"{"name": "web", "replicas": 3, "ports": [8080, 8443]}"#,
+ window,
+ cx,
+ )
+ .language("JSON")
+ });
+ let yaml_search = window.use_keyed_state("devtools-yaml-search", cx, |window, cx| {
+ TextInput::new(window, cx).placeholder("Find a key or a value")
+ });
+ section(
+ "DocumentViewer (MongoDB / JSON) → devtools::JsonViewer · JsonViewer / JsonTree · JsonEditor · YamlViewer · XmlViewer",
+ "A document as a tree: each key with its value, lists and maps with their counts; a search keeps what holds it and opens the way, and Enter on a value copies its path. The editor writes JSON with Format and Minify and says whether it reads. YAML reads as the same tree; XML as elements with their attributes and text.",
+ cx,
+ )
+ .child(
+ div()
+ .flex()
+ .flex_wrap()
+ .gap_6()
+ .child(div().w(px(380.)).child(JsonViewer::new("devtools-json", ORDER, &search)))
+ .child(div().w(px(420.)).child(JsonEditor::new("devtools-json-editor", &editor)))
+ .child(div().w(px(380.)).child(YamlViewer::new("devtools-yaml", DEPLOYMENT, &yaml_search)))
+ .child(div().w(px(420.)).child(XmlViewer::new("devtools-xml", FEED))),
+ )
+}
diff --git a/examples/gallery/pages/devtools/mod.rs b/examples/gallery/pages/devtools/mod.rs
index 002baff..ac2d7b8 100644
--- a/examples/gallery/pages/devtools/mod.rs
+++ b/examples/gallery/pages/devtools/mod.rs
@@ -2,6 +2,7 @@ use gpui::{AnyElement, App, Window, div, prelude::*};
mod data;
mod db;
+mod formats;
use super::Page;
use crate::script::Step;
@@ -24,5 +25,6 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
.child(data::queries(window, cx))
.child(db::structure(window, cx))
.child(data::redis(window, cx))
+ .child(formats::documents(window, cx))
.into_any_element()
}
diff --git a/src/devtools/datum.rs b/src/devtools/datum.rs
new file mode 100644
index 0000000..f2610ef
--- /dev/null
+++ b/src/devtools/datum.rs
@@ -0,0 +1,257 @@
+use gpui::SharedString;
+
+/// A value in a document: nothing, a flag, a number, a text, or a list or a map of values in their order.
+#[derive(Clone, Debug, PartialEq)]
+pub enum Datum {
+ Null,
+ Bool(bool),
+ Number(SharedString),
+ Text(SharedString),
+ List(Vec),
+ Map(Vec<(SharedString, Datum)>),
+}
+
+impl From<&serde_json::Value> for Datum {
+ fn from(value: &serde_json::Value) -> Self {
+ match value {
+ serde_json::Value::Null => Datum::Null,
+ serde_json::Value::Bool(on) => Datum::Bool(*on),
+ serde_json::Value::Number(number) => Datum::Number(number.to_string().into()),
+ serde_json::Value::String(text) => Datum::Text(text.clone().into()),
+ serde_json::Value::Array(items) => Datum::List(items.iter().map(Datum::from).collect()),
+ serde_json::Value::Object(map) => Datum::Map(
+ map.iter()
+ .map(|(key, value)| (key.clone().into(), Datum::from(value)))
+ .collect(),
+ ),
+ }
+ }
+}
+
+/// A line that failed to read, and why.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Unread {
+ pub line: usize,
+ pub why: String,
+}
+
+impl std::fmt::Display for Unread {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "Line {}: {}", self.line, self.why)
+ }
+}
+
+/// JSON read into a value, keys in their written order.
+pub fn read_json(text: &str) -> Result {
+ serde_json::from_str::(text)
+ .map(|value| Datum::from(&value))
+ .map_err(|error| Unread {
+ line: error.line(),
+ why: error.to_string(),
+ })
+}
+
+fn scalar(text: &str) -> Datum {
+ let text = text.trim();
+ let unquoted = |quote: char| {
+ text.strip_prefix(quote)
+ .and_then(|rest| rest.strip_suffix(quote))
+ };
+ if let Some(inner) = unquoted('"').or_else(|| unquoted('\'')) {
+ return Datum::Text(inner.to_string().into());
+ }
+ match text {
+ "" | "~" | "null" => Datum::Null,
+ "true" => Datum::Bool(true),
+ "false" => Datum::Bool(false),
+ _ if text.parse::().is_ok() => Datum::Number(text.to_string().into()),
+ _ => Datum::Text(text.to_string().into()),
+ }
+}
+
+/// Lines of a YAML block: each one's number, indent and words, without comments and blank lines.
+fn lines(text: &str) -> Vec<(usize, usize, &str)> {
+ text.lines()
+ .enumerate()
+ .filter_map(|(ix, line)| {
+ let words = match line.find(" #") {
+ Some(at) => &line[..at],
+ None if line.trim_start().starts_with('#') => "",
+ None => line,
+ };
+ let indent = words.len() - words.trim_start().len();
+ (!words.trim().is_empty() && words.trim() != "---")
+ .then(|| (ix + 1, indent, words.trim_end()))
+ })
+ .collect()
+}
+
+/// The block that starts at `at` and holds the lines at `indent`.
+fn block(lines: &[(usize, usize, &str)], at: &mut usize, indent: usize) -> Result {
+ let list = lines[*at].2.trim_start().starts_with("- ") || lines[*at].2.trim() == "-";
+ if list {
+ let mut items = Vec::new();
+ while *at < lines.len() && lines[*at].1 == indent {
+ let (number, _, words) = lines[*at];
+ let Some(rest) = words.trim_start().strip_prefix('-') else {
+ return Err(Unread {
+ line: number,
+ why: "a list holds only items starting with -".into(),
+ });
+ };
+ let rest = rest.trim_start();
+ *at += 1;
+ items.push(if rest.is_empty() {
+ nested(lines, at, indent)?
+ } else if rest.contains(": ") || rest.ends_with(':') {
+ let inner = indent + words.trim_start().len() - rest.len();
+ entries(lines, at, inner, Some((number, rest)))?
+ } else {
+ scalar(rest)
+ });
+ }
+ Ok(Datum::List(items))
+ } else {
+ entries(lines, at, indent, None)
+ }
+}
+
+/// What lies under a key or a dash with nothing after it: a deeper block, or nothing.
+fn nested(lines: &[(usize, usize, &str)], at: &mut usize, indent: usize) -> Result {
+ match lines.get(*at) {
+ Some((_, deeper, _)) if *deeper > indent => block(lines, at, *deeper),
+ _ => Ok(Datum::Null),
+ }
+}
+
+/// A map's entries at `indent`, the first one given when it sat after a dash.
+fn entries(
+ lines: &[(usize, usize, &str)],
+ at: &mut usize,
+ indent: usize,
+ first: Option<(usize, &str)>,
+) -> Result {
+ let mut map = Vec::new();
+ let mut entry = |number: usize, words: &str, at: &mut usize| -> Result<(), Unread> {
+ let (key, value) = match words.split_once(": ") {
+ Some((key, value)) => (key, value),
+ None => match words.strip_suffix(':') {
+ Some(key) => (key, ""),
+ None => {
+ return Err(Unread {
+ line: number,
+ why: format!("{words:?} is no key and value"),
+ });
+ }
+ },
+ };
+ let key = SharedString::from(key.trim().trim_matches('"').to_string());
+ let value = if value.trim().is_empty() {
+ nested(lines, at, indent)?
+ } else {
+ scalar(value)
+ };
+ map.push((key, value));
+ Ok(())
+ };
+ if let Some((number, words)) = first {
+ entry(number, words, at)?;
+ }
+ while *at < lines.len() && lines[*at].1 == indent {
+ let (number, _, words) = lines[*at];
+ *at += 1;
+ entry(number, words.trim_start(), at)?;
+ }
+ if let Some((number, deeper, _)) = lines.get(*at)
+ && *deeper > indent
+ {
+ return Err(Unread {
+ line: *number,
+ why: "this line is indented further than its block".into(),
+ });
+ }
+ Ok(Datum::Map(map))
+}
+
+/// YAML's block style read into a value: maps, lists and scalars, with comments dropped. Flow style reads as text.
+pub fn read_yaml(text: &str) -> Result {
+ let lines = lines(text);
+ if lines.is_empty() {
+ return Ok(Datum::Null);
+ }
+ let mut at = 0;
+ let datum = block(&lines, &mut at, lines[0].1)?;
+ match lines.get(at) {
+ Some((number, _, _)) => Err(Unread {
+ line: *number,
+ why: "this line sits outside the document's first block".into(),
+ }),
+ None => Ok(datum),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{Datum, read_json, read_yaml};
+
+ fn text(words: &str) -> Datum {
+ Datum::Text(words.to_string().into())
+ }
+
+ #[test]
+ fn json_keeps_its_keys_in_order_and_says_where_it_breaks() {
+ let datum = read_json(r#"{"b": 1, "a": [true, null, "x"]}"#).expect("json");
+ let Datum::Map(entries) = datum else {
+ panic!("a map")
+ };
+ assert_eq!(entries[0].0, "b");
+ assert_eq!(
+ entries[1].1,
+ Datum::List(vec![Datum::Bool(true), Datum::Null, text("x")])
+ );
+ assert_eq!(
+ read_json("{\n \"a\": 1,\n}")
+ .expect_err("a trailing comma")
+ .line,
+ 3
+ );
+ }
+
+ #[test]
+ fn yaml_reads_nested_maps_lists_and_maps_in_lists() {
+ let datum = read_yaml(
+ "apiVersion: apps/v1 # the api\nkind: Deployment\nspec:\n replicas: 3\n ports:\n - 80\n - name: https\n port: 443\n",
+ )
+ .expect("yaml");
+ let Datum::Map(entries) = datum else {
+ panic!("a map")
+ };
+ assert_eq!(entries[0], ("apiVersion".into(), text("apps/v1")));
+ let Datum::Map(spec) = &entries[2].1 else {
+ panic!("spec is a map")
+ };
+ assert_eq!(spec[0], ("replicas".into(), Datum::Number("3".into())));
+ let Datum::List(ports) = &spec[1].1 else {
+ panic!("ports is a list")
+ };
+ assert_eq!(ports[0], Datum::Number("80".into()));
+ assert_eq!(
+ ports[1],
+ Datum::Map(vec![
+ ("name".into(), text("https")),
+ ("port".into(), Datum::Number("443".into()))
+ ])
+ );
+ }
+
+ #[test]
+ fn yaml_says_which_line_it_could_not_read() {
+ assert_eq!(read_yaml("a: 1\njust words\n").expect_err("no key").line, 2);
+ assert_eq!(
+ read_yaml("a: 1\n b: 2\n")
+ .expect_err("stray indent")
+ .line,
+ 2
+ );
+ }
+}
diff --git a/src/devtools/document.rs b/src/devtools/document.rs
new file mode 100644
index 0000000..87b6c22
--- /dev/null
+++ b/src/devtools/document.rs
@@ -0,0 +1,223 @@
+use gpui::{
+ App, ClipboardItem, ElementId, Entity, IntoElement, ParentElement, RenderOnce, SharedString,
+ Styled, Window, div,
+};
+
+use super::datum::{Datum, Unread, read_json, read_yaml};
+use crate::{
+ feedback::InlineMessage,
+ forms::{Input, TextInput},
+ lists::{Tree, TreeNode},
+ primitives::{Icon, IconName, Severity},
+ theme::{ActiveTheme, IconSize, TextSize},
+};
+
+/// A scalar as a row shows it.
+fn preview(datum: &Datum) -> SharedString {
+ match datum {
+ Datum::Null => "null".into(),
+ Datum::Bool(on) => on.to_string().into(),
+ Datum::Number(number) => number.clone(),
+ Datum::Text(text) => format!("\"{text}\"").into(),
+ Datum::List(items) => format!("[{}]", items.len()).into(),
+ Datum::Map(entries) => format!("{{{}}}", entries.len()).into(),
+ }
+}
+
+/// The path to a key under `path`, in JSONPath.
+fn keyed(path: &str, key: &str) -> String {
+ let plain = !key.is_empty() && key.chars().all(|c| c.is_alphanumeric() || c == '_');
+ if plain {
+ format!("{path}.{key}")
+ } else {
+ format!("{path}[\"{key}\"]")
+ }
+}
+
+fn children<'a>(datum: &'a Datum, path: &str) -> Vec<(SharedString, String, &'a Datum)> {
+ match datum {
+ Datum::Map(entries) => entries
+ .iter()
+ .map(|(key, value)| (key.clone(), keyed(path, key), value))
+ .collect(),
+ Datum::List(items) => items
+ .iter()
+ .enumerate()
+ .map(|(ix, item)| (format!("[{ix}]").into(), format!("{path}[{ix}]"), item))
+ .collect(),
+ _ => Vec::new(),
+ }
+}
+
+/// Whether a key or a value at or under `datum` holds `query`.
+fn holds(label: &str, datum: &Datum, query: &str) -> bool {
+ label.to_lowercase().contains(query)
+ || match datum {
+ Datum::Map(_) | Datum::List(_) => children(datum, "")
+ .iter()
+ .any(|(label, _, child)| holds(label, child, query)),
+ scalar => preview(scalar).to_lowercase().contains(query),
+ }
+}
+
+/// Rows for what lies under `datum`, each keyed by its path. With a query, only rows that hold it, or lead to one that does, stay; each one kept is listed in `open`.
+pub(crate) fn nodes(
+ datum: &Datum,
+ path: &str,
+ query: &str,
+ open: &mut Vec,
+) -> Vec {
+ children(datum, path)
+ .into_iter()
+ .filter(|(label, _, child)| query.is_empty() || holds(label, child, query))
+ .map(|(label, path, child)| {
+ let node = TreeNode::new(path.clone(), label.clone()).note(preview(child));
+ match child {
+ Datum::Map(_) | Datum::List(_) => {
+ let whole = label.to_lowercase().contains(query);
+ if !query.is_empty() {
+ open.push(path.clone().into());
+ }
+ node.children(nodes(child, &path, if whole { "" } else { query }, open))
+ }
+ _ => node,
+ }
+ })
+ .collect()
+}
+
+/// The copied path, shown under the tree.
+#[derive(Default)]
+struct Copied(Option);
+
+/// A document read into a tree, or where it failed to read.
+fn document(
+ id: ElementId,
+ read: Result,
+ search: &Entity,
+ window: &mut Window,
+ cx: &mut App,
+) -> gpui::AnyElement {
+ let copied = window.use_keyed_state((id.clone(), "copied"), cx, |_, _| Copied::default());
+ let query = search.read(cx).text().trim().to_lowercase();
+ let theme = cx.theme();
+ let body = match read {
+ Err(unread) => InlineMessage::new(Severity::Danger, unread.to_string()).into_any_element(),
+ Ok(datum) => {
+ let mut open = vec![SharedString::from("$")];
+ let rows = nodes(&datum, "$", &query, &mut open);
+ let root = TreeNode::new("$", "$").note(preview(&datum)).children(rows);
+ let copy = copied.clone();
+ div()
+ .h(theme.list_max_height())
+ .child(
+ Tree::new((id, "tree"), [root])
+ .open(open)
+ .on_activate(move |path, _, cx| {
+ log::info!("document: copied {path}");
+ cx.write_to_clipboard(ClipboardItem::new_string(path.to_string()));
+ copy.update(cx, |copied, cx| {
+ copied.0 = Some(path.clone());
+ cx.notify();
+ });
+ })
+ .size_full(),
+ )
+ .into_any_element()
+ }
+ };
+ let note = copied.read(cx).0.clone().map(|path| {
+ div()
+ .text_size(theme.text_size(TextSize::Xs))
+ .text_color(theme.colors.fg_subtle)
+ .child(format!("Copied {path}"))
+ });
+ div()
+ .flex()
+ .flex_col()
+ .gap_2()
+ .child(Input::new(search).prefix(Icon::new(IconName::Search).size(IconSize::Sm)))
+ .child(body)
+ .children(note)
+ .into_any_element()
+}
+
+/// JSON as a tree: each key or index with its value, maps and lists with their counts. A search keeps the rows that hold its words and opens the way to them; Enter or a double press on a value copies its path. JSON that does not read shows the line where it breaks.
+#[derive(IntoElement)]
+pub struct JsonViewer {
+ id: ElementId,
+ text: SharedString,
+ search: Entity,
+}
+
+impl JsonViewer {
+ /// `search` is the find field's text, which the owner keeps.
+ pub fn new(
+ id: impl Into,
+ text: impl Into,
+ search: &Entity,
+ ) -> Self {
+ Self {
+ id: id.into(),
+ text: text.into(),
+ search: search.clone(),
+ }
+ }
+}
+
+impl RenderOnce for JsonViewer {
+ fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+ document(self.id, read_json(&self.text), &self.search, window, cx)
+ }
+}
+
+/// YAML as a tree, as `JsonViewer` shows JSON: block maps and lists, with comments dropped.
+#[derive(IntoElement)]
+pub struct YamlViewer {
+ id: ElementId,
+ text: SharedString,
+ search: Entity,
+}
+
+impl YamlViewer {
+ /// `search` is the find field's text, which the owner keeps.
+ pub fn new(
+ id: impl Into,
+ text: impl Into,
+ search: &Entity,
+ ) -> Self {
+ Self {
+ id: id.into(),
+ text: text.into(),
+ search: search.clone(),
+ }
+ }
+}
+
+impl RenderOnce for YamlViewer {
+ fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+ document(self.id, read_yaml(&self.text), &self.search, window, cx)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::nodes;
+ use crate::devtools::datum::read_json;
+
+ #[test]
+ fn a_search_keeps_what_holds_it_and_opens_the_way() {
+ let datum = read_json(
+ r#"{"spec": {"replicas": 3, "image": "web:1.2"}, "kind": "Deployment", "odd key": 1}"#,
+ )
+ .expect("json");
+ let mut open = Vec::new();
+ let rows = nodes(&datum, "$", "web", &mut open);
+ assert_eq!(rows.len(), 1, "only spec holds web");
+ assert_eq!(open, ["$.spec"]);
+ let mut all = Vec::new();
+ let every = nodes(&datum, "$", "", &mut all);
+ assert_eq!(every.len(), 3);
+ assert!(all.is_empty(), "without a query nothing is opened");
+ }
+}
diff --git a/src/devtools/json_editor.rs b/src/devtools/json_editor.rs
new file mode 100644
index 0000000..8fb2692
--- /dev/null
+++ b/src/devtools/json_editor.rs
@@ -0,0 +1,133 @@
+use gpui::{App, ElementId, Entity, IntoElement, ParentElement, RenderOnce, Styled, Window, div};
+
+use super::datum::{Datum, read_json};
+use crate::{
+ buttons::{Button, ButtonVariant},
+ editor::CodeEditor,
+ feedback::InlineMessage,
+ primitives::Severity,
+ theme::{ActiveTheme, Radius},
+};
+
+/// How much a value holds: its keys and items, all the way down.
+pub(crate) fn tally(datum: &Datum) -> (usize, usize) {
+ match datum {
+ Datum::Map(entries) => {
+ entries
+ .iter()
+ .fold((entries.len(), 0), |(keys, items), (_, value)| {
+ let (more, most) = tally(value);
+ (keys + more, items + most)
+ })
+ }
+ Datum::List(values) => values
+ .iter()
+ .fold((0, values.len()), |(keys, items), value| {
+ let (more, most) = tally(value);
+ (keys + more, items + most)
+ }),
+ _ => (0, 0),
+ }
+}
+
+/// JSON to write in the code editor, with Format to indent it, Minify to fold it onto one line, and a line that says whether it reads: how many keys and items it holds, or the line where it breaks. Both buttons wait for JSON that reads.
+#[derive(IntoElement)]
+pub struct JsonEditor {
+ id: ElementId,
+ editor: Entity,
+}
+
+impl JsonEditor {
+ /// `editor` holds the text, which the owner keeps.
+ pub fn new(id: impl Into, editor: &Entity) -> Self {
+ Self {
+ id: id.into(),
+ editor: editor.clone(),
+ }
+ }
+}
+
+/// `text` indented, or on one line.
+pub(crate) fn reformat(text: &str, pretty: bool) -> Option {
+ let value: serde_json::Value = serde_json::from_str(text).ok()?;
+ let out = if pretty {
+ serde_json::to_string_pretty(&value)
+ } else {
+ serde_json::to_string(&value)
+ };
+ Some(out.expect("a value read from JSON writes back"))
+}
+
+impl RenderOnce for JsonEditor {
+ fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
+ let text = self.editor.read(cx).text().to_string();
+ let read = read_json(&text);
+ let theme = cx.theme();
+ let status = match &read {
+ Ok(datum) => {
+ let (keys, items) = tally(datum);
+ InlineMessage::new(
+ Severity::Success,
+ format!("Reads as JSON · {keys} keys · {items} items"),
+ )
+ }
+ Err(unread) => InlineMessage::new(Severity::Danger, unread.to_string()),
+ };
+ let button = |label: &'static str, pretty: bool| {
+ let editor = self.editor.clone();
+ Button::new((self.id.clone(), label), label)
+ .variant(ButtonVariant::Secondary)
+ .disabled(read.is_err())
+ .on_click(move |_, _, cx| {
+ let text = editor.read(cx).text().to_string();
+ let Some(out) = reformat(&text, pretty) else {
+ return;
+ };
+ log::info!("json editor: {label}");
+ editor.update(cx, |editor, cx| editor.set_text(out, cx));
+ })
+ };
+ div()
+ .flex()
+ .flex_col()
+ .gap_2()
+ .child(
+ div()
+ .flex()
+ .gap_2()
+ .child(button("Format", true))
+ .child(button("Minify", false)),
+ )
+ .child(
+ div()
+ .h(theme.list_max_height())
+ .rounded(theme.radius(Radius::Lg))
+ .border_1()
+ .border_color(theme.colors.border)
+ .overflow_hidden()
+ .child(self.editor),
+ )
+ .child(status)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{reformat, tally};
+ use crate::devtools::datum::read_json;
+
+ #[test]
+ fn a_document_counts_its_keys_and_items_and_reformats_both_ways() {
+ let text = r#"{"a": [1, {"b": 2}], "c": null}"#;
+ assert_eq!(tally(&read_json(text).expect("json")), (3, 2));
+ assert_eq!(
+ reformat(text, false).as_deref(),
+ Some(r#"{"a":[1,{"b":2}],"c":null}"#)
+ );
+ assert_eq!(
+ reformat(r#"{"a":1}"#, true).as_deref(),
+ Some("{\n \"a\": 1\n}")
+ );
+ assert_eq!(reformat("{", true), None);
+ }
+}
diff --git a/src/devtools/mod.rs b/src/devtools/mod.rs
index 05ad3c9..187bed8 100644
--- a/src/devtools/mod.rs
+++ b/src/devtools/mod.rs
@@ -1,16 +1,24 @@
mod connection_form;
mod connections;
+mod datum;
+mod document;
+mod json_editor;
mod queries;
mod redis;
mod schema;
mod structure;
+mod xml;
#[cfg(all(test, feature = "test-support"))]
mod tests;
pub use connection_form::{ConnectionForm, TestState};
pub use connections::{Connection, ConnectionManager, ConnectionState, Engine};
+pub use datum::{Datum, Unread, read_json, read_yaml};
+pub use document::{JsonViewer, YamlViewer};
+pub use json_editor::JsonEditor;
pub use queries::{Outcome, PlanStep, QueryHistory, QueryPlanViewer, QueryRun};
pub use redis::{RedisKey, RedisKeyBrowser, RedisValue};
pub use schema::{DbTable, ERDiagram, Field, FieldKey, Schema, SchemaTree};
pub use structure::{Index, IndexManager, TableStructureEditor};
+pub use xml::{XmlElement, XmlNode, XmlViewer, read_xml};
diff --git a/src/devtools/tests/formats.rs b/src/devtools/tests/formats.rs
new file mode 100644
index 0000000..ae96649
--- /dev/null
+++ b/src/devtools/tests/formats.rs
@@ -0,0 +1,63 @@
+use gpui::{AnyElement, App, AppContext as _, Entity, IntoElement, TestAppContext, Window, div};
+
+use super::{Bench, bench, settle, tab, tap};
+use crate::{
+ devtools::{JsonEditor, JsonViewer},
+ editor::CodeEditor,
+ forms::TextInput,
+};
+
+fn viewer(_: &Bench, window: &mut Window, cx: &mut App, _: Entity) -> AnyElement {
+ let search = window.use_keyed_state("search", cx, TextInput::new);
+ JsonViewer::new(
+ "viewer",
+ r#"{"spec": {"replicas": 3}, "kind": "Deployment"}"#,
+ &search,
+ )
+ .into_any_element()
+}
+
+/// Stops: the find field, then the tree, whose cursor starts on the root.
+#[gpui::test]
+fn enter_on_a_value_copies_its_path(cx: &mut TestAppContext) {
+ let (_, cx) = bench(viewer, cx);
+ tab(2, cx);
+ for key in ["down", "right", "down", "enter"] {
+ tap(key, cx);
+ }
+ let copied = cx
+ .update(|_, cx| cx.read_from_clipboard())
+ .and_then(|item| item.text());
+ assert_eq!(copied.as_deref(), Some("$.spec.replicas"));
+}
+
+fn editor(bench: &Bench, _: &mut Window, _: &mut App, _: Entity) -> AnyElement {
+ match &bench.editor {
+ Some(editor) => JsonEditor::new("json", editor).into_any_element(),
+ None => div().into_any_element(),
+ }
+}
+
+/// Stops: Format, then Minify.
+#[gpui::test]
+fn format_indents_the_json_and_minify_folds_it(cx: &mut TestAppContext) {
+ let (host, cx) = bench(editor, cx);
+ let editor = cx.update(|window, cx| cx.new(|cx| CodeEditor::new(r#"{"a":[1,2]}"#, window, cx)));
+ host.update(cx, |bench, cx| {
+ bench.editor = Some(editor.clone());
+ cx.notify();
+ });
+ settle(cx);
+ tab(1, cx);
+ tap("space", cx);
+ assert_eq!(
+ editor.read_with(cx, |editor, _| editor.text().to_string()),
+ "{\n \"a\": [\n 1,\n 2\n ]\n}"
+ );
+ tab(2, cx);
+ tap("space", cx);
+ assert_eq!(
+ editor.read_with(cx, |editor, _| editor.text().to_string()),
+ r#"{"a":[1,2]}"#
+ );
+}
diff --git a/src/devtools/tests/mod.rs b/src/devtools/tests/mod.rs
index 75ad9a0..2334f3e 100644
--- a/src/devtools/tests/mod.rs
+++ b/src/devtools/tests/mod.rs
@@ -4,14 +4,17 @@ use gpui::{
};
use super::{Connection, ConnectionForm, ConnectionManager, Engine, Field, TableStructureEditor};
-use crate::{forms, primitives::FocusNext, theme::Theme};
+use crate::{editor::CodeEditor, forms, primitives::FocusNext, theme::Theme};
+
+mod formats;
type Part = fn(&Bench, &mut Window, &mut App, Entity) -> AnyElement;
-/// A view that shows one tool, keeps the words it heard, and holds a choice among its part's cases.
+/// A view that shows one tool, keeps the words it heard, and holds what a test hands its part: an editor, and a choice among the part's cases.
struct Bench {
part: Part,
said: Vec,
+ editor: Option>,
choice: usize,
}
@@ -41,6 +44,7 @@ fn bench(part: Part, cx: &mut TestAppContext) -> (Entity, &mut VisualTest
let (host, cx) = cx.add_window_view(|_, _| Bench {
part,
said: Vec::new(),
+ editor: None,
choice: 0,
});
settle(cx);
diff --git a/src/devtools/xml.rs b/src/devtools/xml.rs
new file mode 100644
index 0000000..7027f24
--- /dev/null
+++ b/src/devtools/xml.rs
@@ -0,0 +1,318 @@
+use gpui::{
+ App, ClipboardItem, ElementId, Entity, IntoElement, ParentElement, RenderOnce, SharedString,
+ Styled, Window, div,
+};
+
+use super::datum::Unread;
+use crate::{
+ feedback::InlineMessage,
+ lists::{Tree, TreeNode},
+ primitives::Severity,
+ theme::{ActiveTheme, TextSize},
+};
+
+/// A piece of an XML document: an element, or the text between tags.
+#[derive(Clone, Debug, PartialEq)]
+pub enum XmlNode {
+ Element(XmlElement),
+ Text(SharedString),
+}
+
+/// An element: its name, its attributes in order, and what it holds.
+#[derive(Clone, Debug, PartialEq)]
+pub struct XmlElement {
+ pub name: SharedString,
+ pub attributes: Vec<(SharedString, SharedString)>,
+ pub children: Vec,
+}
+
+struct Reader<'a> {
+ text: &'a str,
+ at: usize,
+}
+
+impl<'a> Reader<'a> {
+ fn line(&self) -> usize {
+ self.text[..self.at].matches('\n').count() + 1
+ }
+
+ fn fail(&self, why: impl Into) -> Result {
+ Err(Unread {
+ line: self.line(),
+ why: why.into(),
+ })
+ }
+
+ fn rest(&self) -> &'a str {
+ &self.text[self.at..]
+ }
+
+ /// Steps past what `open` starts, through `close`.
+ fn skip(&mut self, open: &str, close: &str) -> Result<(), Unread> {
+ match self.rest().find(close) {
+ Some(end) => {
+ self.at += end + close.len();
+ Ok(())
+ }
+ None => self.fail(format!("{open} is never closed by {close}")),
+ }
+ }
+
+ fn name(&mut self) -> SharedString {
+ let rest = self.rest();
+ let end = rest
+ .find(|c: char| c.is_whitespace() || matches!(c, '>' | '/' | '='))
+ .unwrap_or(rest.len());
+ self.at += end;
+ rest[..end].to_string().into()
+ }
+
+ fn spaces(&mut self) {
+ let rest = self.rest();
+ self.at += rest.len() - rest.trim_start().len();
+ }
+
+ fn element(&mut self) -> Result {
+ self.at += 1;
+ let name = self.name();
+ if name.is_empty() {
+ return self.fail("a tag has no name");
+ }
+ let mut attributes = Vec::new();
+ loop {
+ self.spaces();
+ if self.rest().starts_with("/>") {
+ self.at += 2;
+ return Ok(XmlElement {
+ name,
+ attributes,
+ children: Vec::new(),
+ });
+ }
+ if self.rest().starts_with('>') {
+ self.at += 1;
+ break;
+ }
+ let key = self.name();
+ self.spaces();
+ if key.is_empty() || !self.rest().starts_with('=') {
+ return self.fail(format!("<{name}> has an attribute without a value"));
+ }
+ self.at += 1;
+ self.spaces();
+ let Some(quote) = self
+ .rest()
+ .chars()
+ .next()
+ .filter(|c| matches!(c, '"' | '\''))
+ else {
+ return self.fail(format!("{key} in <{name}> is not quoted"));
+ };
+ self.at += 1;
+ let Some(end) = self.rest().find(quote) else {
+ return self.fail(format!("{key} in <{name}> is never closed"));
+ };
+ attributes.push((key, self.rest()[..end].to_string().into()));
+ self.at += end + 1;
+ }
+ let children = self.content()?;
+ if !self.rest().starts_with("") {
+ return self.fail(format!("<{name}> is never closed"));
+ }
+ self.at += 2;
+ let closing = self.name();
+ self.spaces();
+ if closing != name || !self.rest().starts_with('>') {
+ return self.fail(format!("{closing}> closes <{name}>"));
+ }
+ self.at += 1;
+ Ok(XmlElement {
+ name,
+ attributes,
+ children,
+ })
+ }
+
+ /// What lies between an element's tags, up to its closing tag or the end.
+ fn content(&mut self) -> Result, Unread> {
+ let mut children = Vec::new();
+ while !self.rest().is_empty() && !self.rest().starts_with("") {
+ let rest = self.rest();
+ if rest.starts_with("")?;
+ } else if let Some(data) = rest.strip_prefix("") else {
+ return self.fail("a CDATA section is never closed");
+ };
+ children.push(XmlNode::Text(data[..end].to_string().into()));
+ self.at += "")?;
+ } else if rest.starts_with('<') {
+ children.push(XmlNode::Element(self.element()?));
+ } else {
+ let end = rest.find('<').unwrap_or(rest.len());
+ let words = rest[..end].trim();
+ if !words.is_empty() {
+ children.push(XmlNode::Text(words.to_string().into()));
+ }
+ self.at += end;
+ }
+ }
+ Ok(children)
+ }
+}
+
+/// An XML document read into its root element. A tag that closes the wrong element, or is never closed, fails with its line.
+pub fn read_xml(text: &str) -> Result {
+ let mut reader = Reader { text, at: 0 };
+ let mut roots = reader.content()?.into_iter().filter_map(|node| match node {
+ XmlNode::Element(element) => Some(element),
+ XmlNode::Text(_) => None,
+ });
+ if reader.at < text.len() {
+ return reader.fail("a closing tag with nothing open");
+ }
+ match (roots.next(), roots.next()) {
+ (Some(root), None) => Ok(root),
+ (None, _) => Err(Unread {
+ line: 1,
+ why: "the document holds no element".into(),
+ }),
+ (Some(_), Some(second)) => Err(Unread {
+ line: 1,
+ why: format!("a second root, <{}>", second.name),
+ }),
+ }
+}
+
+/// Rows for what `element` holds, each element's path listed in `open`.
+fn nodes(element: &XmlElement, path: &str, open: &mut Vec) -> Vec {
+ let mut counts: Vec<(SharedString, usize)> = Vec::new();
+ element
+ .children
+ .iter()
+ .enumerate()
+ .map(|(ix, child)| match child {
+ XmlNode::Text(text) => TreeNode::new(format!("{path}/text()[{ix}]"), text.clone()),
+ XmlNode::Element(child) => {
+ let seen = match counts.iter_mut().find(|(name, _)| *name == child.name) {
+ Some((_, count)) => {
+ *count += 1;
+ *count
+ }
+ None => {
+ counts.push((child.name.clone(), 1));
+ 1
+ }
+ };
+ let here = format!("{path}/{}[{seen}]", child.name);
+ open.push(here.clone().into());
+ let attributes: Vec = child
+ .attributes
+ .iter()
+ .map(|(key, value)| format!("{key}=\"{value}\""))
+ .collect();
+ TreeNode::new(here.clone(), format!("<{}>", child.name))
+ .note(attributes.join(" "))
+ .children(nodes(child, &here, open))
+ }
+ })
+ .collect()
+}
+
+/// The copied path, shown under the tree.
+#[derive(Default)]
+struct Copied(Option);
+
+/// XML as a tree of elements, each with its attributes, and the text between tags; comments are dropped. Enter or a double press on a text copies its path. XML that does not read shows the line where it breaks.
+#[derive(IntoElement)]
+pub struct XmlViewer {
+ id: ElementId,
+ text: SharedString,
+}
+
+impl XmlViewer {
+ pub fn new(id: impl Into, text: impl Into) -> Self {
+ Self {
+ id: id.into(),
+ text: text.into(),
+ }
+ }
+}
+
+impl RenderOnce for XmlViewer {
+ fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+ let copied: Entity =
+ window.use_keyed_state((self.id.clone(), "copied"), cx, |_, _| Copied::default());
+ let theme = cx.theme();
+ let body = match read_xml(&self.text) {
+ Err(unread) => {
+ InlineMessage::new(Severity::Danger, unread.to_string()).into_any_element()
+ }
+ Ok(root) => {
+ let path = format!("/{}", root.name);
+ let mut open = vec![SharedString::from(path.clone())];
+ let rows = nodes(&root, &path, &mut open);
+ let copy = copied.clone();
+ div()
+ .h(theme.list_max_height())
+ .child(
+ Tree::new(
+ (self.id, "tree"),
+ [TreeNode::new(path.clone(), format!("<{}>", root.name))
+ .children(rows)],
+ )
+ .open(open)
+ .on_activate(move |path, _, cx| {
+ log::info!("xml viewer: copied {path}");
+ cx.write_to_clipboard(ClipboardItem::new_string(path.to_string()));
+ copy.update(cx, |copied, cx| {
+ copied.0 = Some(path.clone());
+ cx.notify();
+ });
+ })
+ .size_full(),
+ )
+ .into_any_element()
+ }
+ };
+ let note = copied.read(cx).0.clone().map(|path| {
+ div()
+ .text_size(theme.text_size(TextSize::Xs))
+ .text_color(theme.colors.fg_subtle)
+ .child(format!("Copied {path}"))
+ });
+ div().flex().flex_col().gap_2().child(body).children(note)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{XmlNode, read_xml};
+
+ #[test]
+ fn elements_attributes_and_text_read_in_order() {
+ let root = read_xml(
+ "\n\n\n First\n \n",
+ )
+ .expect("xml");
+ assert_eq!(root.name, "feed");
+ assert_eq!(root.attributes, [("lang".into(), "en".into())]);
+ let XmlNode::Element(first) = &root.children[0] else {
+ panic!("an element")
+ };
+ assert_eq!(first.children, [XmlNode::Text("First".into())]);
+ assert_eq!(root.children.len(), 2);
+ }
+
+ #[test]
+ fn a_tag_that_closes_the_wrong_element_says_where() {
+ let unread = read_xml("\n \n \n").expect_err("crossed tags");
+ assert_eq!((unread.line, unread.why.as_str()), (3, " closes "));
+ assert_eq!(
+ read_xml("").expect_err("unclosed").why,
+ " is never closed"
+ );
+ }
+}
diff --git a/tasks/ch31-43.md b/tasks/ch31-43.md
index 538a0d5..cc5e440 100644
--- a/tasks/ch31-43.md
+++ b/tasks/ch31-43.md
@@ -95,11 +95,11 @@
## T33b DB & Dev Tools · Data formats
-- [ ] DocumentViewer (MongoDB / JSON) → `devtools::JsonViewer`
-- [ ] JsonViewer / JsonTree
-- [ ] JsonEditor
-- [ ] YamlViewer
-- [ ] XmlViewer
+- [x] DocumentViewer (MongoDB / JSON) → `devtools::JsonViewer`
+- [x] JsonViewer / JsonTree
+- [x] JsonEditor
+- [x] YamlViewer
+- [x] XmlViewer
## T33c DB & Dev Tools · APIs