Files
HostKeeper/pkg/tui/key_list_tab.go
T

255 lines
5.7 KiB
Go

package tui
import (
"context"
"fmt"
"strings"
"sync"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"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()
if t.state == keyListLoading {
return lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
SubtitleStyle.Render("Loading keys..."))
}
if t.state == keyListError {
var b strings.Builder
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))))
b.WriteString("\n")
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
SubtitleStyle.Render("Press Esc to go back")))
return b.String()
}
type styledRow struct {
text string
plain string
}
var rows []styledRow
title := "SSH Key Pairs"
titlePlain := title
if t.err != nil {
errLine := fmt.Sprintf("Error: %v", t.err)
rows = append(rows, styledRow{text: ErrorStyle.Render(errLine), plain: errLine})
}
if len(t.keys) == 0 {
empty := "No SSH keys stored."
hint := "Press Ctrl+N to add a new key."
rows = append(rows, styledRow{text: SubtitleStyle.Render(empty), plain: empty})
rows = append(rows, styledRow{text: SubtitleStyle.Render(hint), plain: hint})
} else {
for i, key := range t.keys {
var plain string
if key.Type != "" {
plain = fmt.Sprintf(" %s (%s)", key.Name, key.Type)
} else {
plain = fmt.Sprintf(" %s", key.Name)
}
var styled string
if i == t.selected {
styled = lipgloss.NewStyle().
Foreground(gbFg).
Background(gbBgSel).
Bold(true).
Render("▸ " + strings.TrimLeft(plain, " "))
} else {
styled = lipgloss.NewStyle().Foreground(gbFg).Render(plain)
}
rows = append(rows, styledRow{text: styled, plain: plain})
}
}
footerPlain := "Ctrl+Tab:switch Ctrl+Q:close ↑↓:nav Ctrl+N:add Ctrl+E:edit D:delete Esc:back"
footer := styledRow{text: SubtitleStyle.Render(footerPlain), plain: footerPlain}
widestRow := lipgloss.Width(titlePlain)
for _, r := range rows {
if w := lipgloss.Width(r.plain); w > widestRow {
widestRow = w
}
}
if w := lipgloss.Width(footer.plain); w > widestRow {
widestRow = w
}
const sidePad = 6
targetW := widestRow + sidePad*2
var inner strings.Builder
titleStyled := lipgloss.NewStyle().Bold(true).Foreground(gbYellow).Render(titlePlain)
inner.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, titleStyled))
inner.WriteString("\n\n")
// Render rows, excluding the footer (last element)
for _, r := range rows[:len(rows)-1] {
line := lipgloss.PlaceHorizontal(targetW, lipgloss.Center, r.text)
inner.WriteString(line)
inner.WriteString("\n")
}
inner.WriteString("\n")
inner.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, footer.text))
box := BorderStyle.Render(inner.String())
var b strings.Builder
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, box))
return b.String()
}
func (t *KeyListTab) Close() {}
// SetKeys updates the key list data directly (used for refresh)
func (t *KeyListTab) SetKeys(keys []*models.KeyPair) {
t.mu.Lock()
defer t.mu.Unlock()
t.state = keyListReady
t.keys = keys
}
// FindKeyListTab finds the first KeyListTab in a list of tabs
func FindKeyListTab(tabs []Tab) *KeyListTab {
for _, tab := range tabs {
if kt, ok := tab.(*KeyListTab); ok {
return kt
}
}
return nil
}
// keyListLoadedMsg carries the loaded key list
type keyListLoadedMsg struct {
keys []*models.KeyPair
err error
}