Feat/tui browse (#1266)

* Add interactive TUI browser command using Bubbletea v2

Adds `awesome-docker browse` to interactively explore the curated list
in a terminal UI with a category tree (left panel) and detailed resource
view (right panel). Enriches health_cache.yaml with category and
description fields so the cache is self-contained for the TUI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add TUI pagination, scrolloff, and fix visual overflow

Add pagination keybindings (Ctrl+D/PgDn, Ctrl+U/PgUp, g/Home, G/End)
to both tree and list panels. Implement scrolloff (4 lines) to keep
context visible around the cursor. Fix list panel overflow caused by
Unicode characters (★, ⑂) rendering wider than lipgloss measures,
which pushed the footer off-screen. Improve selection highlight
visibility with background colors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Julien Bisconti
2026-03-10 16:20:41 +01:00
committed by GitHub
parent 05266bd8ac
commit a68d6f826d
12 changed files with 2555 additions and 645 deletions

603
internal/tui/model.go Normal file
View File

@@ -0,0 +1,603 @@
package tui
import (
"fmt"
"os/exec"
"runtime"
"strings"
"unicode/utf8"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/veggiemonk/awesome-docker/internal/cache"
)
type panel int
const (
panelTree panel = iota
panelList
)
const entryHeight = 5 // lines rendered per entry in the list panel
const scrollOff = 4 // minimum lines/entries kept visible above and below cursor
// Model is the top-level Bubbletea model.
type Model struct {
roots []*TreeNode
flatTree []FlatNode
activePanel panel
treeCursor int
treeOffset int
listCursor int
listOffset int
currentEntries []cache.HealthEntry
filtering bool
filterText string
width, height int
}
// New creates a new Model from health cache entries.
func New(entries []cache.HealthEntry) Model {
roots := BuildTree(entries)
// Expand first root by default
if len(roots) > 0 {
roots[0].Expanded = true
}
flat := FlattenVisible(roots)
m := Model{
roots: roots,
flatTree: flat,
}
m.updateCurrentEntries()
return m
}
// Init returns an initial command.
func (m Model) Init() tea.Cmd {
return nil
}
// Update handles messages.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
return m, nil
case openURLMsg:
return m, nil
case tea.KeyPressMsg:
// Filter mode input
if m.filtering {
return m.handleFilterKey(msg)
}
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "tab":
if m.activePanel == panelTree {
m.activePanel = panelList
} else {
m.activePanel = panelTree
}
case "/":
m.filtering = true
m.filterText = ""
default:
if m.activePanel == panelTree {
return m.handleTreeKey(msg)
}
return m.handleListKey(msg)
}
}
return m, nil
}
func (m Model) handleFilterKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.filtering = false
m.filterText = ""
m.flatTree = FlattenVisible(m.roots)
m.updateCurrentEntries()
case "enter":
m.filtering = false
case "backspace":
if len(m.filterText) > 0 {
m.filterText = m.filterText[:len(m.filterText)-1]
m.applyFilter()
}
default:
r := msg.String()
if utf8.RuneCountInString(r) == 1 {
m.filterText += r
m.applyFilter()
}
}
return m, nil
}
func (m *Model) applyFilter() {
if m.filterText == "" {
m.flatTree = FlattenVisible(m.roots)
m.updateCurrentEntries()
return
}
query := strings.ToLower(m.filterText)
var filtered []cache.HealthEntry
for _, root := range m.roots {
for _, e := range root.AllEntries() {
if strings.Contains(strings.ToLower(e.Name), query) ||
strings.Contains(strings.ToLower(e.Description), query) ||
strings.Contains(strings.ToLower(e.Category), query) {
filtered = append(filtered, e)
}
}
}
m.currentEntries = filtered
m.listCursor = 0
m.listOffset = 0
}
func (m Model) handleTreeKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "up", "k":
if m.treeCursor > 0 {
m.treeCursor--
m.adjustTreeScroll()
m.updateCurrentEntries()
}
case "down", "j":
if m.treeCursor < len(m.flatTree)-1 {
m.treeCursor++
m.adjustTreeScroll()
m.updateCurrentEntries()
}
case "enter", " ":
if m.treeCursor < len(m.flatTree) {
node := m.flatTree[m.treeCursor].Node
if node.HasChildren() {
node.Expanded = !node.Expanded
m.flatTree = FlattenVisible(m.roots)
if m.treeCursor >= len(m.flatTree) {
m.treeCursor = len(m.flatTree) - 1
}
}
m.adjustTreeScroll()
m.updateCurrentEntries()
}
case "ctrl+d", "pgdown":
half := m.treePanelHeight() / 2
if half < 1 {
half = 1
}
m.treeCursor += half
if m.treeCursor >= len(m.flatTree) {
m.treeCursor = len(m.flatTree) - 1
}
m.adjustTreeScroll()
m.updateCurrentEntries()
case "ctrl+u", "pgup":
half := m.treePanelHeight() / 2
if half < 1 {
half = 1
}
m.treeCursor -= half
if m.treeCursor < 0 {
m.treeCursor = 0
}
m.adjustTreeScroll()
m.updateCurrentEntries()
case "g", "home":
m.treeCursor = 0
m.adjustTreeScroll()
m.updateCurrentEntries()
case "G", "end":
m.treeCursor = len(m.flatTree) - 1
m.adjustTreeScroll()
m.updateCurrentEntries()
case "right", "l":
if m.treeCursor < len(m.flatTree) {
node := m.flatTree[m.treeCursor].Node
if node.HasChildren() && !node.Expanded {
node.Expanded = true
m.flatTree = FlattenVisible(m.roots)
m.adjustTreeScroll()
m.updateCurrentEntries()
} else {
m.activePanel = panelList
}
}
case "left", "h":
if m.treeCursor < len(m.flatTree) {
node := m.flatTree[m.treeCursor].Node
if node.HasChildren() && node.Expanded {
node.Expanded = false
m.flatTree = FlattenVisible(m.roots)
m.adjustTreeScroll()
m.updateCurrentEntries()
}
}
}
return m, nil
}
func (m *Model) adjustTreeScroll() {
visible := m.treePanelHeight()
off := scrollOff
if off > visible/2 {
off = visible / 2
}
if m.treeCursor < m.treeOffset+off {
m.treeOffset = m.treeCursor - off
}
if m.treeCursor >= m.treeOffset+visible-off {
m.treeOffset = m.treeCursor - visible + off + 1
}
if m.treeOffset < 0 {
m.treeOffset = 0
}
}
func (m Model) treePanelHeight() int {
h := m.height - 6 // header, footer, borders, title
if h < 1 {
h = 1
}
return h
}
func (m Model) handleListKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "up", "k":
if m.listCursor > 0 {
m.listCursor--
m.adjustListScroll()
}
case "down", "j":
if m.listCursor < len(m.currentEntries)-1 {
m.listCursor++
m.adjustListScroll()
}
case "ctrl+d", "pgdown":
half := m.visibleListEntries() / 2
if half < 1 {
half = 1
}
m.listCursor += half
if m.listCursor >= len(m.currentEntries) {
m.listCursor = len(m.currentEntries) - 1
}
m.adjustListScroll()
case "ctrl+u", "pgup":
half := m.visibleListEntries() / 2
if half < 1 {
half = 1
}
m.listCursor -= half
if m.listCursor < 0 {
m.listCursor = 0
}
m.adjustListScroll()
case "g", "home":
m.listCursor = 0
m.adjustListScroll()
case "G", "end":
m.listCursor = len(m.currentEntries) - 1
m.adjustListScroll()
case "enter":
if m.listCursor < len(m.currentEntries) {
return m, openURL(m.currentEntries[m.listCursor].URL)
}
case "left", "h":
m.activePanel = panelTree
}
return m, nil
}
func (m *Model) updateCurrentEntries() {
if len(m.flatTree) == 0 {
m.currentEntries = nil
return
}
if m.treeCursor >= len(m.flatTree) {
m.treeCursor = len(m.flatTree) - 1
}
node := m.flatTree[m.treeCursor].Node
m.currentEntries = node.AllEntries()
m.listCursor = 0
m.listOffset = 0
}
func (m Model) visibleListEntries() int {
v := m.listPanelHeight() / entryHeight
if v < 1 {
return 1
}
return v
}
func (m *Model) adjustListScroll() {
visible := m.visibleListEntries()
off := scrollOff
if off > visible/2 {
off = visible / 2
}
if m.listCursor < m.listOffset+off {
m.listOffset = m.listCursor - off
}
if m.listCursor >= m.listOffset+visible-off {
m.listOffset = m.listCursor - visible + off + 1
}
if m.listOffset < 0 {
m.listOffset = 0
}
}
func (m Model) listPanelHeight() int {
// height minus header, footer, borders
h := m.height - 4
if h < 1 {
h = 1
}
return h
}
// View renders the UI.
func (m Model) View() tea.View {
if m.width == 0 || m.height == 0 {
return tea.NewView("Loading...")
}
treeWidth := m.width*3/10 - 2 // 30% minus borders
listWidth := m.width - treeWidth - 6 // remaining minus borders/gaps
contentHeight := m.height - 3 // minus footer
if treeWidth < 10 {
treeWidth = 10
}
if listWidth < 20 {
listWidth = 20
}
if contentHeight < 3 {
contentHeight = 3
}
tree := m.renderTree(treeWidth, contentHeight)
list := m.renderList(listWidth, contentHeight)
// Apply border styles
treeBorder := inactiveBorderStyle
listBorder := inactiveBorderStyle
if m.activePanel == panelTree {
treeBorder = activeBorderStyle
} else {
listBorder = activeBorderStyle
}
treePanel := treeBorder.Width(treeWidth).Height(contentHeight).Render(tree)
listPanel := listBorder.Width(listWidth).Height(contentHeight).Render(list)
body := lipgloss.JoinHorizontal(lipgloss.Top, treePanel, listPanel)
footer := m.renderFooter()
content := lipgloss.JoinVertical(lipgloss.Left, body, footer)
v := tea.NewView(content)
v.AltScreen = true
return v
}
func (m Model) renderTree(width, height int) string {
var b strings.Builder
title := headerStyle.Render("Categories")
b.WriteString(title)
b.WriteString("\n\n")
linesUsed := 2
end := m.treeOffset + height - 2
if end > len(m.flatTree) {
end = len(m.flatTree)
}
for i := m.treeOffset; i < end; i++ {
fn := m.flatTree[i]
if linesUsed >= height {
break
}
indent := strings.Repeat(" ", fn.Depth)
icon := " "
if fn.Node.HasChildren() {
if fn.Node.Expanded {
icon = "▼ "
} else {
icon = "▶ "
}
}
count := fn.Node.TotalEntries()
label := fmt.Sprintf("%s%s%s (%d)", indent, icon, fn.Node.Name, count)
// Truncate to width
if len(label) > width {
label = label[:width-1] + "…"
}
if i == m.treeCursor {
label = treeSelectedStyle.Render(label)
} else {
label = treeNormalStyle.Render(label)
}
b.WriteString(label)
b.WriteString("\n")
linesUsed++
}
return b.String()
}
func (m Model) renderList(width, height int) string {
var b strings.Builder
// Title
title := "Resources"
if m.filtering && m.filterText != "" {
title = fmt.Sprintf("Resources (filter: %s)", m.filterText)
}
b.WriteString(headerStyle.Render(title))
b.WriteString("\n\n")
if len(m.currentEntries) == 0 {
b.WriteString(entryDescStyle.Render(" No entries"))
return b.String()
}
linesUsed := 2
visible := (height - 2) / entryHeight
if visible < 1 {
visible = 1
}
start := m.listOffset
end := start + visible
if end > len(m.currentEntries) {
end = len(m.currentEntries)
}
for idx := start; idx < end; idx++ {
if linesUsed+entryHeight > height {
break
}
e := m.currentEntries[idx]
selected := idx == m.listCursor
// Use a safe width that accounts for Unicode characters (★, ⑂)
// that some terminals render as 2 columns but lipgloss counts as 1.
safeWidth := width - 2
// Line 1: name + stars + forks
stats := fmt.Sprintf("★ %d", e.Stars)
if e.Forks > 0 {
stats += fmt.Sprintf(" ⑂ %d", e.Forks)
}
name := e.Name
statsW := lipgloss.Width(stats)
maxName := safeWidth - statsW - 2 // 2 for minimum gap
if maxName < 4 {
maxName = 4
}
if lipgloss.Width(name) > maxName {
name = truncateToWidth(name, maxName-1) + "…"
}
nameStr := entryNameStyle.Render(name)
statsStr := entryDescStyle.Render(stats)
padding := safeWidth - lipgloss.Width(nameStr) - lipgloss.Width(statsStr)
if padding < 1 {
padding = 1
}
line1 := nameStr + strings.Repeat(" ", padding) + statsStr
// Line 2: URL
url := e.URL
if lipgloss.Width(url) > safeWidth {
url = truncateToWidth(url, safeWidth-1) + "…"
}
line2 := entryURLStyle.Render(url)
// Line 3: description
desc := e.Description
if lipgloss.Width(desc) > safeWidth {
desc = truncateToWidth(desc, safeWidth-3) + "..."
}
line3 := entryDescStyle.Render(desc)
// Line 4: status + last push
statusStr := statusStyle(e.Status).Render(e.Status)
lastPush := ""
if !e.LastPush.IsZero() {
lastPush = fmt.Sprintf(" Last push: %s", e.LastPush.Format("2006-01-02"))
}
line4 := statusStr + entryDescStyle.Render(lastPush)
// Line 5: separator
sepWidth := safeWidth
if sepWidth < 1 {
sepWidth = 1
}
line5 := entryDescStyle.Render(strings.Repeat("─", sepWidth))
entry := fmt.Sprintf("%s\n%s\n%s\n%s\n%s", line1, line2, line3, line4, line5)
if selected && m.activePanel == panelList {
entry = entrySelectedStyle.Render(entry)
}
b.WriteString(entry)
b.WriteString("\n")
linesUsed += entryHeight
}
// Scroll indicator
if len(m.currentEntries) > visible {
indicator := fmt.Sprintf(" %d-%d of %d", start+1, end, len(m.currentEntries))
b.WriteString(footerStyle.Render(indicator))
}
return b.String()
}
func (m Model) renderFooter() string {
if m.filtering {
return filterPromptStyle.Render("/") + entryDescStyle.Render(m.filterText+"█")
}
help := " Tab:switch j/k:nav PgDn/PgUp:page g/G:top/bottom Enter:expand/open /:filter q:quit"
return footerStyle.Render(help)
}
// openURLMsg is sent after attempting to open a URL.
type openURLMsg struct{ err error }
func openURL(url string) tea.Cmd {
return func() tea.Msg {
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", url)
case "windows":
cmd = exec.Command("cmd", "/c", "start", url)
default:
cmd = exec.Command("xdg-open", url)
}
return openURLMsg{err: cmd.Run()}
}
}
// truncateToWidth truncates s to at most maxWidth visible columns.
func truncateToWidth(s string, maxWidth int) string {
if maxWidth <= 0 {
return ""
}
w := 0
for i, r := range s {
rw := lipgloss.Width(string(r))
if w+rw > maxWidth {
return s[:i]
}
w += rw
}
return s
}

