wip-devtools
This commit is contained in:
6 files changed
+428
No files matched your search
@@ -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
|
||||
}
|
||||
+212
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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", "没有告警"},
|
||||
})
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Reference in new issue
Block a user