Files
relspecgo/pkg/ui/filebrowser_screens.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

365 lines
8.6 KiB
Go

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)
}