diff --git a/json_tree.go b/json_tree.go new file mode 100644 index 0000000..fd570f5 --- /dev/null +++ b/json_tree.go @@ -0,0 +1,93 @@ +package mujica + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "regexp" + "strconv" +) + +// jsonNode is one value of a parsed document, in document order. +type jsonNode struct { + key, path string + // value is a scalar's JSON text, or "{}" / "[]" for containers. + value string + depth int + parent int + // end is the index after this node's subtree; count is its child count. + end, count int +} + +func (n jsonNode) container() bool { return n.value == "{}" || n.value == "[]" } + +func (n jsonNode) label() string { + if n.key == "" { + return n.value + } + return n.key + ": " + n.value +} + +// jsonTree is a document flattened depth-first. +type jsonTree struct{ nodes []jsonNode } + +var jsonIdent = regexp.MustCompile(`^[A-Za-z_$][A-Za-z0-9_$]*$`) + +// parseJSONTree flattens data keeping key order; it fails on invalid or trailing JSON. +func parseJSONTree(data string) (jsonTree, error) { + dec := json.NewDecoder(bytes.NewReader([]byte(data))) + dec.UseNumber() + var t jsonTree + if err := jsonParseValue(dec, &t, "", "$", 0, -1); err != nil { + return jsonTree{}, err + } + if _, err := dec.Token(); err != io.EOF { + return jsonTree{}, fmt.Errorf("unexpected data after the value") + } + return t, nil +} + +func jsonParseValue(dec *json.Decoder, t *jsonTree, key, path string, depth, parent int) error { + tok, err := dec.Token() + if err != nil { + return err + } + at := len(t.nodes) + t.nodes = append(t.nodes, jsonNode{key: key, path: path, depth: depth, parent: parent}) + switch v := tok.(type) { + case json.Delim: + t.nodes[at].value = map[json.Delim]string{'{': "{}", '[': "[]"}[v] + for i := 0; dec.More(); i++ { + childKey, childPath := "", fmt.Sprintf("%s[%d]", path, i) + if v == '{' { + kt, err := dec.Token() + if err != nil { + return err + } + childKey = kt.(string) + childPath = path + "." + childKey + if !jsonIdent.MatchString(childKey) { + childPath = path + "[" + strconv.Quote(childKey) + "]" + } + } + if err := jsonParseValue(dec, t, childKey, childPath, depth+1, at); err != nil { + return err + } + t.nodes[at].count++ + } + if _, err := dec.Token(); err != nil { // the closing delimiter + return err + } + case string: + t.nodes[at].value = strconv.Quote(v) + case json.Number: + t.nodes[at].value = v.String() + case bool: + t.nodes[at].value = strconv.FormatBool(v) + case nil: + t.nodes[at].value = "null" + } + t.nodes[at].end = len(t.nodes) + return nil +} diff --git a/json_viewer.go b/json_viewer.go new file mode 100644 index 0000000..0a847c6 --- /dev/null +++ b/json_viewer.go @@ -0,0 +1,212 @@ +package mujica + +import ( + "fmt" + "log/slog" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// JsonViewerState is a JsonViewer's open nodes, search and choice. +type JsonViewerState struct { + // Query finds keys and values; matches open their ancestors. + Query string + // Current is the chosen match. + Current int + open map[string]bool + tree jsonTree + src string + err error + rows []int // visible node indexes + stale bool // rows need rebuilding + searched string + matches []int + sel int + list ui.ListState +} + +// JsonViewerOptions configure a JsonViewer. +type JsonViewerOptions struct { + // Height of the tree; zero means 360. + Height float32 + // Expanded opens every node at first. + Expanded bool +} + +// JsonViewerResult reports this frame's events. +type JsonViewerResult struct { + Element *ui.Element + // Copied is the path written to the clipboard, else "". + Copied string +} + +// JsonViewer shows JSON as a tree with folding, path copy and search, virtualized. +func JsonViewer(c *ui.Context, data string, s *JsonViewerState, opts JsonViewerOptions) JsonViewerResult { + if s == nil || opts.Height < 0 { + panic("mujica: JsonViewer needs a state and Height >= 0") + } + h := opts.Height + if h == 0 { + h = 360 + } + if s.open == nil || data != s.src { + s.src, s.open, s.stale, s.searched = data, map[string]bool{"$": true}, true, "\x00" + s.tree, s.err = parseJSONTree(data) + if opts.Expanded { + for _, n := range s.tree.nodes { + s.open[n.path] = true + } + } + } + u := Density(c).Unit() + var r JsonViewerResult + r.Element = codePanel(c).Height(h) + r.Element.Children(func() { + if s.err != nil { + ui.Row(c).Padding(u * 3).Gap(u * 2).TextColor(Tokens(c).Danger).Role(ui.RoleStatus).Children(func() { + ui.Icon(c, icons.Must("circle-alert")).Size(16, 16) + ui.Text(c, fmt.Sprintf(msg(c, "json_viewer.invalid"), s.err)) + }) + return + } + jsonViewerSearch(s) + codeHeader(c, func() { + moved := SearchInput(c, &s.Query, SearchInputOptions{Find: true, Matches: len(s.matches), Current: &s.Current}).Moved() + if moved && len(s.matches) > 0 { + s.sel = s.matches[s.Current] + s.stale = true + } + path := "" + if s.sel >= 0 && s.sel < len(s.tree.nodes) { + path = s.tree.nodes[s.sel].path + } + codeMono(c, path).Grow(1).MinWidth(0).SingleLine() + if Button(c, msg(c, "json_viewer.copy"), ButtonOptions{Variant: Ghost, Icon: icons.Must("copy"), Disabled: path == ""}).Clicked() && path != "" { + c.WriteClipboard(path) + r.Copied = path + slog.Debug("mujica: json_viewer copy path", "path", path) + } + }) + jsonViewerRows(s) + at := -1 + for i, n := range s.rows { + if n == s.sel { + at = i + } + } + was := at + s.list.Selected = &at + s.list.Label = func(i int) string { return s.tree.nodes[s.rows[i]].label() } + list := codeRows(c, &s.list, len(s.rows), func(i int) { jsonViewerRow(c, s, s.rows[i]) }) + if at != was && at >= 0 { + s.sel = s.rows[at] + } + if at >= 0 && at < len(s.rows) { + n := s.tree.nodes[s.rows[at]] + if n.container() && (list.Shortcut(0, ui.KeyRight) && !s.open[n.path] || list.Shortcut(0, ui.KeyLeft) && s.open[n.path] || list.Submitted()) { + s.open[n.path] = !s.open[n.path] + s.stale = true + } + } + }) + return r +} + +// jsonViewerSearch finds matches and opens their ancestors when the query changes. +func jsonViewerSearch(s *JsonViewerState) { + if s.searched == s.Query { + return + } + s.searched = s.Query + s.matches = s.matches[:0] + q := strings.ToLower(s.Query) + if q != "" { + for i, n := range s.tree.nodes { + if strings.Contains(strings.ToLower(n.key), q) || strings.Contains(strings.ToLower(n.value), q) { + s.matches = append(s.matches, i) + for p := n.parent; p >= 0; p = s.tree.nodes[p].parent { + s.open[s.tree.nodes[p].path] = true + } + } + } + } + s.Current = max(0, min(s.Current, len(s.matches)-1)) + if len(s.matches) > 0 { + s.sel = s.matches[s.Current] + } + s.stale = true +} + +// jsonViewerRows lists the visible nodes, skipping closed subtrees, when anything changed. +func jsonViewerRows(s *JsonViewerState) { + if !s.stale { + return + } + s.stale = false + s.rows = s.rows[:0] + for i := 0; i < len(s.tree.nodes); { + n := s.tree.nodes[i] + s.rows = append(s.rows, i) + if n.container() && !s.open[n.path] { + i = n.end + continue + } + i++ + } + if i := s.list.Selected; i != nil && *i >= 0 { + for at, n := range s.rows { + if n == s.sel { + s.list.ScrollIntoView(at) + } + } + } +} + +// jsonViewerRow shows one node: fold mark, key, and value or size. +func jsonViewerRow(c *ui.Context, s *JsonViewerState, i int) { + k := Tokens(c) + n := s.tree.nodes[i] + ui.Box(c).Width(float32(n.depth) * 16).Shrink(0) + mark := ui.Box(c).Size(theme.MinHitSize, theme.MinHitSize).Center().Shrink(0) + if n.container() { + name := "chevron-right" + if s.open[n.path] { + name = "chevron-down" + } + mark.Role(ui.RoleDisclosure).Label(n.path).Cursor(ui.CursorPointer) + mark.Children(func() { ui.Icon(c, icons.Must(name)).Size(14, 14).TextColor(k.TextMuted) }) + if mark.Clicked() { + s.open[n.path], s.stale = !s.open[n.path], true + } + } + var spans []ui.Span + if n.key != "" { + for _, sp := range codeMatchSpans(c, n.key, s.Query) { + sp.Color, sp.Weight = k.AccentText, max(sp.Weight, 600) + spans = append(spans, sp) + } + spans = append(spans, ui.Span{Text: ": ", Color: k.TextMuted}) + } + switch { + case n.container() && s.open[n.path]: + spans = append(spans, ui.Span{Text: n.value[:1], Color: k.TextMuted}) + case n.container(): + spans = append(spans, ui.Span{Text: fmt.Sprintf(msg(c, "json_viewer.items"), n.value[:1], n.count, n.value[1:]), Color: k.TextMuted}) + default: + tok, _ := internal.Highlight("json", n.value, false) + col := k.Text + if len(tok) > 0 { + col = codeTokenColor(k, tok[0].Kind) + } + for _, sp := range codeMatchSpans(c, n.value, s.Query) { + sp.Color = col + spans = append(spans, sp) + } + } + ui.RichText(c, spans...).Font(theme.MonoFont).FontSize(FontSize(c, codeSize)).SingleLine().Grow(1).MinWidth(0) +} diff --git a/json_viewer_test.go b/json_viewer_test.go new file mode 100644 index 0000000..8ba2c81 --- /dev/null +++ b/json_viewer_test.go @@ -0,0 +1,119 @@ +package mujica + +import ( + "fmt" + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +const jsonSample = `{"ball":{"hall":"east wing","guests":[{"name":"Sakiko","seat":1},{"name":"Uika","seat":2}],"wine":null},"open":true,"two words":3.5}` + +func jsonViewerTester(data *string, s *JsonViewerState, opts JsonViewerOptions, copied *[]string) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if r := JsonViewer(c, *data, s, opts); r.Copied != "" { + *copied = append(*copied, r.Copied) + } + }, 640, 460) +} + +func TestJsonTreePaths(t *testing.T) { + tr, err := parseJSONTree(jsonSample) + if err != nil { + t.Fatal(err) + } + var paths []string + for _, n := range tr.nodes { + paths = append(paths, n.path) + } + want := `$ $.ball $.ball.hall $.ball.guests $.ball.guests[0] $.ball.guests[0].name $.ball.guests[0].seat $.ball.guests[1] $.ball.guests[1].name $.ball.guests[1].seat $.ball.wine $.open $["two words"]` + if strings.Join(paths, " ") != want { + t.Fatalf("paths %v", paths) + } + if tr.nodes[3].count != 2 || tr.nodes[3].end != 10 { + t.Fatalf("guests node %+v", tr.nodes[3]) + } + if _, err := parseJSONTree(`{"a":1} x`); err == nil { + t.Fatal("trailing data accepted") + } +} + +func TestJsonViewerFoldKeyboardCopy(t *testing.T) { + data := jsonSample + var s JsonViewerState + var copied []string + tt := jsonViewerTester(&data, &s, JsonViewerOptions{}, &copied) + if !tt.HasText("ball: { 3 items }") || tt.HasText(`hall: "east wing"`) { + t.Fatalf("closed: %q", tt.Texts()) + } + _ = tt.Click("$.ball") // its fold mark + if !tt.HasText(`hall: "east wing"`) || !tt.HasText("guests: [ 2 items ]") { + t.Fatalf("open: %q", tt.Texts()) + } + _ = tt.Click(`hall: "east wing"`) + _ = tt.Click("Copy path") + if len(copied) != 1 || copied[0] != "$.ball.hall" || tt.Clipboard() != "$.ball.hall" { + t.Fatalf("copy: %v", copied) + } + _ = tt.Click(`hall: "east wing"`) // back on the list + tt.Key(0, ui.KeyDown) // guests + tt.Key(0, ui.KeyRight) + if !tt.HasText("[0]: { 2 items }") && !tt.HasText("{ 2 items }") { + t.Fatalf("Right did not open guests: %q", tt.Texts()) + } + tt.Key(0, ui.KeyLeft) + if tt.HasText("{ 2 items }") { + t.Fatal("Left did not close guests") + } +} + +func TestJsonViewerSearchOpensMatches(t *testing.T) { + data := jsonSample + var s JsonViewerState + var copied []string + tt := jsonViewerTester(&data, &s, JsonViewerOptions{}, &copied) + _ = tt.Click("Find") + tt.Type("uika") + if !tt.HasText("1 of 1") || !tt.HasText("$.ball.guests[1].name") { + t.Fatalf("search: %q", tt.Texts()) + } + data = `{"broken": }` + tt.Frame() + found := false + for _, x := range tt.Texts() { + found = found || strings.HasPrefix(x, "Invalid JSON") + } + if !found { + t.Fatalf("parse error: %q", tt.Texts()) + } +} + +func TestJsonViewerLargeDocumentVirtualized(t *testing.T) { + var b strings.Builder + b.WriteString("[") + for i := range 100000 { + if i > 0 { + b.WriteString(",") + } + fmt.Fprintf(&b, "%d", i) + } + b.WriteString("]") + data := b.String() + var s JsonViewerState + var copied []string + tt := jsonViewerTester(&data, &s, JsonViewerOptions{Expanded: true}, &copied) + if n := len(tt.Texts()); n > 300 { + t.Fatalf("%d texts for 100000 items", n) + } +} + +func TestJsonViewerSnapshot(t *testing.T) { + snapshot(t, "json_viewer", 600, 360, func(c *ui.Context) { + var s JsonViewerState + ui.Column(c).Padding(16).Children(func() { + JsonViewer(c, jsonSample, &s, JsonViewerOptions{Expanded: true, Height: 320}) + }) + }) +} diff --git a/messages_devtools.go b/messages_devtools.go index ebfd5fb..abe4d0e 100644 --- a/messages_devtools.go +++ b/messages_devtools.go @@ -78,6 +78,10 @@ func init() { "trace_waterfall.detail": {"%s · starts at %s · takes %s", "%s · 起于 %s · 耗时 %s"}, "trace_waterfall.error": {"Error", "错误"}, + "json_viewer.invalid": {"Invalid JSON: %v", "JSON 无效:%v"}, + "json_viewer.copy": {"Copy path", "复制路径"}, + "json_viewer.items": {"%s %d items %s", "%s %d 项 %s"}, + "alert_list.empty": {"No alerts", "没有告警"}, }) } diff --git a/testdata/snapshots/darwin/json_viewer-dark.png b/testdata/snapshots/darwin/json_viewer-dark.png new file mode 100644 index 0000000..2993757 Binary files /dev/null and b/testdata/snapshots/darwin/json_viewer-dark.png differ diff --git a/testdata/snapshots/darwin/json_viewer-light.png b/testdata/snapshots/darwin/json_viewer-light.png new file mode 100644 index 0000000..3345091 Binary files /dev/null and b/testdata/snapshots/darwin/json_viewer-light.png differ