feat(ui): file browser and connection string builder dialogs (#44)

Enter on File Path inputs opens a file browser (load/save, extension
filter, hidden toggle, overwrite confirm). Enter on Connection String
inputs opens a builder for PostgreSQL, MSSQL and SQLite with masked
password/preview, parsing and optional connection test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
SG Command
2026-10-03 11:35:12 +02:00
co-authored by Claude Sonnet 5.5
parent b38f53c603
commit 0235a28add
10 changed files with 1310 additions and 0 deletions
+186
View File
@@ -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
}
+62
View File
@@ -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)
}
+210
View File
@@ -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
})
}
+143
View File
@@ -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")
}
}
+134
View 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, ""
}
+364
View File
@@ -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)
}
+124
View File
@@ -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")
}
}
+63
View File
@@ -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)
}
}
+12
View File
@@ -92,6 +92,9 @@ func (se *SchemaEditor) showLoadScreen() {
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)
// Buttons
@@ -190,6 +193,8 @@ func (se *SchemaEditor) showSaveScreen() {
filePath = value
})
se.attachFileBrowser(form, "File Path", "save-database", FileBrowserSave, func() string { return currentFormat })
form.AddTextView("Help", getSaveHelpText(), 0, 5, true, false)
// Buttons
@@ -469,6 +474,8 @@ func getLoadHelpText() string {
return `File-based formats: dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm
Database formats: pgsql (requires connection string)
Press Enter in File Path to browse files, or in Connection String to open the builder.
Examples:
- File path: ~/schemas/mydb.dbml or /path/to/schema.json
- Connection: postgres://user:pass@localhost/dbname`
@@ -520,6 +527,8 @@ func (se *SchemaEditor) showUpdateExistingDatabaseConfirm() {
func getSaveHelpText() string {
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:
- File: ~/schemas/mydb.dbml
- Directory (for code formats): ./models/`
@@ -570,6 +579,9 @@ func (se *SchemaEditor) showImportScreen() {
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) {
skipTables = value
})
+12
View File
@@ -205,12 +205,24 @@ Organize UI code into these files:
- **column_screens.go** - Column editor, new column dialog
- **domain_screens.go** - Domain list, domain editor, new/edit domain dialogs
- **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)
- **schema_dataops.go** - Schema CRUD operations (Create, Read, Update, Delete)
- **table_dataops.go** - Table 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