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,210 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
// connBuilderPage is the page name of the connection string builder dialog.
|
||||
const connBuilderPage = "conn-builder"
|
||||
|
||||
// showConnStringBuilder opens the connection string builder, pre-filled by
|
||||
// parsing current. Save calls onDone with the built string; Esc/Back leaves
|
||||
// the caller's input untouched.
|
||||
func (se *SchemaEditor) showConnStringBuilder(current string, hint ConnKind, returnPage string, onDone func(connString string)) {
|
||||
fields, err := ParseConnString(current, hint)
|
||||
if err != nil {
|
||||
se.showErrorDialog("Error", err.Error()+"\nStarting from defaults.")
|
||||
}
|
||||
|
||||
title := tview.NewTextView().
|
||||
SetText("[::b]Connection String Builder").
|
||||
SetTextAlign(tview.AlignCenter).
|
||||
SetDynamicColors(true)
|
||||
|
||||
preview := tview.NewTextView()
|
||||
preview.SetBorder(true).SetTitle(" Preview (password masked) ").SetTitleAlign(tview.AlignLeft)
|
||||
|
||||
form := tview.NewForm()
|
||||
form.SetBorder(true).SetTitle(" Connection ").SetTitleAlign(tview.AlignLeft)
|
||||
|
||||
updatePreview := func() {
|
||||
preview.SetText(tview.Escape(BuildConnString(fields, true)))
|
||||
}
|
||||
|
||||
closeBuilder := func() {
|
||||
se.pages.RemovePage(connBuilderPage)
|
||||
se.pages.SwitchToPage(returnPage)
|
||||
}
|
||||
|
||||
var render func(focus int)
|
||||
render = func(focus int) {
|
||||
form.Clear(false)
|
||||
|
||||
kindIndex := 0
|
||||
kindLabels := make([]string, len(connKinds))
|
||||
for i, k := range connKinds {
|
||||
kindLabels[i] = string(k)
|
||||
if k == fields.Kind {
|
||||
kindIndex = i
|
||||
}
|
||||
}
|
||||
form.AddDropDown("Type", kindLabels, kindIndex, func(_ string, index int) {
|
||||
if connKinds[index] == fields.Kind {
|
||||
return
|
||||
}
|
||||
fields = DefaultConnFields(connKinds[index])
|
||||
render(0)
|
||||
})
|
||||
|
||||
if fields.Kind == ConnSQLite {
|
||||
form.AddInputField("File Path", fields.FilePath, 50, nil, func(v string) {
|
||||
fields.FilePath = v
|
||||
updatePreview()
|
||||
})
|
||||
if item, ok := form.GetFormItemByLabel("File Path").(*tview.InputField); ok {
|
||||
item.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() != tcell.KeyEnter {
|
||||
return event
|
||||
}
|
||||
se.showFileBrowser(FileBrowserConfig{
|
||||
Mode: FileBrowserLoad,
|
||||
StartPath: fields.FilePath,
|
||||
Extensions: FormatExtensions("sqlite"),
|
||||
ReturnPage: connBuilderPage,
|
||||
OnSelect: func(path string) { item.SetText(path) },
|
||||
})
|
||||
return nil
|
||||
})
|
||||
}
|
||||
} else {
|
||||
form.AddInputField("Host", fields.Host, 50, nil, func(v string) { fields.Host = v; updatePreview() })
|
||||
form.AddInputField("Port", fields.Port, 10, tview.InputFieldInteger, func(v string) { fields.Port = v; updatePreview() })
|
||||
form.AddInputField("Database", fields.Database, 50, nil, func(v string) { fields.Database = v; updatePreview() })
|
||||
form.AddInputField("User", fields.User, 50, nil, func(v string) { fields.User = v; updatePreview() })
|
||||
form.AddPasswordField("Password", fields.Password, 50, '*', func(v string) { fields.Password = v; updatePreview() })
|
||||
|
||||
label := "SSL Mode"
|
||||
if fields.Kind == ConnMSSQL {
|
||||
label = "Encrypt"
|
||||
}
|
||||
modes := SSLModes(fields.Kind)
|
||||
modeIndex := -1
|
||||
for i, m := range modes {
|
||||
if m == fields.SSLMode {
|
||||
modeIndex = i
|
||||
}
|
||||
}
|
||||
if modeIndex < 0 {
|
||||
// Keep a value parsed from an existing string even if it is not a listed option.
|
||||
modes = append([]string{fields.SSLMode}, modes...)
|
||||
modeIndex = 0
|
||||
}
|
||||
form.AddDropDown(label, modes, modeIndex, func(option string, _ int) {
|
||||
fields.SSLMode = option
|
||||
updatePreview()
|
||||
})
|
||||
}
|
||||
|
||||
form.AddButton("Save [F2]", connBuilderSave(se, &fields, closeBuilder, onDone))
|
||||
form.AddButton("Test [F3]", func() { se.testConnectionDialog(fields) })
|
||||
form.AddButton("Back [Esc]", closeBuilder)
|
||||
|
||||
updatePreview()
|
||||
form.SetFocus(focus)
|
||||
se.app.SetFocus(form)
|
||||
}
|
||||
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
switch event.Key() {
|
||||
case tcell.KeyEscape:
|
||||
closeBuilder()
|
||||
return nil
|
||||
case tcell.KeyF2:
|
||||
connBuilderSave(se, &fields, closeBuilder, onDone)()
|
||||
return nil
|
||||
case tcell.KeyF3:
|
||||
se.testConnectionDialog(fields)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
render(0)
|
||||
|
||||
flex := tview.NewFlex().SetDirection(tview.FlexRow).
|
||||
AddItem(title, 1, 0, false).
|
||||
AddItem(form, 0, 1, true).
|
||||
AddItem(preview, 4, 0, false)
|
||||
|
||||
se.pages.AddAndSwitchToPage(connBuilderPage, flex, true)
|
||||
se.app.SetFocus(form)
|
||||
}
|
||||
|
||||
// connBuilderSave returns the Save action: validate, write back, close.
|
||||
func connBuilderSave(se *SchemaEditor, fields *ConnFields, closeBuilder func(), onDone func(string)) func() {
|
||||
return func() {
|
||||
if msg := validateConnFields(*fields); msg != "" {
|
||||
se.showErrorDialog("Error", msg)
|
||||
return
|
||||
}
|
||||
result := BuildConnString(*fields, false)
|
||||
closeBuilder()
|
||||
onDone(result)
|
||||
}
|
||||
}
|
||||
|
||||
// validateConnFields returns a message describing the first missing required field, or "".
|
||||
func validateConnFields(f ConnFields) string {
|
||||
if f.Kind == ConnSQLite {
|
||||
if strings.TrimSpace(f.FilePath) == "" {
|
||||
return "File path is required"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if strings.TrimSpace(f.Host) == "" {
|
||||
return "Host is required"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// testConnectionDialog runs TestConnection in the background and reports the result.
|
||||
func (se *SchemaEditor) testConnectionDialog(fields ConnFields) {
|
||||
if msg := validateConnFields(fields); msg != "" {
|
||||
se.showErrorDialog("Error", msg)
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
err := TestConnection(fields)
|
||||
se.app.QueueUpdateDraw(func() {
|
||||
if err != nil {
|
||||
se.showErrorDialog("Connection Failed", fmt.Sprintf("Connection failed:\n%v", err))
|
||||
return
|
||||
}
|
||||
se.showSuccessDialog("Connection OK", "Connection successful", nil)
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
// attachConnStringBuilder makes Enter on the named input open the builder.
|
||||
func (se *SchemaEditor) attachConnStringBuilder(form *tview.Form, label, returnPage string, 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
|
||||
}
|
||||
hint := ConnPostgres
|
||||
if format != nil && format() == "sqlite" {
|
||||
hint = ConnSQLite
|
||||
}
|
||||
se.showConnStringBuilder(item.GetText(), hint, returnPage, func(s string) { item.SetText(s) })
|
||||
return nil
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user