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)
@@ -186,12 +216,15 @@ func scoredFromCache() ([]scorer.ScoredEntry, error) {
scored := make([]scorer.ScoredEntry, 0, len(hc.Entries))
for _, e := range hc.Entries {
scored = append(scored, scorer.ScoredEntry{
URL: e.URL,
Name: e.Name,
Status: scorer.Status(e.Status),
Stars: e.Stars,
HasLicense: e.HasLicense,
LastPush: e.LastPush,
URL: e.URL,
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

@@ -44,9 +44,11 @@ type HealthEntry struct {
Stars int `yaml:"stars,omitempty"`
Forks int `yaml:"forks,omitempty"`
LastPush time.Time `yaml:"last_push,omitempty"`
HasLicense bool `yaml:"has_license,omitempty"`
HasReadme bool `yaml:"has_readme,omitempty"`
CheckedAt time.Time `yaml:"checked_at"`
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

@@ -23,13 +23,15 @@ const (
// ScoredEntry is a repo with its computed health status.
type ScoredEntry struct {
URL string
Name string
Status Status
Stars int
Forks int
HasLicense bool
LastPush time.Time
URL string
Name string
Status Status
Stars int
Forks int
HasLicense bool
LastPush time.Time
Category string
Description string
}
// ReportSummary contains grouped status counts.
@@ -94,14 +96,16 @@ func ToCacheEntries(scored []ScoredEntry) []cache.HealthEntry {
now := time.Now().UTC()
for i, s := range scored {
entries[i] = cache.HealthEntry{
URL: s.URL,
Name: s.Name,
Status: string(s.Status),
Stars: s.Stars,
Forks: s.Forks,
HasLicense: s.HasLicense,
LastPush: s.LastPush,
CheckedAt: now,
URL: s.URL,
Name: s.Name,
Status: string(s.Status),
Stars: s.Stars,
Forks: s.Forks,
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
}