feat: TUI Key & Snippet Management - Task 19
- KeyListTab: view/add/edit/delete SSH keys (Ctrl+K from host list) - KeyFormTab: add/edit key form with name, type, private key, passphrase - SnippetListTab: view/add/edit/delete snippets (Ctrl+P from host list) - SnippetFormTab: add/edit snippet form with name, command, description, tags - Updated status bar with new hints - Save/delete commands for both key pairs and snippets
This commit is contained in:
@@ -83,6 +83,16 @@ func (t *HostListTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "ctrl+k":
|
||||||
|
return t, func() tea.Msg {
|
||||||
|
return openKeyListMsg{}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "ctrl+p":
|
||||||
|
return t, func() tea.Msg {
|
||||||
|
return openSnippetListMsg{}
|
||||||
|
}
|
||||||
|
|
||||||
case "q", "ctrl+c":
|
case "q", "ctrl+c":
|
||||||
return t, func() tea.Msg {
|
return t, func() tea.Msg {
|
||||||
return quitMsg{}
|
return quitMsg{}
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type keyFormMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
keyFormAdd keyFormMode = iota
|
||||||
|
keyFormEdit
|
||||||
|
)
|
||||||
|
|
||||||
|
type keyFieldID int
|
||||||
|
|
||||||
|
const (
|
||||||
|
keyFieldName keyFieldID = iota
|
||||||
|
keyFieldType
|
||||||
|
keyFieldPrivateKey
|
||||||
|
keyFieldPassphrase
|
||||||
|
keyFieldCount
|
||||||
|
)
|
||||||
|
|
||||||
|
var keyFieldLabels = map[keyFieldID]string{
|
||||||
|
keyFieldName: "Name",
|
||||||
|
keyFieldType: "Type (rsa/ed25519/ecdsa)",
|
||||||
|
keyFieldPrivateKey: "Private Key (PEM)",
|
||||||
|
keyFieldPassphrase: "Passphrase",
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyFormTab is a tab for adding/editing SSH key pairs
|
||||||
|
type KeyFormTab struct {
|
||||||
|
mode keyFormMode
|
||||||
|
editing *models.KeyPair
|
||||||
|
dataDir string
|
||||||
|
|
||||||
|
inputs []textinput.Model
|
||||||
|
focus keyFieldID
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
|
||||||
|
err error
|
||||||
|
saved bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAddKeyFormTab(dataDir string) *KeyFormTab {
|
||||||
|
return newKeyFormTab(keyFormAdd, nil, dataDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEditKeyFormTab(key *models.KeyPair, dataDir string) *KeyFormTab {
|
||||||
|
return newKeyFormTab(keyFormEdit, key, dataDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newKeyFormTab(mode keyFormMode, key *models.KeyPair, dataDir string) *KeyFormTab {
|
||||||
|
inputs := make([]textinput.Model, keyFieldCount)
|
||||||
|
for i := range inputs {
|
||||||
|
inputs[i] = textinput.New()
|
||||||
|
inputs[i].Prompt = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
inputs[keyFieldName].Placeholder = "My SSH Key"
|
||||||
|
inputs[keyFieldType].Placeholder = "ed25519"
|
||||||
|
inputs[keyFieldType].SetValue("ed25519")
|
||||||
|
inputs[keyFieldPrivateKey].Placeholder = "-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||||
|
inputs[keyFieldPassphrase].Placeholder = "Optional passphrase"
|
||||||
|
|
||||||
|
if mode == keyFormEdit && key != nil {
|
||||||
|
inputs[keyFieldName].SetValue(key.Name)
|
||||||
|
inputs[keyFieldType].SetValue(key.Type)
|
||||||
|
inputs[keyFieldPrivateKey].SetValue(key.PrivateKey)
|
||||||
|
inputs[keyFieldPassphrase].SetValue(key.Passphrase)
|
||||||
|
}
|
||||||
|
|
||||||
|
inputs[keyFieldName].Focus()
|
||||||
|
inputs[keyFieldName].Prompt = "> "
|
||||||
|
|
||||||
|
return &KeyFormTab{
|
||||||
|
mode: mode,
|
||||||
|
editing: key,
|
||||||
|
dataDir: dataDir,
|
||||||
|
inputs: inputs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *KeyFormTab) Name() string {
|
||||||
|
if t.mode == keyFormEdit {
|
||||||
|
return "Edit Key"
|
||||||
|
}
|
||||||
|
return "Add Key"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *KeyFormTab) Init() tea.Cmd {
|
||||||
|
return textinput.Blink
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *KeyFormTab) 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 == keyFieldCount-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 *KeyFormTab) nextField() {
|
||||||
|
t.inputs[t.focus].Blur()
|
||||||
|
t.inputs[t.focus].Prompt = ""
|
||||||
|
t.focus++
|
||||||
|
if t.focus >= keyFieldCount {
|
||||||
|
t.focus = keyFieldCount - 1
|
||||||
|
}
|
||||||
|
t.inputs[t.focus].Focus()
|
||||||
|
t.inputs[t.focus].Prompt = "> "
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *KeyFormTab) 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 *KeyFormTab) submit() (Tab, tea.Cmd) {
|
||||||
|
name := t.inputs[keyFieldName].Value()
|
||||||
|
privateKey := t.inputs[keyFieldPrivateKey].Value()
|
||||||
|
|
||||||
|
if name == "" || privateKey == "" {
|
||||||
|
t.err = fmt.Errorf("name and private key are required")
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var key *models.KeyPair
|
||||||
|
if t.mode == keyFormEdit && t.editing != nil {
|
||||||
|
key = t.editing
|
||||||
|
key.Name = name
|
||||||
|
key.Type = t.inputs[keyFieldType].Value()
|
||||||
|
key.PrivateKey = privateKey
|
||||||
|
key.Passphrase = t.inputs[keyFieldPassphrase].Value()
|
||||||
|
} else {
|
||||||
|
key = &models.KeyPair{
|
||||||
|
Name: name,
|
||||||
|
Type: t.inputs[keyFieldType].Value(),
|
||||||
|
PrivateKey: privateKey,
|
||||||
|
Passphrase: t.inputs[keyFieldPassphrase].Value(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.saved = true
|
||||||
|
return t, saveKeyCmd(key, t.dataDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *KeyFormTab) View() string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
title := "Add SSH Key"
|
||||||
|
if t.mode == keyFormEdit {
|
||||||
|
title = "Edit SSH Key"
|
||||||
|
}
|
||||||
|
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 := keyFieldID(0); i < keyFieldCount; i++ {
|
||||||
|
input := t.inputs[i]
|
||||||
|
label := keyFieldLabels[i]
|
||||||
|
style := SubtitleStyle
|
||||||
|
if i == t.focus {
|
||||||
|
style = HighlightStyle
|
||||||
|
}
|
||||||
|
b.WriteString(style.Render(label + ":"))
|
||||||
|
b.WriteString("\n ")
|
||||||
|
b.WriteString(input.View())
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString(SubtitleStyle.Render(
|
||||||
|
"↑↓: navigate Enter: next Ctrl+S: save Esc: cancel"))
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *KeyFormTab) Close() {}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
|
||||||
|
"git.tukangketik.id/swanadiva/hostkeeper/pkg/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
type keyListState int
|
||||||
|
|
||||||
|
const (
|
||||||
|
keyListLoading keyListState = iota
|
||||||
|
keyListReady
|
||||||
|
keyListError
|
||||||
|
)
|
||||||
|
|
||||||
|
// KeyListTab displays and manages SSH key pairs
|
||||||
|
type KeyListTab struct {
|
||||||
|
dataDir string
|
||||||
|
keys []*models.KeyPair
|
||||||
|
selected int
|
||||||
|
state keyListState
|
||||||
|
err error
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewKeyListTab(dataDir string) *KeyListTab {
|
||||||
|
return &KeyListTab{
|
||||||
|
dataDir: dataDir,
|
||||||
|
state: keyListLoading,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *KeyListTab) Name() string { return "SSH Keys" }
|
||||||
|
|
||||||
|
func (t *KeyListTab) Init() tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
store, err := storage.NewJSONStorage(t.dataDir)
|
||||||
|
if err != nil {
|
||||||
|
return keyListLoadedMsg{err: err}
|
||||||
|
}
|
||||||
|
keys, err := store.ListKeyPairs(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return keyListLoadedMsg{err: err}
|
||||||
|
}
|
||||||
|
return keyListLoadedMsg{keys: keys}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *KeyListTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
t.mu.Lock()
|
||||||
|
t.width = msg.Width
|
||||||
|
t.height = msg.Height
|
||||||
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
case keyListLoadedMsg:
|
||||||
|
t.mu.Lock()
|
||||||
|
if msg.err != nil {
|
||||||
|
t.state = keyListError
|
||||||
|
t.err = msg.err
|
||||||
|
} else {
|
||||||
|
t.state = keyListReady
|
||||||
|
t.keys = msg.keys
|
||||||
|
}
|
||||||
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
case saveKeyResultMsg:
|
||||||
|
return t, t.Init()
|
||||||
|
|
||||||
|
case deleteKeyResultMsg:
|
||||||
|
if msg.err != nil {
|
||||||
|
t.mu.Lock()
|
||||||
|
t.err = msg.err
|
||||||
|
t.mu.Unlock()
|
||||||
|
}
|
||||||
|
return t, t.Init()
|
||||||
|
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "up", "k":
|
||||||
|
t.mu.Lock()
|
||||||
|
if t.selected > 0 {
|
||||||
|
t.selected--
|
||||||
|
}
|
||||||
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
case "down", "j":
|
||||||
|
t.mu.Lock()
|
||||||
|
if t.selected < len(t.keys)-1 {
|
||||||
|
t.selected++
|
||||||
|
}
|
||||||
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
case "ctrl+n":
|
||||||
|
return t, func() tea.Msg { return openKeyFormMsg{} }
|
||||||
|
|
||||||
|
case "ctrl+e":
|
||||||
|
t.mu.Lock()
|
||||||
|
keys := t.keys
|
||||||
|
idx := t.selected
|
||||||
|
t.mu.Unlock()
|
||||||
|
if len(keys) > 0 && idx >= 0 && idx < len(keys) {
|
||||||
|
return t, func() tea.Msg { return openKeyFormMsg{editing: keys[idx]} }
|
||||||
|
}
|
||||||
|
|
||||||
|
case "delete", "d":
|
||||||
|
t.mu.Lock()
|
||||||
|
keys := t.keys
|
||||||
|
idx := t.selected
|
||||||
|
t.mu.Unlock()
|
||||||
|
if len(keys) > 0 && idx >= 0 && idx < len(keys) {
|
||||||
|
return t, deleteKeyCmd(keys[idx].ID, t.dataDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "esc":
|
||||||
|
return t, func() tea.Msg { return closeFormMsg{} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *KeyListTab) View() string {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
b.WriteString(AppTitleStyle.Render("SSH Key Pairs"))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
switch t.state {
|
||||||
|
case keyListLoading:
|
||||||
|
b.WriteString(SubtitleStyle.Render("Loading keys..."))
|
||||||
|
return b.String()
|
||||||
|
case keyListError:
|
||||||
|
b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err)))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
b.WriteString(SubtitleStyle.Render("Press Esc to go back"))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.err != nil {
|
||||||
|
b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err)))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(t.keys) == 0 {
|
||||||
|
b.WriteString(SubtitleStyle.Render("No SSH keys stored."))
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(SubtitleStyle.Render("Press Ctrl+N to add a new key."))
|
||||||
|
} else {
|
||||||
|
for i, key := range t.keys {
|
||||||
|
var line string
|
||||||
|
if key.Type != "" {
|
||||||
|
line = fmt.Sprintf(" %s (%s)", key.Name, key.Type)
|
||||||
|
} else {
|
||||||
|
line = fmt.Sprintf(" %s", key.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if i == t.selected {
|
||||||
|
b.WriteString(SelectedStyle.Render(line))
|
||||||
|
} else {
|
||||||
|
b.WriteString(HostNameStyle.Render(line))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(SubtitleStyle.Render("Ctrl+N:add Ctrl+E:edit D:delete Esc:back"))
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *KeyListTab) Close() {}
|
||||||
|
|
||||||
|
// keyListLoadedMsg carries the loaded key list
|
||||||
|
type keyListLoadedMsg struct {
|
||||||
|
keys []*models.KeyPair
|
||||||
|
err error
|
||||||
|
}
|
||||||
@@ -55,6 +55,118 @@ type loadedHostsMsg struct {
|
|||||||
hosts []*models.Host
|
hosts []*models.Host
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Key management messages
|
||||||
|
type openKeyListMsg struct{}
|
||||||
|
|
||||||
|
type openKeyFormMsg struct {
|
||||||
|
editing *models.KeyPair
|
||||||
|
}
|
||||||
|
|
||||||
|
type saveKeyMsg struct {
|
||||||
|
key *models.KeyPair
|
||||||
|
dataDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
type saveKeyResultMsg struct {
|
||||||
|
key *models.KeyPair
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type deleteKeyMsg struct {
|
||||||
|
id string
|
||||||
|
dataDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
type deleteKeyResultMsg struct {
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snippet management messages
|
||||||
|
type openSnippetListMsg struct{}
|
||||||
|
|
||||||
|
type openSnippetFormMsg struct {
|
||||||
|
editing *models.Snippet
|
||||||
|
}
|
||||||
|
|
||||||
|
type saveSnippetMsg struct {
|
||||||
|
snippet *models.Snippet
|
||||||
|
dataDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
type saveSnippetResultMsg struct {
|
||||||
|
snippet *models.Snippet
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type deleteSnippetMsg struct {
|
||||||
|
id string
|
||||||
|
dataDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
type deleteSnippetResultMsg struct {
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveKeyCmd creates a command that saves a key pair to storage
|
||||||
|
func saveKeyCmd(key *models.KeyPair, dataDir string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
store, err := storage.NewJSONStorage(dataDir)
|
||||||
|
if err != nil {
|
||||||
|
return saveKeyResultMsg{err: err}
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := store.SaveKeyPair(ctx, key); err != nil {
|
||||||
|
return saveKeyResultMsg{err: err}
|
||||||
|
}
|
||||||
|
return saveKeyResultMsg{key: key}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteKeyCmd creates a command that deletes a key pair
|
||||||
|
func deleteKeyCmd(id, dataDir string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
store, err := storage.NewJSONStorage(dataDir)
|
||||||
|
if err != nil {
|
||||||
|
return deleteKeyResultMsg{err: err}
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := store.DeleteKeyPair(ctx, id); err != nil {
|
||||||
|
return deleteKeyResultMsg{err: err}
|
||||||
|
}
|
||||||
|
return deleteKeyResultMsg{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveSnippetCmd creates a command that saves a snippet to storage
|
||||||
|
func saveSnippetCmd(snippet *models.Snippet, dataDir string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
store, err := storage.NewJSONStorage(dataDir)
|
||||||
|
if err != nil {
|
||||||
|
return saveSnippetResultMsg{err: err}
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := store.SaveSnippet(ctx, snippet); err != nil {
|
||||||
|
return saveSnippetResultMsg{err: err}
|
||||||
|
}
|
||||||
|
return saveSnippetResultMsg{snippet: snippet}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteSnippetCmd creates a command that deletes a snippet
|
||||||
|
func deleteSnippetCmd(id, dataDir string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
store, err := storage.NewJSONStorage(dataDir)
|
||||||
|
if err != nil {
|
||||||
|
return deleteSnippetResultMsg{err: err}
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := store.DeleteSnippet(ctx, id); err != nil {
|
||||||
|
return deleteSnippetResultMsg{err: err}
|
||||||
|
}
|
||||||
|
return deleteSnippetResultMsg{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// saveHostCmd creates a command that saves a host to storage
|
// saveHostCmd creates a command that saves a host to storage
|
||||||
func saveHostCmd(host *models.Host, dataDir string) tea.Cmd {
|
func saveHostCmd(host *models.Host, dataDir string) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type snippetFormMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
snippetFormAdd snippetFormMode = iota
|
||||||
|
snippetFormEdit
|
||||||
|
)
|
||||||
|
|
||||||
|
type snippetFieldID int
|
||||||
|
|
||||||
|
const (
|
||||||
|
snippetFieldName snippetFieldID = iota
|
||||||
|
snippetFieldCommand
|
||||||
|
snippetFieldDescription
|
||||||
|
snippetFieldTags
|
||||||
|
snippetFieldCount
|
||||||
|
)
|
||||||
|
|
||||||
|
var snippetFieldLabels = map[snippetFieldID]string{
|
||||||
|
snippetFieldName: "Name",
|
||||||
|
snippetFieldCommand: "Command",
|
||||||
|
snippetFieldDescription: "Description",
|
||||||
|
snippetFieldTags: "Tags (comma-separated)",
|
||||||
|
}
|
||||||
|
|
||||||
|
// SnippetFormTab is a tab for adding/editing command snippets
|
||||||
|
type SnippetFormTab struct {
|
||||||
|
mode snippetFormMode
|
||||||
|
editing *models.Snippet
|
||||||
|
dataDir string
|
||||||
|
|
||||||
|
inputs []textinput.Model
|
||||||
|
focus snippetFieldID
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
|
||||||
|
err error
|
||||||
|
saved bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAddSnippetFormTab(dataDir string) *SnippetFormTab {
|
||||||
|
return newSnippetFormTab(snippetFormAdd, nil, dataDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEditSnippetFormTab(snippet *models.Snippet, dataDir string) *SnippetFormTab {
|
||||||
|
return newSnippetFormTab(snippetFormEdit, snippet, dataDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSnippetFormTab(mode snippetFormMode, sn *models.Snippet, dataDir string) *SnippetFormTab {
|
||||||
|
inputs := make([]textinput.Model, snippetFieldCount)
|
||||||
|
for i := range inputs {
|
||||||
|
inputs[i] = textinput.New()
|
||||||
|
inputs[i].Prompt = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
inputs[snippetFieldName].Placeholder = "Check logs"
|
||||||
|
inputs[snippetFieldCommand].Placeholder = "journalctl -u nginx --no-pager -n 100"
|
||||||
|
inputs[snippetFieldDescription].Placeholder = "View last 100 nginx log entries"
|
||||||
|
inputs[snippetFieldTags].Placeholder = "nginx,logs,troubleshooting"
|
||||||
|
|
||||||
|
if mode == snippetFormEdit && sn != nil {
|
||||||
|
inputs[snippetFieldName].SetValue(sn.Name)
|
||||||
|
inputs[snippetFieldCommand].SetValue(sn.Command)
|
||||||
|
inputs[snippetFieldDescription].SetValue(sn.Description)
|
||||||
|
inputs[snippetFieldTags].SetValue(strings.Join(sn.Tags, ","))
|
||||||
|
}
|
||||||
|
|
||||||
|
inputs[snippetFieldName].Focus()
|
||||||
|
inputs[snippetFieldName].Prompt = "> "
|
||||||
|
|
||||||
|
return &SnippetFormTab{
|
||||||
|
mode: mode,
|
||||||
|
editing: sn,
|
||||||
|
dataDir: dataDir,
|
||||||
|
inputs: inputs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SnippetFormTab) Name() string {
|
||||||
|
if t.mode == snippetFormEdit {
|
||||||
|
return "Edit Snippet"
|
||||||
|
}
|
||||||
|
return "Add Snippet"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SnippetFormTab) Init() tea.Cmd {
|
||||||
|
return textinput.Blink
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SnippetFormTab) 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 == snippetFieldCount-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 *SnippetFormTab) nextField() {
|
||||||
|
t.inputs[t.focus].Blur()
|
||||||
|
t.inputs[t.focus].Prompt = ""
|
||||||
|
t.focus++
|
||||||
|
if t.focus >= snippetFieldCount {
|
||||||
|
t.focus = snippetFieldCount - 1
|
||||||
|
}
|
||||||
|
t.inputs[t.focus].Focus()
|
||||||
|
t.inputs[t.focus].Prompt = "> "
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SnippetFormTab) 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 *SnippetFormTab) submit() (Tab, tea.Cmd) {
|
||||||
|
name := t.inputs[snippetFieldName].Value()
|
||||||
|
command := t.inputs[snippetFieldCommand].Value()
|
||||||
|
|
||||||
|
if name == "" || command == "" {
|
||||||
|
t.err = fmt.Errorf("name and command are required")
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var tags []string
|
||||||
|
if tagStr := t.inputs[snippetFieldTags].Value(); tagStr != "" {
|
||||||
|
for _, tag := range strings.Split(tagStr, ",") {
|
||||||
|
if trimmed := strings.TrimSpace(tag); trimmed != "" {
|
||||||
|
tags = append(tags, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sn *models.Snippet
|
||||||
|
if t.mode == snippetFormEdit && t.editing != nil {
|
||||||
|
sn = t.editing
|
||||||
|
sn.Name = name
|
||||||
|
sn.Command = command
|
||||||
|
sn.Description = t.inputs[snippetFieldDescription].Value()
|
||||||
|
sn.Tags = tags
|
||||||
|
} else {
|
||||||
|
sn = &models.Snippet{
|
||||||
|
Name: name,
|
||||||
|
Command: command,
|
||||||
|
Description: t.inputs[snippetFieldDescription].Value(),
|
||||||
|
Tags: tags,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.saved = true
|
||||||
|
return t, saveSnippetCmd(sn, t.dataDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SnippetFormTab) View() string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
title := "Add Snippet"
|
||||||
|
if t.mode == snippetFormEdit {
|
||||||
|
title = "Edit Snippet"
|
||||||
|
}
|
||||||
|
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 := snippetFieldID(0); i < snippetFieldCount; i++ {
|
||||||
|
input := t.inputs[i]
|
||||||
|
label := snippetFieldLabels[i]
|
||||||
|
style := SubtitleStyle
|
||||||
|
if i == t.focus {
|
||||||
|
style = HighlightStyle
|
||||||
|
}
|
||||||
|
b.WriteString(style.Render(label + ":"))
|
||||||
|
b.WriteString("\n ")
|
||||||
|
b.WriteString(input.View())
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString(SubtitleStyle.Render(
|
||||||
|
"↑↓: navigate Enter: next Ctrl+S: save Esc: cancel"))
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SnippetFormTab) Close() {}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
|
||||||
|
"git.tukangketik.id/swanadiva/hostkeeper/pkg/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
type snippetListState int
|
||||||
|
|
||||||
|
const (
|
||||||
|
snippetListLoading snippetListState = iota
|
||||||
|
snippetListReady
|
||||||
|
snippetListError
|
||||||
|
)
|
||||||
|
|
||||||
|
// SnippetListTab displays and manages command snippets
|
||||||
|
type SnippetListTab struct {
|
||||||
|
dataDir string
|
||||||
|
snippets []*models.Snippet
|
||||||
|
selected int
|
||||||
|
state snippetListState
|
||||||
|
err error
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSnippetListTab(dataDir string) *SnippetListTab {
|
||||||
|
return &SnippetListTab{
|
||||||
|
dataDir: dataDir,
|
||||||
|
state: snippetListLoading,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SnippetListTab) Name() string { return "Snippets" }
|
||||||
|
|
||||||
|
func (t *SnippetListTab) Init() tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
store, err := storage.NewJSONStorage(t.dataDir)
|
||||||
|
if err != nil {
|
||||||
|
return snippetListLoadedMsg{err: err}
|
||||||
|
}
|
||||||
|
snippets, err := store.ListSnippets(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return snippetListLoadedMsg{err: err}
|
||||||
|
}
|
||||||
|
return snippetListLoadedMsg{snippets: snippets}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SnippetListTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
t.mu.Lock()
|
||||||
|
t.width = msg.Width
|
||||||
|
t.height = msg.Height
|
||||||
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
case snippetListLoadedMsg:
|
||||||
|
t.mu.Lock()
|
||||||
|
if msg.err != nil {
|
||||||
|
t.state = snippetListError
|
||||||
|
t.err = msg.err
|
||||||
|
} else {
|
||||||
|
t.state = snippetListReady
|
||||||
|
t.snippets = msg.snippets
|
||||||
|
}
|
||||||
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
case saveSnippetResultMsg:
|
||||||
|
return t, t.Init()
|
||||||
|
|
||||||
|
case deleteSnippetResultMsg:
|
||||||
|
if msg.err != nil {
|
||||||
|
t.mu.Lock()
|
||||||
|
t.err = msg.err
|
||||||
|
t.mu.Unlock()
|
||||||
|
}
|
||||||
|
return t, t.Init()
|
||||||
|
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "up", "k":
|
||||||
|
t.mu.Lock()
|
||||||
|
if t.selected > 0 {
|
||||||
|
t.selected--
|
||||||
|
}
|
||||||
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
case "down", "j":
|
||||||
|
t.mu.Lock()
|
||||||
|
if t.selected < len(t.snippets)-1 {
|
||||||
|
t.selected++
|
||||||
|
}
|
||||||
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
case "ctrl+n":
|
||||||
|
return t, func() tea.Msg { return openSnippetFormMsg{} }
|
||||||
|
|
||||||
|
case "ctrl+e":
|
||||||
|
t.mu.Lock()
|
||||||
|
snippets := t.snippets
|
||||||
|
idx := t.selected
|
||||||
|
t.mu.Unlock()
|
||||||
|
if len(snippets) > 0 && idx >= 0 && idx < len(snippets) {
|
||||||
|
return t, func() tea.Msg { return openSnippetFormMsg{editing: snippets[idx]} }
|
||||||
|
}
|
||||||
|
|
||||||
|
case "delete", "d":
|
||||||
|
t.mu.Lock()
|
||||||
|
snippets := t.snippets
|
||||||
|
idx := t.selected
|
||||||
|
t.mu.Unlock()
|
||||||
|
if len(snippets) > 0 && idx >= 0 && idx < len(snippets) {
|
||||||
|
return t, deleteSnippetCmd(snippets[idx].ID, t.dataDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "esc":
|
||||||
|
return t, func() tea.Msg { return closeFormMsg{} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SnippetListTab) View() string {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
b.WriteString(AppTitleStyle.Render("Command Snippets"))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
switch t.state {
|
||||||
|
case snippetListLoading:
|
||||||
|
b.WriteString(SubtitleStyle.Render("Loading snippets..."))
|
||||||
|
return b.String()
|
||||||
|
case snippetListError:
|
||||||
|
b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err)))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
b.WriteString(SubtitleStyle.Render("Press Esc to go back"))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.err != nil {
|
||||||
|
b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err)))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(t.snippets) == 0 {
|
||||||
|
b.WriteString(SubtitleStyle.Render("No snippets stored."))
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(SubtitleStyle.Render("Press Ctrl+N to add a new snippet."))
|
||||||
|
} else {
|
||||||
|
for i, sn := range t.snippets {
|
||||||
|
var line string
|
||||||
|
desc := sn.Description
|
||||||
|
if desc != "" {
|
||||||
|
line = fmt.Sprintf(" %s — %s", sn.Name, desc)
|
||||||
|
} else {
|
||||||
|
line = fmt.Sprintf(" %s", sn.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if i == t.selected {
|
||||||
|
b.WriteString(SelectedStyle.Render(line))
|
||||||
|
} else {
|
||||||
|
b.WriteString(HostNameStyle.Render(line))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(SubtitleStyle.Render("Ctrl+N:add Ctrl+E:edit D:delete Esc:back"))
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SnippetListTab) Close() {}
|
||||||
|
|
||||||
|
// snippetListLoadedMsg carries the loaded snippet list
|
||||||
|
type snippetListLoadedMsg struct {
|
||||||
|
snippets []*models.Snippet
|
||||||
|
err error
|
||||||
|
}
|
||||||
+1
-1
@@ -196,6 +196,6 @@ func renderTabBar(tm *TabManager) string {
|
|||||||
|
|
||||||
// renderStatusBar renders the bottom status bar
|
// renderStatusBar renders the bottom status bar
|
||||||
func renderStatusBar(tm *TabManager) string {
|
func renderStatusBar(tm *TabManager) string {
|
||||||
hints := "Ctrl+Tab:switch Ctrl+Q:close ↑↓:nav Enter:select q:quit"
|
hints := "Ctrl+Tab:switch Ctrl+Q:close ↑↓:nav Enter:select Ctrl+N:add Ctrl+F:SFTP Ctrl+K:keys Ctrl+P:snippets q:quit"
|
||||||
return StatusBarStyle.Render(hints)
|
return StatusBarStyle.Render(hints)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,36 @@ 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 openKeyListMsg:
|
||||||
|
tab := NewKeyListTab(m.dataDir)
|
||||||
|
cmd := m.tabs.Add(tab)
|
||||||
|
return m, cmd
|
||||||
|
|
||||||
|
case openSnippetListMsg:
|
||||||
|
tab := NewSnippetListTab(m.dataDir)
|
||||||
|
cmd := m.tabs.Add(tab)
|
||||||
|
return m, cmd
|
||||||
|
|
||||||
|
case openKeyFormMsg:
|
||||||
|
var tab Tab
|
||||||
|
if msg.editing != nil {
|
||||||
|
tab = NewEditKeyFormTab(msg.editing, m.dataDir)
|
||||||
|
} else {
|
||||||
|
tab = NewAddKeyFormTab(m.dataDir)
|
||||||
|
}
|
||||||
|
cmd := m.tabs.Add(tab)
|
||||||
|
return m, cmd
|
||||||
|
|
||||||
|
case openSnippetFormMsg:
|
||||||
|
var tab Tab
|
||||||
|
if msg.editing != nil {
|
||||||
|
tab = NewEditSnippetFormTab(msg.editing, m.dataDir)
|
||||||
|
} else {
|
||||||
|
tab = NewAddSnippetFormTab(m.dataDir)
|
||||||
|
}
|
||||||
|
cmd := m.tabs.Add(tab)
|
||||||
|
return m, cmd
|
||||||
|
|
||||||
case closeFormMsg:
|
case closeFormMsg:
|
||||||
if m.tabs.Len() > 1 {
|
if m.tabs.Len() > 1 {
|
||||||
m.tabs.CloseActive()
|
m.tabs.CloseActive()
|
||||||
@@ -112,6 +142,39 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case loadedHostsMsg:
|
case loadedHostsMsg:
|
||||||
m.LoadHosts(msg.hosts)
|
m.LoadHosts(msg.hosts)
|
||||||
|
|
||||||
|
case saveKeyResultMsg:
|
||||||
|
if msg.err != nil {
|
||||||
|
m.Error = msg.err
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
// Close form tab and return to list (which auto-refreshes via Init)
|
||||||
|
if m.tabs.Len() > 1 {
|
||||||
|
m.tabs.CloseActive()
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case deleteKeyResultMsg:
|
||||||
|
if msg.err != nil {
|
||||||
|
m.Error = msg.err
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case saveSnippetResultMsg:
|
||||||
|
if msg.err != nil {
|
||||||
|
m.Error = msg.err
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if m.tabs.Len() > 1 {
|
||||||
|
m.tabs.CloseActive()
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case deleteSnippetResultMsg:
|
||||||
|
if msg.err != nil {
|
||||||
|
m.Error = msg.err
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
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