refactor: move V1 code into v1/ subdirectory
- git mv cmd/ internal/ pkg/ test/ go.mod go.sum Makefile build.sh docs/ v1/ - Create v1/README.md with V1 documentation - Update root README for V1 + V2 structure - V1 still builds (cd v1 && go build ./cmd/hostkeeper) and 105 tests pass - Root is now clean for V2 development
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user