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

View File

@@ -125,6 +125,9 @@ report-json-file: $(HEALTH_REPORT_JSON) ## Generate HEALTH_REPORT.json from cach
health-report: health report-file ## Refresh health cache then generate HEALTH_REPORT.md
browse: build ## Launch interactive TUI browser
./$(BINARY) browse
workflow-dev: fmt test lint check-pr website ## Full local development workflow
workflow-pr: fmt test validate ## Recommended workflow before opening a PR

View File

@@ -14,6 +14,7 @@ import (
"github.com/veggiemonk/awesome-docker/internal/linter"
"github.com/veggiemonk/awesome-docker/internal/parser"
"github.com/veggiemonk/awesome-docker/internal/scorer"
"github.com/veggiemonk/awesome-docker/internal/tui"
)
const (
@@ -49,6 +50,7 @@ func main() {
reportCmd(),
validateCmd(),
ciCmd(),
browseCmd(),
)
if err := root.Execute(); err != nil {
@@ -82,6 +84,24 @@ func collectURLs(sections []parser.Section, urls *[]string) {
}
}
type entryMeta struct {
Category string
Description string
}
func collectEntriesWithCategory(sections []parser.Section, parentPath string, out map[string]entryMeta) {
for _, s := range sections {
path := s.Title
if parentPath != "" {
path = parentPath + " > " + s.Title
}
for _, e := range s.Entries {
out[e.URL] = entryMeta{Category: path, Description: e.Description}
}
collectEntriesWithCategory(s.Children, path, out)
}
}
func runLinkChecks(prMode bool) (checkSummary, error) {
doc, err := parseReadme()
if err != nil {
@@ -159,6 +179,16 @@ func runHealth(ctx context.Context) error {
}
scored := scorer.ScoreAll(infos)
meta := make(map[string]entryMeta)
collectEntriesWithCategory(doc.Sections, "", meta)
for i := range scored {
if m, ok := meta[scored[i].URL]; ok {
scored[i].Category = m.Category
scored[i].Description = m.Description
}
}
cacheEntries := scorer.ToCacheEntries(scored)
hc, err := cache.LoadHealthCache(healthCachePath)
@@ -190,8 +220,11 @@ func scoredFromCache() ([]scorer.ScoredEntry, error) {
Name: e.Name,
Status: scorer.Status(e.Status),
Stars: e.Stars,
Forks: e.Forks,
HasLicense: e.HasLicense,
LastPush: e.LastPush,
Category: e.Category,
Description: e.Description,
})
}
return scored, nil
@@ -628,3 +661,23 @@ func ciHealthReportCmd() *cobra.Command {
cmd.Flags().BoolVar(&strict, "strict", false, "Return non-zero when health/report fails")
return cmd
}
func browseCmd() *cobra.Command {
var cachePath string
cmd := &cobra.Command{
Use: "browse",
Short: "Interactive TUI browser for awesome-docker resources",
RunE: func(cmd *cobra.Command, args []string) error {
hc, err := cache.LoadHealthCache(cachePath)
if err != nil {
return fmt.Errorf("load cache: %w", err)
}
if len(hc.Entries) == 0 {
return fmt.Errorf("no cache data; run 'awesome-docker health' first")
}
return tui.Run(hc.Entries)
},
}
cmd.Flags().StringVar(&cachePath, "cache", healthCachePath, "Path to health cache YAML")
return cmd
}

File diff suppressed because it is too large Load Diff

17
go.mod
View File

@@ -3,6 +3,8 @@ module github.com/veggiemonk/awesome-docker
go 1.26.0
require (
charm.land/bubbletea/v2 v2.0.1
charm.land/lipgloss/v2 v2.0.0
github.com/shurcooL/githubv4 v0.0.0-20260209031235-2402fdf4a9ed
github.com/spf13/cobra v1.10.2
github.com/yuin/goldmark v1.7.16
@@ -11,7 +13,22 @@ require (
)
require (
github.com/charmbracelet/colorprofile v0.4.2 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/shurcooL/graphql v0.0.0-20240915155400-7ee5256398cf // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
)

40
go.sum
View File

@@ -1,6 +1,38 @@
charm.land/bubbletea/v2 v2.0.1 h1:B8e9zzK7x9JJ+XvHGF4xnYu9Xa0E0y0MyggY6dbaCfQ=
charm.land/bubbletea/v2 v2.0.1/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo=
charm.land/lipgloss/v2 v2.0.0/go.mod h1:w6SnmsBFBmEFBodiEDurGS/sdUY/u1+v72DqUzc6J14=
github.com/aymanbagabas/go-udiff v0.4.0 h1:TKnLPh7IbnizJIBKFWa9mKayRUBQ9Kh1BPCk6w2PnYM=
github.com/aymanbagabas/go-udiff v0.4.0/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shurcooL/githubv4 v0.0.0-20260209031235-2402fdf4a9ed h1:KT7hI8vYXgU0s2qaMkrfq9tCA1w/iEPgfredVP+4Tzw=
github.com/shurcooL/githubv4 v0.0.0-20260209031235-2402fdf4a9ed/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8=
@@ -11,11 +43,19 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -47,6 +47,8 @@ type HealthEntry struct {
HasLicense bool `yaml:"has_license,omitempty"`
HasReadme bool `yaml:"has_readme,omitempty"`
CheckedAt time.Time `yaml:"checked_at"`
Category string `yaml:"category,omitempty"`
Description string `yaml:"description,omitempty"`
}
// HealthCache is the full YAML cache file.

View File

@@ -30,6 +30,8 @@ type ScoredEntry struct {
Forks int
HasLicense bool
LastPush time.Time
Category string
Description string
}
// ReportSummary contains grouped status counts.
@@ -102,6 +104,8 @@ func ToCacheEntries(scored []ScoredEntry) []cache.HealthEntry {
HasLicense: s.HasLicense,
LastPush: s.LastPush,
CheckedAt: now,
Category: s.Category,
Description: s.Description,
}
}
return entries

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
}