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:
@@ -3,11 +3,20 @@ module git.tukangketik.id/swanadiva/hostkeeper
|
|||||||
go 1.26.4
|
go 1.26.4
|
||||||
|
|
||||||
require (
|
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/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/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/ansi v0.11.6 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.2 // 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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.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/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // 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/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||||
github.com/spf13/afero v1.15.0 // indirect
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
github.com/spf13/cast v1.10.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/pflag v1.0.10 // indirect
|
||||||
github.com/spf13/viper v1.21.0 // indirect
|
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // 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/sys v0.46.0 // indirect
|
||||||
golang.org/x/term v0.44.0 // indirect
|
|
||||||
golang.org/x/text v0.38.0 // indirect
|
golang.org/x/text v0.38.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
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=
|
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 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
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/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 h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
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 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
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 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
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 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
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=
|
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/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 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
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 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
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/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 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
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/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 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
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 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
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=
|
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 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
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 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
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 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=
|
||||||
|
|||||||
@@ -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":
|
case "q", "ctrl+c":
|
||||||
return t, func() tea.Msg {
|
return t, func() tea.Msg {
|
||||||
return quitMsg{}
|
return quitMsg{}
|
||||||
|
|||||||
+49
-3
@@ -1,14 +1,20 @@
|
|||||||
package tui
|
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
|
// quitMsg signals the TUI to exit
|
||||||
type quitMsg struct{}
|
type quitMsg struct{}
|
||||||
|
|
||||||
// openSessionMsg signals the TUI to open a new SSH session tab
|
// openSessionMsg signals the TUI to open a new SSH session tab
|
||||||
type openSessionMsg struct {
|
type openSessionMsg struct {
|
||||||
host *models.Host
|
host *models.Host
|
||||||
dataDir string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sessionOutputMsg carries SSH output to the TUI renderer
|
// sessionOutputMsg carries SSH output to the TUI renderer
|
||||||
@@ -18,3 +24,43 @@ type sessionOutputMsg string
|
|||||||
type sessionDoneMsg struct {
|
type sessionDoneMsg struct {
|
||||||
err error
|
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
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
|
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
|
||||||
|
"git.tukangketik.id/swanadiva/hostkeeper/pkg/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Screen represents different TUI screens (deprecated, use tabs)
|
// 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)
|
cmd := m.tabs.Add(tab)
|
||||||
return m, cmd
|
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:
|
case sessionDoneMsg:
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.Error = msg.err
|
m.Error = msg.err
|
||||||
|
|||||||
Reference in New Issue
Block a user