more knecht

This commit is contained in:
2026-04-04 15:05:08 +02:00
parent 960e12f967
commit b4fddbb5b6
9 changed files with 463 additions and 84 deletions

157
knecht/tui/form.go Normal file
View File

@@ -0,0 +1,157 @@
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
}