158 lines
3.7 KiB
Go
158 lines
3.7 KiB
Go
package tui
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
|
||
"github.com/charmbracelet/bubbles/textinput"
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
"github.com/charmbracelet/lipgloss"
|
||
)
|
||
|
||
var (
|
||
focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("12"))
|
||
labelStyle = lipgloss.NewStyle().Width(32)
|
||
blurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
|
||
cursorStyle = focusedStyle
|
||
)
|
||
|
||
// secretKey returns true for keys that likely contain sensitive values.
|
||
func secretKey(key string) bool {
|
||
upper := strings.ToUpper(key)
|
||
for _, word := range []string{"KEY", "TOKEN", "PASSWORD", "SECRET", "PASS"} {
|
||
if strings.Contains(upper, word) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
type formModel struct {
|
||
title string
|
||
keys []string
|
||
inputs []textinput.Model
|
||
cursor int
|
||
done bool
|
||
}
|
||
|
||
func newFormModel(title string, keys []string) formModel {
|
||
inputs := make([]textinput.Model, len(keys))
|
||
for i, key := range keys {
|
||
t := textinput.New()
|
||
t.Placeholder = "enter value"
|
||
t.CursorStyle = cursorStyle
|
||
if secretKey(key) {
|
||
t.EchoMode = textinput.EchoPassword
|
||
t.EchoCharacter = '•'
|
||
}
|
||
if i == 0 {
|
||
t.Focus()
|
||
t.PromptStyle = focusedStyle
|
||
t.TextStyle = focusedStyle
|
||
}
|
||
inputs[i] = t
|
||
}
|
||
return formModel{title: title, keys: keys, inputs: inputs}
|
||
}
|
||
|
||
func (m formModel) Init() tea.Cmd {
|
||
return textinput.Blink
|
||
}
|
||
|
||
func (m formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
switch msg := msg.(type) {
|
||
case tea.KeyMsg:
|
||
switch msg.String() {
|
||
case "ctrl+c", "esc":
|
||
m.done = true
|
||
m.keys = nil // signal cancelled
|
||
return m, tea.Quit
|
||
|
||
case "tab", "down":
|
||
m.cursor = (m.cursor + 1) % len(m.inputs)
|
||
return m, m.focusAt(m.cursor)
|
||
|
||
case "shift+tab", "up":
|
||
m.cursor = (m.cursor - 1 + len(m.inputs)) % len(m.inputs)
|
||
return m, m.focusAt(m.cursor)
|
||
|
||
case "enter":
|
||
if m.cursor < len(m.inputs)-1 {
|
||
m.cursor++
|
||
return m, m.focusAt(m.cursor)
|
||
}
|
||
// Last field — submit
|
||
m.done = true
|
||
return m, tea.Quit
|
||
}
|
||
}
|
||
|
||
// Forward key events to the focused input
|
||
var cmd tea.Cmd
|
||
m.inputs[m.cursor], cmd = m.inputs[m.cursor].Update(msg)
|
||
return m, cmd
|
||
}
|
||
|
||
func (m formModel) focusAt(idx int) tea.Cmd {
|
||
var cmds []tea.Cmd
|
||
for i := range m.inputs {
|
||
if i == idx {
|
||
m.inputs[i].PromptStyle = focusedStyle
|
||
m.inputs[i].TextStyle = focusedStyle
|
||
cmds = append(cmds, m.inputs[i].Focus())
|
||
} else {
|
||
m.inputs[i].Blur()
|
||
m.inputs[i].PromptStyle = blurredStyle
|
||
m.inputs[i].TextStyle = blurredStyle
|
||
}
|
||
}
|
||
return tea.Batch(cmds...)
|
||
}
|
||
|
||
func (m formModel) View() string {
|
||
var b strings.Builder
|
||
b.WriteString(titleStyle.Render(m.title) + "\n\n")
|
||
|
||
for i, key := range m.keys {
|
||
label := key
|
||
if secretKey(key) {
|
||
label += mutedStyle.Render(" (hidden)")
|
||
}
|
||
if i == m.cursor {
|
||
b.WriteString(focusedStyle.Render("› ") + labelStyle.Render(label) + m.inputs[i].View() + "\n")
|
||
} else {
|
||
b.WriteString(blurredStyle.Render(" ") + labelStyle.Render(label) + m.inputs[i].View() + "\n")
|
||
}
|
||
}
|
||
|
||
b.WriteString("\n" + mutedStyle.Render("tab/↑↓ navigate enter confirm esc cancel"))
|
||
return b.String()
|
||
}
|
||
|
||
// PromptMissingEnv runs a TUI form for the given keys and returns a map of
|
||
// key → value. Returns nil if the user cancelled.
|
||
func PromptMissingEnv(stackName string, keys []string) (map[string]string, error) {
|
||
if len(keys) == 0 {
|
||
return map[string]string{}, nil
|
||
}
|
||
|
||
title := fmt.Sprintf("Missing env vars for %q", stackName)
|
||
m := newFormModel(title, keys)
|
||
p := tea.NewProgram(m)
|
||
result, err := p.Run()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
final := result.(formModel)
|
||
if final.keys == nil {
|
||
return nil, nil // cancelled
|
||
}
|
||
|
||
values := make(map[string]string, len(keys))
|
||
for i, key := range keys {
|
||
values[key] = final.inputs[i].Value()
|
||
}
|
||
return values, nil
|
||
}
|