Compare commits

..

29 Commits

Author SHA1 Message Date
c541e8b941 doc/README: add v0.7.0 download links 2024-07-18 18:15:10 +12:00
877f291a1f makefile: set dist as default target 2024-07-18 18:02:37 +12:00
6145320858 doc: v0.7.0 changelog, update TODO 2024-07-18 18:00:56 +12:00
8296a2fec9 gui: hardcode better windows colours 2024-07-18 17:51:27 +12:00
223d13be58 gui: toggle edit buttons as well 2024-07-18 17:49:16 +12:00
eca27dcd4f sqlite: basic editing support 2024-07-18 17:46:13 +12:00
0f2a3e021a gui: toggle the Query form fields if selected db is not queryable 2024-07-18 17:10:02 +12:00
90259fb2b9 gui: prevent submitting blank queries to db (seems to hang sqlite) 2024-07-18 17:09:48 +12:00
7573cf0453 app: upcast loadedDatabase to more specific interfaces 2024-07-18 17:09:32 +12:00
6dd0635c9e doc/TODO: update 2024-07-14 15:35:56 +12:00
ce3d08740f sqlite: add context actions for compact, export, drop table 2024-07-14 15:34:17 +12:00
8f5e1054fb db: return error from contextAction.Callback (2) 2024-07-14 15:28:18 +12:00
1ac96eb133 move filter consts to each db file 2024-07-14 15:27:54 +12:00
53e9b6555e doc/TODO: more ideas 2024-07-13 18:03:47 +12:00
e1a9f187cb db: return error from RenderForNav, contextAction.Callback 2024-07-13 18:03:42 +12:00
ee3110162b doc/TODO: update 2024-07-06 12:44:37 +12:00
35a83eb483 gui: basic syntax highlighting implementation (disabled) 2024-07-06 12:41:57 +12:00
60add3be86 db: add common errunsupported 2024-07-06 12:02:58 +12:00
2f65ffdd70 db: lift execQuery error handling to parent 2024-07-06 11:59:55 +12:00
aad92d27e9 gui: use icons for toolbar 2024-07-06 11:54:48 +12:00
21151be8a3 gui/images: load more image assets 2024-07-06 11:54:36 +12:00
f78eec1872 bolt: support editing 2024-07-06 11:45:41 +12:00
8af27f8834 gui: change tracking for insert, edit, delete actions 2024-07-06 11:04:19 +12:00
0d3b90b879 gui: prep work for inserting rows 2024-07-05 20:07:19 +12:00
2b59efc410 gui: add refresh button on data tab 2024-07-05 19:46:04 +12:00
7fbf2ef1ed gui: common column handling, set widths automatically 2024-07-05 19:35:24 +12:00
d7e3363173 gui: convert data tables from TListView to TStringGrid 2024-07-05 19:21:08 +12:00
cecfc338d4 gui: bigger default window size 2024-07-05 18:43:02 +12:00
35f09fc072 doc/README: add v0.6.0 download links 2024-06-30 14:17:59 +12:00
23 changed files with 788 additions and 226 deletions

View File

@ -1,6 +1,6 @@
SHELL:=/bin/bash
SOURCES=$(find . -name '*.go' -type f)
.DEFAULT_GOAL := dist
liblcl-2.2.3.zip:
rm -f liblcl-2.2.3.zip

View File