59
internal/tui/styles.go Normal file
View File

@@ -0,0 +1,59 @@
package tui
import "charm.land/lipgloss/v2"
var (
// Panel borders
activeBorderStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#7D56F4"))
inactiveBorderStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#555555"))
// Tree styles
treeSelectedStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FF79C6")).Background(lipgloss.Color("#3B2D50"))
treeNormalStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC"))
// Entry styles
entryNameStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#50FA7B"))
entryURLStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).Italic(true)
entryDescStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC"))
// Status badge styles
statusHealthyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#50FA7B")).Bold(true)
statusInactiveStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFB86C"))
statusStaleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#F1FA8C"))
statusArchivedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5555")).Bold(true)
statusDeadStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Strikethrough(true)
// Selected entry
entrySelectedStyle = lipgloss.NewStyle().Background(lipgloss.Color("#44475A"))
// Header
headerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#BD93F9")).Padding(0, 1)
// Footer
footerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#666666"))
// Filter
filterPromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF79C6")).Bold(true)
)
func statusStyle(status string) lipgloss.Style {
switch status {
case "healthy":
return statusHealthyStyle
case "inactive":
return statusInactiveStyle
case "stale":
return statusStaleStyle
case "archived":
return statusArchivedStyle
case "dead":
return statusDeadStyle
default:
return lipgloss.NewStyle()
}
}

