feat: add prune subcommand, drop archived/stale entries (#1441)
Deploy to GitHub Pages / build (push) Failing after 51s
Deploy to GitHub Pages / deploy (push) Has been skipped
Pull Requests / Weekly QA / test (push) Failing after 1m13s
Broken Links Report / check-links (push) Failing after 45s

* feat: add prune subcommand, drop archived/stale entries, add container-explorer

Add a new `awesome-docker prune` subcommand that removes README entries
whose repository health status matches a configurable set (default:
archived,stale). URLs are read from the local health cache, or from a
markdown report file via --from-report when the cache is outdated.

Apply it against the issue #1439 health report to remove 5 entries
that survived the recent reorg: stitchocker, docker-consul,
blockbridge-docker-volume, docker-explorer, dockdash.

Add google/container-explorer in the Security section as the actively
maintained successor to the now-archived google/docker-explorer.

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

* golangci-lint config

* fix: address golangci-lint findings

Fixes errcheck on bufio.Writer.WriteString, gocritic rangeValCopy via
indexed loops with pointer locals, gosec G703 on user-supplied CLI
output path, noctx by switching to exec.CommandContext with a timeout
in the TUI url opener, prealloc in the scorer test, plus fieldalignment
struct reorders and golines line breaks from --fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Julien Bisconti
2026-05-18 23:46:32 +02:00
committed by GitHub
parent 503e5bd7c7
commit 29222bfcb5
23 changed files with 2548 additions and 2803 deletions
+7 -2
View File
@@ -2,8 +2,10 @@ package builder
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/yuin/goldmark"
@@ -61,10 +63,13 @@ func Build(markdownPath, templatePath, outputPath string) error {
}
}
if !replaced {
return fmt.Errorf("template missing supported markdown placeholder")
return errors.New("template missing supported markdown placeholder")
}
if err := os.WriteFile(outputPath, []byte(output), 0o644); err != nil {
// outputPath is a CLI-provided destination for a generated website file;
// the user controls the destination by design, so the taint warning is expected.
//nolint:gosec // G306,G304: user-supplied output path is intentional for this CLI
if err := os.WriteFile(filepath.Clean(outputPath), []byte(output), 0o644); err != nil {
return fmt.Errorf("write output: %w", err)
}
return nil
+15 -13
View File
@@ -38,17 +38,17 @@ func LoadExcludeList(path string) (*ExcludeList, error) {
// HealthEntry stores metadata about a single entry.
type HealthEntry struct {
URL string `yaml:"url"`
Name string `yaml:"name"`
Status string `yaml:"status"` // healthy, inactive, stale, archived, dead
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"`
LastPush time.Time `yaml:"last_push,omitempty"`
CheckedAt time.Time `yaml:"checked_at"`
URL string `yaml:"url"`
Name string `yaml:"name"`
Status string `yaml:"status"`
Category string `yaml:"category,omitempty"`
Description string `yaml:"description,omitempty"`
Stars int `yaml:"stars,omitempty"`
Forks int `yaml:"forks,omitempty"`
HasLicense bool `yaml:"has_license,omitempty"`
HasReadme bool `yaml:"has_readme,omitempty"`
}
// HealthCache is the full YAML cache file.
@@ -84,15 +84,17 @@ func SaveHealthCache(path string, hc *HealthCache) error {
// Merge updates the cache with new entries, replacing existing ones by URL.
func (hc *HealthCache) Merge(entries []HealthEntry) {
index := make(map[string]int)
for i, e := range hc.Entries {
for i := range hc.Entries {
e := &hc.Entries[i]
index[e.URL] = i
}
for _, e := range entries {
if i, exists := index[e.URL]; exists {
hc.Entries[i] = e
for i := range entries {
e := &entries[i]
if j, exists := index[e.URL]; exists {
hc.Entries[j] = *e
} else {
index[e.URL] = len(hc.Entries)
hc.Entries = append(hc.Entries, e)
hc.Entries = append(hc.Entries, *e)
}
}
}
+8 -10
View File
@@ -13,15 +13,15 @@ import (
// RepoInfo holds metadata about a GitHub repository.
type RepoInfo struct {
PushedAt time.Time
Owner string
Name string
URL string
Stars int
Forks int
IsArchived bool
IsDisabled bool
IsPrivate bool
PushedAt time.Time
Stars int
Forks int
HasLicense bool
}
@@ -104,19 +104,17 @@ func NewGitHubChecker(token string) *GitHubChecker {
func (gc *GitHubChecker) CheckRepo(ctx context.Context, owner, name string) (RepoInfo, error) {
var query struct {
Repository struct {
PushedAt time.Time
LicenseInfo *struct{ Name string }
StargazerCount int
ForkCount int
IsArchived bool
IsDisabled bool
IsPrivate bool
PushedAt time.Time
StargazerCount int
ForkCount int
LicenseInfo *struct {
Name string
}
} `graphql:"repository(owner: $owner, name: $name)"`
}
vars := map[string]interface{}{
vars := map[string]any{
"owner": githubv4.String(owner),
"name": githubv4.String(name),
}
+8 -1
View File
@@ -34,7 +34,14 @@ func TestExtractGitHubRepo(t *testing.T) {
}
if ok {
if owner != tt.owner || name != tt.name {
t.Errorf("ExtractGitHubRepo(%q) = (%q, %q), want (%q, %q)", tt.url, owner, name, tt.owner, tt.name)
t.Errorf(
"ExtractGitHubRepo(%q) = (%q, %q), want (%q, %q)",
tt.url,
owner,
name,
tt.owner,
tt.name,
)
}
}
}
+3 -3
View File
@@ -18,11 +18,11 @@ const (
// LinkResult holds the result of checking a single URL.
type LinkResult struct {
URL string
OK bool
StatusCode int
Redirected bool
RedirectURL string
Error string
StatusCode int
OK bool
Redirected bool
}
func shouldFallbackToGET(statusCode int) bool {
+21 -7
View File
@@ -15,7 +15,9 @@ import (
// by [@author](url), by [@author][ref], by @author
//
// Also handles "Created by", "Maintained by" etc.
var attributionRe = regexp.MustCompile(`\s+(?:(?:[Cc]reated|[Mm]aintained|[Bb]uilt)\s+)?by\s+\[@[^\]]+\](?:\([^)]*\)|\[[^\]]*\])\.?$`)
var attributionRe = regexp.MustCompile(
`\s+(?:(?:[Cc]reated|[Mm]aintained|[Bb]uilt)\s+)?by\s+\[@[^\]]+\](?:\([^)]*\)|\[[^\]]*\])\.?$`,
)
// bareAttributionRe matches: by @author at end of line (no link).
var bareAttributionRe = regexp.MustCompile(`\s+by\s+@\w+\.?$`)
@@ -136,13 +138,19 @@ func FixFile(path string) (int, error) {
w := bufio.NewWriter(out)
for i, line := range lines {
w.WriteString(line)
if _, err := w.WriteString(line); err != nil {
return 0, err
}
if i < len(lines)-1 {
w.WriteString("\n")
if _, err := w.WriteString("\n"); err != nil {
return 0, err
}
}
}
// Preserve trailing newline if original had one
w.WriteString("\n")
if _, err := w.WriteString("\n"); err != nil {
return 0, err
}
return fixCount, w.Flush()
}
@@ -218,11 +226,17 @@ func SortFile(path string) (int, error) {
w := bufio.NewWriter(out)
for i, line := range lines {
w.WriteString(line)
if _, err := w.WriteString(line); err != nil {
return 0, err
}
if i < len(lines)-1 {
w.WriteString("\n")
if _, err := w.WriteString("\n"); err != nil {
return 0, err
}
}
}
w.WriteString("\n")
if _, err := w.WriteString("\n"); err != nil {
return 0, err
}
return fixCount, w.Flush()
}
+6 -2
View File
@@ -30,9 +30,9 @@ const (
// Issue is a single lint problem found.
type Issue struct {
Rule Rule
Message string
Severity Severity
Line int
Message string
}
func (i Issue) String() string {
@@ -79,7 +79,11 @@ func CheckSorted(entries []parser.Entry) []Issue {
Rule: RuleSorted,
Severity: SeverityError,
Line: entries[i].Line,
Message: fmt.Sprintf("%q should come before %q (alphabetical order)", entries[i].Name, entries[i-1].Name),
Message: fmt.Sprintf(
"%q should come before %q (alphabetical order)",
entries[i].Name,
entries[i-1].Name,
),
})
}
}
+5 -1
View File
@@ -19,7 +19,11 @@ func TestParseEntry(t *testing.T) {
t.Errorf("url = %q, want %q", entry.URL, "https://www.docker.com/products/docker-desktop/")
}
if entry.Description != "Official native app. Only for Windows and MacOS." {
t.Errorf("description = %q, want %q", entry.Description, "Official native app. Only for Windows and MacOS.")
t.Errorf(
"description = %q, want %q",
entry.Description,
"Official native app. Only for Windows and MacOS.",
)
}
if len(entry.Markers) != 0 {
t.Errorf("markers = %v, want empty", entry.Markers)
+3 -3
View File
@@ -15,17 +15,17 @@ type Entry struct {
Name string
URL string
Description string
Raw string
Markers []Marker
Line int // 1-based line number in source
Raw string // original line text
Line int
}
// Section is a heading with optional entries and child sections.
type Section struct {
Title string
Level int // heading level: 1 = #, 2 = ##, etc.
Entries []Entry
Children []Section
Level int
Line int
}
+221
View File
@@ -0,0 +1,221 @@
// Package pruner owns the removal of README entries by health status.
//
// Why it exists: maintenance regularly produces a list of archived/stale
// projects (see scorer + cache). Pruner is the seam that translates that list
// into a concrete edit of README.md and config/health_cache.yaml, so the README
// stays in lockstep with the cache instead of drifting via ad-hoc edits.
package pruner
import (
"bufio"
"fmt"
"io"
"os"
"regexp"
"sort"
"strings"
"github.com/veggiemonk/awesome-docker/internal/cache"
"github.com/veggiemonk/awesome-docker/internal/parser"
)
// Removed describes a single entry removed from the README.
type Removed struct {
URL string
Name string
Status string
Line int
}
// Result summarizes a prune run.
type Result struct {
Removed []Removed
// URLs in the target set that didn't appear in the README (already gone,
// non-GitHub indirection, or URL drift between cache and README).
NotFound []string
}
// TargetURLs returns the URL set selected by the given statuses from the cache.
func TargetURLs(hc *cache.HealthCache, statuses []string) map[string]cache.HealthEntry {
want := make(map[string]bool, len(statuses))
for _, s := range statuses {
want[strings.TrimSpace(strings.ToLower(s))] = true
}
out := make(map[string]cache.HealthEntry)
for i := range hc.Entries {
e := &hc.Entries[i]
if want[strings.ToLower(e.Status)] {
out[normalizeURL(e.URL)] = *e
}
}
return out
}
// PruneREADME removes lines whose entry URL is in targets and writes the
// result back to path. If dryRun is true, the file is not modified.
func PruneREADME(path string, targets map[string]cache.HealthEntry, dryRun bool) (Result, error) {
f, err := os.Open(path) //nolint:gosec
if err != nil {
return Result{}, fmt.Errorf("open %s: %w", path, err)
}
lines, err := readLines(f)
f.Close()
if err != nil {
return Result{}, fmt.Errorf("read %s: %w", path, err)
}
var (
kept = make([]string, 0, len(lines))
removed []Removed
hit = make(map[string]bool, len(targets))
)
for i, line := range lines {
entry, perr := parser.ParseEntry(line, i+1)
if perr != nil {
kept = append(kept, line)
continue
}
key := normalizeURL(entry.URL)
meta, ok := targets[key]
if !ok {
kept = append(kept, line)
continue
}
hit[key] = true
removed = append(removed, Removed{
URL: entry.URL,
Name: entry.Name,
Status: meta.Status,
Line: i + 1,
})
}
res := Result{Removed: removed}
for k := range targets {
if !hit[k] {
res.NotFound = append(res.NotFound, targets[k].URL)
}
}
sort.Strings(res.NotFound)
if dryRun || len(removed) == 0 {
return res, nil
}
if err := writeLines(path, kept); err != nil {
return res, fmt.Errorf("write %s: %w", path, err)
}
return res, nil
}
// PruneCache drops entries whose normalized URL is in targets and writes the
// cache back to path. Safe to call when len(targets) == 0 (no-op).
func PruneCache(path string, hc *cache.HealthCache, targets map[string]cache.HealthEntry, dryRun bool) (int, error) {
if len(targets) == 0 {
return 0, nil
}
kept := hc.Entries[:0]
for i := range hc.Entries {
e := &hc.Entries[i]
if _, drop := targets[normalizeURL(e.URL)]; drop {
continue
}
kept = append(kept, *e)
}
dropped := len(hc.Entries) - len(kept)
hc.Entries = kept
if dryRun || dropped == 0 {
return dropped, nil
}
if err := cache.SaveHealthCache(path, hc); err != nil {
return dropped, err
}
return dropped, nil
}
// reportSectionRe matches markdown health-report section headings:
//
// ## Archived (should mark :skull:)
// ## Stale (2+ years inactive)
// ## Inactive (1-2 years)
var reportSectionRe = regexp.MustCompile(`(?i)^##\s+(archived|stale|inactive|dead|healthy)\b`)
// reportEntryRe matches: "- [name](url) - Stars: N - Last push: YYYY-MM-DD"
var reportEntryRe = regexp.MustCompile(`^-\s+\[([^\]]+)\]\((https?://[^)]+)\)`)
// TargetsFromReport parses a markdown health report (same format as the
// `report` subcommand emits) and returns the URL set whose section heading
// matches one of the given statuses.
func TargetsFromReport(r io.Reader, statuses []string) (map[string]cache.HealthEntry, error) {
want := make(map[string]bool, len(statuses))
for _, s := range statuses {
want[strings.TrimSpace(strings.ToLower(s))] = true
}
out := make(map[string]cache.HealthEntry)
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
var current string
for sc.Scan() {
line := sc.Text()
if m := reportSectionRe.FindStringSubmatch(line); m != nil {
current = strings.ToLower(m[1])
continue
}
if !want[current] {
continue
}
if m := reportEntryRe.FindStringSubmatch(line); m != nil {
url := strings.TrimSpace(m[2])
out[normalizeURL(url)] = cache.HealthEntry{
URL: url,
Name: m[1],
Status: current,
}
}
}
if err := sc.Err(); err != nil {
return nil, err
}
return out, nil
}
func normalizeURL(u string) string {
u = strings.TrimSpace(u)
u = strings.TrimSuffix(u, "/")
u = strings.ToLower(u)
return u
}
func readLines(r *os.File) ([]string, error) {
var lines []string
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
lines = append(lines, sc.Text())
}
return lines, sc.Err()
}
func writeLines(path string, lines []string) error {
out, err := os.Create(path) //nolint:gosec
if err != nil {
return err
}
defer out.Close()
w := bufio.NewWriter(out)
for i, line := range lines {
if _, err := w.WriteString(line); err != nil {
return err
}
if i < len(lines)-1 {
if err := w.WriteByte('\n'); err != nil {
return err
}
}
}
if err := w.WriteByte('\n'); err != nil {
return err
}
return w.Flush()
}
+173
View File
@@ -0,0 +1,173 @@
package pruner
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/veggiemonk/awesome-docker/internal/cache"
)
func TestTargetURLs(t *testing.T) {
hc := &cache.HealthCache{Entries: []cache.HealthEntry{
{URL: "https://github.com/A/x", Status: "archived"},
{URL: "https://github.com/B/y", Status: "stale"},
{URL: "https://github.com/C/z", Status: "healthy"},
{URL: "https://github.com/D/w", Status: "inactive"},
}}
got := TargetURLs(hc, []string{"archived", "stale"})
if len(got) != 2 {
t.Fatalf("want 2 targets, got %d", len(got))
}
if _, ok := got["https://github.com/a/x"]; !ok {
t.Errorf("expected lowercased URL key for archived entry")
}
}
func TestTargetsFromReport(t *testing.T) {
r := strings.NewReader(`# Health Report
## Summary
- Stale (2+ years): 2
## Archived (should mark :skull:)
- [a/keep](https://github.com/A/Keep) - Stars: 1 - Last push: 2024-01-01
## Stale (2+ years inactive)
- [b/drop](https://github.com/b/drop) - Stars: 2 - Last push: 2020-01-01
## Inactive (1-2 years)
- [c/skip](https://github.com/c/skip) - Stars: 3 - Last push: 2025-01-01
`)
targets, err := TargetsFromReport(r, []string{"archived", "stale"})
if err != nil {
t.Fatal(err)
}
if len(targets) != 2 {
t.Fatalf("want 2, got %d: %v", len(targets), targets)
}
if _, ok := targets["https://github.com/a/keep"]; !ok {
t.Errorf("missing archived entry (case-insensitive)")
}
if _, ok := targets["https://github.com/b/drop"]; !ok {
t.Errorf("missing stale entry")
}
if _, ok := targets["https://github.com/c/skip"]; ok {
t.Errorf("inactive entry should not have been picked up")
}
}
func TestPruneREADME(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "README.md")
content := `# Header
## Tools
- [keep](https://github.com/keep/me) - Healthy project.
- [drop](https://github.com/drop/me) - Stale project.
- [also-keep](https://github.com/also/keep) - Another one.
`
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
targets := map[string]cache.HealthEntry{
"https://github.com/drop/me": {URL: "https://github.com/drop/me", Status: "stale"},
}
res, err := PruneREADME(path, targets, false)
if err != nil {
t.Fatal(err)
}
if len(res.Removed) != 1 {
t.Fatalf("want 1 removed, got %d", len(res.Removed))
}
if res.Removed[0].URL != "https://github.com/drop/me" {
t.Errorf("unexpected removed URL: %s", res.Removed[0].URL)
}
out, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(out), "drop/me") {
t.Errorf("expected drop/me to be removed from README, got:\n%s", out)
}
if !strings.Contains(string(out), "keep/me") || !strings.Contains(string(out), "also/keep") {
t.Errorf("expected other entries to be preserved, got:\n%s", out)
}
}
func TestPruneREADMEDryRun(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "README.md")
content := "## X\n\n- [drop](https://github.com/drop/me) - Stale.\n"
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
targets := map[string]cache.HealthEntry{
"https://github.com/drop/me": {URL: "https://github.com/drop/me", Status: "stale"},
}
res, err := PruneREADME(path, targets, true)
if err != nil {
t.Fatal(err)
}
if len(res.Removed) != 1 {
t.Fatalf("want 1 removed (preview), got %d", len(res.Removed))
}
got, _ := os.ReadFile(path)
if string(got) != content {
t.Errorf("dry-run modified file: %q", got)
}
}
func TestPruneREADMENotFound(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "README.md")
if err := os.WriteFile(path, []byte("## X\n\n- [k](https://github.com/k/v) - Keep.\n"), 0o644); err != nil {
t.Fatal(err)
}
targets := map[string]cache.HealthEntry{
"https://github.com/gone/missing": {URL: "https://github.com/gone/missing", Status: "stale"},
}
res, err := PruneREADME(path, targets, false)
if err != nil {
t.Fatal(err)
}
if len(res.Removed) != 0 {
t.Errorf("want 0 removed, got %d", len(res.Removed))
}
if len(res.NotFound) != 1 || res.NotFound[0] != "https://github.com/gone/missing" {
t.Errorf("want gone/missing in NotFound, got %v", res.NotFound)
}
}
func TestPruneCache(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "cache.yaml")
hc := &cache.HealthCache{Entries: []cache.HealthEntry{
{URL: "https://github.com/a/keep", Status: "healthy"},
{URL: "https://github.com/b/drop", Status: "stale"},
}}
if err := cache.SaveHealthCache(path, hc); err != nil {
t.Fatal(err)
}
targets := map[string]cache.HealthEntry{
"https://github.com/b/drop": {URL: "https://github.com/b/drop", Status: "stale"},
}
n, err := PruneCache(path, hc, targets, false)
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Errorf("want 1 dropped, got %d", n)
}
if len(hc.Entries) != 1 || hc.Entries[0].URL != "https://github.com/a/keep" {
t.Errorf("unexpected remaining entries: %v", hc.Entries)
}
}
+13 -10
View File
@@ -23,15 +23,15 @@ const (
// ScoredEntry is a repo with its computed health status.
type ScoredEntry struct {
LastPush time.Time
URL string
Name string
Status Status
Category string
Description string
Stars int
Forks int
HasLicense bool
LastPush time.Time
Category string
Description string
}
// ReportSummary contains grouped status counts.
@@ -46,10 +46,10 @@ type ReportSummary struct {
// ReportData is the full machine-readable report model.
type ReportData struct {
GeneratedAt time.Time `json:"generated_at"`
Total int `json:"total"`
Summary ReportSummary `json:"summary"`
Entries []ScoredEntry `json:"entries"`
ByStatus map[Status][]ScoredEntry `json:"by_status"`
Entries []ScoredEntry `json:"entries"`
Summary ReportSummary `json:"summary"`
Total int `json:"total"`
}
// Score computes the health status of a GitHub repo.
@@ -94,7 +94,8 @@ func ScoreAll(infos []checker.RepoInfo) []ScoredEntry {
func ToCacheEntries(scored []ScoredEntry) []cache.HealthEntry {
entries := make([]cache.HealthEntry, len(scored))
now := time.Now().UTC()
for i, s := range scored {
for i := range scored {
s := &scored[i]
entries[i] = cache.HealthEntry{
URL: s.URL,
Name: s.Name,
@@ -135,7 +136,8 @@ func GenerateReport(scored []ScoredEntry) string {
return
}
fmt.Fprintf(&b, "## %s\n\n", title)
for _, e := range entries {
for i := range entries {
e := &entries[i]
fmt.Fprintf(&b, "- [%s](%s) - Stars: %d - Last push: %s\n",
e.Name, e.URL, e.Stars, e.LastPush.Format("2006-01-02"))
}
@@ -152,8 +154,9 @@ func GenerateReport(scored []ScoredEntry) string {
// BuildReportData returns full report data for machine-readable and markdown rendering.
func BuildReportData(scored []ScoredEntry) ReportData {
groups := map[Status][]ScoredEntry{}
for _, s := range scored {
groups[s.Status] = append(groups[s.Status], s)
for i := range scored {
s := &scored[i]
groups[s.Status] = append(groups[s.Status], *s)
}
return ReportData{
+11 -4
View File
@@ -70,7 +70,13 @@ func TestGenerateReport(t *testing.T) {
results := []ScoredEntry{
{URL: "https://github.com/a/a", Name: "a/a", Status: StatusHealthy, Stars: 100, LastPush: time.Now()},
{URL: "https://github.com/b/b", Name: "b/b", Status: StatusArchived, Stars: 50, LastPush: time.Now()},
{URL: "https://github.com/c/c", Name: "c/c", Status: StatusStale, Stars: 10, LastPush: time.Now().AddDate(-3, 0, 0)},
{
URL: "https://github.com/c/c",
Name: "c/c",
Status: StatusStale,
Stars: 10,
LastPush: time.Now().AddDate(-3, 0, 0),
},
}
report := GenerateReport(results)
if !strings.Contains(report, "Healthy: 1") {
@@ -85,8 +91,9 @@ func TestGenerateReport(t *testing.T) {
}
func TestGenerateReportShowsAllEntries(t *testing.T) {
var results []ScoredEntry
for i := 0; i < 55; i++ {
const entryCount = 55
results := make([]ScoredEntry, 0, entryCount)
for i := range entryCount {
results = append(results, ScoredEntry{
URL: fmt.Sprintf("https://github.com/stale/%d", i),
Name: fmt.Sprintf("stale/%d", i),
@@ -100,7 +107,7 @@ func TestGenerateReportShowsAllEntries(t *testing.T) {
if strings.Contains(report, "... and") {
t.Fatal("report should not be truncated")
}
if !strings.Contains(report, "stale/54") {
if !strings.Contains(report, fmt.Sprintf("stale/%d", entryCount-1)) {
t.Fatal("report should contain all entries")
}
}
+43 -73
View File
@@ -1,10 +1,12 @@
package tui
import (
"context"
"fmt"
"os/exec"
"runtime"
"strings"
"time"
"unicode/utf8"
tea "charm.land/bubbletea/v2"
@@ -19,25 +21,25 @@ const (
panelList
)
const entryHeight = 5 // lines rendered per entry in the list panel
const scrollOff = 4 // minimum lines/entries kept visible above and below cursor
const (
entryHeight = 5 // lines rendered per entry in the list panel
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
filterText string
roots []*TreeNode
flatTree []FlatNode
currentEntries []cache.HealthEntry
activePanel panel
treeCursor int
treeOffset int
listCursor int
listOffset int
currentEntries []cache.HealthEntry
filtering bool
filterText string
width, height int
width int
height int
filtering bool
}
// New creates a new Model from health cache entries.
@@ -135,11 +137,13 @@ func (m *Model) applyFilter() {
query := strings.ToLower(m.filterText)
var filtered []cache.HealthEntry
for _, root := range m.roots {
for _, e := range root.AllEntries() {
entries := root.AllEntries()
for i := range entries {
e := &entries[i]
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)
filtered = append(filtered, *e)
}
}
}
@@ -176,10 +180,7 @@ func (m Model) handleTreeKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.updateCurrentEntries()
}
case "ctrl+d", "pgdown":
half := m.treePanelHeight() / 2
if half < 1 {
half = 1
}
half := max(m.treePanelHeight()/2, 1)
m.treeCursor += half
if m.treeCursor >= len(m.flatTree) {
m.treeCursor = len(m.flatTree) - 1
@@ -187,10 +188,7 @@ func (m Model) handleTreeKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.adjustTreeScroll()
m.updateCurrentEntries()
case "ctrl+u", "pgup":
half := m.treePanelHeight() / 2
if half < 1 {
half = 1
}
half := max(m.treePanelHeight()/2, 1)
m.treeCursor -= half
if m.treeCursor < 0 {
m.treeCursor = 0
@@ -233,10 +231,7 @@ func (m Model) handleTreeKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
func (m *Model) adjustTreeScroll() {
visible := m.treePanelHeight()
off := scrollOff
if off > visible/2 {
off = visible / 2
}
off := min(scrollOff, visible/2)
if m.treeCursor < m.treeOffset+off {
m.treeOffset = m.treeCursor - off
}
@@ -249,10 +244,10 @@ func (m *Model) adjustTreeScroll() {
}
func (m Model) treePanelHeight() int {
h := m.height - 6 // header, footer, borders, title
if h < 1 {
h = 1
}
h := max(
// header, footer, borders, title
m.height-6, 1,
)
return h
}
@@ -269,20 +264,14 @@ func (m Model) handleListKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.adjustListScroll()
}
case "ctrl+d", "pgdown":
half := m.visibleListEntries() / 2
if half < 1 {
half = 1
}
half := max(m.visibleListEntries()/2, 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
}
half := max(m.visibleListEntries()/2, 1)
m.listCursor -= half
if m.listCursor < 0 {
m.listCursor = 0
@@ -328,10 +317,7 @@ func (m Model) visibleListEntries() int {
func (m *Model) adjustListScroll() {
visible := m.visibleListEntries()
off := scrollOff
if off > visible/2 {
off = visible / 2
}
off := min(scrollOff, visible/2)
if m.listCursor < m.listOffset+off {
m.listOffset = m.listCursor - off
}
@@ -345,10 +331,7 @@ func (m *Model) adjustListScroll() {
func (m Model) listPanelHeight() int {
// height minus header, footer, borders
h := m.height - 4
if h < 1 {
h = 1
}
h := max(m.height-4, 1)
return h
}
@@ -406,10 +389,7 @@ func (m Model) renderTree(width, height int) string {
b.WriteString("\n\n")
linesUsed := 2
end := m.treeOffset + height - 2
if end > len(m.flatTree) {
end = len(m.flatTree)
}
end := min(m.treeOffset+height-2, len(m.flatTree))
for i := m.treeOffset; i < end; i++ {
fn := m.flatTree[i]
if linesUsed >= height {
@@ -466,16 +446,10 @@ func (m Model) renderList(width, height int) string {
linesUsed := 2
visible := (height - 2) / entryHeight
if visible < 1 {
visible = 1
}
visible := max((height-2)/entryHeight, 1)
start := m.listOffset
end := start + visible
if end > len(m.currentEntries) {
end = len(m.currentEntries)
}
end := min(start+visible, len(m.currentEntries))
for idx := start; idx < end; idx++ {
if linesUsed+entryHeight > height {
@@ -496,19 +470,16 @@ func (m Model) renderList(width, height int) string {
}
name := e.Name
statsW := lipgloss.Width(stats)
maxName := safeWidth - statsW - 2 // 2 for minimum gap
if maxName < 4 {
maxName = 4
}
maxName := max(
// 2 for minimum gap
safeWidth-statsW-2, 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
}
padding := max(safeWidth-lipgloss.Width(nameStr)-lipgloss.Width(statsStr), 1)
line1 := nameStr + strings.Repeat(" ", padding) + statsStr
// Line 2: URL
@@ -529,15 +500,12 @@ func (m Model) renderList(width, height int) string {
statusStr := statusStyle(e.Status).Render(e.Status)
lastPush := ""
if !e.LastPush.IsZero() {
lastPush = fmt.Sprintf(" Last push: %s", e.LastPush.Format("2006-01-02"))
lastPush = " Last push: " + e.LastPush.Format("2006-01-02")
}
line4 := statusStr + entryDescStyle.Render(lastPush)
// Line 5: separator
sepWidth := safeWidth
if sepWidth < 1 {
sepWidth = 1
}
sepWidth := max(safeWidth, 1)
line5 := entryDescStyle.Render(strings.Repeat("─", sepWidth))
entry := fmt.Sprintf("%s\n%s\n%s\n%s\n%s", line1, line2, line3, line4, line5)
@@ -573,14 +541,16 @@ type openURLMsg struct{ err error }
func openURL(url string) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", url)
cmd = exec.CommandContext(ctx, "open", url)
case "windows":
cmd = exec.Command("cmd", "/c", "start", url)
cmd = exec.CommandContext(ctx, "cmd", "/c", "start", url)
default:
cmd = exec.Command("xdg-open", url)
cmd = exec.CommandContext(ctx, "xdg-open", url)
}
return openURLMsg{err: cmd.Run()}
}
+5 -2
View File
@@ -13,8 +13,11 @@ var (
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"))
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"))
+6 -5
View File
@@ -9,11 +9,11 @@ import (
// 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")
Name string
Path string
Children []*TreeNode
Expanded bool
Entries []cache.HealthEntry
Expanded bool
}
// FlatNode is a visible tree node with its indentation depth.
@@ -51,14 +51,15 @@ func BuildTree(entries []cache.HealthEntry) []*TreeNode {
root := &TreeNode{Name: "root"}
nodeMap := map[string]*TreeNode{}
for _, e := range entries {
for i := range entries {
e := &entries[i]
cat := e.Category
if cat == "" {
cat = "Uncategorized"
}
node := ensureNode(root, nodeMap, cat)
node.Entries = append(node.Entries, e)
node.Entries = append(node.Entries, *e)
}
// Sort children at every level
+6 -1
View File
@@ -11,7 +11,12 @@ func TestBuildTree(t *testing.T) {
{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/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"},
}