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,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)
|
||||
}
|
||||
Reference in New Issue
Block a user