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(" is never closed")); + } + self.at += 2; + let closing = self.name(); + self.spaces(); + if closing != name || !self.rest().starts_with('>') { + return self.fail(format!(" 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("")?; + } 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