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