118
internal/tui/tree.go Normal file
View File

@@ -0,0 +1,118 @@
package tui
import (
"sort"
"strings"
"github.com/veggiemonk/awesome-docker/internal/cache"
)
// TreeNode represents a node in the category tree.
type TreeNode struct {
Name string // display name (leaf segment, e.g. "Networking")
Path string // full path (e.g. "Container Operations > Networking")
Children []*TreeNode
Expanded bool
Entries []cache.HealthEntry
}
// FlatNode is a visible tree node with its indentation depth.
type FlatNode struct {
Node *TreeNode
Depth int
}
// HasChildren returns true if this node has child categories.
func (n *TreeNode) HasChildren() bool {
return len(n.Children) > 0
}
// TotalEntries returns the count of entries in this node and all descendants.
func (n *TreeNode) TotalEntries() int {
count := len(n.Entries)
for _, c := range n.Children {
count += c.TotalEntries()
}
return count
}
// AllEntries returns entries from this node and all descendants.
func (n *TreeNode) AllEntries() []cache.HealthEntry {
result := make([]cache.HealthEntry, 0, n.TotalEntries())
result = append(result, n.Entries...)
for _, c := range n.Children {
result = append(result, c.AllEntries()...)
}
return result
}
// BuildTree constructs a tree from flat HealthEntry slice, grouping by Category.
func BuildTree(entries []cache.HealthEntry) []*TreeNode {
root := &TreeNode{Name: "root"}
nodeMap := map[string]*TreeNode{}
for _, e := range entries {
cat := e.Category
if cat == "" {
cat = "Uncategorized"
}
node := ensureNode(root, nodeMap, cat)
node.Entries = append(node.Entries, e)
}
// Sort children at every level
sortTree(root)
return root.Children
}
func ensureNode(root *TreeNode, nodeMap map[string]*TreeNode, path string) *TreeNode {
if n, ok := nodeMap[path]; ok {
return n
}
parts := strings.Split(path, " > ")
current := root
for i, part := range parts {
subpath := strings.Join(parts[:i+1], " > ")
if n, ok := nodeMap[subpath]; ok {
current = n
continue
}
child := &TreeNode{
Name: part,
Path: subpath,
}
current.Children = append(current.Children, child)
nodeMap[subpath] = child
current = child
}
return current
}
func sortTree(node *TreeNode) {
sort.Slice(node.Children, func(i, j int) bool {
return node.Children[i].Name < node.Children[j].Name
})
for _, c := range node.Children {
sortTree(c)
}
}
// FlattenVisible returns visible nodes in depth-first order for rendering.
func FlattenVisible(roots []*TreeNode) []FlatNode {
var result []FlatNode
for _, r := range roots {
flattenNode(r, 0, &result)
}
return result
}
func flattenNode(node *TreeNode, depth int, result *[]FlatNode) {
*result = append(*result, FlatNode{Node: node, Depth: depth})
if node.Expanded {
for _, c := range node.Children {
flattenNode(c, depth+1, result)
}
}
}

