328 lines
8.0 KiB
Go
328 lines
8.0 KiB
Go
|
package main
|
||
|
|
||
|
import (
|
||
|
"fmt"
|
||
|
"path/filepath"
|
||
|
"sort"
|
||
|
"strings"
|
||
|
"time"
|
||
|
"unicode/utf8"
|
||
|
"unsafe"
|
||
|
|
||
|
_ "github.com/ying32/govcl/pkgs/winappres" // Extra _syso files for Windows
|
||
|
"github.com/ying32/govcl/vcl"
|
||
|
"github.com/ying32/govcl/vcl/types"
|
||
|
|
||
|
"go.etcd.io/bbolt"
|
||
|
)
|
||
|
|
||
|
type loadedDatabase struct {
|
||
|
displayName string
|
||
|
path string
|
||
|
db *bbolt.DB
|
||
|
nav *vcl.TTreeNode
|
||
|
}
|
||
|
|
||
|
type navData struct {
|
||
|
ld *loadedDatabase
|
||
|
childrenLoaded bool
|
||
|
bucketPath []string
|
||
|
}
|
||
|
|
||
|
type TMainForm struct {
|
||
|
*vcl.TForm
|
||
|
Menu *vcl.TMainMenu
|
||
|
Buckets *vcl.TTreeView
|
||
|
Tabs *vcl.TPageControl
|
||
|
propertiesBox *vcl.TMemo
|
||
|
contentBox *vcl.TListView
|
||
|
|
||
|
dbs []loadedDatabase
|
||
|
arena []*navData // keepalive
|
||
|
}
|
||
|
|
||
|
var (
|
||
|
mainForm *TMainForm
|
||
|
)
|
||
|
|
||
|
func main() {
|
||
|
vcl.RunApp(&mainForm)
|
||
|
}
|
||
|
|
||
|
func (f *TMainForm) OnFormCreate(sender vcl.IObject) {
|
||
|
const MY_SPACING = 6
|
||
|
const MY_WIDTH = 180
|
||
|
|
||
|
f.SetCaption("yvbolt")
|
||
|
|
||
|
mnuFile := vcl.NewMenuItem(f)
|
||
|
mnuFile.SetCaption("File")
|
||
|
|
||
|
mnuFileOpen := vcl.NewMenuItem(mnuFile)
|
||
|
mnuFileOpen.SetCaption("Open...")
|
||
|
mnuFileOpen.SetShortCutFromString("Ctrl+O")
|
||
|
mnuFileOpen.SetOnClick(f.OnMnuFileOpenClick)
|
||
|
mnuFile.Add(mnuFileOpen)
|
||
|
|
||
|
f.Menu = vcl.NewMainMenu(f)
|
||
|
f.Menu.Items().Add(mnuFile)
|
||
|
|
||
|
f.Buckets = vcl.NewTreeView(f)
|
||
|
f.Buckets.SetParent(f)
|
||
|
f.Buckets.SetAlign(types.AlLeft)
|
||
|
f.Buckets.SetWidth(MY_WIDTH)
|
||
|
f.Buckets.SetReadOnly(true) // prevent click to rename on nodes
|
||
|
f.Buckets.SetOnExpanding(f.OnNavExpanding)
|
||
|
f.Buckets.SetOnChange(f.OnNavChange)
|
||
|
|
||
|
hsplit := vcl.NewSplitter(f)
|
||
|
hsplit.SetParent(f)
|
||
|
hsplit.SetAlign(types.AlLeft)
|
||
|
hsplit.SetLeft(1) // Just needs to be further "over" than f.Buckets for auto-alignment
|
||
|
|
||
|
f.Tabs = vcl.NewPageControl(f)
|
||
|
f.Tabs.SetParent(f)
|
||
|
f.Tabs.SetAlign(types.AlClient)
|
||
|
|
||
|
propertiesTab := vcl.NewTabSheet(f.Tabs)
|
||
|
propertiesTab.SetParent(f.Tabs)
|
||
|
propertiesTab.SetCaption("Properties")
|
||
|
|
||
|
f.propertiesBox = vcl.NewMemo(propertiesTab)
|
||
|
f.propertiesBox.SetParent(propertiesTab)
|
||
|
f.propertiesBox.BorderSpacing().SetAround(MY_SPACING)
|
||
|
f.propertiesBox.SetAlign(types.AlClient) // fill remaining space
|
||
|
f.propertiesBox.SetReadOnly(true)
|
||
|
f.propertiesBox.SetEnabled(false)
|
||
|
f.propertiesBox.SetBorderStyle(types.BsNone)
|
||
|
f.propertiesBox.SetText("Open a database to get started...")
|
||
|
|
||
|
dataTab := vcl.NewTabSheet(f.Tabs)
|
||
|
dataTab.SetParent(f.Tabs)
|
||
|
dataTab.SetCaption("Data")
|
||
|
|
||
|
f.contentBox = vcl.NewListView(dataTab)
|
||
|
f.contentBox.SetParent(dataTab)
|
||
|
f.contentBox.BorderSpacing().SetAround(MY_SPACING)
|
||
|
f.contentBox.SetAlign(types.AlClient) // fill remaining space
|
||
|
f.contentBox.SetViewStyle(types.VsReport) // "Report style" i.e. has columns
|
||
|
f.contentBox.SetAutoWidthLastColumn(true)
|
||
|
f.contentBox.SetReadOnly(true)
|
||
|
colKey := f.contentBox.Columns().Add()
|
||
|
colKey.SetCaption("Key")
|
||
|
colKey.SetWidth(MY_WIDTH)
|
||
|
colKey.SetAlignment(types.TaLeftJustify)
|
||
|
colVal := f.contentBox.Columns().Add()
|
||
|
colVal.SetCaption("Value")
|
||
|
|
||
|
}
|
||
|
|
||
|
func (f *TMainForm) OnMnuFileOpenClick(sender vcl.IObject) {
|
||
|
dlg := vcl.NewOpenDialog(f)
|
||
|
dlg.SetTitle("Select a database file...")
|
||
|
dlg.SetFilter("Bolt database|*.db|SQLite database|*.db3|All files|*.*")
|
||
|
ret := dlg.Execute() // Fake blocking
|
||
|
if ret {
|
||
|
f.addDatabaseFromFile(dlg.FileName())
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func (f *TMainForm) OnNavChange(sender vcl.IObject, node *vcl.TTreeNode) {
|
||
|
|
||
|
if node.Data() == nil {
|
||
|
vcl.ShowMessage("unexpected nil data")
|
||
|
return
|
||
|
}
|
||
|
|
||
|
ndata := (*navData)(node.Data())
|
||
|
|
||
|
// Load properties
|
||
|
|
||
|
bucketDisplayName := strings.Join(ndata.bucketPath, `/`)
|
||
|
content := fmt.Sprintf("Selected database: %#v\n\n\nSelected bucket: %q\n", ndata.ld.db.Stats(), bucketDisplayName)
|
||
|
f.propertiesBox.SetText(content)
|
||
|
|
||
|
// Load data
|
||
|
|
||
|
f.contentBox.SetEnabled(false)
|
||
|
f.contentBox.Clear()
|
||
|
|
||
|
err := ndata.ld.db.View(func(tx *bbolt.Tx) error {
|
||
|
b := boltTargetBucket(tx, ndata.bucketPath)
|
||
|
if b == nil {
|
||
|
// no such bucket
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
// Valid
|
||
|
f.contentBox.Clear()
|
||
|
|
||
|
c := b.Cursor()
|
||
|
for k, v := c.First(); k != nil; k, v = c.Next() {
|
||
|
dataEntry := f.contentBox.Items().Add()
|
||
|
dataEntry.SetCaption(formatUtf8(k))
|
||
|
dataEntry.SubItems().Add(formatUtf8(v))
|
||
|
}
|
||
|
return nil
|
||
|
})
|
||
|
if err != nil {
|
||
|
vcl.ShowMessage(fmt.Sprintf("Failed to load data for bucket %q: %s", bucketDisplayName, err.Error()))
|
||
|
return
|
||
|
}
|
||
|
|
||
|
// Valid
|
||
|
f.contentBox.SetEnabled(true)
|
||
|
}
|
||
|
|
||
|
func formatUtf8(in []byte) string {
|
||
|
if !utf8.Valid(in) {
|
||
|
return fmt.Sprintf("<<Invalid UTF-8 %q>>", in)
|
||
|
}
|
||
|
|
||
|
return string(in)
|
||
|
}
|
||
|
|
||
|
func boltTargetBucket(tx *bbolt.Tx, path []string) *bbolt.Bucket {
|
||
|
|
||
|
// If we are already deep in buckets, go directly there to find children
|
||
|
if len(path) == 0 {
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
b := tx.Bucket([]byte(path[0]))
|
||
|
if b == nil {
|
||
|
return nil // unexpectedly missing
|
||
|
}
|
||
|
|
||
|
for i := 1; i < len(path); i += 1 {
|
||
|
b = b.Bucket([]byte(path[i]))
|
||
|
if b == nil {
|
||
|
return nil // unexpectedly missing
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return b // OK
|
||
|
}
|
||
|
|
||
|
func boltChildBucketNames(db *bbolt.DB, path []string) ([]string, error) {
|
||
|
var nextBucketNames []string
|
||
|
|
||
|
err := db.View(func(tx *bbolt.Tx) error {
|
||
|
|
||
|
// If we are already deep in buckets, go directly there to find children
|
||
|
if len(path) > 0 {
|
||
|
b := tx.Bucket([]byte(path[0]))
|
||
|
if b == nil {
|
||
|
return fmt.Errorf("Unexpected missing root bucket %q", path[0])
|
||
|
}
|
||
|
|
||
|
for i := 1; i < len(path); i += 1 {
|
||
|
b = b.Bucket([]byte(path[i]))
|
||
|
if b == nil {
|
||
|
return fmt.Errorf("Unexpected missing bucket %q", strings.Join(path[0:i], `/`))
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Find child buckets of this bucket
|
||
|
b.ForEachBucket(func(bucketName []byte) error {
|
||
|
nextBucketNames = append(nextBucketNames, string(bucketName))
|
||
|
return nil
|
||
|
})
|
||
|
|
||
|
} else {
|
||
|
// Find root bucket names
|
||
|
return tx.ForEach(func(bucketName []byte, _ *bbolt.Bucket) error {
|
||
|
nextBucketNames = append(nextBucketNames, string(bucketName))
|
||
|
return nil
|
||
|
})
|
||
|
}
|
||
|
|
||
|
// OK
|
||
|
return nil
|
||
|
})
|
||
|
if err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
|
||
|
sort.Strings(nextBucketNames)
|
||
|
|
||
|
return nextBucketNames, nil
|
||
|
}
|
||
|
|
||
|
func (f *TMainForm) OnNavExpanding(sender vcl.IObject, node *vcl.TTreeNode, allowExpansion *bool) {
|
||
|
|
||
|
if node.Data() == nil {
|
||
|
vcl.ShowMessage("unexpected nil data")
|
||
|
*allowExpansion = false
|
||
|
return
|
||
|
}
|
||
|
|
||
|
ndata := (*navData)(node.Data())
|
||
|
|
||
|
if ndata.childrenLoaded {
|
||
|
return
|
||
|
}
|
||
|
|
||
|
// Find the child buckets from this point under the element
|
||
|
nextBucketNames, err := boltChildBucketNames(ndata.ld.db, ndata.bucketPath)
|
||
|
if err != nil {
|
||
|
vcl.ShowMessage(fmt.Sprintf("Failed to find child buckets under %q: %s", strings.Join(ndata.bucketPath, `/`), err.Error()))
|
||
|
*allowExpansion = false
|
||
|
return
|
||
|
}
|
||
|
|
||
|
ndata.childrenLoaded = true // don't repeat this work
|
||
|
|
||
|
if len(nextBucketNames) == 0 {
|
||
|
node.SetHasChildren(false)
|
||
|
*allowExpansion = false
|
||
|
|
||
|
} else {
|
||
|
|
||
|
// Populate LCL child nodes
|
||
|
for _, bucketName := range nextBucketNames {
|
||
|
|
||
|
node := f.Buckets.Items().AddChild(node, formatUtf8([]byte(bucketName)))
|
||
|
node.SetHasChildren(true) // dynamically populate in OnNavExpanding
|
||
|
navData := &navData{
|
||
|
ld: ndata.ld,
|
||
|
childrenLoaded: false, // will be loaded dynamically
|
||
|
bucketPath: []string{}, // empty = root
|
||
|
}
|
||
|
navData.bucketPath = append(navData.bucketPath, ndata.bucketPath...)
|
||
|
navData.bucketPath = append(navData.bucketPath, bucketName)
|
||
|
node.SetData(unsafe.Pointer(navData))
|
||
|
f.arena = append(f.arena, navData) // keepalive
|
||
|
}
|
||
|
|
||
|
*allowExpansion = true
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func (f *TMainForm) addDatabaseFromFile(path string) {
|
||
|
// TODO load in background thread to stop blocking the UI
|
||
|
db, err := bbolt.Open(path, 0644, &bbolt.Options{Timeout: 1 * time.Second})
|
||
|
if err != nil {
|
||
|
vcl.ShowMessage(fmt.Sprintf("Failed to load database '%s': %s", path, err.Error()))
|
||
|
return
|
||
|
}
|
||
|
|
||
|
entry := loadedDatabase{
|
||
|
path: path,
|
||
|
displayName: filepath.Base(path),
|
||
|
db: db,
|
||
|
}
|
||
|
|
||
|
entry.nav = f.Buckets.Items().Add(nil, entry.displayName)
|
||
|
entry.nav.SetHasChildren(true) // dynamically populate in OnNavExpanding
|
||
|
navData := &navData{
|
||
|
ld: &entry,
|
||
|
childrenLoaded: false, // will be loaded dynamically
|
||
|
bucketPath: []string{}, // empty = root
|
||
|
}
|
||
|
entry.nav.SetData(unsafe.Pointer(navData))
|
||
|
|
||
|
f.dbs = append(f.dbs, entry)
|
||
|
f.arena = append(f.arena, navData) // keepalive
|
||
|
}
|