initial commit

This commit is contained in:
mappu 2024-06-03 16:49:04 +12:00
commit f22d149a66
6 changed files with 356 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
liblcl*
yvbolt

8
LICENSE Normal file
View File

@ -0,0 +1,8 @@
ISC License
Copyright 2024 mappy
Copyright 2024 The yvbolt Author(s)
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

3
README.md Normal file
View File

@ -0,0 +1,3 @@
# yvbolt
A graphical database browser using GoVCL.

9
go.mod Normal file
View File

@ -0,0 +1,9 @@
module yvbolt
go 1.19
require (
github.com/ying32/govcl v2.2.3+incompatible // indirect
go.etcd.io/bbolt v1.3.10 // indirect
golang.org/x/sys v0.4.0 // indirect
)

6
go.sum Normal file
View File

@ -0,0 +1,6 @@
github.com/ying32/govcl v2.2.3+incompatible h1:Iyfcl26yNE1USm+3uG+btQyhkoFIV18+VITrUdHu8Lw=
github.com/ying32/govcl v2.2.3+incompatible/go.mod h1:yZVtbJ9Md1nAVxtHKIriKZn4K6TQYqI1en3sN/m9FJ8=
go.etcd.io/bbolt v1.3.10 h1:+BqfJTcCzTItrop8mq/lbzL8wSGtj94UO/3U31shqG0=
go.etcd.io/bbolt v1.3.10/go.mod h1:bK3UQLPJZly7IlNmV7uVHJDxfe5aK9Ll93e/74Y9oEQ=
golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

327
main.go Normal file
View File

@ -0,0 +1,327 @@
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
}