109
internal/tui/tree_test.go Normal file
View File

@@ -0,0 +1,109 @@
package tui
import (
"testing"
"github.com/veggiemonk/awesome-docker/internal/cache"
)
func TestBuildTree(t *testing.T) {
entries := []cache.HealthEntry{
{URL: "https://github.com/a/b", Name: "a/b", Category: "Projects > Networking", Description: "desc1"},
{URL: "https://github.com/c/d", Name: "c/d", Category: "Projects > Networking", Description: "desc2"},
{URL: "https://github.com/e/f", Name: "e/f", Category: "Projects > Security", Description: "desc3"},
{URL: "https://github.com/g/h", Name: "g/h", Category: "Docker Images > Base Tools", Description: "desc4"},
{URL: "https://github.com/i/j", Name: "i/j", Category: "", Description: "no category"},
}
roots := BuildTree(entries)
// Should have 3 roots: Docker Images, Projects, Uncategorized (sorted)
if len(roots) != 3 {
t.Fatalf("expected 3 roots, got %d", len(roots))
}
if roots[0].Name != "Docker Images" {
t.Errorf("expected first root 'Docker Images', got %q", roots[0].Name)
}
if roots[1].Name != "Projects" {
t.Errorf("expected second root 'Projects', got %q", roots[1].Name)
}
if roots[2].Name != "Uncategorized" {
t.Errorf("expected third root 'Uncategorized', got %q", roots[2].Name)
}
// Projects > Networking should have 2 entries
projects := roots[1]
if len(projects.Children) != 2 {
t.Fatalf("expected 2 children under Projects, got %d", len(projects.Children))
}
networking := projects.Children[0] // Networking < Security alphabetically
if networking.Name != "Networking" {
t.Errorf("expected 'Networking', got %q", networking.Name)
}
if len(networking.Entries) != 2 {
t.Errorf("expected 2 entries in Networking, got %d", len(networking.Entries))
}
}
func TestBuildTreeEmpty(t *testing.T) {
roots := BuildTree(nil)
if len(roots) != 0 {
t.Errorf("expected 0 roots for nil input, got %d", len(roots))
}
}
func TestTotalEntries(t *testing.T) {
entries := []cache.HealthEntry{
{URL: "https://a", Category: "A > B"},
{URL: "https://b", Category: "A > B"},
{URL: "https://c", Category: "A > C"},
{URL: "https://d", Category: "A"},
}
roots := BuildTree(entries)
if len(roots) != 1 {
t.Fatalf("expected 1 root, got %d", len(roots))
}
if roots[0].TotalEntries() != 4 {
t.Errorf("expected 4 total entries, got %d", roots[0].TotalEntries())
}
}
func TestFlattenVisible(t *testing.T) {
entries := []cache.HealthEntry{
{URL: "https://a", Category: "A > B"},
{URL: "https://b", Category: "A > C"},
}
roots := BuildTree(entries)
// Initially not expanded, should see just root
flat := FlattenVisible(roots)
if len(flat) != 1 {
t.Fatalf("expected 1 visible node (collapsed), got %d", len(flat))
}
if flat[0].Depth != 0 {
t.Errorf("expected depth 0, got %d", flat[0].Depth)
}
// Expand root
roots[0].Expanded = true
flat = FlattenVisible(roots)
if len(flat) != 3 {
t.Fatalf("expected 3 visible nodes (expanded), got %d", len(flat))
}
if flat[1].Depth != 1 {
t.Errorf("expected depth 1 for child, got %d", flat[1].Depth)
}
}
func TestAllEntries(t *testing.T) {
entries := []cache.HealthEntry{
{URL: "https://a", Category: "A > B"},
{URL: "https://b", Category: "A"},
}
roots := BuildTree(entries)
all := roots[0].AllEntries()
if len(all) != 2 {
t.Errorf("expected 2 entries from AllEntries, got %d", len(all))
}
}

14
internal/tui/tui.go Normal file
View File

@@ -0,0 +1,14 @@
package tui
import (
tea "charm.land/bubbletea/v2"
"github.com/veggiemonk/awesome-docker/internal/cache"
)
// Run launches the TUI browser. It blocks until the user quits.
func Run(entries []cache.HealthEntry) error {
m := New(entries)
p := tea.NewProgram(m)
_, err := p.Run()
return err
}