package ui
import (
"fmt"
"regexp"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"updater-tui/internal/plan"
)
// Columns mirrors the Python renderer's fixed grid.
//
// The look the Python side has is mostly this: one grid, the same column
// positions on every line, and a right-aligned value hanging off the edge. It is
// worth reproducing exactly rather than approximating, because the eye reads the
// alignment before it reads the content.
type Columns struct {
Width int
Indent int
RightMargin int
}
// minGrid is the narrowest width the column maths will work against.
//
// It is a floor on the *grid*, not on the terminal: inflating Width itself made
// every row render wider than a narrow terminal, so each line wrapped and the
// whole screen looked broken. Keep the floor internal to the column positions.
const minGrid = 64
// NewColumns builds the grid for a terminal width.
func NewColumns(width int) Columns {
return Columns{Width: max(width, 20), Indent: 2, RightMargin: 2}
}
// grid is the width used for placing columns, with a floor so that verCol and
// noteCol do not collapse into each other on a very narrow terminal.
func (c Columns) grid() int { return max(c.Width, minGrid) }
// Right is the column at which right-aligned content ends.
func (c Columns) Right() int { return c.Width - c.RightMargin }
// verCol is where the incoming version starts, never past the real edge.
func (c Columns) verCol() int {
return min(c.Indent+int(float64(c.grid()-c.Indent)*0.55), c.Width-6)
}
// noteCol is where a trailing annotation starts, never past the real edge.
func (c Columns) noteCol() int {
return min(c.Indent+int(float64(c.grid()-c.Indent)*0.76), c.Width-2)
}
// Kind determines how a node draws.
type Kind int
const (
KindSection Kind = iota
KindSpacer
KindPackage
KindFinding
KindCheck
KindStep
KindAdvisory
)
// Node is one row. It carries structured fields rather than a pre-rendered
// string, so the grid can align it, the cursor can highlight the full width, and
// truncation never leaves stray escape sequences behind.
type Node struct {
Kind Kind
Title string // sections
Meta string // sections: right-aligned summary
Tag string // packages: U / D / ! / !!
TagRole string
Name string
Old, New string
Note string
NoteRole string
Right string // right-aligned value, e.g. a size
ShowArrow bool
Glyph string // findings, checks
GlyphRole string
Text string
Evidence string
Index string // plan steps
Command string
//: which section a package belongs to, so decisions can tell a repo package
//: from an AUR one without threading the plan through every call
Section string
//: per-run exclusion and permanent holds, shown on the row
Skipped bool
Ignored bool
//: the version already installed
Installed string
//: a check that can be run again, named by this
Action string
//: the command that resolves this row, and what to say before running it
Run []string
Warning string
//: column width for a plan step's command, shared by the whole section
Width int
//: picked with `s`, and whether any picking is happening at all
Soloed bool
SoloActive bool
//: the ordered commands a plan step will run
Queue []plan.Stage
Detail []string
Children []*Node
Expanded bool
depth int
}
// HasChildren reports whether the node can be expanded.
func (n *Node) HasChildren() bool { return len(n.Children) > 0 }
// Toggle expands or collapses the node.
func (n *Node) Toggle() {
if n.HasChildren() {
n.Expanded = !n.Expanded
}
}
// Flatten returns the visible nodes in render order, with depths assigned.
func Flatten(nodes []*Node) []*Node {
var out []*Node
var walk func(list []*Node, depth int)
walk = func(list []*Node, depth int) {
for _, n := range list {
n.depth = depth
out = append(out, n)
if n.HasChildren() && n.Expanded {
walk(n.Children, depth+1)
}
}
}
walk(nodes, 0)
return out
}
// -- helpers --------------------------------------------------------------
func humanBytes(n int64) string {
neg := n < 0
if neg {
n = -n
}
units := []string{"B", "KiB", "MiB", "GiB", "TiB"}
v := float64(n)
i := 0
for v >= 1024 && i < len(units)-1 {
v /= 1024
i++
}
sign := ""
if neg {
sign = "-"
}
if i == 0 {
return fmt.Sprintf("%s%d B", sign, n)
}
return fmt.Sprintf("%s%.1f %s", sign, v, units[i])
}
func sizeDelta(n int64) string {
switch {
case n > 0:
return "+" + humanBytes(n)
case n < 0:
return "-" + humanBytes(-n)
default:
return "0 B"
}
}
// spread right-aligns `right` within a width-wide line, clamping both sides so
// the result can never exceed the width.
//
// Padding without clamping is how a narrow terminal ends up wrapping every row:
// a long section meta or a long package name pushed the line past the edge.
// The right side wins, because it carries the version and the size; the left is
// truncated to make room for it.
func spread(left, right string, width int) string {
if width <= 0 {
return ""
}
rightW := ansi.StringWidth(right)
if rightW > width-1 {
right = ansi.Truncate(right, max(0, width-1), "…")
rightW = ansi.StringWidth(right)
}
room := width - rightW - 1
if room < 0 {
room = 0
}
if room == 0 {
left = ""
} else if ansi.StringWidth(left) > room {
left = ansi.Truncate(left, room, "…")
}
gap := width - ansi.StringWidth(left) - rightW
if gap < 1 {
gap = 1
}
return left + strings.Repeat(" ", gap) + right
}
func role(name string) lipgloss.Style {
switch name {
case "accent":
return Accent
case "ok":
return OK
case "warn":
return Warn
case "alert":
return Alert
case "err":
return Err
case "old":
return Outgoing
case "new":
return Incoming
case "ver":
return Ver
case "muted":
return Muted
case "title":
return Title
default:
return Text
}
}
// -- building -------------------------------------------------------------
func section(title, meta string) *Node {
return &Node{Kind: KindSection, Title: title, Meta: meta, Expanded: true}
}
func pkgDetail(name, repo, old, new string, download, delta int64, note string) []string {
lines := []string{
Title.Render(name),
"",
" " + Muted.Render("version ") + Outgoing.Render(old) +
Muted.Render(" → ") + Incoming.Render(new),
}
if repo != "" {
lines = append(lines, " "+Muted.Render("repo ")+Ver.Render(repo))
}
if download > 0 {
lines = append(lines, " "+Muted.Render("download ")+Text.Render(humanBytes(download)))
}
if delta != 0 {
style := Incoming
if delta > 0 {
style = Warn
}
lines = append(lines, " "+Muted.Render("on disk ")+style.Render(sizeDelta(delta)))
}
if note != "" {
lines = append(lines, "", " "+Warn.Render(note))
}
return lines
}
func severityRole(severity string) string {
switch severity {
case "BLOCK":
return "alert"
case "LOOK":
return "warn"
default:
return "muted"
}
}
// advisoryInScope reports whether any of an advisory's packages will be applied.
func advisoryInScope(a plan.Advisory, keep func(string) bool) bool {
if len(a.Packages) == 0 {
return true // nothing to scope it by
}
for _, name := range a.Packages {
if keep(name) {
return true
}
}
return false
}
// actionFor marks a check whose detail is worth opening.
func actionFor(c plan.Check) string {
if len(c.Details) > 0 {
return "detail"
}
return ""
}
// advisoryRole colours an advisory by how serious it is.
//
// Fixability has its own channel -- the "fixed in X" or "no fix available"
// text beside it -- and colouring the severity label by that instead made
// Critical and Low the same shade, which is the one distinction a security
// list exists to make.
func advisoryRole(severity string) string {
switch strings.ToLower(severity) {
case "critical", "high":
return "alert"
case "medium":
return "warn"
case "low":
return "accent"
default:
return "muted"
}
}
// advisoryNode renders one security advisory, drillable for its CVE list.
func advisoryNode(a plan.Advisory) *Node {
r := advisoryRole(a.Severity)
fix := "no fix available"
if a.HasFix() {
fix = "fixed in " + a.Fixed
}
detail := []string{
Title.Render(a.ID),
"",
" " + role(r).Render(a.Severity) + " " + Muted.Render(fix),
" " + Muted.Render("affects "+strings.Join(a.Packages, ", ")),
"",
}
for i, issue := range a.Issues {
if i >= 20 {
detail = append(detail, " "+Muted.Render(
fmt.Sprintf("+ %d more", len(a.Issues)-20)))
break
}
detail = append(detail, " "+Ver.Render(issue))
}
detail = append(detail, "", " "+Muted.Render(a.URL))
return &Node{
Kind: KindAdvisory, Name: a.ID, GlyphRole: r,
Tag: a.Severity, TagRole: r,
Title: strings.Join(a.Packages, ", "),
Evidence: fix,
Note: fmt.Sprintf("%d CVE(s)", len(a.Issues)), NoteRole: "ver",
Detail: detail,
}
}
func findingNode(f plan.Finding) *Node {
r := severityRole(f.Severity)
return &Node{
Kind: KindFinding, Glyph: f.Glyph(), GlyphRole: r,
Text: f.Title, Evidence: f.Evidence,
Detail: []string{
role(r).Render(f.Glyph() + " " + f.Title),
"", " " + Ver.Render(f.Evidence),
"", " " + Muted.Render("rule: "+f.Rule),
},
}
}
// BuildTree turns a plan into the scrollable column.
func BuildTree(p *plan.Plan, audits map[string][]plan.Finding, threshold int,
keep func(string) bool) []*Node {
if threshold <= 0 {
threshold = 8
}
if keep == nil {
keep = func(string) bool { return true }
}
var roots []*Node
if len(p.Repo) > 0 {
var download, delta int64
for _, u := range p.Repo {
download += u.Download
delta += u.Delta
}
sec := section("Repository", fmt.Sprintf("%d packages · %s to download · %s on disk",
len(p.Repo), humanBytes(download), sizeDelta(delta)))
for _, u := range p.Repo {
sec.Children = append(sec.Children, &Node{
Kind: KindPackage, Section: "Repository",
Tag: "U", TagRole: "accent", ShowArrow: true,
Ignored: u.Ignored,
Name: u.Name, Old: u.Old, New: u.New, Right: sizeDelta(u.Delta),
Detail: pkgDetail(u.Name, u.Repo, u.Old, u.New, u.Download, u.Delta, ""),
})
}
roots = append(roots, sec)
}
if len(p.Aur) > 0 {
blocked := 0
for _, a := range p.Aur {
if a.Tag == "!!" {
blocked++
}
}
meta := fmt.Sprintf("%d packages", len(p.Aur)+len(p.Vcs))
if len(p.Vcs) > 0 {
meta += fmt.Sprintf(" · %d devel rebuild", len(p.Vcs))
}
if blocked > 0 {
meta += fmt.Sprintf(" · %d need attention", blocked)
}
sec := section("AUR", meta)
for _, a := range p.Aur {
r := "accent"
switch a.Tag {
case "!!":
r = "alert"
case "!":
r = "warn"
case "D":
r = "ver"
}
noteRole := r
if !a.WasAudited() {
// make it obvious, in a colour that reads as "look at me",
// rather than letting an unexamined package look clean
r = "warn"
noteRole = "warn"
}
node := &Node{
Kind: KindPackage, Section: "AUR",
Tag: a.Tag, TagRole: r, ShowArrow: true, Ignored: a.Ignored,
Name: a.Name, Old: a.Old, New: a.New,
Note: a.Note, NoteRole: noteRole,
Detail: pkgDetail(a.Name, "", a.Old, a.New, 0, 0, a.Note),
}
for _, f := range audits[a.Name] {
node.Children = append(node.Children, findingNode(f))
}
sec.Children = append(sec.Children, node)
}
// VCS packages belong with the rest of the AUR: they are the same kind
// of thing, updated under the same rules, and a separate section implied
// they could not be acted on.
for _, v := range p.Vcs {
sec.Children = append(sec.Children, &Node{
Kind: KindPackage, Section: "AUR",
Name: v.Name,
Tag: "V", TagRole: "warn", ShowArrow: true,
Old: v.Old, New: v.New,
Note: "devel rebuild",
Detail: []string{
Title.Render(v.Name), "",
" " + Muted.Render("installed ") + Ver.Render(v.Old),
" " + Muted.Render("would be ") + Ver.Render(v.New),
" " + Muted.Render("source ") + Text.Render("git"),
"",
" " + Muted.Render("paru rebuilds a devel package when upstream"),
" " + Muted.Render("has moved. A version comparison cannot tell"),
" " + Muted.Render("you that: the AUR's recorded version lags"),
" " + Muted.Render("whatever was built last time."),
},
})
}
roots = append(roots, sec)
}
if len(p.Checks) > 0 {
sec := section("Checks", fmt.Sprintf("%d item(s)", len(p.Checks)))
for _, c := range p.Checks {
glyph, r := "✓", "ok"
if !c.OK {
glyph, r = "!", "warn"
}
// The row already says the label, and the overlay is titled with
// it. Repeating both at the top of the body was noise on every
// check that had anything to say.
sec.Children = append(sec.Children, &Node{
Kind: KindCheck, Glyph: glyph, GlyphRole: r,
Text: c.Label, Evidence: c.Detail,
Detail: c.Details, Action: actionFor(c),
Run: c.Action, Warning: c.Warning,
})
}
roots = append(roots, sec)
}
if len(p.Flatpak) > 0 {
sec := section("Flatpak", fmt.Sprintf("%d update(s)", len(p.Flatpak)))
for _, f := range p.Flatpak {
sec.Children = append(sec.Children, &Node{
Kind: KindPackage, Tag: "U", TagRole: "accent", ShowArrow: true,
Name: f.Application, New: f.Version, Right: f.Origin,
})
}
roots = append(roots, sec)
}
if len(p.Advisories) > 0 || p.AckedCount > 0 {
fixable := 0
for _, a := range p.Advisories {
if a.HasFix() {
fixable++
}
}
meta := fmt.Sprintf("%d advisories · %d fixable", len(p.Advisories), fixable)
if p.AckedCount > 0 {
meta += fmt.Sprintf(" · %d acknowledged", p.AckedCount)
}
// An advisory about a package that is not being applied is not this
// run's problem, and listing it beside the ones that are buries them.
relevant := make([]plan.Advisory, 0, len(p.Advisories))
for _, a := range p.Advisories {
if advisoryInScope(a, keep) {
relevant = append(relevant, a)
}
}
if len(relevant) == 0 {
sec := section("Security", "nothing affects the packages selected")
sec.Children = append(sec.Children, &Node{
Kind: KindCheck, Glyph: "✓", GlyphRole: "ok",
Text: "in scope", Evidence: fmt.Sprintf(
"none of the %d advisories affect what will be applied",
len(p.Advisories)),
})
roots = append(roots, sec)
} else {
meta = fmt.Sprintf("%d advisories", len(relevant))
fixable := 0
for _, a := range relevant {
if a.HasFix() {
fixable++
}
}
meta += fmt.Sprintf(" · %d fixable", fixable)
if p.AckedCount > 0 {
meta += fmt.Sprintf(" · %d acknowledged", p.AckedCount)
}
sec := section("Security", meta)
for _, a := range relevant {
sec.Children = append(sec.Children, advisoryNode(a))
}
roots = append(roots, sec)
}
}
if len(p.Steps) > 0 {
sec := section("Plan", "")
// Wide enough for the longest command in the list. A fixed width let
// `paru -S --needed` run into its own explanation.
commandWidth := 14
for _, step := range p.Steps {
if n := len(step.Command) + 2; n > commandWidth {
commandWidth = n
}
}
if commandWidth > 26 {
commandWidth = 26
}
for _, step := range p.Steps {
sec.Children = append(sec.Children, &Node{
Kind: KindStep, Index: step.Index, Command: step.Command,
Evidence: step.Detail, Detail: step.Details,
Width: commandWidth, Queue: step.Queue,
})
}
roots = append(roots, sec)
}
// a blank line before each heading. The eye needs the break more than it
// needs the extra row of content.
spaced := make([]*Node, 0, len(roots)*2)
for i, r := range roots {
if i > 0 {
spaced = append(spaced, &Node{Kind: KindSpacer})
}
spaced = append(spaced, r)
}
return spaced
}
// Selectable reports whether the cursor may rest on a node.
func (n *Node) Selectable() bool { return n.Kind != KindSpacer }
// -- rendering ------------------------------------------------------------
// Render draws a node as one full-width line on the shared grid.
func (n *Node) Render(cols Columns, selected bool) string {
line := ansi.Truncate(n.render(cols), cols.Width, "")
return n.decorate(line, cols, selected)
}
func (n *Node) decorate(line string, cols Columns, selected bool) string {
line = tidy(ansi.Truncate(line, cols.Width, "…"))
if selected {
if pad := cols.Width - ansi.StringWidth(line); pad > 0 {
line += strings.Repeat(" ", pad)
}
return Selected.Render(line)
}
return line
}
func (n *Node) render(cols Columns) string {
switch n.Kind {
case KindSpacer:
return ""
case KindSection:
marker := " "
if n.HasChildren() {
if n.Expanded {
marker = "▾ "
} else {
marker = "▸ "
}
}
left := strings.Repeat(" ", cols.Indent) + marker +
Title.Render(strings.ToUpper(n.Title))
if n.Meta != "" {
return spread(left, Muted.Render(n.Meta), cols.Width)
}
return left
case KindPackage:
var b strings.Builder
b.WriteString(strings.Repeat(" ", cols.Indent))
// A decided package shows its state in the tag column instead of its
// upgrade tag. Changing the row's *shape* is what makes a decision
// visible at a glance; dimming alone was too easy to miss.
tag, tagRole := n.Tag, n.TagRole
switch {
case n.Ignored:
tag, tagRole = "∅", "muted"
case n.Soloed:
// picked: it will be applied, and when solo is on that is the
// exception worth marking rather than the rule
tag, tagRole = "◆", "ok"
case n.Skipped && n.SoloActive:
// held only because something else was picked, which reads
// differently from being skipped on purpose
tag, tagRole = "·", "muted"
case n.Skipped:
tag, tagRole = "⊘", "warn"
}
b.WriteString(role(tagRole).Render(tag))
b.WriteString(" ")
nameEnd := cols.verCol() - 6
nameStyle := Text
if n.Ignored || (n.Skipped && !n.Soloed) {
nameStyle = Muted
}
b.WriteString(nameStyle.Render(ansi.Truncate(n.Name,
max(8, nameEnd-ansi.StringWidth(b.String())), "…")))
left := b.String()
switch {
case n.Old != "" || n.New != "":
// the outgoing version is right-aligned so the arrows line up across
// rows of differing version lengths; that alignment is most of the look
if n.Old != "" {
left = spread(left, Outgoing.Render(n.Old), cols.verCol()-5)
}
if n.ShowArrow {
left += Muted.Render(" → ")
}
left += Incoming.Render(n.New)
case n.Installed != "":
// a VCS package has one version, not a pair
left = spread(left, Muted.Render(n.Installed), cols.verCol())
}
note, noteRole := n.Note, n.NoteRole
// The decision outranks any other annotation, and the precedence here
// must match the tag glyph above: an ignore is the stronger, permanent
// decision, so it wins in both places.
switch {
case n.Ignored:
note, noteRole = "ignored", "muted"
case n.Skipped:
note, noteRole = "skipped", "warn"
}
if note != "" && cols.noteCol() > ansi.StringWidth(left)+4 {
text := ansi.Truncate(note, cols.noteCol()-ansi.StringWidth(left)-2, "…")
left = spread(left, role(noteRole).Render(text), cols.noteCol())
}
if n.Right != "" {
left = spread(left, Muted.Render(n.Right), cols.Width)
}
return left
case KindFinding:
var b strings.Builder
b.WriteString(strings.Repeat(" ", cols.Indent+4))
b.WriteString(" ")
b.WriteString(role(n.GlyphRole).Render(n.Glyph))
b.WriteString(" ")
b.WriteString(Muted.Render(ansi.Truncate(n.Text,
max(10, cols.verCol()-ansi.StringWidth(b.String())-2), "…")))
if n.Evidence != "" {
// The evidence sits next to its own text rather than spread to the
// far edge, which on a wide screen puts it a hand's width away from
// the finding it belongs to. It is dropped rather than squeezed
// when there is no room.
const gap = 2
room := cols.Right() - ansi.StringWidth(b.String()) - gap
if room >= 16 {
b.WriteString(strings.Repeat(" ", gap))
b.WriteString(Desc.Render(ansi.Truncate(n.Evidence, room, "…")))
}
}
return b.String()
case KindAdvisory:
// severity, id, the affected packages and how many CVEs: enough to
// decide whether to open it, which is what the row is for
var b strings.Builder
b.WriteString(strings.Repeat(" ", cols.Indent))
b.WriteString(role(n.TagRole).Render(fmt.Sprintf("%-13s", n.Tag)))
b.WriteString(role(n.TagRole).Render(fmt.Sprintf("%-11s", n.Name)))
left := b.String()
left += Text.Render(ansi.Truncate(n.Title,
max(8, cols.Right()-ansi.StringWidth(left)-12), "…"))
if n.Note != "" {
left = spread(left, Muted.Render(n.Note), cols.Right())
}
return left
case KindCheck:
var b strings.Builder
b.WriteString(strings.Repeat(" ", cols.Indent))
b.WriteString(role(n.GlyphRole).Render(n.Glyph))
b.WriteString(" ")
b.WriteString(Text.Render(fmt.Sprintf("%-12s", n.Text)))
b.WriteString(Muted.Render(ansi.Truncate(n.Evidence,
max(4, cols.Right()-ansi.StringWidth(b.String())), "…")))
return b.String()
case KindStep:
var b strings.Builder
b.WriteString(strings.Repeat(" ", cols.Indent))
b.WriteString(Muted.Render(n.Index))
b.WriteString(" ")
b.WriteString(Accent.Render(fmt.Sprintf("%-*s", n.Width, n.Command)))
b.WriteString(Muted.Render(ansi.Truncate(n.Evidence,
max(4, cols.Right()-ansi.StringWidth(b.String())), "…")))
return b.String()
}
return ""
}
// escRe matches a colour sequence immediately followed by a reset: a segment
// that ended up with no text in it. Truncating a styled label can cut the text
// and leave the escapes behind, which renders as nothing but still moves the
// cursor in some terminals.
var escRe = regexp.MustCompile(`\x1b\[[0-9;]*m\x1b\[0m`)
func tidy(s string) string {
for {
next := escRe.ReplaceAllString(s, "")
if next == s {
return s
}
s = next
}
}