feat(ui): file browser and connection string builder dialogs (#44)
Enter on File Path inputs opens a file browser (load/save, extension filter, hidden toggle, overwrite confirm). Enter on Connection String inputs opens a builder for PostgreSQL, MSSQL and SQLite with masked password/preview, parsing and optional connection test. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
b38f53c603
commit
0235a28add
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user