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 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() if t.state == snippetListLoading { return lipgloss.PlaceHorizontal(t.width, lipgloss.Center, SubtitleStyle.Render("Loading snippets...")) } if t.state == snippetListError { 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 } titlePlain := "Command Snippets" var rows []styledRow if t.err != nil { errLine := fmt.Sprintf("Error: %v", t.err) rows = append(rows, styledRow{text: ErrorStyle.Render(errLine), plain: errLine}) } if len(t.snippets) == 0 { empty := "No snippets stored." hint := "Press Ctrl+N to add a new snippet." rows = append(rows, styledRow{text: SubtitleStyle.Render(empty), plain: empty}) rows = append(rows, styledRow{text: SubtitleStyle.Render(hint), plain: hint}) } else { for i, sn := range t.snippets { var plain string if sn.Description != "" { plain = fmt.Sprintf(" %s — %s", sn.Name, sn.Description) } else { plain = fmt.Sprintf(" %s", sn.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}) } } // Responsive width sidePad := adaptiveSidePad(t.width) widestContent := lipgloss.Width(titlePlain) for _, r := range rows { if w := lipgloss.Width(r.plain); w > widestContent { widestContent = w } } targetW := clampWidth(widestContent+sidePad*2, t.width) innerW := targetW - sidePad*2 if innerW < 1 { innerW = 1 } // Footer (wrapped) footerText := "Ctrl+Tab:switch Ctrl+Q:close ↑↓:nav Ctrl+N:add Ctrl+E:edit D:delete Esc:back" footerWrapped := wrapFooter(footerText, innerW) // Render 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") for _, r := range rows { styled := r.text if lipgloss.Width(r.plain) > innerW { styled = truncateStr(r.text, innerW) } line := lipgloss.PlaceHorizontal(targetW, lipgloss.Center, styled) inner.WriteString(line) inner.WriteString("\n") } inner.WriteString("\n") for _, line := range strings.Split(footerWrapped, "\n") { inner.WriteString(lipgloss.PlaceHorizontal(targetW, lipgloss.Center, SubtitleStyle.Render(line))) inner.WriteString("\n") } box := BorderStyle.Render(inner.String()) var b strings.Builder b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, box)) return b.String() } func (t *SnippetListTab) Close() {} // SetSnippets updates the snippet list data directly (used for refresh) func (t *SnippetListTab) SetSnippets(snippets []*models.Snippet) { t.mu.Lock() defer t.mu.Unlock() t.state = snippetListReady t.snippets = snippets } // FindSnippetListTab finds the first SnippetListTab in a list of tabs func FindSnippetListTab(tabs []Tab) *SnippetListTab { for _, tab := range tabs { if st, ok := tab.(*SnippetListTab); ok { return st } } return nil } // snippetListLoadedMsg carries the loaded snippet list type snippetListLoadedMsg struct { snippets []*models.Snippet err error }