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>
365 lines
8.6 KiB
Go
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)
|
|
}
|