Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d961536186 |
@@ -44,6 +44,17 @@ relspec convert --from pgsql --from-conn "postgres://..." --to sqlite --to-path
|
||||
relspec convert --from json --from-list "a.json,b.json" --to yaml --to-path merged.yaml
|
||||
```
|
||||
|
||||
### `batch` — Convert many inputs in one run
|
||||
|
||||
Converts each input independently (one output per input, unlike `--from-list`
|
||||
which merges). `--input` takes paths or globs; outputs go to `--to-dir`.
|
||||
Use `--keep-going` to continue past failures (exit is still non-zero) and
|
||||
`--dry-run` to validate without writing. For named workflows see `relspec job run`.
|
||||
|
||||
```bash
|
||||
relspec batch --from dbml --input "schemas/*.dbml" --to json --to-dir out/
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
batchSourceType string
|
||||
batchInputs []string
|
||||
batchTargetType string
|
||||
batchTargetDir string
|
||||
batchPackageName string
|
||||
batchSchemaFilter string
|
||||
batchFlattenSchema bool
|
||||
batchNullableTypes string
|
||||
batchNullableArrays string
|
||||
batchContinueOnError bool
|
||||
batchKeepGoing bool
|
||||
batchDryRun bool
|
||||
)
|
||||
|
||||
var batchCmd = &cobra.Command{
|
||||
Use: "batch",
|
||||
Short: "Convert many input files to a target format in one run",
|
||||
Long: `Convert each input file independently to the target format.
|
||||
|
||||
Unlike 'convert --from-list', which merges all inputs into one output, batch
|
||||
mode writes one output per input into --to-dir. The output is named after the
|
||||
input file (without its extension). Directory-style targets (gorm, bun,
|
||||
drizzle) get a sub-directory per input.
|
||||
|
||||
Inputs are given with --input, which accepts file paths and glob patterns and
|
||||
may be repeated or comma-separated. Inputs are processed in sorted order and
|
||||
duplicates are removed. The command exits non-zero if any input fails.
|
||||
|
||||
For named, multi-step workflows use 'relspec job run' instead.
|
||||
|
||||
Examples:
|
||||
# Convert every DBML file in a directory to JSON
|
||||
relspec batch --from dbml --input "schemas/*.dbml" --to json --to-dir out/
|
||||
|
||||
# Convert specific files to GORM models, one package directory per input
|
||||
relspec batch --from json --input a.json,b.json \
|
||||
--to gorm --to-dir models/ --package models
|
||||
|
||||
# Validate everything first, writing nothing
|
||||
relspec batch --from yaml --input "specs/*.yaml" --to pgsql --to-dir sql/ --dry-run
|
||||
|
||||
# Report all failures instead of stopping at the first
|
||||
relspec batch --from json --input "*.json" --to yaml --to-dir out/ --keep-going`,
|
||||
RunE: runBatch,
|
||||
}
|
||||
|
||||
func init() {
|
||||
batchCmd.Flags().StringVar(&batchSourceType, "from", "", "Source format for every input (dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, sqlite)")
|
||||
batchCmd.Flags().StringSliceVar(&batchInputs, "input", nil, "Input file path or glob pattern (repeatable, comma-separated)")
|
||||
batchCmd.Flags().StringVar(&batchTargetType, "to", "", "Target format")
|
||||
batchCmd.Flags().StringVar(&batchTargetDir, "to-dir", "", "Output directory; one output per input is written here")
|
||||
batchCmd.Flags().StringVar(&batchPackageName, "package", "", "Package name (for code generation formats like gorm/bun)")
|
||||
batchCmd.Flags().StringVar(&batchSchemaFilter, "schema", "", "Filter to a specific schema by name")
|
||||
batchCmd.Flags().BoolVar(&batchFlattenSchema, "flatten-schema", false, "Flatten schema.table names to schema_table")
|
||||
batchCmd.Flags().StringVar(&batchNullableTypes, "types", "", "Nullable type package for code-gen writers (bun/gorm)")
|
||||
batchCmd.Flags().StringVar(&batchNullableArrays, "array-nullable", "", "Nullable array representation for the Bun writer")
|
||||
batchCmd.Flags().BoolVar(&batchContinueOnError, "continue-on-error", false, "Prepend \\set ON_ERROR_STOP off to generated SQL (pgsql output only)")
|
||||
batchCmd.Flags().BoolVar(&batchKeepGoing, "keep-going", false, "Process remaining inputs after a failure; still exits non-zero")
|
||||
batchCmd.Flags().BoolVar(&batchDryRun, "dry-run", false, "Read and validate every input and print the plan without writing any output")
|
||||
|
||||
for _, f := range []string{"from", "input", "to", "to-dir"} {
|
||||
if err := batchCmd.MarkFlagRequired(f); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error marking %s flag as required: %v\n", f, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// batchDirTargets are writers that emit a directory of files rather than one file.
|
||||
var batchDirTargets = map[string]bool{"gorm": true, "bun": true, "drizzle": true}
|
||||
|
||||
// batchExtensions maps single-file target formats to their output extension.
|
||||
var batchExtensions = map[string]string{
|
||||
"dbml": ".dbml", "dctx": ".dctx", "drawdb": ".ddb", "json": ".json",
|
||||
"yaml": ".yaml", "yml": ".yaml", "pgsql": ".sql", "postgres": ".sql",
|
||||
"postgresql": ".sql", "sql": ".sql", "mssql": ".sql", "sqlserver": ".sql",
|
||||
"mssql2016": ".sql", "mssql2017": ".sql", "mssql2019": ".sql", "mssql2022": ".sql",
|
||||
"sqlite": ".sql", "sqlite3": ".sql", "prisma": ".prisma", "typeorm": ".ts",
|
||||
"graphql": ".graphql", "gql": ".graphql",
|
||||
}
|
||||
|
||||
// expandBatchInputs resolves paths and glob patterns into a sorted,
|
||||
// de-duplicated file list. A pattern that matches nothing is an error.
|
||||
func expandBatchInputs(patterns []string) ([]string, error) {
|
||||
seen := map[string]bool{}
|
||||
var files []string
|
||||
for _, p := range patterns {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
matches, err := filepath.Glob(p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid pattern %q: %w", p, err)
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return nil, fmt.Errorf("no files match %q", p)
|
||||
}
|
||||
for _, m := range matches {
|
||||
if !seen[m] {
|
||||
seen[m] = true
|
||||
files = append(files, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return nil, fmt.Errorf("no input files given")
|
||||
}
|
||||
sort.Strings(files)
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// batchOutputPaths returns the output path for each input. It errors when two
|
||||
// inputs would collide on the same output name.
|
||||
func batchOutputPaths(files []string, targetType, dir string) ([]string, error) {
|
||||
key := strings.ToLower(targetType)
|
||||
ext := ""
|
||||
if !batchDirTargets[key] {
|
||||
var ok bool
|
||||
if ext, ok = batchExtensions[key]; !ok {
|
||||
return nil, fmt.Errorf("unsupported target format: %s", targetType)
|
||||
}
|
||||
}
|
||||
outs := make([]string, len(files))
|
||||
owner := map[string]string{}
|
||||
for i, f := range files {
|
||||
stem := strings.TrimSuffix(filepath.Base(f), filepath.Ext(f))
|
||||
out := filepath.Join(dir, stem+ext)
|
||||
if prev, dup := owner[out]; dup {
|
||||
return nil, fmt.Errorf("inputs %s and %s would both write %s", prev, f, out)
|
||||
}
|
||||
owner[out] = f
|
||||
outs[i] = out
|
||||
}
|
||||
return outs, nil
|
||||
}
|
||||
|
||||
func runBatch(cmd *cobra.Command, args []string) error {
|
||||
files, err := expandBatchInputs(batchInputs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outs, err := batchOutputPaths(files, batchTargetType, batchTargetDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\n=== RelSpec Batch Converter ===\n")
|
||||
fmt.Fprintf(os.Stderr, "Started at: %s\n", getCurrentTimestamp())
|
||||
fmt.Fprintf(os.Stderr, "Inputs: %d file(s), %s -> %s\n\n", len(files), batchSourceType, batchTargetType)
|
||||
|
||||
out := outWriter(cmd)
|
||||
if batchDryRun {
|
||||
fmt.Fprintf(out, "RelSpec batch plan (dry run - nothing written):\n")
|
||||
}
|
||||
|
||||
var failed []string
|
||||
for i, f := range files {
|
||||
fmt.Fprintf(os.Stderr, "[%d/%d] %s -> %s\n", i+1, len(files), f, outs[i])
|
||||
if err := processBatchItem(cmd, f, outs[i]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, " ✗ %v\n", err)
|
||||
failed = append(failed, fmt.Sprintf("%s: %v", f, err))
|
||||
if !batchKeepGoing {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, " ✓ done\n")
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\n=== Batch Complete: %d ok, %d failed ===\n", len(files)-len(failed), len(failed))
|
||||
if len(failed) > 0 {
|
||||
return fmt.Errorf("batch finished with %d failure(s):\n %s", len(failed), strings.Join(failed, "\n "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func processBatchItem(cmd *cobra.Command, in, outPath string) error {
|
||||
db, err := readDatabaseForConvert(batchSourceType, in, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read source: %w", err)
|
||||
}
|
||||
finalizeCommentedRefs(db, stderrWarn)
|
||||
|
||||
if batchDryRun {
|
||||
if err := validateWriteTarget(db, batchTargetType, batchPackageName, batchSchemaFilter, ""); err != nil {
|
||||
return fmt.Errorf("dry run validation failed: %w", err)
|
||||
}
|
||||
w := outWriter(cmd)
|
||||
fmt.Fprintf(w, " %s -> %s (database '%s')\n", in, outPath, db.Name)
|
||||
printDryRunPlan(w, db)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(batchTargetDir, 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
if err := writeDatabase(db, batchTargetType, outPath, batchPackageName, batchSchemaFilter, batchFlattenSchema, batchNullableTypes, batchNullableArrays, batchContinueOnError, ""); err != nil {
|
||||
return fmt.Errorf("failed to write target: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func saveBatchState(t *testing.T) {
|
||||
t.Helper()
|
||||
a, b, c, d, e, f, g := batchSourceType, batchInputs, batchTargetType, batchTargetDir, batchPackageName, batchKeepGoing, batchDryRun
|
||||
t.Cleanup(func() {
|
||||
batchSourceType, batchInputs, batchTargetType, batchTargetDir, batchPackageName, batchKeepGoing, batchDryRun = a, b, c, d, e, f, g
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunBatch_ConvertsEachInput(t *testing.T) {
|
||||
saveBatchState(t)
|
||||
dir := t.TempDir()
|
||||
writeTestJSON(t, filepath.Join(dir, "a.json"), []string{"users"})
|
||||
writeTestJSON(t, filepath.Join(dir, "b.json"), []string{"posts"})
|
||||
outDir := filepath.Join(dir, "out")
|
||||
|
||||
batchSourceType, batchTargetType, batchTargetDir = "json", "yaml", outDir
|
||||
batchPackageName, batchKeepGoing, batchDryRun = "", false, false
|
||||
batchInputs = []string{filepath.Join(dir, "*.json")}
|
||||
|
||||
cmd, _ := newDryRunCmd()
|
||||
if err := runBatch(cmd, nil); err != nil {
|
||||
t.Fatalf("batch: %v", err)
|
||||
}
|
||||
for _, name := range []string{"a.yaml", "b.yaml"} {
|
||||
if _, err := os.Stat(filepath.Join(outDir, name)); err != nil {
|
||||
t.Errorf("expected %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatch_DryRunWritesNothing(t *testing.T) {
|
||||
saveBatchState(t)
|
||||
dir := t.TempDir()
|
||||
writeTestJSON(t, filepath.Join(dir, "a.json"), []string{"users"})
|
||||
outDir := filepath.Join(dir, "out")
|
||||
|
||||
batchSourceType, batchTargetType, batchTargetDir = "json", "yaml", outDir
|
||||
batchPackageName, batchKeepGoing, batchDryRun = "", false, true
|
||||
batchInputs = []string{filepath.Join(dir, "a.json")}
|
||||
|
||||
cmd, buf := newDryRunCmd()
|
||||
if err := runBatch(cmd, nil); err != nil {
|
||||
t.Fatalf("dry run: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(outDir); !os.IsNotExist(err) {
|
||||
t.Fatal("dry run must not create the output directory")
|
||||
}
|
||||
if !strings.Contains(buf.String(), "users") || !strings.Contains(buf.String(), "a.yaml") {
|
||||
t.Errorf("plan incomplete:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatch_FailureHandling(t *testing.T) {
|
||||
saveBatchState(t)
|
||||
dir := t.TempDir()
|
||||
writeTestJSON(t, filepath.Join(dir, "a.json"), []string{"users"})
|
||||
if err := os.WriteFile(filepath.Join(dir, "b.json"), []byte("{not json"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeTestJSON(t, filepath.Join(dir, "c.json"), []string{"posts"})
|
||||
outDir := filepath.Join(dir, "out")
|
||||
|
||||
batchSourceType, batchTargetType, batchTargetDir = "json", "yaml", outDir
|
||||
batchPackageName, batchDryRun = "", false
|
||||
batchInputs = []string{filepath.Join(dir, "*.json")}
|
||||
cmd, _ := newDryRunCmd()
|
||||
|
||||
// Default: stop at first failure.
|
||||
batchKeepGoing = false
|
||||
err := runBatch(cmd, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "b.json") {
|
||||
t.Fatalf("expected failure naming b.json, got %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(outDir, "c.yaml")); !os.IsNotExist(statErr) {
|
||||
t.Error("c.json should not be processed without --keep-going")
|
||||
}
|
||||
|
||||
// --keep-going: remaining inputs are processed, exit still fails.
|
||||
batchKeepGoing = true
|
||||
if err := runBatch(cmd, nil); err == nil {
|
||||
t.Fatal("expected non-zero result with --keep-going")
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(outDir, "c.yaml")); statErr != nil {
|
||||
t.Errorf("c.yaml should be written with --keep-going: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandBatchInputs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, n := range []string{"b.json", "a.json"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, n), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got, err := expandBatchInputs([]string{filepath.Join(dir, "*.json"), filepath.Join(dir, "a.json")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 || filepath.Base(got[0]) != "a.json" || filepath.Base(got[1]) != "b.json" {
|
||||
t.Errorf("want sorted deduped [a b], got %v", got)
|
||||
}
|
||||
if _, err := expandBatchInputs([]string{filepath.Join(dir, "*.nope")}); err == nil {
|
||||
t.Error("unmatched pattern should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchOutputPaths(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
files []string
|
||||
target string
|
||||
want []string
|
||||
wantErr string
|
||||
}{
|
||||
{"file target", []string{"x/a.dbml"}, "json", []string{"out/a.json"}, ""},
|
||||
{"dir target", []string{"x/a.json"}, "gorm", []string{"out/a"}, ""},
|
||||
{"collision", []string{"x/a.json", "y/a.json"}, "yaml", nil, "both write"},
|
||||
{"unsupported", []string{"a.json"}, "nope", nil, "unsupported target"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := batchOutputPaths(tt.files, tt.target, "out")
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("want error %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || len(got) != len(tt.want) || got[0] != filepath.FromSlash(tt.want[0]) {
|
||||
t.Fatalf("got %v, %v; want %v", got, err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ JSON, YAML, SQL, etc.).`,
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(convertCmd)
|
||||
rootCmd.AddCommand(batchCmd)
|
||||
rootCmd.AddCommand(diffCmd)
|
||||
rootCmd.AddCommand(inspectCmd)
|
||||
rootCmd.AddCommand(scriptsCmd)
|
||||
|
||||
@@ -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