Compare commits

..
Author SHA1 Message Date
Hermes AgentandClaude Sonnet 5.5 d36806047b feat(cli): add --watch mode to convert (#39)
Poll source files/directories and regenerate output on change.
Output path is excluded from watching; errors don't stop the loop.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:29:42 +02:00
14 changed files with 270 additions and 1311 deletions
+9
View File
@@ -42,8 +42,17 @@ relspec convert --from pgsql --from-conn "postgres://..." --to sqlite --to-path
# Multiple input files merged
relspec convert --from json --from-list "a.json,b.json" --to yaml --to-path merged.yaml
# Watch mode: regenerate whenever the source file(s) change (Ctrl-C to stop)
relspec convert --from dbml --from-path schema.dbml --to gorm --to-path models/ --package models --watch
```
`--watch` works with `--from-path` and `--from-list` (not live database
connections or `--dry-run`). Source files are polled every `--watch-interval`
(default 500ms), a directory source is watched recursively, and the output path
is ignored so generating into the source tree does not loop. Conversion errors
are printed and watching continues.
PostgreSQL connections opened by relspec set `application_name` by default to
`relspecgo/<version>` (with component suffixes internally, e.g. readers/writers).
If you need a custom value, provide `application_name` explicitly in the connection
+17 -1
View File
@@ -59,6 +59,8 @@ var (
convertContinueOnError bool
convertExtraFields string
convertDryRun bool
convertWatch bool
convertWatchInterval time.Duration
)
var convertCmd = &cobra.Command{
@@ -167,7 +169,11 @@ Examples:
# Convert SQLite to PostgreSQL SQL
relspec convert --from sqlite --from-path database.db \
--to pgsql --to-path schema.sql`,
--to pgsql --to-path schema.sql
# Regenerate GORM models every time the DBML file changes
relspec convert --from dbml --from-path schema.dbml \
--to gorm --to-path models/ --package models --watch`,
RunE: runConvert,
}
@@ -189,6 +195,9 @@ func init() {
convertCmd.Flags().BoolVar(&convertDryRun, "dry-run", false, "Read and validate the input and print the plan without writing any output")
convertCmd.Flags().BoolVar(&convertWatch, "watch", false, "Watch the source files (--from-path or --from-list) and regenerate the output whenever they change")
convertCmd.Flags().DurationVar(&convertWatchInterval, "watch-interval", 500*time.Millisecond, "Polling interval used by --watch")
err := convertCmd.MarkFlagRequired("from")
if err != nil {
fmt.Fprintf(os.Stderr, "Error marking from flag as required: %v\n", err)
@@ -204,6 +213,13 @@ func init() {
}
func runConvert(cmd *cobra.Command, args []string) error {
if convertWatch {
return runConvertWatch(cmd.Context(), os.Stderr, func() error { return runConvertOnce(cmd) })
}
return runConvertOnce(cmd)
}
func runConvertOnce(cmd *cobra.Command) error {
fmt.Fprintf(os.Stderr, "\n=== RelSpec Schema Converter ===\n")
fmt.Fprintf(os.Stderr, "Started at: %s\n\n", getCurrentTimestamp())
+152
View File
@@ -0,0 +1,152 @@
package main
import (
"context"
"fmt"
"io"
"io/fs"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
)
// watchSnapshot maps a file path to its modification time and size.
type watchSnapshot map[string]string
// takeWatchSnapshot records the state of every file under the given paths.
// Directories are walked recursively. Anything at or below the excluded path
// (typically the output path) is skipped so regenerating output does not
// retrigger the watcher. Missing paths are simply absent from the snapshot, so
// creating them later counts as a change.
func takeWatchSnapshot(paths []string, exclude string) watchSnapshot {
snap := watchSnapshot{}
exclude = absPathOrSelf(exclude)
for _, root := range paths {
_ = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if exclude != "" && isWithin(absPathOrSelf(p), exclude) {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
snap[p] = fmt.Sprintf("%d-%d", info.ModTime().UnixNano(), info.Size())
return nil
})
}
return snap
}
func (s watchSnapshot) equal(o watchSnapshot) bool {
if len(s) != len(o) {
return false
}
for k, v := range s {
if o[k] != v {
return false
}
}
return true
}
func absPathOrSelf(p string) string {
if p == "" {
return ""
}
if abs, err := filepath.Abs(p); err == nil {
return abs
}
return p
}
// isWithin reports whether path equals dir or is located below it.
func isWithin(path, dir string) bool {
if path == dir {
return true
}
return strings.HasPrefix(path, dir+string(filepath.Separator))
}
// watchLoop runs fn once immediately and again whenever the watched paths
// change, until ctx is cancelled. Changes are debounced: fn runs only after
// the snapshot has stayed unchanged for one poll interval. Errors from fn are
// reported to w and do not stop the loop.
func watchLoop(ctx context.Context, w io.Writer, paths []string, exclude string, interval time.Duration, fn func() error) {
run := func() {
if err := fn(); err != nil {
fmt.Fprintf(w, "Error: %v\n", err)
}
fmt.Fprintf(w, "Watching for changes (Ctrl-C to stop)...\n")
}
last := takeWatchSnapshot(paths, exclude)
run()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
}
cur := takeWatchSnapshot(paths, exclude)
if cur.equal(last) {
continue
}
// Debounce: wait until writes settle.
for settled := false; !settled; {
select {
case <-ctx.Done():
return
case <-time.After(interval):
}
next := takeWatchSnapshot(paths, exclude)
settled = next.equal(cur)
cur = next
}
fmt.Fprintf(w, "\nChange detected, regenerating...\n")
last = cur
run()
}
}
// runConvertWatch runs the conversion once and then again whenever the source
// files change, until interrupted.
func runConvertWatch(parent context.Context, w io.Writer, run func() error) error {
var paths []string
switch {
case len(convertFromList) > 0:
paths = convertFromList
case convertSourcePath != "":
paths = []string{convertSourcePath}
default:
return fmt.Errorf("--watch requires --from-path or --from-list (live database connections cannot be watched)")
}
if convertDryRun {
return fmt.Errorf("--watch cannot be combined with --dry-run")
}
if convertWatchInterval <= 0 {
return fmt.Errorf("--watch-interval must be positive")
}
if parent == nil {
parent = context.Background()
}
ctx, stop := signal.NotifyContext(parent, os.Interrupt, syscall.SIGTERM)
defer stop()
watchLoop(ctx, w, paths, convertTargetPath, convertWatchInterval, run)
return nil
}
+92
View File
@@ -0,0 +1,92 @@
package main
import (
"bytes"
"context"
"io"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
)
func TestWatchSnapshotExcludesOutput(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "out")
if err := os.MkdirAll(out, 0o755); err != nil {
t.Fatal(err)
}
src := filepath.Join(dir, "schema.dbml")
if err := os.WriteFile(src, []byte("a"), 0o644); err != nil {
t.Fatal(err)
}
before := takeWatchSnapshot([]string{dir}, out)
if err := os.WriteFile(filepath.Join(out, "gen.go"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if after := takeWatchSnapshot([]string{dir}, out); !before.equal(after) {
t.Errorf("writing into the excluded output path changed the snapshot")
}
if err := os.WriteFile(src, []byte("changed"), 0o644); err != nil {
t.Fatal(err)
}
if after := takeWatchSnapshot([]string{dir}, out); before.equal(after) {
t.Errorf("modifying a source file did not change the snapshot")
}
}
func TestWatchLoopRerunsOnChange(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "schema.dbml")
if err := os.WriteFile(src, []byte("a"), 0o644); err != nil {
t.Fatal(err)
}
var runs atomic.Int32
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan struct{})
go func() {
defer close(done)
watchLoop(ctx, io.Discard, []string{src}, "", 10*time.Millisecond, func() error {
runs.Add(1)
return nil
})
}()
waitFor(t, func() bool { return runs.Load() == 1 })
if err := os.WriteFile(src, []byte("changed content"), 0o644); err != nil {
t.Fatal(err)
}
waitFor(t, func() bool { return runs.Load() == 2 })
cancel()
<-done
}
func TestRunConvertWatchValidation(t *testing.T) {
oldPath, oldList, oldDry, oldInt := convertSourcePath, convertFromList, convertDryRun, convertWatchInterval
defer func() {
convertSourcePath, convertFromList, convertDryRun, convertWatchInterval = oldPath, oldList, oldDry, oldInt
}()
convertSourcePath, convertFromList, convertDryRun, convertWatchInterval = "", nil, false, time.Second
if err := runConvertWatch(context.Background(), &bytes.Buffer{}, nil); err == nil {
t.Error("expected error without --from-path/--from-list")
}
convertSourcePath, convertDryRun = "x.dbml", true
if err := runConvertWatch(context.Background(), &bytes.Buffer{}, nil); err == nil {
t.Error("expected error combining --watch with --dry-run")
}
}
func waitFor(t *testing.T, cond func() bool) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("condition not met in time")
}
-186
View File
@@ -1,186 +0,0 @@
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
@@ -1,62 +0,0 @@
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
@@ -1,210 +0,0 @@
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
@@ -1,143 +0,0 @@
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
@@ -1,134 +0,0 @@
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
@@ -1,364 +0,0 @@
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
@@ -1,124 +0,0 @@
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
@@ -1,63 +0,0 @@
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,9 +92,6 @@ 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
@@ -193,8 +190,6 @@ 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
@@ -474,8 +469,6 @@ 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`
@@ -527,8 +520,6 @@ 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/`
@@ -579,9 +570,6 @@ 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,24 +205,12 @@ 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