2442920616
- Status bar footer centered within terminal width using lipgloss.Width - Auth Type field replaced with toggle pills (password/key) Space/Enter/←/→ cycle between options on the focused field Selected type shown with SelectedStyle/TagStyle (green), inactive type shown in muted SubtitleStyle Help hint 'Space/←/→ to toggle' shown when focused - Added toggleAuthType() and cycleAuthType() helpers
350 lines
7.5 KiB
Go
350 lines
7.5 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
|
|
)
|
|
|
|
type formMode int
|
|
|
|
const (
|
|
formModeAdd formMode = iota
|
|
formModeEdit
|
|
)
|
|
|
|
type fieldID int
|
|
|
|
const (
|
|
fieldName fieldID = iota
|
|
fieldHostname
|
|
fieldPort
|
|
fieldUsername
|
|
fieldAuthType
|
|
fieldPassword
|
|
fieldGroup
|
|
fieldTags
|
|
fieldNotes
|
|
fieldCount
|
|
)
|
|
|
|
var fieldLabels = map[fieldID]string{
|
|
fieldName: "Name",
|
|
fieldHostname: "Hostname",
|
|
fieldPort: "Port",
|
|
fieldUsername: "Username",
|
|
fieldAuthType: "Auth Type",
|
|
fieldPassword: "Password",
|
|
fieldGroup: "Group",
|
|
fieldTags: "Tags",
|
|
fieldNotes: "Notes",
|
|
}
|
|
|
|
// HostFormTab is a tab for adding/editing hosts
|
|
type HostFormTab struct {
|
|
mode formMode
|
|
editing *models.Host
|
|
dataDir string
|
|
|
|
inputs []textinput.Model
|
|
focus fieldID
|
|
width int
|
|
height int
|
|
|
|
err error
|
|
saved bool
|
|
}
|
|
|
|
// NewAddHostFormTab creates a new host add form tab
|
|
func NewAddHostFormTab(dataDir string) *HostFormTab {
|
|
return newHostFormTab(formModeAdd, nil, dataDir)
|
|
}
|
|
|
|
// NewEditHostFormTab creates a new host edit form tab
|
|
func NewEditHostFormTab(host *models.Host, dataDir string) *HostFormTab {
|
|
return newHostFormTab(formModeEdit, host, dataDir)
|
|
}
|
|
|
|
func newHostFormTab(mode formMode, host *models.Host, dataDir string) *HostFormTab {
|
|
inputs := make([]textinput.Model, fieldCount)
|
|
|
|
for i := range inputs {
|
|
inputs[i] = textinput.New()
|
|
inputs[i].Prompt = ""
|
|
}
|
|
|
|
inputs[fieldName].Placeholder = "My Server"
|
|
inputs[fieldHostname].Placeholder = "192.168.1.1 or server.example.com"
|
|
inputs[fieldPort].Placeholder = "22"
|
|
inputs[fieldPort].SetValue("22")
|
|
inputs[fieldUsername].Placeholder = "root"
|
|
inputs[fieldAuthType].SetValue("password")
|
|
inputs[fieldPassword].EchoMode = textinput.EchoPassword
|
|
inputs[fieldPassword].Placeholder = "Enter password"
|
|
inputs[fieldGroup].Placeholder = "production"
|
|
inputs[fieldTags].Placeholder = "web,backend"
|
|
inputs[fieldNotes].Placeholder = "Optional notes..."
|
|
|
|
if mode == formModeEdit && host != nil {
|
|
inputs[fieldName].SetValue(host.Name)
|
|
inputs[fieldHostname].SetValue(host.Hostname)
|
|
inputs[fieldPort].SetValue(strconv.Itoa(host.Port))
|
|
inputs[fieldUsername].SetValue(host.Username)
|
|
inputs[fieldAuthType].SetValue(host.Auth.Type)
|
|
if host.Auth.Password != "" {
|
|
inputs[fieldPassword].SetValue(host.Auth.Password)
|
|
}
|
|
inputs[fieldGroup].SetValue(host.Group)
|
|
inputs[fieldTags].SetValue(strings.Join(host.Tags, ","))
|
|
inputs[fieldNotes].SetValue(host.Notes)
|
|
}
|
|
|
|
inputs[fieldName].Focus()
|
|
inputs[fieldName].Prompt = "> "
|
|
|
|
return &HostFormTab{
|
|
mode: mode,
|
|
editing: host,
|
|
dataDir: dataDir,
|
|
inputs: inputs,
|
|
}
|
|
}
|
|
|
|
func (t *HostFormTab) Name() string {
|
|
if t.mode == formModeEdit {
|
|
return "Edit: " + t.editing.Name
|
|
}
|
|
return "Add Host"
|
|
}
|
|
|
|
func (t *HostFormTab) Init() tea.Cmd {
|
|
return textinput.Blink
|
|
}
|
|
|
|
func (t *HostFormTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
|
|
if t.saved {
|
|
return t, nil
|
|
}
|
|
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
t.width = msg.Width
|
|
t.height = msg.Height
|
|
|
|
case tea.KeyMsg:
|
|
switch msg.String() {
|
|
case "esc":
|
|
return t, func() tea.Msg { return closeFormMsg{} }
|
|
|
|
case "enter":
|
|
if t.focus == fieldAuthType {
|
|
t.toggleAuthType()
|
|
return t, nil
|
|
}
|
|
if t.focus == fieldCount-1 {
|
|
return t.submit()
|
|
}
|
|
t.nextField()
|
|
|
|
case " ", "left", "right":
|
|
if t.focus == fieldAuthType {
|
|
t.toggleAuthType()
|
|
return t, nil
|
|
}
|
|
fallthrough
|
|
|
|
case "tab", "down":
|
|
t.nextField()
|
|
|
|
case "shift+tab", "up":
|
|
t.prevField()
|
|
|
|
case "ctrl+s":
|
|
return t.submit()
|
|
|
|
default:
|
|
if t.focus == fieldAuthType {
|
|
// ignore typing on auth type field
|
|
return t, nil
|
|
}
|
|
var cmd tea.Cmd
|
|
t.inputs[t.focus], cmd = t.inputs[t.focus].Update(msg)
|
|
return t, cmd
|
|
}
|
|
}
|
|
|
|
return t, nil
|
|
}
|
|
|
|
func (t *HostFormTab) nextField() {
|
|
t.inputs[t.focus].Blur()
|
|
t.inputs[t.focus].Prompt = ""
|
|
t.focus++
|
|
if t.focus >= fieldCount {
|
|
t.focus = fieldCount - 1
|
|
}
|
|
t.inputs[t.focus].Focus()
|
|
t.inputs[t.focus].Prompt = "> "
|
|
}
|
|
|
|
func (t *HostFormTab) prevField() {
|
|
t.inputs[t.focus].Blur()
|
|
t.inputs[t.focus].Prompt = ""
|
|
t.focus--
|
|
if t.focus < 0 {
|
|
t.focus = 0
|
|
}
|
|
t.inputs[t.focus].Focus()
|
|
t.inputs[t.focus].Prompt = "> "
|
|
}
|
|
|
|
func cycleAuthType(current string) string {
|
|
switch current {
|
|
case "password":
|
|
return "key"
|
|
case "key":
|
|
return "password"
|
|
default:
|
|
return "password"
|
|
}
|
|
}
|
|
|
|
func (t *HostFormTab) toggleAuthType() {
|
|
current := t.inputs[fieldAuthType].Value()
|
|
t.inputs[fieldAuthType].SetValue(cycleAuthType(current))
|
|
}
|
|
|
|
func (t *HostFormTab) submit() (Tab, tea.Cmd) {
|
|
name := t.inputs[fieldName].Value()
|
|
hostname := t.inputs[fieldHostname].Value()
|
|
username := t.inputs[fieldUsername].Value()
|
|
|
|
if name == "" || hostname == "" || username == "" {
|
|
t.err = fmt.Errorf("name, hostname, and username are required")
|
|
return t, nil
|
|
}
|
|
|
|
port := 22
|
|
if p := t.inputs[fieldPort].Value(); p != "" {
|
|
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
|
|
port = parsed
|
|
}
|
|
}
|
|
|
|
authType := t.inputs[fieldAuthType].Value()
|
|
if authType == "" {
|
|
authType = "password"
|
|
}
|
|
|
|
var tags []string
|
|
if tagStr := t.inputs[fieldTags].Value(); tagStr != "" {
|
|
for _, tag := range strings.Split(tagStr, ",") {
|
|
if trimmed := strings.TrimSpace(tag); trimmed != "" {
|
|
tags = append(tags, trimmed)
|
|
}
|
|
}
|
|
}
|
|
|
|
var host *models.Host
|
|
if t.mode == formModeEdit && t.editing != nil {
|
|
host = t.editing
|
|
host.Name = name
|
|
host.Hostname = hostname
|
|
host.Port = port
|
|
host.Username = username
|
|
host.Auth.Type = authType
|
|
host.Auth.Password = t.inputs[fieldPassword].Value()
|
|
host.Group = t.inputs[fieldGroup].Value()
|
|
host.Tags = tags
|
|
host.Notes = t.inputs[fieldNotes].Value()
|
|
} else {
|
|
host = &models.Host{
|
|
Name: name,
|
|
Hostname: hostname,
|
|
Port: port,
|
|
Username: username,
|
|
Auth: models.AuthConfig{
|
|
Type: authType,
|
|
Password: t.inputs[fieldPassword].Value(),
|
|
},
|
|
Group: t.inputs[fieldGroup].Value(),
|
|
Tags: tags,
|
|
Notes: t.inputs[fieldNotes].Value(),
|
|
}
|
|
}
|
|
|
|
t.saved = true
|
|
return t, saveHostCmd(host, t.dataDir)
|
|
}
|
|
|
|
func (t *HostFormTab) View() string {
|
|
var b strings.Builder
|
|
|
|
title := "Add New Host"
|
|
if t.mode == formModeEdit {
|
|
title = "Edit Host"
|
|
}
|
|
b.WriteString(AppTitleStyle.Render(title))
|
|
b.WriteString("\n\n")
|
|
|
|
if t.err != nil {
|
|
b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err)))
|
|
b.WriteString("\n\n")
|
|
}
|
|
|
|
for i := fieldID(0); i < fieldCount; i++ {
|
|
input := t.inputs[i]
|
|
|
|
label := fieldLabels[i]
|
|
style := SubtitleStyle
|
|
if i == t.focus {
|
|
style = HighlightStyle
|
|
}
|
|
b.WriteString(style.Render(label + ":"))
|
|
b.WriteString("\n")
|
|
|
|
if i == fieldAuthType {
|
|
// Render auth type as toggle pills
|
|
current := input.Value()
|
|
pills := []string{"password", "key"}
|
|
var parts []string
|
|
for _, p := range pills {
|
|
if p == current {
|
|
if i == t.focus {
|
|
parts = append(parts, SelectedStyle.Render(" "+p+" "))
|
|
} else {
|
|
parts = append(parts, TagStyle.Render(" "+p+" "))
|
|
}
|
|
} else {
|
|
parts = append(parts, SubtitleStyle.Render(" "+p+" "))
|
|
}
|
|
}
|
|
b.WriteString(" ")
|
|
b.WriteString(strings.Join(parts, " "))
|
|
b.WriteString("\n")
|
|
if i == t.focus {
|
|
b.WriteString(InfoStyle.Render(" Space/←/→ to toggle"))
|
|
}
|
|
b.WriteString("\n")
|
|
} else {
|
|
renderedInput := input.View()
|
|
b.WriteString(" ")
|
|
b.WriteString(renderedInput)
|
|
b.WriteString("\n\n")
|
|
}
|
|
}
|
|
|
|
// Help text
|
|
b.WriteString(SubtitleStyle.Render(
|
|
"↑↓: navigate Enter: next Ctrl+S: save Esc: cancel"))
|
|
|
|
return b.String()
|
|
}
|
|
|
|
func (t *HostFormTab) Close() {}
|