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