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