@ -1,13 +1,11 @@
# yvbolt
A graphical browser for multiple databases using [GoVCL](https://z-kit.cc/en/).
This is an experimental application and you should generally prefer to use [qbolt](https://code.ivysaur.me/qbolt).
A graphical interface for multiple databases using [GoVCL](https://z-kit.cc/en/).
## Features
- Native desktop application, running on Linux, Windows, and macOS
- Connect to multiple databases
- Connect to multiple databases at once
- Browse table/bucket content
- Use context menu to perform special table/bucket actions
- Run custom SQL queries
@ -17,12 +15,16 @@ This is an experimental application and you should generally prefer to use [qbol
- Badger v4
- Bolt
- Recursive bucket support
- Option to open as readonly
- Option to open as readonly for shared access
- Supports editing
- See also [qbolt](https://code.ivysaur.me/qbolt) for more/different functionality
- Debconf
- Pebble
- Redis
- SQLite
- Drivers: mattn (CGo), modernc.org (no cgo), experimental command-line driver
- Supports editing
- Integrated vacuum and export commands
## License
@ -32,7 +34,7 @@ This project redistributes images from the famfamfam/silk icon set under the [CC
This project includes trademarked logo images for each supported database type.
## Usage
## Compiling
1. `CGO_ENABLED=1 go build`
2. [Download liblcl](https://github.com/ying32/govcl/releases/download/v2.2.3/liblcl-2.2.3.zip) for your platform, or [compile it yourself](https://github.com/ying32/liblcl) (tested with v2.2.3)
@ -41,6 +43,20 @@ This project includes trademarked logo images for each supported database type.
## Changelog
2024-07-18 v0.7.0
- SQLite, Bolt: Initial support for editing data (insert, per-cell update, delete)
- SQLite: Add context menu actions for compact (vacuum), export, and drop table
- App: New grid widget
- App: Add refresh button
- App: Bigger window size, use icons for toolbars, better UI colours for Windows
- App: Prevent submitting blank queries to database
- Refactor database interface and error handling
[⬇️ Download for Windows x86_64](https://git.ivysaur.me/code.ivysaur.me/yvbolt/releases/download/v0.7.0/yvbolt.win64.zip)
[⬇️ Download for Linux x86_64](https://git.ivysaur.me/code.ivysaur.me/yvbolt/releases/download/v0.7.0/yvbolt.linux64.tar.xz)
2024-06-30 v0.6.0
- Debconf: Add as supported database
@ -51,6 +67,10 @@ This project includes trademarked logo images for each supported database type.
- Build: Change compression parameters for release builds
- Build: Compile CGO with -O2 for release builds
[⬇️ Download for Windows x86_64](https://git.ivysaur.me/code.ivysaur.me/yvbolt/releases/download/v0.6.0/yvbolt.win64.zip)
[⬇️ Download for Linux x86_64](https://git.ivysaur.me/code.ivysaur.me/yvbolt/releases/download/v0.6.0/yvbolt.linux64.tar.xz)
2024-06-29 v0.5.0
- Pebble: Add as supported database

27
TODO
View File

@ -1,6 +1,10 @@
- Insert
- Update cell
- Delete row(s)
- Syntax highlighting in editor
- Mutation
- Get real primary key for mutation instead of string approximation
- Badger: Support insert/update/delete
- Pebble: Support insert/update/delete
- Debconf: Support insert/update/delete
- Redis: Support insert/update/delete
- Binary data viewer
- Detect jpg/png and show as image
- More DB types
@ -10,19 +14,19 @@
- MSSQL (recursive navigation for instances)
- Other K/V stores from https://github.com/smallnest/kvbench
- Windows registry
- SSH tunnels
- Badger encryption key dialog
- Pebble: connection options dialog
- SQLite: DB compact action
- SQLite: DB export backup action
- SQLite: drop table action
- SQLite: show views, triggers, indexes in nav
- LDAP
- Connection dialog
- SSH tunnels
- Badger encryption key dialog
- Pebble: connection options dialog
- SQLite CLI driver:
- Context support
- Attach to SSH tunnel
- Configure binary path
- https://ricardoanderegg.com/posts/sqlite-remote-explorer-gui/
- https://github.com/litements/litexplore
- Debconf: separate groups by first slash in name
- SQLite: drop table doesn't autorefresh nav since callback is late
- Build
- Build own liblcl binaries in docker
- Win32 icon resource
@ -32,3 +36,6 @@
- Context/interrupt slow queries
- Faster virtual rendering
- https://github.com/ying32/govcl/blob/master/samples/listviewvirtualdata/main.go
- Query history
- Test suite
- `CREATE TABLE foo (id integer primary key, aaa text not null, bbb text not null);`

BIN
assets/add.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 733 B

BIN
assets/delete.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 715 B

BIN
assets/lightning_go.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 739 B

BIN
assets/pencil.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

BIN
assets/pencil_add.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 B

BIN
assets/pencil_delete.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 603 B

BIN
assets/pencil_go.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 666 B

BIN
assets/resultset_next.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 B

View File

@ -7,7 +7,6 @@ import (
"github.com/dgraph-io/badger/v4"
"github.com/ying32/govcl/vcl"
"github.com/ying32/govcl/vcl/types"
)
type badgerLoadedDatabase struct {
@ -34,7 +33,7 @@ func (ld *badgerLoadedDatabase) Keepalive(ndata *navData) {
ld.arena = append(ld.arena, ndata)
}
func (ld *badgerLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
func (ld *badgerLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) error {
// Load properties
@ -42,25 +41,15 @@ func (ld *badgerLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
f.propertiesBox.SetText(content)
// Load data
f.contentBox.SetEnabled(false)
f.contentBox.Clear()
// Badger always uses Key + Value as the columns
f.contentBox.Columns().Clear()
colKey := f.contentBox.Columns().Add()
colKey.SetCaption("Key")
colKey.SetWidth(MY_WIDTH)
colKey.SetAlignment(types.TaLeftJustify)
colKey.Title().SetCaption("Key")
colVal := f.contentBox.Columns().Add()
colVal.SetCaption("Value")
colVal.Title().SetCaption("Value")
err := ld.db.View(func(txn *badger.Txn) error {
// Valid
f.contentBox.Clear()
// Create iterator
opts := badger.DefaultIteratorOptions
opts.PrefetchSize = 64
@ -71,9 +60,10 @@ func (ld *badgerLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
item := it.Item()
k := item.Key()
err := item.Value(func(v []byte) error {
dataEntry := f.contentBox.Items().Add()
dataEntry.SetCaption(formatUtf8(k))
dataEntry.SubItems().Add(formatUtf8(v))
rpos := f.contentBox.RowCount()
f.contentBox.SetRowCount(rpos + 1)
f.contentBox.SetCells(0, rpos, formatUtf8(k))
f.contentBox.SetCells(1, rpos, formatUtf8(v))
return nil
})
@ -84,12 +74,13 @@ func (ld *badgerLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
return nil
})
if err != nil {
vcl.ShowMessage(fmt.Sprintf("Failed to load data: %s", err.Error()))
return
return err
}
// Valid
vcl_stringgrid_columnwidths(f.contentBox)
f.contentBox.SetEnabled(true)
return nil
}
func (ld *badgerLoadedDatabase) NavChildren(ndata *navData) ([]string, error) {
@ -107,10 +98,6 @@ func (ld *badgerLoadedDatabase) NavContext(ndata *navData) ([]contextAction, err
return nil, nil // No special actions are supported
}
func (ld *badgerLoadedDatabase) ExecQuery(query string, resultArea *vcl.TListView) {
vcl.ShowMessage("Badger doesn't support querying")
}
func (ld *badgerLoadedDatabase) Close() {
_ = ld.db.Close()
ld.arena = nil

View File

@ -1,6 +1,7 @@
package main
import (
"errors"
"fmt"
"path/filepath"
"sort"
@ -9,11 +10,14 @@ import (
"unsafe"
"github.com/ying32/govcl/vcl"
"github.com/ying32/govcl/vcl/types"
"go.etcd.io/bbolt"
"go.etcd.io/bbolt/version"
)
const (
boltFilter = "Bolt database|*.db|All files|*.*"
)
type boltLoadedDatabase struct {
displayName string
path string
@ -39,7 +43,7 @@ func (ld *boltLoadedDatabase) Keepalive(ndata *navData) {
ld.arena = append(ld.arena, ndata)
}
func (ld *boltLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
func (ld *boltLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) error {
// Load properties
@ -48,19 +52,11 @@ func (ld *boltLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
f.propertiesBox.SetText(content)
// Load data
f.contentBox.SetEnabled(false)
f.contentBox.Clear()
// Bolt always uses Key + Value as the columns
f.contentBox.Columns().Clear()
colKey := f.contentBox.Columns().Add()
colKey.SetCaption("Key")
colKey.SetWidth(MY_WIDTH)
colKey.SetAlignment(types.TaLeftJustify)
colKey.Title().SetCaption("Key")
colVal := f.contentBox.Columns().Add()
colVal.SetCaption("Value")
colVal.Title().SetCaption("Value")
err := ld.db.View(func(tx *bbolt.Tx) error {
b := boltTargetBucket(tx, ndata.bucketPath)
@ -70,23 +66,74 @@ func (ld *boltLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
}
// 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))
rpos := f.contentBox.RowCount()
f.contentBox.SetRowCount(rpos + 1)
f.contentBox.SetCells(0, rpos, formatUtf8(k))
f.contentBox.SetCells(1, rpos, formatUtf8(v))
}
return nil
})
if err != nil {
vcl.ShowMessage(fmt.Sprintf("Failed to load data for bucket %q: %s", bucketDisplayName, err.Error()))
return
return err
}
// Valid
vcl_stringgrid_columnwidths(f.contentBox)
f.contentBox.SetEnabled(true)
return nil
}
func (n *boltLoadedDatabase) ApplyChanges(f *TMainForm, ndata *navData) error {
if n.db.IsReadOnly() {
return errors.New("Database was opened read-only")
}
// We have rendered row IDs, need to convert back to a bolt primary key
// TODO stash the real key inside f.contentBox.Objects()
// FIXME breaks if you try and edit the primary key(!)
primaryKeyForRendered := func(rowid int32) []byte {
return []byte(f.contentBox.Cells(0, rowid))
}
return n.db.Update(func(tx *bbolt.Tx) error {
// Get current bucket handle
b := boltTargetBucket(tx, ndata.bucketPath)
// Edit
for rowid, _ /*editcells*/ := range f.updateRows {
k := primaryKeyForRendered(rowid)
v := f.contentBox.Cells(1, rowid) // There's only one value cell
err := b.Put(k, []byte(v))
if err != nil {
return fmt.Errorf("Updating cell %q: %w", formatUtf8(k), err)
}
}
// Delete by key (affects rowids after re-render)
for rowid, _ := range f.deleteRows {
k := primaryKeyForRendered(rowid)
err := b.Delete(k)
if err != nil {
return fmt.Errorf("Deleting cell %q: %w", formatUtf8(k), err)
}
}
// Insert all new entries
for rowid, _ := range f.insertRows {
k := primaryKeyForRendered(rowid)
v := f.contentBox.Cells(1, rowid) // There's only one value cell
err := b.Put(k, []byte(v))
if err != nil {
return fmt.Errorf("Inserting cell %q: %w", formatUtf8(k), err)
}
}
// Done
return nil
})
}
func (ld *boltLoadedDatabase) NavChildren(ndata *navData) ([]string, error) {
@ -100,13 +147,14 @@ func (ld *boltLoadedDatabase) NavContext(ndata *navData) (ret []contextAction, e
if len(ndata.bucketPath) > 0 {
ret = append(ret, contextAction{"Delete bucket", ld.DeleteBucket})
}
return
}
func (ld *boltLoadedDatabase) AddChildBucket(ndata *navData) {
func (ld *boltLoadedDatabase) AddChildBucket(sender vcl.IComponent, ndata *navData) error {
bucketName := ""
if !vcl.InputQuery(APPNAME, "Enter a name for the new bucket:", &bucketName) {
return // cancel
return nil // cancel
}
err := ld.db.Update(func(tx *bbolt.Tx) error {
@ -121,11 +169,12 @@ func (ld *boltLoadedDatabase) AddChildBucket(ndata *navData) {
return err
})
if err != nil {
vcl.ShowMessageFmt("Error adding bucket: %v", err)
return fmt.Errorf("Error adding bucket: %w", err)
}
return nil
}
func (ld *boltLoadedDatabase) DeleteBucket(ndata *navData) {
func (ld *boltLoadedDatabase) DeleteBucket(sender vcl.IComponent, ndata *navData) error {
err := ld.db.Update(func(tx *bbolt.Tx) error {
// Find parent of this bucket.
if len(ndata.bucketPath) >= 2 {
@ -138,12 +187,9 @@ func (ld *boltLoadedDatabase) DeleteBucket(ndata *navData) {
}
})
if err != nil {
vcl.ShowMessageFmt("Error deleting bucket %q: %v", strings.Join(ndata.bucketPath, `/`), err)
return fmt.Errorf("Error deleting bucket %q: %w", strings.Join(ndata.bucketPath, `/`), err)
}
}
func (ld *boltLoadedDatabase) ExecQuery(query string, resultArea *vcl.TListView) {
vcl.ShowMessage("Bolt doesn't support querying")
return nil
}
func (ld *boltLoadedDatabase) Close() {

View File

@ -35,7 +35,7 @@ func (ld *debconfLoadedDatabase) Keepalive(ndata *navData) {
ld.arena = append(ld.arena, ndata)
}
func (ld *debconfLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
func (ld *debconfLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) error {
// Load properties
@ -44,36 +44,30 @@ func (ld *debconfLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
// Load data
f.contentBox.SetEnabled(false)
f.contentBox.Clear()
// debconf always uses Key + Value as the columns
indexes := make(map[string]int)
f.contentBox.Columns().Clear()
for i, cname := range ld.db.AllColumnNames {
indexes[cname] = i
col := f.contentBox.Columns().Add()
col.SetCaption(cname)
col.SetWidth(MY_WIDTH)
col.Title().SetCaption(cname)
}
for _, entry := range ld.db.Entries {
cell := f.contentBox.Items().Add()
cell.SetCaption(entry.Name)
rpos := f.contentBox.RowCount()
f.contentBox.SetRowCount(rpos + 1)
f.contentBox.SetCells(0, rpos, entry.Name)
texts := make([]string, len(ld.db.AllColumnNames))
for _, proppair := range entry.Properties {
texts[indexes[proppair[0]]-1 /* compensate for 'Name' always being first */] = proppair[1]
f.contentBox.SetCells(int32(indexes[proppair[0]]), rpos, proppair[1])
}
cell.SubItems().AddStrings2(texts)
}
// Valid
vcl_stringgrid_columnwidths(f.contentBox)
f.contentBox.SetEnabled(true)
return nil
}
func (ld *debconfLoadedDatabase) NavChildren(ndata *navData) ([]string, error) {
@ -91,10 +85,6 @@ func (ld *debconfLoadedDatabase) NavContext(ndata *navData) ([]contextAction, er
return nil, nil // No special actions are supported
}
func (ld *debconfLoadedDatabase) ExecQuery(query string, resultArea *vcl.TListView) {
vcl.ShowMessage("debconf doesn't support querying")
}
func (ld *debconfLoadedDatabase) Close() {
ld.arena = nil
}

View File

@ -18,13 +18,9 @@ func (n *noLoadedDatabase) RootElement() *vcl.TTreeNode {
return nil
}
func (n *noLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
func (n *noLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) error {
f.propertiesBox.SetText("Open a database to get started...")
f.contentBox.SetEnabled(false)
f.contentBox.Clear()
}
func (n *noLoadedDatabase) ExecQuery(query string, resultArea *vcl.TListView) {
return nil
}
func (n *noLoadedDatabase) NavChildren(ndata *navData) ([]string, error) {

View File

@ -9,7 +9,6 @@ import (
"github.com/cockroachdb/pebble"
"github.com/cockroachdb/pebble/vfs"
"github.com/ying32/govcl/vcl"
"github.com/ying32/govcl/vcl/types"
)
type pebbleLoadedDatabase struct {
@ -36,7 +35,7 @@ func (ld *pebbleLoadedDatabase) Keepalive(ndata *navData) {
ld.arena = append(ld.arena, ndata)
}
func (ld *pebbleLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
func (ld *pebbleLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) error {
ctx := context.Background()
@ -46,19 +45,12 @@ func (ld *pebbleLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
f.propertiesBox.SetText(content)
// Load data
f.contentBox.SetEnabled(false)
f.contentBox.Clear()
// pebble always uses Key + Value as the columns
f.contentBox.Columns().Clear()
colKey := f.contentBox.Columns().Add()
colKey.SetCaption("Key")
colKey.SetWidth(MY_WIDTH)
colKey.SetAlignment(types.TaLeftJustify)
colKey.Title().SetCaption("Key")
colVal := f.contentBox.Columns().Add()
colVal.SetCaption("Value")
colVal.Title().SetCaption("Value")
itr := ld.db.NewIterWithContext(ctx, nil)
defer itr.Close()
@ -67,17 +59,19 @@ func (ld *pebbleLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
k := itr.Key()
v, err := itr.ValueAndErr()
if err != nil {
vcl.ShowMessage(fmt.Sprintf("Failed to load data for key %q: %s", formatAny(k), err.Error()))
return
return fmt.Errorf("Failed to load data for key %q: %w", formatAny(k), err)
}
dataEntry := f.contentBox.Items().Add()
dataEntry.SetCaption(formatUtf8(k))
dataEntry.SubItems().Add(formatUtf8(v))
rpos := f.contentBox.RowCount()
f.contentBox.SetRowCount(rpos + 1)
f.contentBox.SetCells(0, rpos, formatUtf8(k))
f.contentBox.SetCells(0, rpos, formatUtf8(v))
}
// Valid
vcl_stringgrid_columnwidths(f.contentBox)
f.contentBox.SetEnabled(true)
return nil
}
func (ld *pebbleLoadedDatabase) NavChildren(ndata *navData) ([]string, error) {
@ -95,10 +89,6 @@ func (ld *pebbleLoadedDatabase) NavContext(ndata *navData) ([]contextAction, err
return nil, nil // No special actions are supported
}
func (ld *pebbleLoadedDatabase) ExecQuery(query string, resultArea *vcl.TListView) {
vcl.ShowMessage("pebble doesn't support querying")
}
func (ld *pebbleLoadedDatabase) Close() {
_ = ld.db.Close()
ld.arena = nil

View File

@ -10,7 +10,6 @@ import (
"github.com/redis/go-redis/v9"
"github.com/ying32/govcl/vcl"
"github.com/ying32/govcl/vcl/types"
)
type redisLoadedDatabase struct {
@ -41,83 +40,71 @@ func (ld *redisLoadedDatabase) Keepalive(ndata *navData) {
ld.arena = append(ld.arena, ndata)
}
func (ld *redisLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
func (ld *redisLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) error {
ctx := context.Background()
if len(ndata.bucketPath) == 0 {
// Top-level: Show info() on main Properties tab
infostr, err := ld.db.Info(ctx).Result()
if err != nil {
vcl.ShowMessage(fmt.Sprintf("Retreiving database info: %v", err))
return
return fmt.Errorf("Retreiving database info: %w", err)
}
f.propertiesBox.SetText(infostr)
// Disable data tab
f.contentBox.SetEnabled(false)
f.contentBox.Clear()
// Leave data tab disabled (default behaviour)
return nil
} else if len(ndata.bucketPath) == 1 {
// One selected database
// Figure out its content
err := ld.db.Do(ctx, "SELECT", ndata.bucketPath[0]).Err()
if err != nil {
vcl.ShowMessage(fmt.Sprintf("Switching to database %q: %v", ndata.bucketPath[0], err))
return
return fmt.Errorf("Switching to database %q: %w", ndata.bucketPath[0], err)
}
allKeys, err := ld.db.Keys(ctx, "*").Result()
if err != nil {
vcl.ShowMessage(fmt.Sprintf("Listing keys in database %q: %v", ndata.bucketPath[0], err))
return
return fmt.Errorf("Listing keys in database %q: %w", ndata.bucketPath[0], err)
}
f.propertiesBox.SetText(fmt.Sprintf("Database %s\nTotal keys: %d\n", ndata.bucketPath[0], len(allKeys)))
// Redis always uses Key + Value as the columns
f.contentBox.SetEnabled(false)
f.contentBox.Clear()
f.contentBox.Columns().Clear()
colKey := f.contentBox.Columns().Add()
colKey.SetCaption("Key")
colKey.SetWidth(MY_WIDTH)
colKey.SetAlignment(types.TaLeftJustify)
colKey.Title().SetCaption("Key")
colType := f.contentBox.Columns().Add()
colType.SetCaption("Type")
colType.Title().SetCaption("Type")
colVal := f.contentBox.Columns().Add()
colVal.SetCaption("Value")
colVal.Title().SetCaption("Value")
for _, key := range allKeys {
typeName, err := ld.db.Type(ctx, key).Result()
if err != nil {
vcl.ShowMessage(fmt.Sprintf("Loading %q/%q: %v", ndata.bucketPath[0], key, err))
return
return fmt.Errorf("Loading %q/%q: %w", ndata.bucketPath[0], key, err)
}
dataEntry := f.contentBox.Items().Add()
dataEntry.SetCaption(key) // formatUtf8
dataEntry.SubItems().Add(typeName)
rpos := f.contentBox.RowCount()
f.contentBox.SetRowCount(rpos + 1)
f.contentBox.SetCells(0, rpos, formatUtf8([]byte(key)))
f.contentBox.SetCells(1, rpos, typeName)
switch typeName {
case "string":
val, err := ld.db.Get(ctx, key).Result()
if err != nil {
vcl.ShowMessage(fmt.Sprintf("Loading %q/%q: %v", ndata.bucketPath[0], key, err))
return
return fmt.Errorf("Loading %q/%q: %w", ndata.bucketPath[0], key, err)
}
dataEntry.SubItems().Add(val)
f.contentBox.SetCells(2, rpos, val)
case "hash":
val, err := ld.db.HGetAll(ctx, key).Result()
if err != nil {
vcl.ShowMessage(fmt.Sprintf("Loading %q/%q: %v", ndata.bucketPath[0], key, err))
return
return fmt.Errorf("Loading %q/%q: %w", ndata.bucketPath[0], key, err)
}
// It's a map[string]string
dataEntry.SubItems().Add(formatAny(val))
f.contentBox.SetCells(2, rpos, formatAny(val))
case "lists":
fallthrough
@ -129,17 +116,18 @@ func (ld *redisLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
fallthrough
default:
dataEntry.SubItems().Add("<<<other object type>>>")
f.contentBox.SetCells(2, rpos, "<<<other object type>>>")
}
}
// Valid
vcl_stringgrid_columnwidths(f.contentBox)
f.contentBox.SetEnabled(true)
return nil
} else {
vcl.ShowMessage(fmt.Sprintf("Unexpected nav position %q", ndata.bucketPath))
return
return fmt.Errorf("Unexpected nav position %q", ndata.bucketPath)
}
}
@ -170,48 +158,50 @@ func (ld *redisLoadedDatabase) NavContext(ndata *navData) ([]contextAction, erro
return nil, nil // No special actions are supported
}
func (ld *redisLoadedDatabase) ExecQuery(query string, resultArea *vcl.TListView) {
func (ld *redisLoadedDatabase) ExecQuery(query string, resultArea *vcl.TStringGrid) error {
ctx := context.Background()
// Need to parse the query into separate string+args fields for the protocol
fields, err := lexer.Fields(query)
if err != nil {
vcl.ShowMessage(fmt.Sprintf("Parsing the query: %v", err))
return
return fmt.Errorf("Parsing the query: %w", err)
}
fields_boxed := box_interface(fields)
ret, err := ld.db.Do(ctx, fields_boxed...).Result()
if err != nil {
vcl.ShowMessage(fmt.Sprintf("The redis query returned an error: %v", err))
return
return fmt.Errorf("The redis query returned an error: %w", err)
}
resultArea.SetEnabled(false)
resultArea.Clear()
vcl_stringgrid_clear(resultArea)
resultArea.Columns().Clear()
colVal := resultArea.Columns().Add()
colVal.SetCaption("Result")
colVal.Title().SetCaption("Result")
// The result is probably a single value or a string slice
switch ret := ret.(type) {
case []string:
// Multiple values
for _, single := range ret {
cell := resultArea.Items().Add()
cell.SetCaption(single) // formatUtf8
rpos := resultArea.RowCount()
resultArea.SetRowCount(rpos + 1)
resultArea.SetCells(0, rpos, formatUtf8([]byte(single)))
}
default:
// Single value
dataEntry := resultArea.Items().Add()
dataEntry.SetCaption(formatAny(ret)) // formatUtf8
rpos := resultArea.RowCount()
resultArea.SetRowCount(rpos + 1)
resultArea.SetCells(0, rpos, formatAny(ret)) // formatUtf8
}
vcl_stringgrid_columnwidths(resultArea)
resultArea.SetEnabled(true)
return nil
}
func (ld *redisLoadedDatabase) Close() {

View File

@ -1,19 +1,22 @@
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"path/filepath"
"strings"
"unsafe"
_ "yvbolt/sqliteclidriver"
"github.com/ying32/govcl/vcl"
"github.com/ying32/govcl/vcl/types"
)
const (
sqliteTablesCaption = "Tables"
sqliteFilter = "SQLite database|*.db;*.db3;*.sqlite;*.sqlite3|All files|*.*"
)
type sqliteLoadedDatabase struct {
@ -37,19 +40,17 @@ func (ld *sqliteLoadedDatabase) Keepalive(ndata *navData) {
ld.arena = append(ld.arena, ndata)
}
func (ld *sqliteLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
func (ld *sqliteLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) error {
if len(ndata.bucketPath) == 0 {
// Top-level
f.propertiesBox.SetText("Please select...")
f.contentBox.SetEnabled(false)
f.contentBox.Clear()
return nil
} else if len(ndata.bucketPath) == 1 {
// Category (tables, ...)
f.propertiesBox.SetText("Please select...")
f.contentBox.SetEnabled(false)
f.contentBox.Clear()
return nil
} else if len(ndata.bucketPath) == 2 && ndata.bucketPath[0] == sqliteTablesCaption {
// Render for specific table
@ -66,17 +67,13 @@ func (ld *sqliteLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
// Display table properties
f.propertiesBox.SetText(fmt.Sprintf("Selected table %q\n\nSchema:\n\n%s", tableName, schemaStmt))
f.contentBox.SetEnabled(false)
f.contentBox.Clear()
// Load column details
// Use SELECT form instead of common PRAGMA table_info so we can just get names
// We could possibly get this from the main data select, but this will
// work even when there are 0 results
columnNames, err := ld.sqliteGetColumnNamesForTable(tableName)
if err != nil {
vcl.ShowMessageFmt("Failed to load columns for table %q: %s", tableName, err.Error())
return
return fmt.Errorf("Failed to load columns for table %q: %w", tableName)
}
populateColumns(columnNames, f.contentBox)
@ -87,17 +84,19 @@ func (ld *sqliteLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
// Select * with small limit
datar, err := ld.db.Query(`SELECT * FROM "` + tableName + `" LIMIT 1000`) // WARNING can't prepare this parameter, but it comes from the DB (trusted)
if err != nil {
vcl.ShowMessageFmt("Failed to load data for table %q: %s", tableName, err.Error())
return
return fmt.Errorf("Failed to load data for table %q: %w", tableName, err)
}
defer datar.Close()
populateRows(datar, f.contentBox)
// We successfully populated the data grid
vcl_stringgrid_columnwidths(f.contentBox)
f.contentBox.SetEnabled(true)
return nil
} else {
// ??? unknown
return errors.New("?")
}
@ -129,17 +128,14 @@ func (ld *sqliteLoadedDatabase) sqliteGetColumnNamesForTable(tableName string) (
return ret, nil
}
func populateColumns(names []string, dest *vcl.TListView) {
dest.Columns().Clear()
func populateColumns(names []string, dest *vcl.TStringGrid) {
for _, columnName := range names {
col := dest.Columns().Add()
col.SetCaption(columnName)
col.SetWidth(MY_WIDTH)
col.SetAlignment(types.TaLeftJustify)
col.Title().SetCaption(columnName)
}
}
func populateRows(rr *sql.Rows, dest *vcl.TListView) {
func populateRows(rr *sql.Rows, dest *vcl.TStringGrid) {
numColumns := int(dest.Columns().Count())
@ -156,10 +152,10 @@ func populateRows(rr *sql.Rows, dest *vcl.TListView) {
return
}
dataEntry := dest.Items().Add()
dataEntry.SetCaption(formatAny(fields[0]))
for i := 1; i < len(fields); i += 1 {
dataEntry.SubItems().Add(formatAny(fields[i]))
rpos := dest.RowCount()
dest.SetRowCount(rpos + 1)
for i := 0; i < len(fields); i += 1 {
dest.SetCells(int32(i), rpos, formatAny(fields[i]))
}
}
@ -169,29 +165,149 @@ func populateRows(rr *sql.Rows, dest *vcl.TListView) {
}
}
func (ld *sqliteLoadedDatabase) ExecQuery(query string, resultArea *vcl.TListView) {
func (ld *sqliteLoadedDatabase) ExecQuery(query string, resultArea *vcl.TStringGrid) error {
rr, err := ld.db.Query(query)
if err != nil {
vcl.ShowMessage(err.Error())
return
return err
}
defer rr.Close()
resultArea.SetEnabled(false)
resultArea.Clear()
vcl_stringgrid_clear(resultArea)
columns, err := rr.Columns()
if err != nil {
vcl.ShowMessage(err.Error())
return
return err
}
populateColumns(columns, resultArea)
populateRows(rr, resultArea)
vcl_stringgrid_columnwidths(resultArea)
resultArea.SetEnabled(true)
return nil
}
func (n *sqliteLoadedDatabase) ApplyChanges(f *TMainForm, ndata *navData) (retErr error) {
if len(ndata.bucketPath) != 2 {
return errors.New("invalid selection")
}
tableName := ndata.bucketPath[1]
// We have rendered row IDs, need to convert back to an SQLite primary key
// TODO stash the real key inside f.contentBox.Objects()
// FIXME breaks if you try and edit the primary key(!)
ctx := context.Background()
tx, err := n.db.BeginTx(ctx, nil)
if err != nil {
return err
}
var commitOK bool = false
defer func() {
if !commitOK {
err := tx.Rollback()
if err != nil {
retErr = err
}
}
}()
// Data grid properties
var columnNames []string
for i := int32(0); i < f.contentBox.ColCount(); i++ {
columnNames = append(columnNames, f.contentBox.Columns().Items(i).Title().Caption())
}
// Query sqlite table metadata to determine which of these is the PRIMARY KEY
var primaryColumnName string
err = tx.QueryRowContext(ctx, `SELECT l.name FROM pragma_table_info(?) as l WHERE l.pk = 1;`, tableName).Scan(&primaryColumnName)
if err != nil {
return fmt.Errorf("Finding primary key for update: %w", err)
}
// Convert it to an index
var primaryColumnIdx int = -1
for i := 0; i < len(columnNames); i++ {
if columnNames[i] == primaryColumnName {
primaryColumnIdx = i
break
}
}
if primaryColumnIdx == -1 {
return fmt.Errorf("Primary key %q missing from available columns", primaryColumnName)
}
// SQLite can only LIMIT 1 on update/delete if it was compiled with
// SQLITE_ENABLE_UPDATE_DELETE_LIMIT, which isn't the case for the mattn
// cgo library
// Skip that, and just rely on primary key uniqueness
// Edit
for rowid, editcells := range f.updateRows {
stmt := `UPDATE "` + tableName + `" SET `
params := []interface{}{} // FIXME reinstate types for the driver (although SQLite doesn't mind)
for ct, cell := range editcells {
if ct > 0 {
stmt += `, `
}
stmt += `"` + columnNames[cell] + `" = ?`
params = append(params, f.contentBox.Cells(cell, rowid))
}
stmt += ` WHERE "` + primaryColumnName + `" = ?`
pkVal := f.contentBox.Cells(int32(primaryColumnIdx), rowid)
params = append(params, pkVal)
_, err = tx.ExecContext(ctx, stmt, params...)
if err != nil {
return fmt.Errorf("Updating row %q: %w", pkVal, err)
}
}
// Delete by key (affects rowids after re-render)
for rowid, _ := range f.deleteRows {
pkVal := f.contentBox.Cells(int32(primaryColumnIdx), rowid)
stmt := `DELETE FROM "` + tableName + `" WHERE "` + primaryColumnName + `" = ?`
_, err = tx.ExecContext(ctx, stmt, pkVal)
if err != nil {
return fmt.Errorf("Deleting row %q: %w", pkVal, err)
}
}
// Insert all new entries
for rowid, _ := range f.insertRows {
stmt := `INSERT INTO "` + tableName + `" (` + strings.Join(columnNames, `, `) + `) VALUES (`
params := []interface{}{} // FIXME reinstate types for the driver (although SQLite doesn't mind)
for colid := 0; colid < len(columnNames); colid++ {
if colid > 0 {
stmt += `, `
}
stmt += "?"
params = append(params, f.contentBox.Cells(int32(colid), rowid))
}
stmt += `)`
_, err = tx.ExecContext(ctx, stmt, params...)
if err != nil {
return fmt.Errorf("Inserting row: %w", err)
}
}
err = tx.Commit()
if err != nil {
return err
}
commitOK = true // no need for rollback
return nil
}
func (ld *sqliteLoadedDatabase) NavChildren(ndata *navData) ([]string, error) {
@ -232,8 +348,52 @@ func (ld *sqliteLoadedDatabase) NavChildren(ndata *navData) ([]string, error) {
return nil, fmt.Errorf("unknown nav path %#v", ndata.bucketPath)
}
func (ld *sqliteLoadedDatabase) NavContext(ndata *navData) ([]contextAction, error) {
return nil, nil // No special actions are supported
func (ld *sqliteLoadedDatabase) NavContext(ndata *navData) (ret []contextAction, err error) {
if len(ndata.bucketPath) == 0 {
ret = append(ret, contextAction{"Compact database", ld.CompactDatabase})
ret = append(ret, contextAction{"Export backup...", ld.ExportBackup})
}
if len(ndata.bucketPath) == 2 {
ret = append(ret, contextAction{"Drop table", ld.DropTable})
}
return
}
func (ld *sqliteLoadedDatabase) CompactDatabase(sender vcl.IComponent, ndata *navData) error {
_, err := ld.db.Exec(`VACUUM;`)
return err
}
func (ld *sqliteLoadedDatabase) ExportBackup(sender vcl.IComponent, ndata *navData) error {
// Popup for output file
dlg := vcl.NewSaveDialog(sender)
dlg.SetTitle("Save backup as...")
dlg.SetFilter(sqliteFilter)
ret := dlg.Execute() // Fake blocking
if !ret {
return nil // cancelled
}
_, err := ld.db.Exec(`VACUUM INTO ?`, dlg.FileName())
return err
}
func (ld *sqliteLoadedDatabase) DropTable(sender vcl.IComponent, ndata *navData) error {
if len(ndata.bucketPath) != 2 {
return errors.New("Invalid selection")
}
//
tableName := ndata.bucketPath[1]
if !vcl_confirm_dialog(sender, "Drop table", fmt.Sprintf("Are you sure you want to drop the table %q?", tableName)) {
return nil // cancelled
}
_, err := ld.db.Exec(`DROP TABLE "` + tableName + `"`) // WARNING can't prepare this parameter, but it comes from the DB (trusted)
return err
}
func (ld *sqliteLoadedDatabase) Close() {

View File

@ -10,14 +10,22 @@ import (
var assetsFs embed.FS
const (
imgArrowRefresh int32 = iota
imgAdd int32 = iota
imgArrowRefresh
imgChartBar
imgDatabase
imgDatabaseAdd
imgDatabaseDelete
imgDatabaseLightning
imgDatabaseSave
imgDelete
imgLightning
imgLightningGo
imgPencil
imgPencilAdd
imgPencilDelete
imgPencilGo
imgResultsetNext
imgTable
imgTableAdd
imgTableDelete
@ -48,6 +56,9 @@ func loadImages(owner vcl.IComponent) *vcl.TImageList {
}
ilist := vcl.NewImageList(owner)
// ls assets | sort | sed -re 's~(.+)~ilist.Add(mustLoad("assets/\1"), nil)~'
ilist.Add(mustLoad("assets/add.png"), nil)
ilist.Add(mustLoad("assets/arrow_refresh.png"), nil)
ilist.Add(mustLoad("assets/chart_bar.png"), nil)
ilist.Add(mustLoad("assets/database.png"), nil)
@ -55,7 +66,14 @@ func loadImages(owner vcl.IComponent) *vcl.TImageList {
ilist.Add(mustLoad("assets/database_delete.png"), nil)
ilist.Add(mustLoad("assets/database_lightning.png"), nil)
ilist.Add(mustLoad("assets/database_save.png"), nil)
ilist.Add(mustLoad("assets/delete.png"), nil)
ilist.Add(mustLoad("assets/lightning.png"), nil)
ilist.Add(mustLoad("assets/lightning_go.png"), nil)
ilist.Add(mustLoad("assets/pencil.png"), nil)
ilist.Add(mustLoad("assets/pencil_add.png"), nil)
ilist.Add(mustLoad("assets/pencil_delete.png"), nil)
ilist.Add(mustLoad("assets/pencil_go.png"), nil)
ilist.Add(mustLoad("assets/resultset_next.png"), nil)
ilist.Add(mustLoad("assets/table.png"), nil)
ilist.Add(mustLoad("assets/table_add.png"), nil)
ilist.Add(mustLoad("assets/table_delete.png"), nil)
@ -67,6 +85,7 @@ func loadImages(owner vcl.IComponent) *vcl.TImageList {
ilist.Add(mustLoad("assets/vendor_mysql.png"), nil)
ilist.Add(mustLoad("assets/vendor_redis.png"), nil)
ilist.Add(mustLoad("assets/vendor_sqlite.png"), nil)
return ilist
}

View File

@ -10,7 +10,7 @@ var ErrNavNotExist error = errors.New("The selected item no longer exists")
type contextAction struct {
Name string
Callback func(ndata *navData)
Callback func(sender vcl.IComponent, ndata *navData) error
}
// loadedDatabase is a DB-agnostic interface for each loaded database.
@ -18,14 +18,21 @@ type loadedDatabase interface {
DisplayName() string
DriverName() string
RootElement() *vcl.TTreeNode
RenderForNav(f *TMainForm, ndata *navData)
ExecQuery(query string, resultArea *vcl.TListView)
RenderForNav(f *TMainForm, ndata *navData) error
NavChildren(ndata *navData) ([]string, error)
NavContext(ndata *navData) ([]contextAction, error)
Keepalive(ndata *navData)
Close()
}
type queryableLoadedDatabase interface {
ExecQuery(query string, resultArea *vcl.TStringGrid) error
}
type editableLoadedDatabase interface {
ApplyChanges(f *TMainForm, ndata *navData) error
}
// navData is the .Data() pointer for each TTreeNode in the left-hand tree.
type navData struct {
ld loadedDatabase

341
main.go
View File

@ -11,11 +11,17 @@ import (
"github.com/pkg/browser"
"github.com/ying32/govcl/vcl"
"github.com/ying32/govcl/vcl/types"
"github.com/ying32/govcl/vcl/types/colors"
)
const (
APPNAME = "yvbolt"
HOMEPAGE_URL = "https://code.ivysaur.me/yvbolt"
CO_INSERT = colors.ClYellow
CO_EDIT_IMPLICIT = colors.ClLightgreen
CO_EDIT_EXPLICIT = colors.ClGreen
CO_DELETE = colors.ClRed
)
type TMainForm struct {
@ -30,9 +36,17 @@ type TMainForm struct {
Buckets *vcl.TTreeView
Tabs *vcl.TPageControl
propertiesBox *vcl.TMemo
contentBox *vcl.TListView
queryInput *vcl.TMemo
queryResult *vcl.TListView
contentBox *vcl.TStringGrid
dataInsertBtn *vcl.TToolButton
dataDelRowBtn *vcl.TToolButton
dataCommitBtn *vcl.TToolButton
isEditing bool
insertRows map[int32]struct{} // Rows in the StringGrid that are to-be-inserted
deleteRows map[int32]struct{}
updateRows map[int32][]int32 // Row->cells that are to-be-updated
queryExecBtn *vcl.TToolButton
queryInput *vcl.TRichEdit
queryResult *vcl.TStringGrid
none *noLoadedDatabase
dbs []loadedDatabase
@ -51,6 +65,8 @@ func (f *TMainForm) OnFormCreate(sender vcl.IObject) {
f.SetCaption(APPNAME)
f.ScreenCenter()
f.SetWidth(1280)
f.SetHeight(640)
f.ImageList.GetIcon(imgDatabaseLightning, f.Icon())
mnuFile := vcl.NewMenuItem(f)
@ -135,7 +151,7 @@ func (f *TMainForm) OnFormCreate(sender vcl.IObject) {
mnuQueryExecute.SetCaption("Execute")
mnuQueryExecute.SetShortCutFromString("F5")
mnuQueryExecute.SetOnClick(f.OnQueryExecute)
mnuQueryExecute.SetImageIndex(imgLightning)
mnuQueryExecute.SetImageIndex(imgResultsetNext)
mnuQuery.Add(mnuQueryExecute)
mnuHelp := vcl.NewMenuItem(f)
@ -207,14 +223,56 @@ func (f *TMainForm) OnFormCreate(sender vcl.IObject) {
dataTab.SetCaption("Data")
dataTab.SetImageIndex(imgTable)
f.contentBox = vcl.NewListView(dataTab)
dataButtonBar := vcl.NewToolBar(dataTab)
dataButtonBar.SetParent(dataTab)
dataButtonBar.SetAlign(types.AlTop)
dataButtonBar.BorderSpacing().SetLeft(MY_SPACING)
dataButtonBar.BorderSpacing().SetTop(MY_SPACING)
dataButtonBar.BorderSpacing().SetBottom(1)
dataButtonBar.BorderSpacing().SetRight(MY_SPACING)
dataButtonBar.SetEdgeBorders(0)
dataButtonBar.SetImages(f.ImageList)
dataRefreshBtn := vcl.NewToolButton(dataButtonBar)
dataRefreshBtn.SetParent(dataButtonBar)
dataRefreshBtn.SetHint("Refresh")
dataRefreshBtn.SetShowHint(true)
dataRefreshBtn.SetImageIndex(imgArrowRefresh)
dataRefreshBtn.SetOnClick(func(sender vcl.IObject) { f.RefreshCurrentItem() })
f.dataInsertBtn = vcl.NewToolButton(dataButtonBar)
f.dataInsertBtn.SetParent(dataButtonBar)
f.dataInsertBtn.SetImageIndex(imgAdd)
f.dataInsertBtn.SetHint("Insert")
f.dataInsertBtn.SetShowHint(true)
f.dataInsertBtn.SetOnClick(f.OnDataInsertClick)
f.dataDelRowBtn = vcl.NewToolButton(dataButtonBar)
f.dataDelRowBtn.SetParent(dataButtonBar)
f.dataDelRowBtn.SetImageIndex(imgDelete)
f.dataDelRowBtn.SetHint("Delete Row")
f.dataDelRowBtn.SetShowHint(true)
f.dataDelRowBtn.SetOnClick(f.OnDataDeleteRowClick)
f.dataCommitBtn = vcl.NewToolButton(dataButtonBar)
f.dataCommitBtn.SetParent(dataButtonBar)
f.dataCommitBtn.SetImageIndex(imgPencilGo)
f.dataCommitBtn.SetHint("Commit")
f.dataCommitBtn.SetShowHint(true)
f.dataCommitBtn.SetOnClick(f.OnDataCommitClick)
f.contentBox = vcl.NewStringGrid(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()
f.contentBox.BorderSpacing().SetLeft(MY_SPACING)
f.contentBox.BorderSpacing().SetRight(MY_SPACING)
f.contentBox.BorderSpacing().SetBottom(MY_SPACING)
f.contentBox.SetAlign(types.AlClient) // fill remaining space
f.contentBox.SetOptions(f.contentBox.Options().Include(types.GoThumbTracking, types.GoColSizing, types.GoDblClickAutoSize, types.GoEditing))
f.contentBox.SetOnPrepareCanvas(f.OnDataPrepareCanvas)
f.contentBox.SetOnEditingDone(f.OnDataCellEdited)
f.contentBox.SetOnGetEditText(f.OnDataCellEditStarting)
f.contentBox.SetDefaultColWidth(MY_WIDTH)
vcl_stringgrid_clear(f.contentBox)
queryTab := vcl.NewTabSheet(f.Tabs)
queryTab.SetParent(f.Tabs)
@ -232,13 +290,14 @@ func (f *TMainForm) OnFormCreate(sender vcl.IObject) {
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.queryExecBtn = vcl.NewToolButton(queryButtonBar)
f.queryExecBtn.SetParent(queryButtonBar)
f.queryExecBtn.SetHint("Execute")
f.queryExecBtn.SetShowHint(true)
f.queryExecBtn.SetImageIndex(imgResultsetNext)
f.queryExecBtn.SetOnClick(f.OnQueryExecute)
f.queryInput = vcl.NewMemo(queryTab)
f.queryInput = vcl.NewRichEdit(queryTab)
f.queryInput.SetParent(queryTab)
f.queryInput.SetHeight(MY_HEIGHT)
f.queryInput.SetAlign(types.AlTop)
@ -248,26 +307,27 @@ func (f *TMainForm) OnFormCreate(sender vcl.IObject) {
} else {
f.queryInput.Font().SetName("monospace")
}
f.queryInput.SetCursor(types.CrIBeam) // Use text cursor instead of default pointer cursor
f.queryInput.BorderSpacing().SetLeft(MY_SPACING)
//f.queryInput.BorderSpacing().SetTop(1)
f.queryInput.BorderSpacing().SetRight(MY_SPACING)
f.queryInput.SetBorderStyle(types.BsFrame)
// f.queryInput.SetOnKeyUp(f.OnQueryTextChanged) // Performs extremely slowly
vsplit := vcl.NewSplitter(queryTab)
vsplit.SetParent(queryTab)
vsplit.SetAlign(types.AlTop)
vsplit.SetTop(2)
f.queryResult = vcl.NewListView(queryTab)
f.queryResult = vcl.NewStringGrid(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.SetAlign(types.AlClient) // fill remaining space
f.queryResult.BorderSpacing().SetLeft(MY_SPACING)
f.queryResult.BorderSpacing().SetRight(MY_SPACING)
f.queryResult.BorderSpacing().SetBottom(MY_SPACING)
f.queryResult.SetOptions(f.queryResult.Options().Include(types.GoThumbTracking))
f.queryResult.SetDefaultColWidth(MY_WIDTH)
vcl_stringgrid_clear(f.queryResult)
f.none = &noLoadedDatabase{}
f.OnNavChange(f, nil) // calls f.none.RenderForNav and sets up status bar content
@ -276,7 +336,7 @@ func (f *TMainForm) OnFormCreate(sender vcl.IObject) {
func (f *TMainForm) OnMnuFileBoltNewClick(sender vcl.IObject) {
dlg := vcl.NewSaveDialog(f)
dlg.SetTitle("Save database as...")
dlg.SetFilter("Bolt database|*.db|All files|*.*")
dlg.SetFilter(boltFilter)
ret := dlg.Execute() // Fake blocking
if ret {
f.boltAddDatabaseFromFile(dlg.FileName(), false)
@ -286,7 +346,7 @@ func (f *TMainForm) OnMnuFileBoltNewClick(sender vcl.IObject) {
func (f *TMainForm) OnMnuFileBoltOpenClick(sender vcl.IObject) {
dlg := vcl.NewOpenDialog(f)
dlg.SetTitle("Select a database file...")
dlg.SetFilter("Bolt database|*.db|All files|*.*")
dlg.SetFilter(boltFilter)
ret := dlg.Execute() // Fake blocking
if ret {
f.boltAddDatabaseFromFile(dlg.FileName(), false)
@ -296,7 +356,7 @@ func (f *TMainForm) OnMnuFileBoltOpenClick(sender vcl.IObject) {
func (f *TMainForm) OnMnuFileBoltOpenReadonlyClick(sender vcl.IObject) {
dlg := vcl.NewOpenDialog(f)
dlg.SetTitle("Select a database file...")
dlg.SetFilter("Bolt database|*.db|All files|*.*")
dlg.SetFilter(boltFilter)
ret := dlg.Execute() // Fake blocking
if ret {
f.boltAddDatabaseFromFile(dlg.FileName(), true)
@ -308,7 +368,7 @@ func (f *TMainForm) OnMnuFileSqliteOpenClick(sender vcl.IObject) {
dlg := vcl.NewOpenDialog(f)
dlg.SetTitle("Select a database file...")
dlg.SetFilter("SQLite database|*.db;*.db3;*.sqlite;*.sqlite3|All files|*.*")
dlg.SetFilter(sqliteFilter)
ret := dlg.Execute() // Fake blocking
if ret {
f.sqliteAddDatabaseFromFile(dlg.FileName(), cliDriver)
@ -451,7 +511,10 @@ func (f *TMainForm) OnNavContextPopup(sender vcl.IObject, mousePos types.TPoint,
mnuAction.SetCaption(action.Name)
cb := action.Callback // Copy to avoid reuse of loop variable
mnuAction.SetOnClick(func(sender vcl.IObject) {
cb(ndata)
err = cb(f, ndata)
if err != nil {
vcl.ShowMessage(err.Error())
}
f.OnNavContextRefresh(curItem, ndata)
})
mnu.Items().Add(mnuAction)
@ -475,6 +538,18 @@ func (f *TMainForm) OnNavContextPopup(sender vcl.IObject, mousePos types.TPoint,
mnu.Popup2()
}
func (f *TMainForm) RefreshCurrentItem() {
item := f.Buckets.Selected()
if item == nil {
return // nothing to do
}
ndata := (*navData)(item.Data())
f.OnNavContextRefresh(item, ndata) // Refresh LHS pane/children
f.OnNavChange(f.Buckets, item) // Refresh RHS pane/data content
}
func (f *TMainForm) OnNavContextRefresh(item *vcl.TTreeNode, ndata *navData) {
isExpanded := item.Expanded()
@ -491,6 +566,131 @@ func (f *TMainForm) OnNavContextRefresh(item *vcl.TTreeNode, ndata *navData) {
item.SetExpanded(isExpanded)
}
func (f *TMainForm) OnDataPrepareCanvas(sender vcl.IObject, aCol, aRow int32, aState types.TGridDrawState) {
if _, ok := f.deleteRows[aRow]; ok {
f.contentBox.Canvas().Brush().SetColor(CO_DELETE)
return
}
if _, ok := f.insertRows[aRow]; ok {
f.contentBox.Canvas().Brush().SetColor(CO_INSERT)
return
}
if er, ok := f.updateRows[aRow]; ok {
// This row is being edited
if int32slice_contains(er, aCol) {
f.contentBox.Canvas().Brush().SetColor(CO_EDIT_EXPLICIT)
} else {
f.contentBox.Canvas().Brush().SetColor(CO_EDIT_IMPLICIT)
}
return
}
}
// func (f *TMainForm) OnDataCellEditStarting(sender vcl.IObject, aCol, aRow int32, editor **vcl.TWinControl) {
func (f *TMainForm) OnDataCellEditStarting(sender vcl.IObject, aCol, aRow int32, value *string) {
f.isEditing = true
}
func (f *TMainForm) OnDataCellEdited(sender vcl.IObject) {
// The OnEditingDone event fires whenever the TStringGrid loses focus, even
// if editing was not currently taking place
// To detect real edits, set a flag in the OnSelectEditor event
if !f.isEditing {
return
}
f.isEditing = false
aRow := f.contentBox.Row()
aCol := f.contentBox.Col()
// If this is an insert row, no need to patch updateRows
if _, ok := f.insertRows[aRow]; ok {
return // nothing to do
}
if chk, ok := f.updateRows[aRow]; ok {
if int32slice_contains(chk, aCol) {
// nothing to do
} else {
chk = append(chk, aCol)
f.updateRows[aRow] = chk
}
} else {
f.updateRows[aRow] = []int32{aCol}
}
// If this row was marked for deletion, this new event takes priority
delete(f.deleteRows, aRow)
// Signal repaint
f.contentBox.InvalidateRow(aRow)
}
func (f *TMainForm) OnDataInsertClick(sender vcl.IObject) {
if !f.contentBox.Enabled() {
return // Not an active data view
}
rpos := f.contentBox.RowCount()
f.contentBox.SetRowCount(rpos + 1)
f.insertRows[rpos] = struct{}{}
// Scroll to bottom
f.contentBox.SetTopRow(rpos)
}
func (f *TMainForm) OnDataDeleteRowClick(sender vcl.IObject) {
if !f.contentBox.Enabled() {
return // Not an active data view
}
rpos := f.contentBox.Row()
f.deleteRows[rpos] = struct{}{}
// If this row was marked for edit, this takes priority
delete(f.updateRows, rpos)
// Repaint
f.contentBox.InvalidateRow(rpos)
}
func (f *TMainForm) OnDataCommitClick(sender vcl.IObject) {
if !f.contentBox.Enabled() {
return // Not an active data view
}
node := f.Buckets.Selected()
if node == nil {
vcl.ShowMessage("No database selected")
return
}
scrollPos := f.contentBox.TopRow()
ndata := (*navData)(node.Data())
editableLd, ok := ndata.ld.(editableLoadedDatabase)
if !ok {
vcl.ShowMessage("Unsupported action for this database")
return
}
err := editableLd.ApplyChanges(f, ndata)
if err != nil {
vcl.ShowMessage(err.Error())
}
// Refresh content
f.OnNavChange(f.Buckets, node) // Refresh RHS pane/data content
// Preserve scroll position
f.contentBox.SetTopRow(scrollPos)
}
func (f *TMainForm) OnNavContextClose(sender vcl.IObject) {
curItem := f.Buckets.Selected()
if curItem == nil {
@ -508,6 +708,46 @@ func (f *TMainForm) OnNavContextClose(sender vcl.IObject) {
// n.b. This triggers OnNavChange, which will then re-render from noLoadedDatabase{}
}
// func (f *TMainForm) OnQueryTextChanged(sender vcl.IObject) {
func (f *TMainForm) OnQueryTextChanged(sender vcl.IObject, key *types.Char, shift types.TShiftState) {
// FIXME changing the text colour calls the onchange handler recursively
// FIXME changing the text colour pushes into the undo stack
// Preserve
f.queryInput.Lines().BeginUpdate()
origPos := f.queryInput.SelStart()
origLen := f.queryInput.SelLength()
defer func() {
f.queryInput.SetSelStart(origPos)
f.queryInput.SetSelLength(origLen)
f.queryInput.Lines().EndUpdate()
}()
tx := strings.ToLower(f.queryInput.Text())
// Reset all existing colors
f.queryInput.SetSelStart(0)
f.queryInput.SetSelLength(int32(len(tx)))
f.queryInput.SelAttributes().SetColor(colors.ClBlack)
searchPos := 0
for {
matchPos := strings.Index(tx[searchPos:], "select")
if matchPos == -1 {
break
}
matchPos += searchPos // compensate for slicing
f.queryInput.SetSelStart(int32(matchPos))
f.queryInput.SetSelLength(6)
f.queryInput.SelAttributes().SetColor(colors.ClRed)
searchPos = matchPos + 6 // strlen(SELECT)
}
}
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 {
@ -520,6 +760,10 @@ func (f *TMainForm) OnQueryExecute(sender vcl.IObject) {
queryString = f.queryInput.SelText() // Just the selected text
}
if strings.TrimSpace(queryString) == "" {
return // prevent blank query
}
// Execute
node := f.Buckets.Selected()
if node == nil {
@ -528,7 +772,18 @@ func (f *TMainForm) OnQueryExecute(sender vcl.IObject) {
}
ndata := (*navData)(node.Data())
ndata.ld.ExecQuery(queryString, f.queryResult)
queryableLd, ok := ndata.ld.(queryableLoadedDatabase)
if !ok {
vcl.ShowMessage("Unsupported action for this database")
return
}
err := queryableLd.ExecQuery(queryString, f.queryResult)
if err != nil {
vcl.ShowMessage(err.Error())
return
}
}
func (f *TMainForm) OnNavChange(sender vcl.IObject, node *vcl.TTreeNode) {
@ -541,7 +796,33 @@ func (f *TMainForm) OnNavChange(sender vcl.IObject, node *vcl.TTreeNode) {
ld = ndata.ld
}
ld.RenderForNav(f, ndata) // Handover to the database type's own renderer function
// Reset some controls that the render function is expected to populate
f.insertRows = make(map[int32]struct{})
f.updateRows = make(map[int32][]int32)
f.deleteRows = make(map[int32]struct{})
f.isEditing = false
f.propertiesBox.Clear()
vcl_stringgrid_clear(f.contentBox)
err := ld.RenderForNav(f, ndata) // Handover to the database type's own renderer function
if err != nil {
vcl.ShowMessage(err.Error())
// Ensure elements are disabled
f.contentBox.SetEnabled(false)
}
// Toggle the Edit functionality
_, ok := ld.(editableLoadedDatabase)
f.dataCommitBtn.SetEnabled(ok)
f.dataDelRowBtn.SetEnabled(ok)
f.dataInsertBtn.SetEnabled(ok)
// Toggle the Query functionality
_, ok = ld.(queryableLoadedDatabase)
f.queryInput.SetEnabled(ok)
f.queryResult.SetEnabled(ok)
f.queryExecBtn.SetEnabled(ok)
// We're in charge of common status bar text updates
f.StatusBar.SetSimpleText(ld.DisplayName() + " | " + ld.DriverName())

View File

@ -8,4 +8,14 @@ func box_interface[T any](input []T) []interface{} {
ret = append(ret, v)
}
return ret
}
func int32slice_contains(haystack []int32, needle int32) bool {
for _, v := range haystack {
if v == needle {
return true
}
}
return false
}

View File

@ -12,6 +12,8 @@ const (
MY_SPACING = 6
MY_HEIGHT = 90
MY_WIDTH = 180
MAX_AUTO_COL_WIDTH = 240
)
// vcl_row makes a TPanel row inside the target component.
@ -57,8 +59,65 @@ func vcl_default_tab_background() types.TColor {
if runtime.GOOS == "windows" {
// Assuming that uxtheme is loaded
// @ref https://stackoverflow.com/a/20332712
return colors.ClBtnHighlight
// return colors.ClBtnHighlight
// None of the colors.** constants seem to be quite right on a test
// Windows 11 machine - should be #f9f9f9
return 0x00f9f9f9
} else {
return colors.ClBtnFace // 0x00f0f0f0
}
}
func vcl_stringgrid_clear(d *vcl.TStringGrid) {
d.SetFixedCols(0) // No left-hand cols
d.SetRowCount(1) // The 0th row is the column headers
d.SetEnabled(false)
d.Columns().Clear()
d.Invalidate()
}
func vcl_stringgrid_columnwidths(d *vcl.TStringGrid) {
// Skip slow processing for very large result sets
if d.RowCount() > 1000 {
return
}
d.AutoAdjustColumns()
// AutoAdjustColumns will leave some columns massively too large by themselves
// Reign them back in
ct := d.Columns().Count()
for i := int32(0); i < ct; i++ {
if d.ColWidths(i) > MAX_AUTO_COL_WIDTH {
d.SetColWidths(i, MAX_AUTO_COL_WIDTH)
}
}
}
func vcl_confirm_dialog(sender vcl.IComponent, title string, message string) bool {
dlg := vcl.NewTaskDialog(sender)
dlg.SetCaption(APPNAME)
dlg.SetTitle(title)
dlg.SetText(message)
dlg.SetCommonButtons(types.NewSet())
yesBtn := dlg.Buttons().Add()
yesBtn.SetCaption("Confirm")
yesBtn.SetModalResult(types.MrYes)
noBtn := dlg.Buttons().Add()
noBtn.SetCaption("Cancel")
noBtn.SetModalResult(types.MrCancel)
ret := dlg.Execute()
if !ret {
return false // dialog closed
}
if dlg.ModalResult() != types.MrYes {
return false // other button clicked
}
return true // confirmed
}