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:
3
Makefile
3
Makefile
@@ -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
|
||||
|
||||
@@ -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
17
go.mod
@@ -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
40
go.sum
@@ -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=
|
||||
|
||||
8
internal/cache/cache.go
vendored
8
internal/cache/cache.go
vendored
@@ -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.
|
||||
|
||||
@@ -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
603
internal/tui/model.go
Normal 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
59
internal/tui/styles.go
Normal 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
118
internal/tui/tree.go
Normal 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
109
internal/tui/tree_test.go
Normal 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
14
internal/tui/tui.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user