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 } }