feat(ui): file browser and connection string builder dialogs #50
@@ -0,0 +1,186 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConnKind identifies the database type a connection string targets.
|
||||||
|
type ConnKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ConnPostgres ConnKind = "postgres"
|
||||||
|
ConnMSSQL ConnKind = "mssql"
|
||||||
|
ConnSQLite ConnKind = "sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// connKinds lists the kinds offered by the builder dialog, in display order.
|
||||||
|
var connKinds = []ConnKind{ConnPostgres, ConnMSSQL, ConnSQLite}
|
||||||
|
|
||||||
|
// maskedPassword is substituted for the password in previews.
|
||||||
|
const maskedPassword = "****"
|
||||||
|
|
||||||
|
// ConnFields holds the editable parts of a connection string.
|
||||||
|
type ConnFields struct {
|
||||||
|
Kind ConnKind
|
||||||
|
Host string
|
||||||
|
Port string
|
||||||
|
Database string
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
SSLMode string
|
||||||
|
FilePath string // SQLite only
|
||||||
|
|
||||||
|
// Extra keeps query parameters the builder has no field for, so that
|
||||||
|
// parsing and rebuilding an existing string does not drop them.
|
||||||
|
Extra url.Values
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConnFields returns sensible defaults for the given kind.
|
||||||
|
func DefaultConnFields(kind ConnKind) ConnFields {
|
||||||
|
f := ConnFields{Kind: kind}
|
||||||
|
switch kind {
|
||||||
|
case ConnPostgres:
|
||||||
|
f.Host, f.Port, f.User, f.SSLMode = "localhost", "5432", "postgres", "disable"
|
||||||
|
case ConnMSSQL:
|
||||||
|
f.Host, f.Port, f.User, f.SSLMode = "localhost", "1433", "sa", "disable"
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSLModes returns the valid SSL/encryption options for a kind.
|
||||||
|
func SSLModes(kind ConnKind) []string {
|
||||||
|
switch kind {
|
||||||
|
case ConnPostgres:
|
||||||
|
return []string{"disable", "allow", "prefer", "require", "verify-ca", "verify-full"}
|
||||||
|
case ConnMSSQL:
|
||||||
|
return []string{"disable", "false", "true"}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f ConnFields) sslParam() string {
|
||||||
|
if f.Kind == ConnMSSQL {
|
||||||
|
return "encrypt"
|
||||||
|
}
|
||||||
|
return "sslmode"
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildConnString renders the fields as a connection string. With mask set,
|
||||||
|
// a non-empty password is replaced by asterisks (for previews).
|
||||||
|
func BuildConnString(f ConnFields, mask bool) string {
|
||||||
|
if f.Kind == ConnSQLite {
|
||||||
|
return f.FilePath
|
||||||
|
}
|
||||||
|
|
||||||
|
u := &url.URL{Scheme: "postgres"}
|
||||||
|
if f.Kind == ConnMSSQL {
|
||||||
|
u.Scheme = "sqlserver"
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.Port != "" {
|
||||||
|
u.Host = net.JoinHostPort(f.Host, f.Port)
|
||||||
|
} else {
|
||||||
|
u.Host = f.Host
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.User != "" {
|
||||||
|
if f.Password != "" {
|
||||||
|
pw := f.Password
|
||||||
|
if mask {
|
||||||
|
pw = maskedPassword
|
||||||
|
}
|
||||||
|
u.User = url.UserPassword(f.User, pw)
|
||||||
|
} else {
|
||||||
|
u.User = url.User(f.User)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query := url.Values{}
|
||||||
|
for k, v := range f.Extra {
|
||||||
|
query[k] = v
|
||||||
|
}
|
||||||
|
if f.Kind == ConnMSSQL {
|
||||||
|
if f.Database != "" {
|
||||||
|
query.Set("database", f.Database)
|
||||||
|
}
|
||||||
|
} else if f.Database != "" {
|
||||||
|
u.Path = "/" + f.Database
|
||||||
|
}
|
||||||
|
if f.SSLMode != "" {
|
||||||
|
query.Set(f.sslParam(), f.SSLMode)
|
||||||
|
}
|
||||||
|
u.RawQuery = query.Encode()
|
||||||
|
|
||||||
|
out := u.String()
|
||||||
|
if mask {
|
||||||
|
// url escapes '*' in the userinfo; keep the preview readable.
|
||||||
|
out = strings.Replace(out, url.QueryEscape(maskedPassword), maskedPassword, 1)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetectConnKind guesses the kind from a connection string's scheme. Anything
|
||||||
|
// that is not a recognised URL is treated as a SQLite file path.
|
||||||
|
func DetectConnKind(s string) ConnKind {
|
||||||
|
lower := strings.ToLower(strings.TrimSpace(s))
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(lower, "postgres://"), strings.HasPrefix(lower, "postgresql://"):
|
||||||
|
return ConnPostgres
|
||||||
|
case strings.HasPrefix(lower, "sqlserver://"), strings.HasPrefix(lower, "mssql://"):
|
||||||
|
return ConnMSSQL
|
||||||
|
}
|
||||||
|
return ConnSQLite
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseConnString splits a connection string into fields. An empty string
|
||||||
|
// yields the defaults for hint. Missing ports fall back to the kind default.
|
||||||
|
func ParseConnString(s string, hint ConnKind) (ConnFields, error) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return DefaultConnFields(hint), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
kind := DetectConnKind(s)
|
||||||
|
if kind == ConnSQLite {
|
||||||
|
path := s
|
||||||
|
for _, prefix := range []string{"sqlite://", "sqlite3://"} {
|
||||||
|
path = strings.TrimPrefix(path, prefix)
|
||||||
|
}
|
||||||
|
return ConnFields{Kind: ConnSQLite, FilePath: path}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := url.Parse(s)
|
||||||
|
if err != nil {
|
||||||
|
return DefaultConnFields(kind), fmt.Errorf("invalid connection string: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f := ConnFields{
|
||||||
|
Kind: kind,
|
||||||
|
Host: u.Hostname(),
|
||||||
|
Port: u.Port(),
|
||||||
|
}
|
||||||
|
if f.Port == "" {
|
||||||
|
f.Port = DefaultConnFields(kind).Port
|
||||||
|
}
|
||||||
|
if u.User != nil {
|
||||||
|
f.User = u.User.Username()
|
||||||
|
f.Password, _ = u.User.Password()
|
||||||
|
}
|
||||||
|
|
||||||
|
query := u.Query()
|
||||||
|
if kind == ConnMSSQL {
|
||||||
|
f.Database = query.Get("database")
|
||||||
|
query.Del("database")
|
||||||
|
} else {
|
||||||
|
f.Database = strings.TrimPrefix(u.Path, "/")
|
||||||
|
}
|
||||||
|
f.SSLMode = query.Get(f.sslParam())
|
||||||
|
query.Del(f.sslParam())
|
||||||
|
if len(query) > 0 {
|
||||||
|
f.Extra = query
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
_ "github.com/microsoft/go-mssqldb"
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// connTestTimeout bounds how long "Test connection" may block.
|
||||||
|
const connTestTimeout = 5 * time.Second
|
||||||
|
|
||||||
|
// TestConnection opens and pings the database described by f. Any occurrence
|
||||||
|
// of the password in the returned error is masked.
|
||||||
|
func TestConnection(f ConnFields) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), connTestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err := testConnection(ctx, f)
|
||||||
|
if err != nil && f.Password != "" {
|
||||||
|
err = fmt.Errorf("%s", strings.ReplaceAll(err.Error(), f.Password, maskedPassword))
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func testConnection(ctx context.Context, f ConnFields) error {
|
||||||
|
switch f.Kind {
|
||||||
|
case ConnPostgres:
|
||||||
|
conn, err := pgx.Connect(ctx, BuildConnString(f, false))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return conn.Close(ctx)
|
||||||
|
case ConnMSSQL:
|
||||||
|
return pingSQL(ctx, "sqlserver", BuildConnString(f, false))
|
||||||
|
case ConnSQLite:
|
||||||
|
if f.FilePath == "" {
|
||||||
|
return fmt.Errorf("file path is required")
|
||||||
|
}
|
||||||
|
// Opening a missing SQLite file would silently create it.
|
||||||
|
if _, err := os.Stat(f.FilePath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return pingSQL(ctx, "sqlite", f.FilePath)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("unsupported connection type %q", f.Kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pingSQL(ctx context.Context, driver, dsn string) error {
|
||||||
|
db, err := sql.Open(driver, dsn)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
return db.PingContext(ctx)
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
)
|
||||||
|
|
||||||
|
// connBuilderPage is the page name of the connection string builder dialog.
|
||||||
|
const connBuilderPage = "conn-builder"
|
||||||
|
|
||||||
|
// showConnStringBuilder opens the connection string builder, pre-filled by
|
||||||
|
// parsing current. Save calls onDone with the built string; Esc/Back leaves
|
||||||
|
// the caller's input untouched.
|
||||||
|
func (se *SchemaEditor) showConnStringBuilder(current string, hint ConnKind, returnPage string, onDone func(connString string)) {
|
||||||
|
fields, err := ParseConnString(current, hint)
|
||||||
|
if err != nil {
|
||||||
|
se.showErrorDialog("Error", err.Error()+"\nStarting from defaults.")
|
||||||
|
}
|
||||||
|
|
||||||
|
title := tview.NewTextView().
|
||||||
|
SetText("[::b]Connection String Builder").
|
||||||
|
SetTextAlign(tview.AlignCenter).
|
||||||
|
SetDynamicColors(true)
|
||||||
|
|
||||||
|
preview := tview.NewTextView()
|
||||||
|
preview.SetBorder(true).SetTitle(" Preview (password masked) ").SetTitleAlign(tview.AlignLeft)
|
||||||
|
|
||||||
|
form := tview.NewForm()
|
||||||
|
form.SetBorder(true).SetTitle(" Connection ").SetTitleAlign(tview.AlignLeft)
|
||||||
|
|
||||||
|
updatePreview := func() {
|
||||||
|
preview.SetText(tview.Escape(BuildConnString(fields, true)))
|
||||||
|
}
|
||||||
|
|
||||||
|
closeBuilder := func() {
|
||||||
|
se.pages.RemovePage(connBuilderPage)
|
||||||
|
se.pages.SwitchToPage(returnPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
var render func(focus int)
|
||||||
|
render = func(focus int) {
|
||||||
|
form.Clear(false)
|
||||||
|
|
||||||
|
kindIndex := 0
|
||||||
|
kindLabels := make([]string, len(connKinds))
|
||||||
|
for i, k := range connKinds {
|
||||||
|
kindLabels[i] = string(k)
|
||||||
|
if k == fields.Kind {
|
||||||
|
kindIndex = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
form.AddDropDown("Type", kindLabels, kindIndex, func(_ string, index int) {
|
||||||
|
if connKinds[index] == fields.Kind {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fields = DefaultConnFields(connKinds[index])
|
||||||
|
render(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
if fields.Kind == ConnSQLite {
|
||||||
|
form.AddInputField("File Path", fields.FilePath, 50, nil, func(v string) {
|
||||||
|
fields.FilePath = v
|
||||||
|
updatePreview()
|
||||||
|
})
|
||||||
|
if item, ok := form.GetFormItemByLabel("File Path").(*tview.InputField); ok {
|
||||||
|
item.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() != tcell.KeyEnter {
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
se.showFileBrowser(FileBrowserConfig{
|
||||||
|
Mode: FileBrowserLoad,
|
||||||
|
StartPath: fields.FilePath,
|
||||||
|
Extensions: FormatExtensions("sqlite"),
|
||||||
|
ReturnPage: connBuilderPage,
|
||||||
|
OnSelect: func(path string) { item.SetText(path) },
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
form.AddInputField("Host", fields.Host, 50, nil, func(v string) { fields.Host = v; updatePreview() })
|
||||||
|
form.AddInputField("Port", fields.Port, 10, tview.InputFieldInteger, func(v string) { fields.Port = v; updatePreview() })
|
||||||
|
form.AddInputField("Database", fields.Database, 50, nil, func(v string) { fields.Database = v; updatePreview() })
|
||||||
|
form.AddInputField("User", fields.User, 50, nil, func(v string) { fields.User = v; updatePreview() })
|
||||||
|
form.AddPasswordField("Password", fields.Password, 50, '*', func(v string) { fields.Password = v; updatePreview() })
|
||||||
|
|
||||||
|
label := "SSL Mode"
|
||||||
|
if fields.Kind == ConnMSSQL {
|
||||||
|
label = "Encrypt"
|
||||||
|
}
|
||||||
|
modes := SSLModes(fields.Kind)
|
||||||
|
modeIndex := -1
|
||||||
|
for i, m := range modes {
|
||||||
|
if m == fields.SSLMode {
|
||||||
|
modeIndex = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if modeIndex < 0 {
|
||||||
|
// Keep a value parsed from an existing string even if it is not a listed option.
|
||||||
|
modes = append([]string{fields.SSLMode}, modes...)
|
||||||
|
modeIndex = 0
|
||||||
|
}
|
||||||
|
form.AddDropDown(label, modes, modeIndex, func(option string, _ int) {
|
||||||
|
fields.SSLMode = option
|
||||||
|
updatePreview()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
form.AddButton("Save [F2]", connBuilderSave(se, &fields, closeBuilder, onDone))
|
||||||
|
form.AddButton("Test [F3]", func() { se.testConnectionDialog(fields) })
|
||||||
|
form.AddButton("Back [Esc]", closeBuilder)
|
||||||
|
|
||||||
|
updatePreview()
|
||||||
|
form.SetFocus(focus)
|
||||||
|
se.app.SetFocus(form)
|
||||||
|
}
|
||||||
|
|
||||||
|
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
switch event.Key() {
|
||||||
|
case tcell.KeyEscape:
|
||||||
|
closeBuilder()
|
||||||
|
return nil
|
||||||
|
case tcell.KeyF2:
|
||||||
|
connBuilderSave(se, &fields, closeBuilder, onDone)()
|
||||||
|
return nil
|
||||||
|
case tcell.KeyF3:
|
||||||
|
se.testConnectionDialog(fields)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
|
||||||
|
render(0)
|
||||||
|
|
||||||
|
flex := tview.NewFlex().SetDirection(tview.FlexRow).
|
||||||
|
AddItem(title, 1, 0, false).
|
||||||
|
AddItem(form, 0, 1, true).
|
||||||
|
AddItem(preview, 4, 0, false)
|
||||||
|
|
||||||
|
se.pages.AddAndSwitchToPage(connBuilderPage, flex, true)
|
||||||
|
se.app.SetFocus(form)
|
||||||
|
}
|
||||||
|
|
||||||
|
// connBuilderSave returns the Save action: validate, write back, close.
|
||||||
|
func connBuilderSave(se *SchemaEditor, fields *ConnFields, closeBuilder func(), onDone func(string)) func() {
|
||||||
|
return func() {
|
||||||
|
if msg := validateConnFields(*fields); msg != "" {
|
||||||
|
se.showErrorDialog("Error", msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result := BuildConnString(*fields, false)
|
||||||
|
closeBuilder()
|
||||||
|
onDone(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateConnFields returns a message describing the first missing required field, or "".
|
||||||
|
func validateConnFields(f ConnFields) string {
|
||||||
|
if f.Kind == ConnSQLite {
|
||||||
|
if strings.TrimSpace(f.FilePath) == "" {
|
||||||
|
return "File path is required"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(f.Host) == "" {
|
||||||
|
return "Host is required"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// testConnectionDialog runs TestConnection in the background and reports the result.
|
||||||
|
func (se *SchemaEditor) testConnectionDialog(fields ConnFields) {
|
||||||
|
if msg := validateConnFields(fields); msg != "" {
|
||||||
|
se.showErrorDialog("Error", msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
err := TestConnection(fields)
|
||||||
|
se.app.QueueUpdateDraw(func() {
|
||||||
|
if err != nil {
|
||||||
|
se.showErrorDialog("Connection Failed", fmt.Sprintf("Connection failed:\n%v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
se.showSuccessDialog("Connection OK", "Connection successful", nil)
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// attachConnStringBuilder makes Enter on the named input open the builder.
|
||||||
|
func (se *SchemaEditor) attachConnStringBuilder(form *tview.Form, label, returnPage string, format func() string) {
|
||||||
|
item, ok := form.GetFormItemByLabel(label).(*tview.InputField)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() != tcell.KeyEnter {
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
hint := ConnPostgres
|
||||||
|
if format != nil && format() == "sqlite" {
|
||||||
|
hint = ConnSQLite
|
||||||
|
}
|
||||||
|
se.showConnStringBuilder(item.GetText(), hint, returnPage, func(s string) { item.SetText(s) })
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildConnString(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
fields ConnFields
|
||||||
|
mask bool
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "postgres defaults with db",
|
||||||
|
fields: func() ConnFields { f := DefaultConnFields(ConnPostgres); f.Database = "app"; return f }(),
|
||||||
|
want: "postgres://postgres@localhost:5432/app?sslmode=disable",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "postgres password unmasked",
|
||||||
|
fields: ConnFields{Kind: ConnPostgres, Host: "db", Port: "5433", Database: "x", User: "u", Password: "p@ss/w", SSLMode: "require"},
|
||||||
|
want: "postgres://u:p%40ss%2Fw@db:5433/x?sslmode=require",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "postgres password masked",
|
||||||
|
fields: ConnFields{Kind: ConnPostgres, Host: "db", Port: "5432", Database: "x", User: "u", Password: "secret"},
|
||||||
|
mask: true,
|
||||||
|
want: "postgres://u:****@db:5432/x",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mssql",
|
||||||
|
fields: ConnFields{Kind: ConnMSSQL, Host: "sql", Port: "1433", Database: "shop", User: "sa", Password: "pw", SSLMode: "disable"},
|
||||||
|
want: "sqlserver://sa:pw@sql:1433?database=shop&encrypt=disable",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sqlite is the plain path",
|
||||||
|
fields: ConnFields{Kind: ConnSQLite, FilePath: "/tmp/a b.db"},
|
||||||
|
want: "/tmp/a b.db",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := BuildConnString(tt.fields, tt.mask); got != tt.want {
|
||||||
|
t.Errorf("got %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMaskedBuildHidesPassword(t *testing.T) {
|
||||||
|
f := ConnFields{Kind: ConnMSSQL, Host: "h", User: "u", Password: "hunter2"}
|
||||||
|
if got := BuildConnString(f, true); strings.Contains(got, "hunter2") {
|
||||||
|
t.Errorf("masked string leaks password: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseConnString(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want ConnFields
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "postgres full",
|
||||||
|
in: "postgres://u:p%40ss@db:5433/app?sslmode=require&application_name=x",
|
||||||
|
want: ConnFields{Kind: ConnPostgres, Host: "db", Port: "5433", Database: "app", User: "u", Password: "p@ss", SSLMode: "require"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "postgresql scheme, default port",
|
||||||
|
in: "postgresql://u@db/app",
|
||||||
|
want: ConnFields{Kind: ConnPostgres, Host: "db", Port: "5432", Database: "app", User: "u"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mssql",
|
||||||
|
in: "sqlserver://sa:pw@sql:1444?database=shop&encrypt=true",
|
||||||
|
want: ConnFields{Kind: ConnMSSQL, Host: "sql", Port: "1444", Database: "shop", User: "sa", Password: "pw", SSLMode: "true"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sqlite path",
|
||||||
|
in: "/data/app.db",
|
||||||
|
want: ConnFields{Kind: ConnSQLite, FilePath: "/data/app.db"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sqlite scheme",
|
||||||
|
in: "sqlite:///data/app.db",
|
||||||
|
want: ConnFields{Kind: ConnSQLite, FilePath: "/data/app.db"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := ParseConnString(tt.in, ConnPostgres)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got.Extra = nil
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("got %+v, want %+v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseConnStringEmptyUsesHintDefaults(t *testing.T) {
|
||||||
|
got, err := ParseConnString(" ", ConnMSSQL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Kind != ConnMSSQL || got.Port != "1433" || got.Host != "localhost" {
|
||||||
|
t.Errorf("unexpected defaults: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseConnStringInvalid(t *testing.T) {
|
||||||
|
if _, err := ParseConnString("postgres://u:p@host:badport/db", ConnPostgres); err == nil {
|
||||||
|
t.Error("expected error for invalid port")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnStringRoundTrip(t *testing.T) {
|
||||||
|
for _, in := range []string{
|
||||||
|
"postgres://u:pw@db:5433/app?application_name=x&sslmode=require",
|
||||||
|
"sqlserver://sa:pw@sql:1433?application+name=x&database=shop&encrypt=false",
|
||||||
|
} {
|
||||||
|
f, err := ParseConnString(in, ConnPostgres)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := BuildConnString(f, false); got != in {
|
||||||
|
t.Errorf("round trip: got %q, want %q", got, in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTestConnectionSQLite(t *testing.T) {
|
||||||
|
if err := TestConnection(ConnFields{Kind: ConnSQLite}); err == nil {
|
||||||
|
t.Error("expected error for empty path")
|
||||||
|
}
|
||||||
|
if err := TestConnection(ConnFields{Kind: ConnSQLite, FilePath: t.TempDir() + "/missing.db"}); err == nil {
|
||||||
|
t.Error("expected error for missing file")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileEntry is a single row in the file browser.
|
||||||
|
type FileEntry struct {
|
||||||
|
Name string
|
||||||
|
IsDir bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatExtensions maps a UI format name to the file extensions it reads or writes.
|
||||||
|
var formatExtensions = map[string][]string{
|
||||||
|
"dbml": {".dbml"},
|
||||||
|
"dctx": {".dctx"},
|
||||||
|
"drawdb": {".json"},
|
||||||
|
"graphql": {".graphql", ".gql"},
|
||||||
|
"json": {".json"},
|
||||||
|
"yaml": {".yaml", ".yml"},
|
||||||
|
"gorm": {".go"},
|
||||||
|
"bun": {".go"},
|
||||||
|
"drizzle": {".ts"},
|
||||||
|
"prisma": {".prisma"},
|
||||||
|
"typeorm": {".ts"},
|
||||||
|
"pgsql": {".sql"},
|
||||||
|
"sqlite": {".db", ".sqlite", ".sqlite3"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// directoryFormats are formats whose reader/writer accepts a directory.
|
||||||
|
var directoryFormats = map[string]bool{
|
||||||
|
"gorm": true, "bun": true, "drizzle": true, "typeorm": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatExtensions returns the extensions for a format, or nil (no filter) if unknown.
|
||||||
|
func FormatExtensions(format string) []string {
|
||||||
|
return formatExtensions[format]
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDirectoryFormat reports whether a format can be loaded from or saved to a directory.
|
||||||
|
func IsDirectoryFormat(format string) bool {
|
||||||
|
return directoryFormats[format]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExpandHome replaces a leading ~ with the user's home directory.
|
||||||
|
func ExpandHome(p string) string {
|
||||||
|
if strings.HasPrefix(p, "~") {
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
return filepath.Join(home, p[1:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchesExtension reports whether name has one of exts (case-insensitive).
|
||||||
|
// An empty extension list matches everything.
|
||||||
|
func MatchesExtension(name string, exts []string) bool {
|
||||||
|
if len(exts) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ext := strings.ToLower(filepath.Ext(name))
|
||||||
|
for _, e := range exts {
|
||||||
|
if strings.ToLower(e) == ext {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDir returns the entries of dir: directories first, then files that match
|
||||||
|
// exts, each group sorted case-insensitively. Hidden (dot) entries are skipped
|
||||||
|
// unless showHidden is set.
|
||||||
|
func ListDir(dir string, exts []string, showHidden bool) ([]FileEntry, error) {
|
||||||
|
items, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var dirs, files []FileEntry
|
||||||
|
for _, item := range items {
|
||||||
|
name := item.Name()
|
||||||
|
if !showHidden && strings.HasPrefix(name, ".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
isDir := item.IsDir()
|
||||||
|
if !isDir && item.Type()&os.ModeSymlink != 0 {
|
||||||
|
// Follow symlinks so links to directories are navigable.
|
||||||
|
if info, err := os.Stat(filepath.Join(dir, name)); err == nil {
|
||||||
|
isDir = info.IsDir()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isDir {
|
||||||
|
dirs = append(dirs, FileEntry{Name: name, IsDir: true})
|
||||||
|
} else if MatchesExtension(name, exts) {
|
||||||
|
files = append(files, FileEntry{Name: name})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
byName := func(s []FileEntry) {
|
||||||
|
sort.Slice(s, func(i, j int) bool {
|
||||||
|
return strings.ToLower(s[i].Name) < strings.ToLower(s[j].Name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
byName(dirs)
|
||||||
|
byName(files)
|
||||||
|
return append(dirs, files...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveStart works out where the browser should open for the current input
|
||||||
|
// value. It returns the directory to show and, if the input named a file, its
|
||||||
|
// base name. Falls back to the working directory.
|
||||||
|
func ResolveStart(input string) (dir, name string) {
|
||||||
|
input = strings.TrimSpace(input)
|
||||||
|
if input != "" {
|
||||||
|
p := ExpandHome(input)
|
||||||
|
if abs, err := filepath.Abs(p); err == nil {
|
||||||
|
p = abs
|
||||||
|
}
|
||||||
|
if info, err := os.Stat(p); err == nil && info.IsDir() {
|
||||||
|
return p, ""
|
||||||
|
}
|
||||||
|
if info, err := os.Stat(filepath.Dir(p)); err == nil && info.IsDir() {
|
||||||
|
return filepath.Dir(p), filepath.Base(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
wd = "."
|
||||||
|
}
|
||||||
|
return wd, ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileBrowserMode selects between picking an existing path and choosing a save target.
|
||||||
|
type FileBrowserMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
FileBrowserLoad FileBrowserMode = iota
|
||||||
|
FileBrowserSave
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileBrowserConfig configures the file browser dialog.
|
||||||
|
type FileBrowserConfig struct {
|
||||||
|
Mode FileBrowserMode
|
||||||
|
StartPath string // current value of the input; may be empty
|
||||||
|
Extensions []string // empty = show all files
|
||||||
|
AllowDir bool // a directory is a valid result (directory-based formats)
|
||||||
|
ReturnPage string // page to switch back to when the dialog closes
|
||||||
|
OnSelect func(path string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// attachFileBrowser makes Enter on the named input open the file browser,
|
||||||
|
// filtered for the currently selected format.
|
||||||
|
func (se *SchemaEditor) attachFileBrowser(form *tview.Form, label, returnPage string, mode FileBrowserMode, format func() string) {
|
||||||
|
item, ok := form.GetFormItemByLabel(label).(*tview.InputField)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() != tcell.KeyEnter {
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
f := format()
|
||||||
|
se.showFileBrowser(FileBrowserConfig{
|
||||||
|
Mode: mode,
|
||||||
|
StartPath: item.GetText(),
|
||||||
|
Extensions: FormatExtensions(f),
|
||||||
|
AllowDir: IsDirectoryFormat(f),
|
||||||
|
ReturnPage: returnPage,
|
||||||
|
OnSelect: func(path string) { item.SetText(path) },
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// showFileBrowser displays the file browser page. Esc closes it without
|
||||||
|
// calling OnSelect, leaving the originating input unchanged.
|
||||||
|
func (se *SchemaEditor) showFileBrowser(cfg FileBrowserConfig) {
|
||||||
|
const pageName = "file-browser"
|
||||||
|
|
||||||
|
dir, startName := ResolveStart(cfg.StartPath)
|
||||||
|
showHidden := false
|
||||||
|
useFilter := len(cfg.Extensions) > 0
|
||||||
|
var entries []FileEntry // rows shown below the ".." row
|
||||||
|
|
||||||
|
title := tview.NewTextView().
|
||||||
|
SetText("[::b]Select File").
|
||||||
|
SetTextAlign(tview.AlignCenter).
|
||||||
|
SetDynamicColors(true)
|
||||||
|
if cfg.Mode == FileBrowserSave {
|
||||||
|
title.SetText("[::b]Save As")
|
||||||
|
}
|
||||||
|
|
||||||
|
info := tview.NewTextView().SetDynamicColors(true)
|
||||||
|
|
||||||
|
fileTable := tview.NewTable().SetSelectable(true, false).SetFixed(0, 0)
|
||||||
|
fileTable.SetBorder(true)
|
||||||
|
|
||||||
|
nameInput := tview.NewInputField().SetLabel("File name: ").SetFieldWidth(0)
|
||||||
|
nameInput.SetText(startName)
|
||||||
|
|
||||||
|
closeBrowser := func() {
|
||||||
|
se.pages.RemovePage(pageName)
|
||||||
|
se.pages.SwitchToPage(cfg.ReturnPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
finish := func(path string) {
|
||||||
|
closeBrowser()
|
||||||
|
cfg.OnSelect(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
var refresh func()
|
||||||
|
refresh = func() {
|
||||||
|
exts := cfg.Extensions
|
||||||
|
if !useFilter {
|
||||||
|
exts = nil
|
||||||
|
}
|
||||||
|
list, err := ListDir(dir, exts, showHidden)
|
||||||
|
if err != nil {
|
||||||
|
se.showErrorDialog("Error", fmt.Sprintf("Cannot read %s: %v", dir, err))
|
||||||
|
list = nil
|
||||||
|
}
|
||||||
|
entries = list
|
||||||
|
|
||||||
|
fileTable.Clear()
|
||||||
|
fileTable.SetCell(0, 0, tview.NewTableCell("[..]").SetTextColor(tcell.ColorAqua))
|
||||||
|
for i, e := range entries {
|
||||||
|
cell := tview.NewTableCell(e.Name)
|
||||||
|
if e.IsDir {
|
||||||
|
cell.SetText(e.Name + "/").SetTextColor(tcell.ColorAqua)
|
||||||
|
}
|
||||||
|
fileTable.SetCell(i+1, 0, cell)
|
||||||
|
}
|
||||||
|
fileTable.Select(0, 0)
|
||||||
|
if len(entries) > 0 {
|
||||||
|
fileTable.Select(1, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
filterText := "all files"
|
||||||
|
if useFilter {
|
||||||
|
filterText = fmt.Sprintf("%v", cfg.Extensions)
|
||||||
|
}
|
||||||
|
hiddenText := "hidden: off"
|
||||||
|
if showHidden {
|
||||||
|
hiddenText = "hidden: on"
|
||||||
|
}
|
||||||
|
info.SetText(fmt.Sprintf("%s [yellow](%s, filter: %s)[-]", tview.Escape(dir), hiddenText, tview.Escape(filterText)))
|
||||||
|
fileTable.SetTitle(" Files ")
|
||||||
|
}
|
||||||
|
|
||||||
|
goUp := func() {
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
if parent == dir {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prev := filepath.Base(dir)
|
||||||
|
dir = parent
|
||||||
|
refresh()
|
||||||
|
for i, e := range entries {
|
||||||
|
if e.Name == prev {
|
||||||
|
fileTable.Select(i+1, 0)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selected := func() (FileEntry, bool) {
|
||||||
|
row, _ := fileTable.GetSelection()
|
||||||
|
if row < 1 || row > len(entries) {
|
||||||
|
return FileEntry{}, false
|
||||||
|
}
|
||||||
|
return entries[row-1], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// confirmOverwrite asks before replacing an existing file (not directories).
|
||||||
|
confirmOverwrite := func(path string) {
|
||||||
|
modal := tview.NewModal().
|
||||||
|
SetText(fmt.Sprintf("File already exists:\n%s\n\nOverwrite it?", path)).
|
||||||
|
AddButtons([]string{"Cancel", "Overwrite"}).
|
||||||
|
SetDoneFunc(func(_ int, label string) {
|
||||||
|
se.pages.RemovePage("overwrite-confirm")
|
||||||
|
se.pages.SwitchToPage(pageName)
|
||||||
|
if label == "Overwrite" {
|
||||||
|
finish(path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
modal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyEscape {
|
||||||
|
se.pages.RemovePage("overwrite-confirm")
|
||||||
|
se.pages.SwitchToPage(pageName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
se.pages.AddAndSwitchToPage("overwrite-confirm", modal, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
chooseSave := func() {
|
||||||
|
name := nameInput.GetText()
|
||||||
|
if name == "" {
|
||||||
|
if cfg.AllowDir {
|
||||||
|
finish(dir)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
se.showErrorDialog("Error", "Enter a file name")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
if st, err := os.Stat(path); err == nil {
|
||||||
|
if st.IsDir() {
|
||||||
|
se.showErrorDialog("Error", name+" is a directory")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
confirmOverwrite(path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
finish(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// chooseHighlighted handles Select: the highlighted entry in load mode, or
|
||||||
|
// the typed name in save mode.
|
||||||
|
chooseHighlighted := func() {
|
||||||
|
if cfg.Mode == FileBrowserSave {
|
||||||
|
chooseSave()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e, ok := selected()
|
||||||
|
switch {
|
||||||
|
case ok && !e.IsDir:
|
||||||
|
finish(filepath.Join(dir, e.Name))
|
||||||
|
case ok && cfg.AllowDir:
|
||||||
|
finish(filepath.Join(dir, e.Name))
|
||||||
|
case cfg.AllowDir:
|
||||||
|
finish(dir)
|
||||||
|
default:
|
||||||
|
se.showErrorDialog("Error", "Select a file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
activate := func() {
|
||||||
|
row, _ := fileTable.GetSelection()
|
||||||
|
if row == 0 {
|
||||||
|
goUp()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e, ok := selected()
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if e.IsDir {
|
||||||
|
dir = filepath.Join(dir, e.Name)
|
||||||
|
refresh()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cfg.Mode == FileBrowserSave {
|
||||||
|
nameInput.SetText(e.Name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
finish(filepath.Join(dir, e.Name))
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleHidden := func() { showHidden = !showHidden; refresh() }
|
||||||
|
toggleFilter := func() {
|
||||||
|
if len(cfg.Extensions) > 0 {
|
||||||
|
useFilter = !useFilter
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
btnSelect := tview.NewButton("Select [s]").SetSelectedFunc(chooseHighlighted)
|
||||||
|
btnHidden := tview.NewButton("Hidden [h]").SetSelectedFunc(toggleHidden)
|
||||||
|
btnFilter := tview.NewButton("Filter [f]").SetSelectedFunc(toggleFilter)
|
||||||
|
btnBack := tview.NewButton("Back [b]").SetSelectedFunc(closeBrowser)
|
||||||
|
|
||||||
|
btnFlex := tview.NewFlex().
|
||||||
|
AddItem(btnSelect, 0, 1, false).
|
||||||
|
AddItem(btnHidden, 0, 1, false).
|
||||||
|
AddItem(btnFilter, 0, 1, false).
|
||||||
|
AddItem(btnBack, 0, 1, false)
|
||||||
|
|
||||||
|
flex := tview.NewFlex().SetDirection(tview.FlexRow).
|
||||||
|
AddItem(title, 1, 0, false).
|
||||||
|
AddItem(info, 1, 0, false).
|
||||||
|
AddItem(fileTable, 0, 1, true)
|
||||||
|
|
||||||
|
focusOrder := []tview.Primitive{fileTable}
|
||||||
|
if cfg.Mode == FileBrowserSave {
|
||||||
|
flex.AddItem(nameInput, 1, 0, false)
|
||||||
|
focusOrder = append(focusOrder, nameInput)
|
||||||
|
}
|
||||||
|
flex.AddItem(btnFlex, 1, 0, false)
|
||||||
|
focusOrder = append(focusOrder, btnSelect, btnHidden, btnFilter, btnBack)
|
||||||
|
|
||||||
|
// Circular Tab / Shift+Tab across every focusable widget.
|
||||||
|
cycle := func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
step := 0
|
||||||
|
switch event.Key() {
|
||||||
|
case tcell.KeyTab:
|
||||||
|
step = 1
|
||||||
|
case tcell.KeyBacktab:
|
||||||
|
step = -1
|
||||||
|
default:
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
for i, p := range focusOrder {
|
||||||
|
if p.HasFocus() {
|
||||||
|
se.app.SetFocus(focusOrder[(i+step+len(focusOrder))%len(focusOrder)])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fileTable.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event = cycle(event); event == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch event.Key() {
|
||||||
|
case tcell.KeyEscape:
|
||||||
|
closeBrowser()
|
||||||
|
return nil
|
||||||
|
case tcell.KeyEnter:
|
||||||
|
activate()
|
||||||
|
return nil
|
||||||
|
case tcell.KeyBackspace, tcell.KeyBackspace2, tcell.KeyLeft:
|
||||||
|
goUp()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch event.Rune() {
|
||||||
|
case 's':
|
||||||
|
chooseHighlighted()
|
||||||
|
return nil
|
||||||
|
case 'h':
|
||||||
|
toggleHidden()
|
||||||
|
return nil
|
||||||
|
case 'f':
|
||||||
|
toggleFilter()
|
||||||
|
return nil
|
||||||
|
case 'b':
|
||||||
|
closeBrowser()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
|
||||||
|
nameInput.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event = cycle(event); event == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch event.Key() {
|
||||||
|
case tcell.KeyEscape:
|
||||||
|
closeBrowser()
|
||||||
|
return nil
|
||||||
|
case tcell.KeyEnter:
|
||||||
|
chooseSave()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, b := range []*tview.Button{btnSelect, btnHidden, btnFilter, btnBack} {
|
||||||
|
b.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event = cycle(event); event == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if event.Key() == tcell.KeyEscape {
|
||||||
|
closeBrowser()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh()
|
||||||
|
if startName != "" {
|
||||||
|
for i, e := range entries {
|
||||||
|
if e.Name == startName {
|
||||||
|
fileTable.Select(i+1, 0)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
se.pages.AddAndSwitchToPage(pageName, flex, true)
|
||||||
|
se.app.SetFocus(fileTable)
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func touch(t *testing.T, path string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, nil, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func names(entries []FileEntry) []string {
|
||||||
|
var out []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir {
|
||||||
|
out = append(out, e.Name+"/")
|
||||||
|
} else {
|
||||||
|
out = append(out, e.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchesExtension(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
exts []string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"a.dbml", []string{".dbml"}, true},
|
||||||
|
{"A.DBML", []string{".dbml"}, true},
|
||||||
|
{"a.json", []string{".dbml"}, false},
|
||||||
|
{"a.yml", []string{".yaml", ".yml"}, true},
|
||||||
|
{"noext", []string{".sql"}, false},
|
||||||
|
{"anything", nil, true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := MatchesExtension(tt.name, tt.exts); got != tt.want {
|
||||||
|
t.Errorf("MatchesExtension(%q, %v) = %v, want %v", tt.name, tt.exts, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListDirFilterAndHidden(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
touch(t, filepath.Join(dir, "b.dbml"))
|
||||||
|
touch(t, filepath.Join(dir, "A.dbml"))
|
||||||
|
touch(t, filepath.Join(dir, "c.json"))
|
||||||
|
touch(t, filepath.Join(dir, ".hidden.dbml"))
|
||||||
|
touch(t, filepath.Join(dir, "sub", "x.txt"))
|
||||||
|
touch(t, filepath.Join(dir, ".git", "x"))
|
||||||
|
|
||||||
|
got, err := ListDir(dir, FormatExtensions("dbml"), false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if want := []string{"sub/", "A.dbml", "b.dbml"}; !reflect.DeepEqual(names(got), want) {
|
||||||
|
t.Errorf("filtered: got %v, want %v", names(got), want)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _ = ListDir(dir, FormatExtensions("dbml"), true)
|
||||||
|
if want := []string{".git/", "sub/", ".hidden.dbml", "A.dbml", "b.dbml"}; !reflect.DeepEqual(names(got), want) {
|
||||||
|
t.Errorf("hidden: got %v, want %v", names(got), want)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _ = ListDir(dir, nil, false)
|
||||||
|
if want := []string{"sub/", "A.dbml", "b.dbml", "c.json"}; !reflect.DeepEqual(names(got), want) {
|
||||||
|
t.Errorf("no filter: got %v, want %v", names(got), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListDirMissing(t *testing.T) {
|
||||||
|
if _, err := ListDir(filepath.Join(t.TempDir(), "nope"), nil, false); err == nil {
|
||||||
|
t.Error("expected error for missing directory")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveStart(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
file := filepath.Join(dir, "schema.dbml")
|
||||||
|
touch(t, file)
|
||||||
|
wd, _ := os.Getwd()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
wantDir string
|
||||||
|
wantFileName string
|
||||||
|
}{
|
||||||
|
{"existing file", file, dir, "schema.dbml"},
|
||||||
|
{"directory", dir, dir, ""},
|
||||||
|
{"new file in existing dir", filepath.Join(dir, "new.dbml"), dir, "new.dbml"},
|
||||||
|
{"empty", "", wd, ""},
|
||||||
|
{"nonexistent parent", filepath.Join(dir, "no", "such", "f.dbml"), wd, ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
d, n := ResolveStart(tt.in)
|
||||||
|
if d != tt.wantDir || n != tt.wantFileName {
|
||||||
|
t.Errorf("got (%q, %q), want (%q, %q)", d, n, tt.wantDir, tt.wantFileName)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatExtensions(t *testing.T) {
|
||||||
|
if got := FormatExtensions("yaml"); !reflect.DeepEqual(got, []string{".yaml", ".yml"}) {
|
||||||
|
t.Errorf("yaml: %v", got)
|
||||||
|
}
|
||||||
|
if FormatExtensions("unknown") != nil {
|
||||||
|
t.Error("unknown format should not filter")
|
||||||
|
}
|
||||||
|
if !IsDirectoryFormat("gorm") || IsDirectoryFormat("json") {
|
||||||
|
t.Error("directory format detection wrong")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newDialogTestEditor() *SchemaEditor {
|
||||||
|
se := &SchemaEditor{app: tview.NewApplication(), pages: tview.NewPages()}
|
||||||
|
se.pages.AddPage("origin", tview.NewBox(), true, true)
|
||||||
|
return se
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileBrowserOpensOnEachMode(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
touch(t, filepath.Join(dir, "a.dbml"))
|
||||||
|
|
||||||
|
for _, mode := range []FileBrowserMode{FileBrowserLoad, FileBrowserSave} {
|
||||||
|
se := newDialogTestEditor()
|
||||||
|
se.showFileBrowser(FileBrowserConfig{
|
||||||
|
Mode: mode,
|
||||||
|
StartPath: filepath.Join(dir, "a.dbml"),
|
||||||
|
Extensions: FormatExtensions("dbml"),
|
||||||
|
ReturnPage: "origin",
|
||||||
|
OnSelect: func(string) { t.Error("OnSelect must not fire without a selection") },
|
||||||
|
})
|
||||||
|
if !se.pages.HasPage("file-browser") {
|
||||||
|
t.Errorf("mode %d: file-browser page missing", mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnStringBuilderOpensForEachKind(t *testing.T) {
|
||||||
|
for _, in := range []string{
|
||||||
|
"",
|
||||||
|
"postgres://u:pw@db:5432/app?sslmode=disable",
|
||||||
|
"sqlserver://sa:pw@sql:1433?database=shop&encrypt=disable",
|
||||||
|
"/tmp/app.db",
|
||||||
|
"postgres://u:p@host:badport/db", // parse error falls back to defaults
|
||||||
|
} {
|
||||||
|
se := newDialogTestEditor()
|
||||||
|
se.showConnStringBuilder(in, ConnPostgres, "origin", func(string) {
|
||||||
|
t.Error("onDone must not fire without Save")
|
||||||
|
})
|
||||||
|
if !se.pages.HasPage(connBuilderPage) {
|
||||||
|
t.Errorf("%q: builder page missing", in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConnFields(t *testing.T) {
|
||||||
|
if validateConnFields(ConnFields{Kind: ConnSQLite}) == "" {
|
||||||
|
t.Error("sqlite without path should be invalid")
|
||||||
|
}
|
||||||
|
if validateConnFields(ConnFields{Kind: ConnPostgres}) == "" {
|
||||||
|
t.Error("postgres without host should be invalid")
|
||||||
|
}
|
||||||
|
if msg := validateConnFields(DefaultConnFields(ConnMSSQL)); msg != "" {
|
||||||
|
t.Errorf("defaults should be valid, got %q", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,6 +92,9 @@ func (se *SchemaEditor) showLoadScreen() {
|
|||||||
connString = value
|
connString = value
|
||||||
})
|
})
|
||||||
|
|
||||||
|
se.attachFileBrowser(form, "File Path", "load-database", FileBrowserLoad, func() string { return currentFormat })
|
||||||
|
se.attachConnStringBuilder(form, "Connection String", "load-database", func() string { return currentFormat })
|
||||||
|
|
||||||
form.AddTextView("Help", getLoadHelpText(), 0, 5, true, false)
|
form.AddTextView("Help", getLoadHelpText(), 0, 5, true, false)
|
||||||
|
|
||||||
// Buttons
|
// Buttons
|
||||||
@@ -190,6 +193,8 @@ func (se *SchemaEditor) showSaveScreen() {
|
|||||||
filePath = value
|
filePath = value
|
||||||
})
|
})
|
||||||
|
|
||||||
|
se.attachFileBrowser(form, "File Path", "save-database", FileBrowserSave, func() string { return currentFormat })
|
||||||
|
|
||||||
form.AddTextView("Help", getSaveHelpText(), 0, 5, true, false)
|
form.AddTextView("Help", getSaveHelpText(), 0, 5, true, false)
|
||||||
|
|
||||||
// Buttons
|
// Buttons
|
||||||
@@ -469,6 +474,8 @@ func getLoadHelpText() string {
|
|||||||
return `File-based formats: dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm
|
return `File-based formats: dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm
|
||||||
Database formats: pgsql (requires connection string)
|
Database formats: pgsql (requires connection string)
|
||||||
|
|
||||||
|
Press Enter in File Path to browse files, or in Connection String to open the builder.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
- File path: ~/schemas/mydb.dbml or /path/to/schema.json
|
- File path: ~/schemas/mydb.dbml or /path/to/schema.json
|
||||||
- Connection: postgres://user:pass@localhost/dbname`
|
- Connection: postgres://user:pass@localhost/dbname`
|
||||||
@@ -520,6 +527,8 @@ func (se *SchemaEditor) showUpdateExistingDatabaseConfirm() {
|
|||||||
func getSaveHelpText() string {
|
func getSaveHelpText() string {
|
||||||
return `File-based formats: dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, pgsql (SQL export)
|
return `File-based formats: dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, pgsql (SQL export)
|
||||||
|
|
||||||
|
Press Enter in File Path to browse for a target.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
- File: ~/schemas/mydb.dbml
|
- File: ~/schemas/mydb.dbml
|
||||||
- Directory (for code formats): ./models/`
|
- Directory (for code formats): ./models/`
|
||||||
@@ -570,6 +579,9 @@ func (se *SchemaEditor) showImportScreen() {
|
|||||||
connString = value
|
connString = value
|
||||||
})
|
})
|
||||||
|
|
||||||
|
se.attachFileBrowser(form, "File Path", "import-database", FileBrowserLoad, func() string { return currentFormat })
|
||||||
|
se.attachConnStringBuilder(form, "Connection String", "import-database", func() string { return currentFormat })
|
||||||
|
|
||||||
form.AddInputField("Skip Tables (comma-separated)", "", 50, nil, func(value string) {
|
form.AddInputField("Skip Tables (comma-separated)", "", 50, nil, func(value string) {
|
||||||
skipTables = value
|
skipTables = value
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -205,12 +205,24 @@ Organize UI code into these files:
|
|||||||
- **column_screens.go** - Column editor, new column dialog
|
- **column_screens.go** - Column editor, new column dialog
|
||||||
- **domain_screens.go** - Domain list, domain editor, new/edit domain dialogs
|
- **domain_screens.go** - Domain list, domain editor, new/edit domain dialogs
|
||||||
- **dialogs.go** - Confirmation dialogs (exit, delete)
|
- **dialogs.go** - Confirmation dialogs (exit, delete)
|
||||||
|
- **filebrowser_screens.go** - File browser dialog (`file-browser`), opened with Enter on File Path inputs
|
||||||
|
- **connstring_screens.go** - Connection string builder dialog (`conn-builder`), opened with Enter on Connection String inputs
|
||||||
|
|
||||||
### Data Operations Files (Business Logic)
|
### Data Operations Files (Business Logic)
|
||||||
|
|
||||||
- **schema_dataops.go** - Schema CRUD operations (Create, Read, Update, Delete)
|
- **schema_dataops.go** - Schema CRUD operations (Create, Read, Update, Delete)
|
||||||
- **table_dataops.go** - Table CRUD operations
|
- **table_dataops.go** - Table CRUD operations
|
||||||
- **column_dataops.go** - Column CRUD operations
|
- **column_dataops.go** - Column CRUD operations
|
||||||
|
- **filebrowser.go** - Directory listing, extension filtering and start-path resolution (no tview)
|
||||||
|
- **connstring.go**, **connstring_check.go** - Connection string build/parse/mask and connection test (no tview)
|
||||||
|
|
||||||
|
### Input Dialogs
|
||||||
|
|
||||||
|
- **File browser** - Enter on a File Path input opens it. Up/Down move, Enter opens a directory or selects a file,
|
||||||
|
Backspace/`[..]` goes to the parent, `h` toggles hidden files, `f` toggles the format extension filter,
|
||||||
|
`s` selects, Esc/`b` cancels (input unchanged). Save mode adds a file name field and asks before overwriting.
|
||||||
|
- **Connection string builder** - Enter on a Connection String input opens it. Fields per type (PostgreSQL, MSSQL,
|
||||||
|
SQLite), masked password and preview, F2 save, F3 test connection, Esc cancels (input unchanged).
|
||||||
|
|
||||||
## Code Separation Rules
|
## Code Separation Rules
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user