From 3dc482e35316f6094dc53219766cd7d6f1cae8cd Mon Sep 17 00:00:00 2001 From: swanadiva Date: Tue, 23 Jun 2026 15:05:08 +0700 Subject: [PATCH] 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 --- go.mod | 20 +-- go.sum | 24 ++++ pkg/tui/host_form_tab.go | 298 +++++++++++++++++++++++++++++++++++++++ pkg/tui/host_list_tab.go | 13 ++ pkg/tui/messages.go | 52 ++++++- pkg/tui/tui.go | 44 ++++++ 6 files changed, 440 insertions(+), 11 deletions(-) create mode 100644 pkg/tui/host_form_tab.go diff --git a/go.mod b/go.mod index 9d15e91..df90cc9 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,20 @@ module git.tukangketik.id/swanadiva/hostkeeper go 1.26.4 require ( + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/google/uuid v1.6.0 + github.com/spf13/cobra v1.10.2 + github.com/spf13/viper v1.21.0 + golang.org/x/crypto v0.53.0 + golang.org/x/term v0.44.0 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/bubbles v1.0.0 // indirect - github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect @@ -17,7 +26,6 @@ require ( github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -32,14 +40,10 @@ require ( github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect - github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect - github.com/spf13/viper v1.21.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.53.0 // indirect golang.org/x/sys v0.46.0 // indirect - golang.org/x/term v0.44.0 // indirect golang.org/x/text v0.38.0 // indirect ) diff --git a/go.sum b/go.sum index 9c3bf03..76e6fcb 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= @@ -21,16 +23,26 @@ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEX github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -47,8 +59,12 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= @@ -65,6 +81,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= @@ -73,6 +91,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= @@ -82,3 +102,7 @@ golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/tui/host_form_tab.go b/pkg/tui/host_form_tab.go new file mode 100644 index 0000000..ab00f8b --- /dev/null +++ b/pkg/tui/host_form_tab.go @@ -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() {} diff --git a/pkg/tui/host_list_tab.go b/pkg/tui/host_list_tab.go index afedd44..5b50412 100644 --- a/pkg/tui/host_list_tab.go +++ b/pkg/tui/host_list_tab.go @@ -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{} diff --git a/pkg/tui/messages.go b/pkg/tui/messages.go index 0d892d0..4868f5b 100644 --- a/pkg/tui/messages.go +++ b/pkg/tui/messages.go @@ -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} + } +} diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index a73644e..269cdcb 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -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