yvbolt/main.go

644 lines
17 KiB
Go

package main
import (
"errors"
"fmt"
"runtime"
"runtime/debug"
"strings"
"unsafe"
"github.com/pkg/browser"
"github.com/ying32/govcl/vcl"
"github.com/ying32/govcl/vcl/types"
)
const (
APPNAME = "yvbolt"
HOMEPAGE_URL = "https://code.ivysaur.me/yvbolt"
)
type TMainForm struct {
*vcl.TForm
ImageList *vcl.TImageList
Menu *vcl.TMainMenu
SQLiteUseCliDriver *vcl.TMenuItem
StatusBar *vcl.TStatusBar
Buckets *vcl.TTreeView
Tabs *vcl.TPageControl
propertiesBox *vcl.TMemo
contentBox *vcl.TListView
queryInput *vcl.TMemo
queryResult *vcl.TListView
none *noLoadedDatabase
dbs []loadedDatabase
}
var (
mainForm *TMainForm
)
func main() {
vcl.RunApp(&mainForm)
}
func (f *TMainForm) OnFormCreate(sender vcl.IObject) {
f.ImageList = loadImages(f)
f.SetCaption(APPNAME)
f.ScreenCenter()
f.ImageList.GetIcon(imgDatabaseLightning, f.Icon())
mnuFile := vcl.NewMenuItem(f)
mnuFile.SetCaption("File")
//
mnuFileBadger := vcl.NewMenuItem(mnuFile)
mnuFileBadger.SetCaption("Badger")
mnuFileBadger.SetImageIndex(imgVendorDgraph)
mnuFile.Add(mnuFileBadger)
vcl_menuitem(mnuFileBadger, "Open database...", imgDatabaseAdd, f.OnMnuFileBadgerOpenClick)
vcl_menuitem(mnuFileBadger, "New in-memory database", imgDatabaseAdd, f.OnMnuFileBadgerMemoryClick)
//
mnuFileBolt := vcl.NewMenuItem(mnuFile)
mnuFileBolt.SetCaption("Bolt")
mnuFileBolt.SetImageIndex(imgVendorGithub)
mnuFile.Add(mnuFileBolt)
vcl_menuitem(mnuFileBolt, "New database...", imgDatabaseAdd, f.OnMnuFileBoltNewClick)
vcl_menuitem(mnuFileBolt, "Open database...", imgDatabaseAdd, f.OnMnuFileBoltOpenClick)
vcl_menuitem(mnuFileBolt, "Open database (read-only)...", imgDatabaseAdd, f.OnMnuFileBoltOpenReadonlyClick)
//
mnuFileDebconf := vcl.NewMenuItem(mnuFile)
mnuFileDebconf.SetCaption("Debconf")
mnuFileDebconf.SetImageIndex(imgVendorDebian)
mnuFile.Add(mnuFileDebconf)
vcl_menuitem(mnuFileDebconf, "Open database...", imgDatabaseAdd, f.OnMnuFileDebianOpenClick)
//
mnuFilePebble := vcl.NewMenuItem(mnuFile)
mnuFilePebble.SetCaption("Pebble")
mnuFilePebble.SetImageIndex(imgVendorCockroach)
mnuFile.Add(mnuFilePebble)
vcl_menuitem(mnuFilePebble, "Open database...", imgDatabaseAdd, f.OnMnuFilePebbleOpenClick)
vcl_menuitem(mnuFilePebble, "New in-memory database", imgDatabaseAdd, f.OnMnuFilePebbleMemoryClick)
//
mnuFileRedis := vcl.NewMenuItem(mnuFile)
mnuFileRedis.SetCaption("Redis")
mnuFileRedis.SetImageIndex(imgVendorRedis)
mnuFile.Add(mnuFileRedis)
vcl_menuitem(mnuFileRedis, "Connect...", imgDatabaseAdd, f.OnMnuFileRedisConnectClick)
//
mnuFileSqlite := vcl.NewMenuItem(mnuFile)
mnuFileSqlite.SetCaption("SQLite")
mnuFileSqlite.SetImageIndex(imgVendorSqlite)
mnuFile.Add(mnuFileSqlite)
vcl_menuitem(mnuFileSqlite, "Open database...", imgDatabaseAdd, f.OnMnuFileSqliteOpenClick)
vcl_menuitem(mnuFileSqlite, "New in-memory database", imgDatabaseAdd, f.OnMnuFileSqliteMemoryClick)
vcl_menuseparator(mnuFileSqlite)
f.SQLiteUseCliDriver = vcl_menuitem(mnuFileSqlite, "Connect using CLI driver (experimental)", -1, nil)
f.SQLiteUseCliDriver.SetAutoCheck(true)
//
vcl_menuseparator(mnuFile)
mnuFileExit := vcl.NewMenuItem(mnuFile)
mnuFileExit.SetCaption("Exit")
mnuFileExit.SetOnClick(f.OnMnuFileExitClick)
mnuFile.Add(mnuFileExit)
mnuQuery := vcl.NewMenuItem(f)
mnuQuery.SetCaption("Query")
mnuQueryExecute := vcl.NewMenuItem(mnuQuery)
mnuQueryExecute.SetCaption("Execute")
mnuQueryExecute.SetShortCutFromString("F5")
mnuQueryExecute.SetOnClick(f.OnQueryExecute)
mnuQueryExecute.SetImageIndex(imgLightning)
mnuQuery.Add(mnuQueryExecute)
mnuHelp := vcl.NewMenuItem(f)
mnuHelp.SetCaption("Help")
mnuHelpVersion := vcl.NewMenuItem(mnuHelp)
mnuHelpVersion.SetCaption("Driver versions...")
mnuHelpVersion.SetOnClick(f.OnMenuHelpVersion)
mnuHelp.Add(mnuHelpVersion)
mnuHelpHomepage := vcl.NewMenuItem(mnuHelp)
mnuHelpHomepage.SetCaption("About " + APPNAME)
mnuHelpHomepage.SetShortCutFromString("F1")
mnuHelpHomepage.SetOnClick(f.OnMnuHelpHomepage)
mnuHelp.Add(mnuHelpHomepage)
f.Menu = vcl.NewMainMenu(f)
f.Menu.SetImages(f.ImageList)
f.Menu.Items().Add(mnuFile)
f.Menu.Items().Add(mnuQuery)
f.Menu.Items().Add(mnuHelp)
//
f.StatusBar = vcl.NewStatusBar(f)
f.StatusBar.SetParent(f)
f.StatusBar.SetSimpleText("")
//
f.Buckets = vcl.NewTreeView(f)
f.Buckets.SetParent(f)
f.Buckets.SetImages(f.ImageList)
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)
f.Buckets.SetOnContextPopup(f.OnNavContextPopup)
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) // fill remaining space
f.Tabs.SetImages(f.ImageList)
propertiesTab := vcl.NewTabSheet(f.Tabs)
propertiesTab.SetParent(f.Tabs)
propertiesTab.SetCaption("Properties")
propertiesTab.SetImageIndex(imgChartBar)
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(true) // Need to leave it enabled so scrolling works
f.propertiesBox.SetColor(vcl_default_tab_background())
f.propertiesBox.SetBorderStyle(types.BsNone)
f.propertiesBox.SetScrollBars(types.SsAutoVertical)
dataTab := vcl.NewTabSheet(f.Tabs)
dataTab.SetParent(f.Tabs)
dataTab.SetCaption("Data")
dataTab.SetImageIndex(imgTable)
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)
f.contentBox.Columns().Clear()
queryTab := vcl.NewTabSheet(f.Tabs)
queryTab.SetParent(f.Tabs)
queryTab.SetCaption("Query")
queryTab.SetImageIndex(imgLightning)
queryButtonBar := vcl.NewToolBar(queryTab)
queryButtonBar.SetParent(queryTab)
queryButtonBar.SetAlign(types.AlTop)
queryButtonBar.BorderSpacing().SetLeft(MY_SPACING)
queryButtonBar.BorderSpacing().SetTop(MY_SPACING)
//queryButtonBar.BorderSpacing().SetBottom(1)
queryButtonBar.BorderSpacing().SetRight(MY_SPACING)
queryButtonBar.SetEdgeBorders(0)
queryButtonBar.SetImages(f.ImageList)
queryButtonBar.SetShowCaptions(true)
queryExecBtn := vcl.NewToolButton(queryButtonBar)
queryExecBtn.SetParent(queryButtonBar)
queryExecBtn.SetCaption("Execute")
// queryExecBtn.SetImageIndex(imgLightning)
queryExecBtn.SetOnClick(f.OnQueryExecute)
f.queryInput = vcl.NewMemo(queryTab)
f.queryInput.SetParent(queryTab)
f.queryInput.SetHeight(MY_HEIGHT)
f.queryInput.SetAlign(types.AlTop)
f.queryInput.SetTop(1)
if runtime.GOOS == "windows" {
f.queryInput.Font().SetName("Consolas")
} else {
f.queryInput.Font().SetName("monospace")
}
f.queryInput.BorderSpacing().SetLeft(MY_SPACING)
//f.queryInput.BorderSpacing().SetTop(1)
f.queryInput.BorderSpacing().SetRight(MY_SPACING)
f.queryInput.SetBorderStyle(types.BsFrame)
vsplit := vcl.NewSplitter(queryTab)
vsplit.SetParent(queryTab)
vsplit.SetAlign(types.AlTop)
vsplit.SetTop(2)
f.queryResult = vcl.NewListView(queryTab)
f.queryResult.SetParent(queryTab)
f.queryResult.SetAlign(types.AlClient) // fill remaining space
f.queryResult.SetViewStyle(types.VsReport) // "Report style" i.e. has columns
f.queryResult.SetAutoWidthLastColumn(true)
f.queryResult.SetReadOnly(true)
f.queryResult.Columns().Clear()
f.queryResult.BorderSpacing().SetLeft(MY_SPACING)
f.queryResult.BorderSpacing().SetRight(MY_SPACING)
f.queryResult.BorderSpacing().SetBottom(MY_SPACING)
f.none = &noLoadedDatabase{}
f.OnNavChange(f, nil) // calls f.none.RenderForNav and sets up status bar content
}
func (f *TMainForm) OnMnuFileBoltNewClick(sender vcl.IObject) {
dlg := vcl.NewSaveDialog(f)
dlg.SetTitle("Save database as...")
dlg.SetFilter("Bolt database|*.db|All files|*.*")
ret := dlg.Execute() // Fake blocking
if ret {
f.boltAddDatabaseFromFile(dlg.FileName(), false)
}
}
func (f *TMainForm) OnMnuFileBoltOpenClick(sender vcl.IObject) {
dlg := vcl.NewOpenDialog(f)
dlg.SetTitle("Select a database file...")
dlg.SetFilter("Bolt database|*.db|All files|*.*")
ret := dlg.Execute() // Fake blocking
if ret {
f.boltAddDatabaseFromFile(dlg.FileName(), false)
}
}
func (f *TMainForm) OnMnuFileBoltOpenReadonlyClick(sender vcl.IObject) {
dlg := vcl.NewOpenDialog(f)
dlg.SetTitle("Select a database file...")
dlg.SetFilter("Bolt database|*.db|All files|*.*")
ret := dlg.Execute() // Fake blocking
if ret {
f.boltAddDatabaseFromFile(dlg.FileName(), true)
}
}
func (f *TMainForm) OnMnuFileSqliteOpenClick(sender vcl.IObject) {
cliDriver := f.SQLiteUseCliDriver.Checked()
dlg := vcl.NewOpenDialog(f)
dlg.SetTitle("Select a database file...")
dlg.SetFilter("SQLite database|*.db;*.db3;*.sqlite;*.sqlite3|All files|*.*")
ret := dlg.Execute() // Fake blocking
if ret {
f.sqliteAddDatabaseFromFile(dlg.FileName(), cliDriver)
}
}
func (f *TMainForm) OnMnuFileBadgerOpenClick(sender vcl.IObject) {
dlg := vcl.NewSelectDirectoryDialog(f)
dlg.SetTitle("Select a database directory...")
ret := dlg.Execute() // Fake blocking
if ret {
f.badgerAddDatabaseFromDirectory(dlg.FileName())
}
}
func (f *TMainForm) OnMnuFileBadgerMemoryClick(sender vcl.IObject) {
f.badgerAddDatabaseFromMemory()
}
func (f *TMainForm) OnMnuFilePebbleOpenClick(sender vcl.IObject) {
dlg := vcl.NewSelectDirectoryDialog(f)
dlg.SetTitle("Select a database directory...")
ret := dlg.Execute() // Fake blocking
if ret {
f.pebbleAddDatabaseFrom(dlg.FileName(), nil)
}
}
func (f *TMainForm) OnMnuFileDebianOpenClick(sender vcl.IObject) {
dlg := vcl.NewOpenDialog(f)
dlg.SetTitle("Select a database file...")
dlg.SetFilter("Debconf database|*.dat|All files|*.*")
if runtime.GOOS == "linux" {
dlg.SetInitialDir(`/var/cache/debconf/`)
}
ret := dlg.Execute() // Fake blocking
if ret {
f.debconfAddDatabaseFrom(dlg.FileName())
}
}
func (f *TMainForm) OnMnuFilePebbleMemoryClick(sender vcl.IObject) {
f.pebbleAddDatabaseFromMemory()
}
func (f *TMainForm) OnMnuFileSqliteMemoryClick(sender vcl.IObject) {
cliDriver := f.SQLiteUseCliDriver.Checked()
f.sqliteAddDatabaseFromFile(`:memory:`, cliDriver)
}
func (f *TMainForm) OnMnuFileRedisConnectClick(sender vcl.IObject) {
var child *TRedisConnectionDialog
vcl.Application.CreateForm(&child)
defer child.Free()
var ret TRedisConnectionDialogResult
child.CustomReturn = &ret
mr := child.ShowModal() // Fake blocking
if mr != types.MrOk || ret == nil {
return // Cancelled
}
// Connect
f.redisConnect(ret)
}
func (f *TMainForm) OnMnuFileExitClick(sender vcl.IObject) {
f.Close()
}
func (f *TMainForm) OnMnuHelpHomepage(sender vcl.IObject) {
err := browser.OpenURL(HOMEPAGE_URL)
if err != nil {
vcl.ShowMessage("Opening browser: " + err.Error())
}
}
func (f *TMainForm) OnMenuHelpVersion(sender vcl.IObject) {
bi, ok := debug.ReadBuildInfo()
if !ok {
return
}
info := "This version of " + APPNAME + " was compiled with:\n"
for _, dep := range bi.Deps {
// Filter to only interesting things
switch dep.Path {
case `github.com/cockroachdb/pebble`,
`github.com/dgraph-io/badger/v4`,
`github.com/mattn/go-sqlite3`,
`github.com/redis/go-redis/v9`,
`go.etcd.io/bbolt`,
`modernc.org/sqlite`:
info += fmt.Sprintf("- %s %s\n", dep.Path, dep.Version)
}
}
vcl.ShowMessage(info)
}
func (f *TMainForm) OnNavContextPopup(sender vcl.IObject, mousePos types.TPoint, handled *bool) {
*handled = true
curItem := f.Buckets.Selected()
if curItem == nil {
// Nothing is selected at all
return
}
ndata := (*navData)(curItem.Data())
mnu := vcl.NewPopupMenu(f.Buckets)
mnu.SetImages(f.ImageList)
mnuRefresh := vcl.NewMenuItem(mnu)
mnuRefresh.SetCaption("Refresh")
mnuRefresh.SetImageIndex(imgArrowRefresh)
mnuRefresh.SetOnClick(func(sender vcl.IObject) { f.OnNavContextRefresh(curItem, ndata) })
mnu.Items().Add(mnuRefresh)
// Check what custom actions the ndata->db itself wants to add
actions, err := ndata.ld.NavContext(ndata)
if err != nil {
vcl.ShowMessage(err.Error())
return
}
if len(actions) > 0 {
mnuSep := vcl.NewMenuItem(mnu)
mnuSep.SetCaption("-")
mnu.Items().Add(mnuSep)
for _, action := range actions {
mnuAction := vcl.NewMenuItem(mnu)
mnuAction.SetCaption(action.Name)
cb := action.Callback // Copy to avoid reuse of loop variable
mnuAction.SetOnClick(func(sender vcl.IObject) {
cb(ndata)
f.OnNavContextRefresh(curItem, ndata)
})
mnu.Items().Add(mnuAction)
}
}
if curItem.Parent() == nil {
// Top-level item (database connection). Allow closing by right-click.
mnuSep := vcl.NewMenuItem(mnu)
mnuSep.SetCaption("-")
mnu.Items().Add(mnuSep)
mnuClose := vcl.NewMenuItem(mnu)
mnuClose.SetCaption("Close")
mnuClose.SetOnClick(f.OnNavContextClose)
mnuClose.SetImageIndex(imgDatabaseDelete)
mnu.Items().Add(mnuClose)
}
// Show popup
mnu.Popup2()
}
func (f *TMainForm) OnNavContextRefresh(item *vcl.TTreeNode, ndata *navData) {
isExpanded := item.Expanded()
// Reset nav node to 'unloaded' state
item.Collapse(true)
item.DeleteChildren()
item.SetHasChildren(true)
ndata.childrenLoaded = false
// Trigger a virtual reload
item.Expand(false) // Calls OnNavExpanding to dynamically detect children
// Restore previous gui state
item.SetExpanded(isExpanded)
}
func (f *TMainForm) OnNavContextClose(sender vcl.IObject) {
curItem := f.Buckets.Selected()
if curItem == nil {
return // Nothing selected (shouldn't happen)
}
if curItem.Parent() != nil {
return // Selection is not top-level DB connection (shouldn't happen)
}
ndata := (*navData)(curItem.Data())
ndata.ld.Close()
curItem.Delete()
// n.b. This triggers OnNavChange, which will then re-render from noLoadedDatabase{}
}
func (f *TMainForm) OnQueryExecute(sender vcl.IObject) {
// If query tab is not selected, switch to it, but do not exec
if f.Tabs.ActivePageIndex() != 2 {
f.Tabs.SetActivePageIndex(2)
return
}
queryString := f.queryInput.Text()
if f.queryInput.SelLength() > 0 {
queryString = f.queryInput.SelText() // Just the selected text
}
// Execute
node := f.Buckets.Selected()
if node == nil {
vcl.ShowMessage("No database selected")
return
}
ndata := (*navData)(node.Data())
ndata.ld.ExecQuery(queryString, f.queryResult)
}
func (f *TMainForm) OnNavChange(sender vcl.IObject, node *vcl.TTreeNode) {
var ld loadedDatabase = f.none
var ndata *navData = nil
if node != nil && node.Data() != nil {
ndata = (*navData)(node.Data())
ld = ndata.ld
}
ld.RenderForNav(f, ndata) // Handover to the database type's own renderer function
// We're in charge of common status bar text updates
f.StatusBar.SetSimpleText(ld.DisplayName() + " | " + ld.DriverName())
}
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())
err := f.NavLoadChildren(node, ndata)
if err != nil {
if errors.Is(err, ErrNavNotExist) {
// The nav entry has been deleted.
// This is a normal thing to happen when e.g. deleting a table
// f.StatusBar.SetSimpleText(err.Error()) // Just gets overridden when the selection changes
node.Delete()
return
}
vcl.ShowMessage(err.Error())
*allowExpansion = false // Permanently block
return
}
*allowExpansion = node.HasChildren()
// While we're here - preload one single level deep (not any deeper)
if node.HasChildren() {
// This node has children that we haven't processed. Process them now
cc := node.GetFirstChild()
for {
ndataChild := (*navData)(cc.Data())
if ndataChild.childrenLoaded {
break // We always do them together, so if one's done, no need to keep looking
}
f.NavLoadChildren(cc, ndataChild)
cc = cc.GetNextSibling()
if cc == nil {
break
}
}
}
}
func (f *TMainForm) NavLoadChildren(node *vcl.TTreeNode, ndata *navData) error {
if ndata.childrenLoaded {
return nil // Nothing to do
}
// Find the child buckets from this point under the element
nextBucketNames, err := ndata.ld.NavChildren(ndata)
if err != nil {
return fmt.Errorf("Failed to find child buckets under %q: %w", strings.Join(ndata.bucketPath, `/`), err)
}
ndata.childrenLoaded = true // don't repeat this work
if len(nextBucketNames) == 0 {
node.SetHasChildren(false)
} else {
node.SetHasChildren(true)
// Populate LCL child nodes
for _, bucketName := range nextBucketNames {
node := f.Buckets.Items().AddChild(node, formatUtf8([]byte(bucketName)))
node.SetHasChildren(true) // dynamically populate in OnNavExpanding
node.SetImageIndex(imgTable)
node.SetSelectedIndex(imgTable)
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))
ndata.ld.Keepalive(navData)
}
}
return nil
}