Files
relspecgo/pkg/ui/filebrowser.go
T
SG CommandandClaude Sonnet 5.5 0235a28add 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>
2026-10-03 11:35:12 +02:00

135 lines
3.5 KiB
Go

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, ""
}