238 lines
5.3 KiB
Go
238 lines
5.3 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+N:add Ctrl+E:edit D:delete Esc:back Ctrl+Tab:switch Ctrl+Q:close"
|
|
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
|
|
rowShift := (targetW - widestRow) / 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")
|
|
|
|
shift := strings.Repeat(" ", rowShift)
|
|
for _, r := range rows {
|
|
line := lipgloss.PlaceHorizontal(targetW, lipgloss.Left, shift+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() {}
|
|
|
|
// keyListLoadedMsg carries the loaded key list
|
|
type keyListLoadedMsg struct {
|
|
keys []*models.KeyPair
|
|
err error
|
|
}
|