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:
swanadiva
2026-06-23 15:10:20 +07:00
parent 94ec00c303
commit 43858dda87
8 changed files with 1029 additions and 1 deletions
+193
View File
@@ -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
}