Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d36806047b |
@@ -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
@@ -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())
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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, ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user