feat: TUI Host Forms (add/edit) - Task 17
- HostFormTab with textinput fields for all host fields - Add (Ctrl+N) and Edit (Ctrl+E) from host list - Save to JSON storage, auto-reload on save - Esc to cancel, navigable form with keyboard
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
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].Placeholder = "password / key / both"
|
||||
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 == fieldCount-1 {
|
||||
return t.submit()
|
||||
}
|
||||
t.nextField()
|
||||
|
||||
case "tab", "down":
|
||||
t.nextField()
|
||||
|
||||
case "shift+tab", "up":
|
||||
t.prevField()
|
||||
|
||||
default:
|
||||
if msg.String() == "ctrl+s" {
|
||||
return t.submit()
|
||||
}
|
||||
t.inputs[t.focus].Update(msg)
|
||||
}
|
||||
}
|
||||
|
||||
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 (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")
|
||||
|
||||
renderedInput := input.View()
|
||||
|
||||
// Add parentheses showing auth options
|
||||
if i == fieldAuthType && i != t.focus {
|
||||
b.WriteString(" ")
|
||||
} else {
|
||||
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() {}
|
||||
@@ -62,6 +62,19 @@ func (t *HostListTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
|
||||
case "ctrl+n":
|
||||
return t, func() tea.Msg {
|
||||
return openHostFormMsg{}
|
||||
}
|
||||
|
||||
case "ctrl+e":
|
||||
if len(t.hosts) > 0 {
|
||||
host := t.hosts[t.selectedIndex]
|
||||
return t, func() tea.Msg {
|
||||
return openHostFormMsg{editing: host}
|
||||
}
|
||||
}
|
||||
|
||||
case "q", "ctrl+c":
|
||||
return t, func() tea.Msg {
|
||||
return quitMsg{}
|
||||
|
||||
+49
-3
@@ -1,14 +1,20 @@
|
||||
package tui
|
||||
|
||||
import "git.tukangketik.id/swanadiva/hostkeeper/internal/models"
|
||||
import (
|
||||
"context"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
|
||||
"git.tukangketik.id/swanadiva/hostkeeper/pkg/storage"
|
||||
)
|
||||
|
||||
// quitMsg signals the TUI to exit
|
||||
type quitMsg struct{}
|
||||
|
||||
// openSessionMsg signals the TUI to open a new SSH session tab
|
||||
type openSessionMsg struct {
|
||||
host *models.Host
|
||||
dataDir string
|
||||
host *models.Host
|
||||
}
|
||||
|
||||
// sessionOutputMsg carries SSH output to the TUI renderer
|
||||
@@ -18,3 +24,43 @@ type sessionOutputMsg string
|
||||
type sessionDoneMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
// openHostFormMsg signals the TUI to open a host form tab
|
||||
type openHostFormMsg struct {
|
||||
editing *models.Host // nil for add mode
|
||||
}
|
||||
|
||||
// closeFormMsg signals the TUI to close the active form tab
|
||||
type closeFormMsg struct{}
|
||||
|
||||
// saveHostMsg is produced when the form needs to save a host
|
||||
type saveHostMsg struct {
|
||||
host *models.Host
|
||||
dataDir string
|
||||
}
|
||||
|
||||
// saveHostResultMsg is produced after a save attempt
|
||||
type saveHostResultMsg struct {
|
||||
host *models.Host
|
||||
err error
|
||||
}
|
||||
|
||||
// loadedHostsMsg is produced after reloading hosts from storage
|
||||
type loadedHostsMsg struct {
|
||||
hosts []*models.Host
|
||||
}
|
||||
|
||||
// saveHostCmd creates a command that saves a host to storage
|
||||
func saveHostCmd(host *models.Host, dataDir string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
store, err := storage.NewJSONStorage(dataDir)
|
||||
if err != nil {
|
||||
return saveHostResultMsg{err: err}
|
||||
}
|
||||
ctx := context.Background()
|
||||
if err := store.SaveHost(ctx, host); err != nil {
|
||||
return saveHostResultMsg{err: err}
|
||||
}
|
||||
return saveHostResultMsg{host: host}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
|
||||
"git.tukangketik.id/swanadiva/hostkeeper/pkg/storage"
|
||||
)
|
||||
|
||||
// Screen represents different TUI screens (deprecated, use tabs)
|
||||
@@ -63,6 +66,47 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
cmd := m.tabs.Add(tab)
|
||||
return m, cmd
|
||||
|
||||
case openHostFormMsg:
|
||||
var tab Tab
|
||||
if msg.editing != nil {
|
||||
tab = NewEditHostFormTab(msg.editing, m.dataDir)
|
||||
} else {
|
||||
tab = NewAddHostFormTab(m.dataDir)
|
||||
}
|
||||
cmd := m.tabs.Add(tab)
|
||||
return m, cmd
|
||||
|
||||
case closeFormMsg:
|
||||
if m.tabs.Len() > 1 {
|
||||
m.tabs.CloseActive()
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case saveHostResultMsg:
|
||||
if msg.err != nil {
|
||||
m.Error = msg.err
|
||||
return m, nil
|
||||
}
|
||||
// Close the form tab, switch back to host list
|
||||
if m.tabs.Len() > 1 {
|
||||
m.tabs.CloseActive()
|
||||
}
|
||||
// Reload hosts
|
||||
return m, func() tea.Msg {
|
||||
store, err := storage.NewJSONStorage(m.dataDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
hosts, err := store.ListHosts(context.Background())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return loadedHostsMsg{hosts: hosts}
|
||||
}
|
||||
|
||||
case loadedHostsMsg:
|
||||
m.LoadHosts(msg.hosts)
|
||||
|
||||
case sessionDoneMsg:
|
||||
if msg.err != nil {
|
||||
m.Error = msg.err
|
||||
|
||||
Reference in New Issue
Block a user