diff --git a/messages_devtools.go b/messages_devtools.go index 5a02788..57d55fd 100644 --- a/messages_devtools.go +++ b/messages_devtools.go @@ -87,6 +87,14 @@ func init() { "json_editor.valid": {"Valid JSON", "JSON 有效"}, "json_editor.at": {"Error at line %d, column %d", "错误位于第 %d 行第 %d 列"}, + "schema_tree.label": {"Schema", "数据库结构"}, + "schema_tree.empty": {"No databases", "没有数据库"}, + "schema_tree.database": {"Database", "数据库"}, + "schema_tree.table": {"Table", "表"}, + "schema_tree.view": {"View", "视图"}, + "schema_tree.column": {"Column", "列"}, + "schema_tree.index": {"Index", "索引"}, + "alert_list.empty": {"No alerts", "没有告警"}, }) } diff --git a/schema_tree.go b/schema_tree.go new file mode 100644 index 0000000..0c8fed3 --- /dev/null +++ b/schema_tree.go @@ -0,0 +1,155 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// SchemaKind is what a schema node is. The zero value is SchemaDatabase. +type SchemaKind int + +const ( + SchemaDatabase SchemaKind = iota + SchemaTable + SchemaView + SchemaColumn + SchemaIndex +) + +// SchemaNode is one database object; Detail is a column type or index columns. +type SchemaNode struct { + Name string + Kind SchemaKind + Detail string + Children []SchemaNode +} + +// SchemaTreeState is a SchemaTree's open nodes and choice. +type SchemaTreeState struct { + outline ui.OutlineState[string] + selected int + nodes map[string]*SchemaNode + kids map[string][]string + roots []string + src []SchemaNode +} + +// Selected returns the chosen node's path, as "shop/orders/id", and whether one is chosen. +func (s *SchemaTreeState) Selected() (string, bool) { + if s.selected < 0 || s.selected >= s.outline.Rows() { + return "", false + } + return s.outline.Item(s.selected), true +} + +// SchemaTreeOptions configure a SchemaTree. +type SchemaTreeOptions struct { + // Height of the tree; zero means 360. + Height float32 +} + +// SchemaTree shows databases, tables, views, columns and indexes as a keyboard tree, virtualized. +func SchemaTree(c *ui.Context, s *SchemaTreeState, roots []SchemaNode, opts SchemaTreeOptions) ChoiceResult { + if s == nil || opts.Height < 0 { + panic("mujica: SchemaTree needs a state and Height >= 0") + } + h := opts.Height + if h == 0 { + h = 360 + } + schemaIndex(s, roots) + k := Tokens(c) + d := Density(c) + var r ChoiceResult + r.Element = codePanel(c).Height(h).Label(msg(c, "schema_tree.label")) + r.Element.Children(func() { + if len(s.roots) == 0 { + codeCaption(c, msg(c, "schema_tree.empty")).Padding(d.Unit() * 4) + return + } + was := s.selected + s.outline.List.Selected = &s.selected + s.outline.List.Label = func(i int) string { return s.nodes[s.outline.Item(i)].Name } + restore := dataSelectTheme(c, 0) + e := ui.Outline(c, &s.outline, s.roots, func(p string) []string { return s.kids[p] }, func(p string) { + n := s.nodes[p] + ui.Row(c).Grow(1).MinWidth(0).MinHeight(d.ControlHeight()-2*d.Unit()).Gap(d.Unit()*2).AlignItems(ui.Center). + Label(fmt.Sprintf("%s %s %s", schemaWord(c, n.Kind), n.Name, n.Detail)).Children(func() { + sel, _ := s.Selected() + codeSelectMark(c, sel == p) + letter, col := schemaBadge(k, n.Kind) + codeBadge(c, letter, col) + ui.Text(c, n.Name).SingleLine().Shrink(0) + if n.Detail != "" { + codeMono(c, n.Detail).TextColor(k.TextMuted).MinWidth(0) + } + }) + }).Grow(1) + restore() + internal.Ring(e, k.Focus, theme.ControlRadius) + r.changed = s.selected != was + }) + if r.changed { + p, _ := s.Selected() + slog.Debug("mujica: schema_tree select", "path", p) + } + return r +} + +// schemaIndex maps paths to nodes once per new schema. +func schemaIndex(s *SchemaTreeState, roots []SchemaNode) { + if s.nodes != nil && len(roots) == len(s.src) && (len(roots) == 0 || &roots[0] == &s.src[0]) { + return + } + if s.nodes == nil { + s.selected = -1 // nothing chosen at first + } + s.src, s.nodes, s.kids, s.roots = roots, map[string]*SchemaNode{}, map[string][]string{}, nil + var walk func(list []SchemaNode, parent string) []string + walk = func(list []SchemaNode, parent string) []string { + var paths []string + for i := range list { + n := &list[i] + if n.Name == "" || n.Kind < SchemaDatabase || n.Kind > SchemaIndex { + panic(fmt.Sprintf("mujica: SchemaTree node %q needs a Name and a known Kind", n.Name)) + } + p := n.Name + if parent != "" { + p = parent + "/" + n.Name + } + if _, dup := s.nodes[p]; dup { + panic("mujica: SchemaTree has two nodes at " + p) + } + s.nodes[p] = n + paths = append(paths, p) + if len(n.Children) > 0 { + s.kids[p] = walk(n.Children, p) + } + } + return paths + } + s.roots = walk(roots, "") +} + +func schemaBadge(k theme.Tokens, kind SchemaKind) (string, ui.Color) { + switch kind { + case SchemaDatabase: + return "D", k.AccentText + case SchemaTable: + return "T", k.Info + case SchemaView: + return "V", k.Success + case SchemaIndex: + return "I", k.Warning + } + return "C", k.TextMuted +} + +func schemaWord(c *ui.Context, kind SchemaKind) string { + return msg(c, [...]string{"schema_tree.database", "schema_tree.table", "schema_tree.view", "schema_tree.column", "schema_tree.index"}[kind]) +} diff --git a/schema_tree_test.go b/schema_tree_test.go new file mode 100644 index 0000000..2a487b1 --- /dev/null +++ b/schema_tree_test.go @@ -0,0 +1,90 @@ +package mujica + +import ( + "fmt" + "testing" + + "github.com/egoist/mygo/ui" +) + +var schemaSample = []SchemaNode{{Name: "court", Kind: SchemaDatabase, Children: []SchemaNode{ + {Name: "guests", Kind: SchemaTable, Children: []SchemaNode{ + {Name: "id", Kind: SchemaColumn, Detail: "bigint primary key"}, + {Name: "name", Kind: SchemaColumn, Detail: "text not null"}, + {Name: "guests_name_idx", Kind: SchemaIndex, Detail: "(name)"}, + }}, + {Name: "seating", Kind: SchemaView}, +}}} + +func schemaTester(s *SchemaTreeState, roots []SchemaNode, changes *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if SchemaTree(c, s, roots, SchemaTreeOptions{}).Changed() { + *changes++ + } + }, 520, 420) +} + +func TestSchemaTreeKeyboardOpenAndChoose(t *testing.T) { + var s SchemaTreeState + n := 0 + tt := schemaTester(&s, schemaSample, &n) + if _, ok := s.Selected(); ok || tt.HasText("guests") { + t.Fatalf("start: %q", tt.Texts()) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) // court + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyDown) // guests + tt.Key(0, ui.KeyRight) + for _, s := range []string{"guests", "seating", "bigint primary key", "(name)"} { + if !tt.HasText(s) { + t.Fatalf("missing %q in %q", s, tt.Texts()) + } + } + tt.Key(0, ui.KeyDown) + if p, _ := s.Selected(); p != "court/guests/id" || n == 0 { + t.Fatalf("selected %q changes %d", p, n) + } +} + +func TestSchemaTreeEmptyRulesMany(t *testing.T) { + var s SchemaTreeState + n := 0 + tt := schemaTester(&s, nil, &n) + if !tt.HasText("No databases") { + t.Fatal("empty text") + } + cols := make([]SchemaNode, 100000) + for i := range cols { + cols[i] = SchemaNode{Name: fmt.Sprint("c", i), Kind: SchemaColumn} + } + big := []SchemaNode{{Name: "wide", Kind: SchemaTable, Children: cols}} + var bs SchemaTreeState + tt = schemaTester(&bs, big, &n) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyRight) + if got := len(tt.Texts()); got > 400 || !tt.HasText("c0") { + t.Fatalf("%d texts for 100000 columns", got) + } + defer func() { + if recover() == nil { + t.Fatal("duplicate path did not panic") + } + }() + var ds SchemaTreeState + schemaTester(&ds, []SchemaNode{{Name: "a"}, {Name: "a"}}, &n) +} + +func TestSchemaTreeSnapshot(t *testing.T) { + var s SchemaTreeState + snapshotAfter(t, "schema_tree", 500, 300, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { SchemaTree(c, &s, schemaSample, SchemaTreeOptions{Height: 260}) }) + }, func(tt *ui.Tester) { + s = SchemaTreeState{} + for _, k := range []ui.Key{ui.KeyTab, ui.KeyDown, ui.KeyRight, ui.KeyDown, ui.KeyRight, ui.KeyDown} { + tt.Key(0, k) + } + }) +} diff --git a/testdata/snapshots/darwin/schema_tree-dark.png b/testdata/snapshots/darwin/schema_tree-dark.png new file mode 100644 index 0000000..b11c6b2 Binary files /dev/null and b/testdata/snapshots/darwin/schema_tree-dark.png differ diff --git a/testdata/snapshots/darwin/schema_tree-light.png b/testdata/snapshots/darwin/schema_tree-light.png new file mode 100644 index 0000000..f1e1389 Binary files /dev/null and b/testdata/snapshots/darwin/schema_tree-light.png differ