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 Enter:SSH Ctrl+N:add Ctrl+F:SFTP Ctrl+K:keys Ctrl+P:snippets q:quit" 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 }