git/orchard/main
tui/internal/ui/detail_modal.go
359 lines9 KB last change raw · history
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")
}