package ui
import (
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/ansi"
"updater-tui/internal/plan"
)
// detailTabs are the four questions you can ask about a package. They are tabs
// rather than separate keys because they are the same question at different
// depths, and switching between them is the whole point of drilling down.
var detailTabs = []string{"DIFF", "AUDIT", "SECURITY", "INFO"}
// detailMsg carries a fetched payload back into the update loop.
type detailMsg struct {
pkg string
detail *plan.Detail
err error
}
// detailModal is the drill-down overlay.
type detailModal struct {
pkg string
detail *plan.Detail
loading bool
err error
tab int
scroll int
lines []string
built bool
width int
}
func newDetail(pkg string, width int) *detailModal {
return &detailModal{pkg: pkg, loading: true, width: width}
}
func (d *detailModal) Update(msg tea.Msg) (modal, tea.Cmd) {
switch msg := msg.(type) {
case detailMsg:
if msg.pkg != d.pkg {
return d, nil // a stale fetch for a package we have moved on from
}
d.loading = false
d.detail, d.err = msg.detail, msg.err
d.built = false
return d, nil
case tea.KeyMsg:
switch msg.String() {
case "q", "esc", "ctrl+c":
return nil, nil
case "tab", "l", "right":
d.tab = (d.tab + 1) % len(detailTabs)
d.scroll, d.built = 0, false
case "shift+tab", "h", "left":
d.tab--
if d.tab < 0 {
d.tab = len(detailTabs) - 1
}
d.scroll, d.built = 0, false
case "j", "down":
d.scroll++
case "k", "up":
d.scroll--
case "ctrl+d", "pgdown":
d.scroll += 10
case "ctrl+u", "pgup":
d.scroll -= 10
case "g", "home":
d.scroll = 0
case "G", "end":
d.scroll = 1 << 30
}
}
return d, nil
}
func (d *detailModal) View(width, height int) string {
var b strings.Builder
title := Title.Render(d.pkg)
if d.detail != nil {
// A version that goes backwards is not an upgrade, and the arrow alone
// does not say so. The list marks it; the title must too.
mark := ""
if d.detail.Downgrade {
mark = Outgoing.Render("D") + " "
}
title += " " + mark + Muted.Render(fmt.Sprintf("%s → %s",
d.detail.Installed, d.detail.Available))
}
b.WriteString(ansi.Truncate(title, max(8, innerWidth(width)), "…"))
b.WriteString("\n\n")
if d.loading {
b.WriteString(Muted.Render("loading…"))
return place(Box.Render(b.String()), width, height)
}
if d.err != nil {
b.WriteString(Err.Render("could not load detail") + "\n\n" +
Muted.Render(d.err.Error()))
return place(Box.Render(b.String()), width, height)
}
b.WriteString(tabBar(detailTabs, d.tab))
b.WriteString("\n\n")
inner := innerWidth(width)
rows := height - 10
if rows < 3 {
rows = 3
}
if !d.built {
d.lines = d.build(d.detail, inner)
d.built = true
}
total := len(d.lines)
if d.scroll > total-rows {
d.scroll = total - rows
}
if d.scroll < 0 {
d.scroll = 0
}
end := min(total, d.scroll+rows)
for _, line := range d.lines[d.scroll:end] {
b.WriteString(ansi.Truncate(line, inner, "…") + "\n")
}
for i := end - d.scroll; i < rows; i++ {
b.WriteString("\n")
}
pos := fmt.Sprintf("[%d/%d]", d.scroll+1, max(1, total))
hint := "[tab] section • [j/k] scroll • [esc] close"
gap := inner - ansi.StringWidth(hint) - len(pos)
if gap < 1 {
gap = 1
}
b.WriteString("\n" + Desc.Render(hint) + strings.Repeat(" ", gap) + Muted.Render(pos))
return place(Box.Render(b.String()), width, height)
}
// innerWidth is the usable interior of a modal box, once the border and padding
// are accounted for.
func innerWidth(width int) int { return max(20, width-8) }
func (d *detailModal) build(detail *plan.Detail, width int) []string {
switch d.tab {
case 0:
return d.buildDiff(detail, width)
case 1:
return d.buildAudit(detail, width)
case 2:
return d.buildSecurity(detail, width)
default:
return d.buildInfo(detail, width)
}
}
func (d *detailModal) buildDiff(detail *plan.Detail, width int) []string {
var out []string
// BaselineNote is set by the backend and says which revision was used, or
// why none was. Never invent a reason here: "recorded after the first build"
// was a guess, and it was wrong for a package paru simply has not downloaded.
if detail.BaselineNote != "" {
out = append(out, Muted.Render(detail.BaselineNote))
} else {
out = append(out, Muted.Render(detail.BaselineLabel()))
}
diff := detail.Diff
if len(diff.Lines) == 0 {
if len(detail.Pkgbuild) > 0 {
out = append(out, "", Warn.Render("no diff — showing the current PKGBUILD"), "")
for i, text := range detail.Pkgbuild {
out = append(out, Muted.Render(fmt.Sprintf("%4d ", i+1))+Ver.Render(text))
}
return out
}
out = append(out, "", Muted.Render("nothing to diff"),
Muted.Render("and no PKGBUILD could be fetched either"))
return out
}
out = append(out, "",
Incoming.Render(fmt.Sprintf("+%d", diff.Added))+" "+
Err.Render(fmt.Sprintf("-%d", diff.Removed))+
Muted.Render(fmt.Sprintf(" %d lines", len(diff.Lines))), "")
if diff.Truncated {
out = append(out, Warn.Render("truncated — the full diff is larger"), "")
}
for _, line := range diff.Lines {
if line.Kind == "gap" {
out = append(out, Muted.Render(" ⋮"))
continue
}
oldNo, newNo := " ", " "
if line.Old != nil {
oldNo = fmt.Sprintf("%4d", *line.Old)
}
if line.New != nil {
newNo = fmt.Sprintf("%4d", *line.New)
}
marker, style := " ", Muted
switch line.Kind {
case "add":
marker, style = "+", Incoming
case "del":
marker, style = "-", Outgoing
}
text := ansi.Truncate(line.Text, max(10, width-18), "…")
out = append(out, Muted.Render(oldNo+" "+newNo)+" "+style.Render(marker+" "+text))
}
return out
}
func (d *detailModal) buildAudit(detail *plan.Detail, width int) []string {
if len(detail.Findings) == 0 {
return []string{
Incoming.Render("nothing unusual"),
"",
Muted.Render("no patterns were newly introduced in this revision"),
Muted.Render("compared with: " + detail.BaselineLabel()),
}
}
var out []string
out = append(out, Title.Render(fmt.Sprintf("%d finding(s)", len(detail.Findings))), "")
for _, f := range detail.Findings {
style := role(f.Role())
out = append(out, style.Render(f.Glyph()+" "+f.Title))
if f.Evidence != "" {
out = append(out, " "+Ver.Render(ansi.Truncate(f.Evidence, max(10, width-6), "…")))
}
where := f.Rule
if f.Where != "" {
where += " in " + f.Where
}
out = append(out, " "+Muted.Render(ansi.Truncate(where,
max(4, width-4), "…")), "")
}
return out
}
func (d *detailModal) buildSecurity(detail *plan.Detail, width int) []string {
if len(detail.Advisories) == 0 {
return []string{Incoming.Render("no advisories affect this package")}
}
var out []string
for _, a := range detail.Advisories {
style := OK
if a.HasFix() {
style = Warn
}
head := style.Render(a.Severity)
if a.HasFix() {
head += " " + Incoming.Render("fixed in "+a.Fixed)
} else {
head += " " + Muted.Render("no fix available")
}
out = append(out, ansi.Truncate(head, width, "…"),
ansi.Truncate(" "+Title.Render(a.ID)+" "+Muted.Render(a.Kind),
width, "…"))
for i, issue := range a.Issues {
if i >= 12 {
out = append(out, " "+Muted.Render(
fmt.Sprintf("+ %d more", len(a.Issues)-12)))
break
}
out = append(out, " "+Ver.Render(issue))
}
out = append(out, " "+Muted.Render(a.URL), "")
}
return out
}
func (d *detailModal) buildInfo(detail *plan.Detail, width int) []string {
field := func(label, value string) string {
if value == "" {
value = "—"
}
return Muted.Render(fmt.Sprintf("%-14s", label)) + Text.Render(
ansi.Truncate(value, max(10, width-16), "…"))
}
var out []string
out = append(out,
field("package", detail.Package),
field("kind", detail.Kind),
field("installed", detail.Installed),
field("available", detail.Available),
"",
field("baseline", detail.BaselineLabel()),
)
if detail.CurrentCommit != "" {
out = append(out,
field("aur head", shortSHA(detail.CurrentCommit)+" "+detail.CurrentCommitSummary),
field("committed", formatWhen(detail.CurrentCommitWhen)))
}
m := detail.Meta
if m.Maintainer != "" || m.Votes > 0 || m.URL != "" {
out = append(out, "")
maintainer := m.Maintainer
if m.Orphaned || maintainer == "" {
maintainer = "ORPHANED"
}
out = append(out, field("maintainer", maintainer))
if m.Votes > 0 {
out = append(out, field("votes", fmt.Sprintf("%d (popularity %.2f)",
m.Votes, m.Popularity)))
}
out = append(out, field("upstream", m.URL))
}
if len(m.Depends) > 0 {
out = append(out, "", Muted.Render("depends"))
out = append(out, ansi.Truncate(" "+Ver.Render(strings.Join(m.Depends, ", ")),
width, "…"))
}
if len(m.MakeDepends) > 0 {
out = append(out, "", Muted.Render("make depends"))
out = append(out, ansi.Truncate(
" "+Ver.Render(strings.Join(m.MakeDepends, ", ")), width, "…"))
}
deps := planDepends(detail)
if deps != "" {
out = append(out, "", Muted.Render("in-set dependencies"), " "+Text.Render(deps))
}
return out
}
// planDepends is left as a hook: the plan carries the graph, the detail does not.
func planDepends(detail *plan.Detail) string {
_ = detail
return ""
}
func shortSHA(sha string) string {
if len(sha) > 8 {
return sha[:8]
}
return sha
}
func formatWhen(stamp string) string {
if stamp == "" {
return ""
}
t, err := time.Parse(time.RFC3339, stamp)
if err != nil {
return stamp
}
return t.Format("2006-01-02")
}