package ui
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"updater-tui/internal/config"
"updater-tui/internal/plan"
)
// sudoMsg reports whether the credentials were cached.
type sudoMsg struct{ err error }
// refreshMsg asks for the local checks to be re-read after a command ran.
type refreshMsg struct{}
// checksMsg carries a refreshed set of machine-local checks.
type checksMsg struct {
covers []string
checks []plan.Check
err error
}
// loadedMsg carries the backend's plan back into the update loop.
type loadedMsg struct {
plan *plan.Plan
err error
}
// copiedMsg reports a clipboard write so the status line can confirm it.
type copiedMsg struct{ what string }
// streamEventMsg carries one protocol event in from the backend.
type streamEventMsg struct{ event plan.Event }
// streamErrorMsg reports a line the reader could not parse.
type streamErrorMsg struct{ err error }
// streamClosedMsg means the backend has exited.
type streamClosedMsg struct{}
// waitForEvent reads the next thing the backend says.
//
// Both channels are watched in one select so a protocol error cannot sit
// unread, but once the error channel closes we must not keep selecting on it:
// a closed channel is always ready and would spin.
func waitForEvent(s *plan.Stream) tea.Cmd {
return func() tea.Msg {
select {
case event, ok := <-s.Events:
if !ok {
return streamClosedMsg{}
}
return streamEventMsg{event: event}
case err, ok := <-s.ProtocolErrors:
if !ok {
event, stillOpen := <-s.Events
if !stillOpen {
return streamClosedMsg{}
}
return streamEventMsg{event: event}
}
return streamErrorMsg{err: err}
}
}
}
// undoKind distinguishes the two decisions that can be reversed.
type undoKind int
const (
undoSkip undoKind = iota
undoIgnore
)
type undoEntry struct {
kind undoKind
name string
//: which map the decision wrote to, and what it held before. A toggle
//: leaves the key present either way, so undo has to be told the prior
//: state rather than inferring it -- inferring left "skip then unskip"
//: unable to put the skip back.
repo bool
wasOn bool
}
// Model is the whole application state.
type Model struct {
backend plan.Backend
cfg *config.Config
doc *plan.Plan
roots []*Node
all []*Node
visible []*Node
cursor int
offset int
width int
height int
loading bool
//: when the current wait began, so the screen can say how long it has been
loadingAt time.Time
err error
hint string
//: decisions. Skip and hold are per-run and reversible; ignore is written to
//: the config and survives restarts.
skipped map[string]bool
held map[string]bool
//: packages explicitly picked with `s`. While any are picked, only those
//: are applied and everything else is held back -- the inverse of skipping
//: one at a time, for "I want these two and nothing else".
solo map[string]bool
undo []undoEntry
//: cancels the apply's context, called when the run ends so nothing is
//: left registered against context.Background()
cancelApply context.CancelFunc
filter string
filtering bool
modal modal
//: set while a confirmation is for clearing acknowledgements rather than
//: applying, so one modal can serve both
pendingClear bool
//: likewise for marking the news as read
pendingNews bool
//: and for running a check's suggested command
pendingCommand []string
//: set when a confirmation is waiting on sudo credentials
pendingApply bool
//: whether sudo has been satisfied this session
sudoReady bool
status string
statusAt time.Time
//: animation frame for the startup spinner
spin int
//: apply mode
stream *plan.Stream
build *BuildView
abortArmed bool
}
// New builds a model that will load through the given backend.
func New(backend plan.Backend, cfg *config.Config) Model {
if cfg == nil {
cfg = config.Load()
}
return Model{
backend: backend, cfg: cfg,
skipped: map[string]bool{}, held: map[string]bool{}, solo: map[string]bool{},
loading: true, loadingAt: time.Now(), width: 100, height: 30,
}
}
// Init kicks off the first load.
func (m Model) Init() tea.Cmd { return tea.Batch(m.load(), spinnerTick()) }
// SetHint records how to fix a missing backend.
func (m *Model) SetHint(h string) { m.hint = h }
// reloadChecks refreshes the local checks, keeping the rest of the plan and
// every expansion exactly as it was.
func (m Model) reloadChecks() tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
checks, covers, err := m.backend.LoadChecks(ctx)
return checksMsg{checks: checks, covers: covers, err: err}
}
}
// mergeChecks folds a refreshed set of checks into the plan.
//
// `covers` names the labels the refresh actually recomputed. Those are
// authoritative -- a row the backend no longer reports is gone, and dropping
// it is the point, because otherwise a resolved condition keeps its stale glyph
// and its "run the fix" action forever. Everything outside `covers` is a row
// the refresh cannot see, and is left exactly as the plan found it.
func (m *Model) mergeChecks(fresh []plan.Check, covers []string) {
covered := make(map[string]bool, len(covers))
for _, label := range covers {
covered[label] = true
}
byLabel := make(map[string]plan.Check, len(fresh))
for _, c := range fresh {
byLabel[c.Label] = c
}
kept := make([]plan.Check, 0, len(m.doc.Checks)+len(fresh))
for _, existing := range m.doc.Checks {
if !covered[existing.Label] {
kept = append(kept, existing)
continue
}
if updated, ok := byLabel[existing.Label]; ok {
kept = append(kept, updated)
}
// covered and absent from the refresh: the condition is resolved
}
present := make(map[string]bool, len(kept))
for _, k := range kept {
present[k.Label] = true
}
for _, c := range fresh {
if !present[c.Label] {
kept = append(kept, c)
}
}
m.doc.Checks = kept
m.rebuildTree()
}
func (m Model) load() tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel()
doc, err := m.backend.Load(ctx)
return loadedMsg{plan: doc, err: err}
}
}
// -- update ---------------------------------------------------------------
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if size, ok := msg.(tea.WindowSizeMsg); ok {
m.width, m.height = size.Width, size.Height
return m, nil
}
// The stream is read whether or not anything is open over it, and the
// reader is always re-armed.
//
// Handing these to a modal first was a deadlock: the modal ignored the
// event and returned no command, so the next read was never issued, the
// backend's stdout pipe filled, and the backend blocked on write. The
// build stopped, the clock stopped, and the screen sat there looking
// busy -- opening the help mid-compile was enough to do it.
if m.build != nil {
switch event := msg.(type) {
case streamEventMsg:
m.build.Apply(event.event)
return m, waitForEvent(m.stream)
case streamErrorMsg:
m.build.AddProtocolError(event.err)
return m, waitForEvent(m.stream)
case streamClosedMsg:
return m.closeStream()
}
}
// A modal owns the keyboard, not the program.
//
// Everything that is not a keypress belongs to the model and is handled
// whatever is on screen. Handing these to an open modal first stalls the
// program: a modal replies "not mine" and returns no command, so a
// loadedMsg arrives, is dropped, and the spinner turns over a plan that
// never lands.
if _, isKey := msg.(tea.KeyMsg); !isKey {
return m.handleModelMessage(msg)
}
// A modal owns the keyboard while it is open, and one can be opened over
// the dashboard as well as over the list.
if m.modal != nil {
next, cmd := m.modal.Update(msg)
m.modal = next
return m, cmd
}
// apply mode owns the keyboard once a run has started
if m.build != nil {
return m.updateApply(msg)
}
return m.handleKey(msg.(tea.KeyMsg))
}
// handleModelMessage deals with everything that is not a keypress.
func (m Model) handleModelMessage(msg tea.Msg) (tea.Model, tea.Cmd) {
// A payload a modal asked for belongs to that modal. The two sets are
// disjoint by message type, which is what makes this a rule rather than a
// guess: the model owns plan loading, checks and confirmation; a fetch is
// owned by whoever requested it.
switch msg.(type) {
case detailMsg, textBodyMsg:
if m.modal != nil {
next, cmd := m.modal.Update(msg)
m.modal = next
return m, cmd
}
return m, nil // the modal asking for it has since closed
}
switch msg := msg.(type) {
// A confirmation answers with a message, not with the keypress. The modal
// closes on the key and the answer arrives a moment later, so it is handled
// here, after the modal has gone, rather than looked for while it is still
// on screen.
case cancelledMsg:
m.forgetPending()
return m, nil
case confirmedMsg:
return m.confirmed()
case spinnerTickMsg:
m.spin++
if m.modal != nil {
// advance the modal's frame, but do not let it re-arm: the tick is
// one chain and the model owns it
next, _ := m.modal.Update(msg)
m.modal = next
}
if m.wantsSpinner() {
return m, spinnerTick()
}
return m, nil
case copiedMsg:
// the loader was raised for whatever command produced this, and every
// outcome lands here, success or not
m.loading = false
m.setStatus("copied " + msg.what)
return m, nil
case pagerMsg:
// suspend the TUI, run the pager, and come back when it exits
pager := os.Getenv("UPDATER_PAGER")
if pager == "" {
pager = "delta"
}
if _, err := exec.LookPath(pager); err != nil {
if _, err := exec.LookPath("less"); err != nil {
m.setStatus("no pager found (" + pager + " or less)")
return m, nil
}
pager = "less"
}
cmd := exec.Command(pager, msg.path)
return m, tea.ExecProcess(cmd, func(err error) tea.Msg {
if err != nil {
return copiedMsg{what: "(pager failed)"}
}
return copiedMsg{what: "closed the pager"}
})
case sudoMsg:
m.pendingApply = false
if msg.err != nil {
m.modal = newMessage("NOT AUTHENTICATED",
Err.Render("sudo could not be satisfied, so nothing was run"),
"",
Muted.Render(msg.err.Error()),
"",
Muted.Render("Run `sudo -v` in a terminal, then apply again."))
return m, nil
}
m.sudoReady = true
return m.startApply()
case refreshMsg:
return m, m.reloadChecks()
case checksMsg:
m.loading = false
if msg.err != nil {
m.setStatus("could not refresh the checks: " + msg.err.Error())
return m, nil
}
m.mergeChecks(msg.checks, msg.covers)
m.setStatus("checks refreshed")
return m, nil
case loadedMsg:
m.loading = false
if msg.err != nil {
m.err = msg.err
return m, nil
}
m.err = nil
m.doc = msg.plan
m.rebuildTree()
m.setStatus(fmt.Sprintf("loaded %d row(s)", len(m.visible)))
return m, nil
}
return m, nil
}
// forgetPending drops whatever a confirmation was about.
func (m *Model) forgetPending() {
m.pendingCommand = nil
m.pendingNews = false
m.pendingClear = false
}
// confirmed carries out whatever was just agreed to.
func (m Model) confirmed() (tea.Model, tea.Cmd) {
if len(m.pendingCommand) > 0 {
command := m.pendingCommand
m.pendingCommand = nil
name := command[0]
if _, err := exec.LookPath(name); err != nil {
m.setStatus(name + " is not installed")
return m, nil
}
// suspend the TUI: these commands are interactive and some need root,
// which cannot be asked for behind an alt screen
m.beginLoading()
return m, tea.ExecProcess(exec.Command(name, command[1:]...),
func(err error) tea.Msg {
if err != nil {
return copiedMsg{what: "(the command failed)"}
}
// the command changed this machine, so re-read only the facts
// about this machine
return refreshMsg{}
})
}
if m.pendingNews {
m.pendingNews = false
m.cfg.MarkNewsSeen()
if err := m.cfg.Save(); err != nil {
m.setStatus("could not save: " + err.Error())
} else {
m.setStatus("news marked as read")
}
return m, nil
}
if m.pendingClear {
m.pendingClear = false
count := m.cfg.ClearAcks()
if err := m.cfg.Save(); err != nil {
m.setStatus("could not save: " + err.Error())
} else {
m.setStatus(fmt.Sprintf("cleared %d acknowledgement(s)", count))
}
return m, m.load()
}
m.forgetPending()
return m.startApply()
}
// beginLoading starts a wait and records when, so the screen can say how long
// it has been. One owner, because the elapsed time is only honest if every
// path that begins a wait also begins its clock.
func (m *Model) beginLoading() {
m.loading = true
m.loadingAt = time.Now()
}
// wantsSpinner is the single owner of whether the tick chain continues.
//
// It has to outlive any one screen: the model's own loader and a modal's
// spinner both animate, and a chain that stops when either forgets to re-arm
// leaves a frozen spinner on screen.
func (m Model) wantsSpinner() bool {
if m.loading && m.doc == nil {
return true
}
switch modal := m.modal.(type) {
case *loadingModal:
return true
case *textModal:
return modal.loading
}
return false
}
// skipCurrent leaves the package being built and moves to the next.
//
// One package per invocation is what makes this possible: inside a single
// transaction there is nothing to lift out without unpicking what the database
// has already been told. The backend stops the child and carries on, so this
// is not a stop and nothing is lost.
func (m Model) skipCurrent() (tea.Model, tea.Cmd) {
if m.build == nil || m.build.Finished {
return m, nil
}
if !m.build.CanSkip() {
m.setStatus("only the AUR phase builds one package at a time; " +
"this one cannot be skipped on its own")
return m, nil
}
if m.stream != nil && m.stream.Skip != nil {
m.stream.Skip()
names := make([]string, 0, len(m.build.Running))
for _, state := range m.build.Running {
names = append(names, state.Name)
}
m.setStatus("skipping " + strings.Join(names, ", "))
}
return m, nil
}
// closeStream records the backend's exit and releases the run.
func (m Model) closeStream() (tea.Model, tea.Cmd) {
m.build.Finished = true
if m.cancelApply != nil {
m.cancelApply()
m.cancelApply = nil
}
if m.stream != nil {
err := m.stream.Wait()
m.build.Return = exitCode(err)
if detail := strings.TrimSpace(m.stream.Stderr()); detail != "" && err != nil {
m.build.AddMessage("backend: " + lastLine(detail))
}
}
return m, nil
}
// updateApply handles everything while a run is in progress.
func (m Model) updateApply(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
return m, nil
case tea.KeyMsg:
if m.build.Output != nil {
// The output view owns the keyboard while it is open, exactly as
// a modal does elsewhere -- except for the key that opened it,
// which closes it again. Not while a search is being typed:
// "error" contains an o, and the view would close mid-word.
if msg.String() == "o" && !m.build.Output.searching {
m.build.Output, m.build.OutputKind = nil, overlayNone
return m, nil
}
next, cmd := m.build.Output.Update(msg)
if next == nil {
m.build.Output, m.build.OutputKind = nil, overlayNone
} else {
m.build.Output = next.(*textModal)
}
return m, cmd
}
switch msg.String() {
case "?":
m.modal = newRunHelp()
case "D":
m.build.OpenFinished()
case "s":
return m.skipCurrent()
case "o":
m.build.OpenOutput()
case "v":
m.build.Dashboard = !m.build.Dashboard
case "j", "down":
m.build.MoveCursor(1)
case "k", "up":
m.build.MoveCursor(-1)
case "d":
m.build.Dismiss()
case "l":
m.build.LogOpen = !m.build.LogOpen
case "esc":
if m.build.Finished {
// once it is over there is nothing to collapse, and this is
// the way back when a warning has taken over the enter key
m.build, m.stream, m.abortArmed = nil, nil, false
return m, m.load()
}
// collapsed, not abandoned: the run is still going
m.build.Dashboard = false
case "q":
if !m.build.Finished {
m.setStatus("a transaction is in progress — ctrl+c twice to abort")
return m, nil
}
return m, tea.Quit
case "ctrl+c":
if m.build.Finished {
return m, tea.Quit
}
if !m.abortArmed {
m.abortArmed = true
m.setStatus("press ctrl+c again to abort the transaction")
return m, nil
}
if m.stream != nil {
m.stream.Abort()
}
m.setStatus("aborting…")
// The reader armed when the run started is still outstanding and
// will deliver streamClosedMsg. Arming another puts two readers on
// one channel: closeStream runs twice, Wait() is called twice, and
// the second failure overwrites a clean exit 0 with "exited 1".
return m, nil
case "R":
return m.runRecovery()
case "enter":
// A warning is worth reading in full, so enter opens it when one
// is selected; otherwise it is the way back to the list.
if modal := m.build.OpenMessage(); modal != nil {
m.build.Output, m.build.OutputKind = modal, overlayMessage
return m, nil
}
if m.build.Finished {
m.build, m.stream, m.abortArmed = nil, nil, false
return m, m.load()
}
}
return m, nil
}
return m, nil
}
func exitCode(err error) int {
if err == nil {
return 0
}
var exit *exec.ExitError
if errors.As(err, &exit) {
return exit.ExitCode()
}
return 1
}
func lastLine(s string) string {
lines := strings.Split(strings.TrimSpace(s), "\n")
if len(lines) == 0 {
return s
}
return lines[len(lines)-1]
}
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.filtering {
return m.handleFilterKey(msg)
}
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "?":
m.modal = newHelp()
return m, nil
case "r":
m.beginLoading()
m.err = nil
return m, m.load()
case "up", "k":
m.move(-1)
case "down", "j":
m.move(1)
case "ctrl+u", "pgup":
m.move(-m.listHeight() / 2)
case "ctrl+d", "pgdown":
m.move(m.listHeight() / 2)
case "home", "g":
m.move(-len(m.visible))
case "end", "G":
m.move(len(m.visible))
case " ":
return m.toggleSkip()
case "enter", "right", "l":
return m.enter()
case "left", "h":
return m.collapse()
case "s":
return m.toggleSolo()
case "S":
return m.clearSolo()
case "i":
return m.toggleIgnore()
case "c":
return m.ackAdvisory()
case "C":
return m.clearAcks()
case "n":
return m.markNewsRead()
case "R":
return m.runAction()
case "u":
return m.undoLast()
case "a":
return m.confirmApply()
case "/":
m.filtering = true
m.filter = ""
m.rebuildView()
return m, nil
case "x":
m.filter, m.filtering = "", false
m.rebuildView()
m.setStatus("filter cleared")
return m, nil
case "y":
return m.copyLine()
case "o":
return m.openDiff()
}
return m, nil
}
func (m Model) handleFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.filter, m.filtering = "", false
m.rebuildView()
return m, nil
case "enter":
m.filtering = false
m.setStatus(fmt.Sprintf("%d match(es)", len(m.visible)))
return m, nil
case "backspace":
if m.filter != "" {
m.filter = m.filter[:len(m.filter)-1]
m.rebuildView()
}
case "up", "k":
m.move(-1)
case "down", "j":
m.move(1)
default:
if len(msg.Runes) > 0 {
m.filter += string(msg.Runes)
m.rebuildView()
}
}
return m, nil
}
// -- decisions ------------------------------------------------------------
func (m Model) selected() *Node {
if m.cursor < 0 || m.cursor >= len(m.visible) {
return nil
}
return m.visible[m.cursor]
}
// isRepo reports whether a node is a repository package rather than an AUR one.
func (m Model) isRepo(node *Node) bool {
return node != nil && node.Kind == KindPackage && node.Section == "Repository"
}
func (m Model) toggleSkip() (tea.Model, tea.Cmd) {
node := m.selected()
if node == nil || node.Kind != KindPackage {
return m, nil
}
if m.isRepo(node) {
// Holding a repo package back makes the run a partial upgrade. pacman
// cannot update everything except one thing and stay consistent, so the
// decision is recorded and the confirm dialog says so plainly.
before := m.held[node.Name]
m.held[node.Name] = !before
m.undo = append(m.undo, undoEntry{kind: undoSkip, name: node.Name,
repo: true, wasOn: before})
if m.held[node.Name] {
m.setStatus("holding " + node.Name + " — a partial upgrade")
} else {
m.setStatus("released " + node.Name)
}
} else {
before := m.skipped[node.Name]
m.skipped[node.Name] = !before
m.undo = append(m.undo, undoEntry{kind: undoSkip, name: node.Name,
wasOn: before})
if m.skipped[node.Name] {
m.setStatus("skipping " + node.Name)
} else {
m.setStatus("including " + node.Name)
}
}
m.applyDecisions()
return m, nil
}
func (m Model) toggleIgnore() (tea.Model, tea.Cmd) {
node := m.selected()
if node == nil || node.Kind != KindPackage {
return m, nil
}
now := m.cfg.Toggle(node.Name)
if err := m.cfg.Save(); err != nil {
m.setStatus("could not save config: " + err.Error())
} else if now {
m.setStatus("ignoring " + node.Name + " permanently")
} else {
m.setStatus("no longer ignoring " + node.Name)
}
m.undo = append(m.undo, undoEntry{kind: undoIgnore, name: node.Name})
m.applyDecisions()
return m, nil
}
// ackAdvisory marks a security advisory as read, so it stops being reported.
//
// Not "fixed": most advisories on a rolling distribution have no fix at all, so
// the only useful action available is to stop being told about the ones you have
// already considered.
func (m Model) ackAdvisory() (tea.Model, tea.Cmd) {
node := m.selected()
if node == nil || node.Kind != KindAdvisory {
return m, nil
}
m.cfg.Ack(node.Name)
if err := m.cfg.Save(); err != nil {
m.setStatus("could not save the acknowledgement: " + err.Error())
return m, nil
}
m.removeNode(node)
m.setStatus("acknowledged " + node.Name)
m.rebuildView()
return m, nil
}
// clearAcks forgets every acknowledgement, behind a confirmation because there
// is no way to undo it from here.
func (m Model) clearAcks() (tea.Model, tea.Cmd) {
if len(m.cfg.AckedList()) == 0 {
m.setStatus("no advisories are acknowledged")
return m, nil
}
m.pendingClear = true
m.modal = newConfirm(
[]string{"forget every advisory acknowledgement"},
[]string{fmt.Sprintf("%d advisory(ies) will be reported again",
len(m.cfg.AckedList()))},
nil)
return m, nil
}
// runAction offers to run the command a check suggests.
//
// Some of these need root. The TUI is suspended and the command runs in the real
// terminal, so sudo can prompt properly instead of failing behind an alt screen
// with nowhere to ask.
func (m Model) runAction() (tea.Model, tea.Cmd) {
node := m.selected()
if node == nil || len(node.Run) == 0 {
m.setStatus("this row has nothing to run")
return m, nil
}
warnings := []string{}
if node.Warning != "" {
warnings = append(warnings, node.Warning)
}
m.pendingCommand = node.Run
m.modal = newConfirm(
[]string{strings.Join(node.Run, " ")},
[]string{"runs in this terminal, so it can ask for your password"},
warnings)
return m, nil
}
// runRecovery finishes in the terminal what the unattended run could not.
//
// A conflict is a question, not a fault: pacman has to be told whether to
// replace a package, and --noconfirm cannot answer. The only place that
// question can be put is the real terminal, so the interface steps aside and
// lets paru ask.
func (m Model) runRecovery() (tea.Model, tea.Cmd) {
if m.build == nil || m.build.Recovery == nil {
return m, nil
}
command := m.build.Recovery.Command
if len(command) == 0 {
return m, nil
}
if _, err := exec.LookPath(command[0]); err != nil {
m.setStatus(command[0] + " is not installed")
return m, nil
}
return m, tea.ExecProcess(exec.Command(command[0], command[1:]...),
func(err error) tea.Msg {
if err != nil {
return copiedMsg{what: "(the recovery run did not finish cleanly)"}
}
return copiedMsg{what: "done"}
})
}
// warningTabs builds the warnings page, scoped to what will be applied.
func (m Model) warningTabs() []tabSection {
tabs := make([]tabSection, 0, len(m.doc.Warnings))
for _, w := range m.doc.Warnings {
// A warning about a package that is not being applied is true but not
// relevant to this run, and listing it buries the ones that are.
if len(w.Packages) > 0 && len(m.solo) > 0 {
inScope := 0
for _, name := range w.Packages {
if m.willApply(name) {
inScope++
}
}
if inScope == 0 {
lines := []string{
"All of these are held back, so this does not affect",
"what is about to be applied:",
"",
}
lines = append(lines, indentAll(w.Packages)...)
lines = append(lines, "",
"Clear the solo selection with S to see them in context.")
tabs = append(tabs, tabSection{
title: w.Title,
note: fmt.Sprintf("none of the %d package(s) here are "+
"selected for this run", len(w.Packages)),
lines: lines,
})
continue
}
}
tabs = append(tabs, tabSection{
title: w.Title, lines: w.Details,
flagged: w.Flagged, note: w.Headline,
})
}
return tabs
}
func indentAll(items []string) []string {
out := make([]string, 0, len(items))
for _, item := range items {
out = append(out, " "+item)
}
return out
}
// toggleSolo picks a package and holds everything else back.
//
// The inverse of skipping one row at a time. Wanting two packages out of twenty
// should not mean pressing space eighteen times, and it should not leave the
// checks describing work that is not going to happen.
func (m Model) toggleSolo() (tea.Model, tea.Cmd) {
node := m.selected()
if node == nil || node.Kind != KindPackage {
m.setStatus("solo picks a package: put the cursor on one")
return m, nil
}
if m.solo[node.Name] {
delete(m.solo, node.Name)
if len(m.solo) == 0 {
m.setStatus("solo off: the whole plan applies again")
} else {
m.setStatus("dropped " + node.Name + " from the selection")
}
} else {
m.solo[node.Name] = true
m.setStatus("solo: " + node.Name + " only")
}
m.applyDecisions()
m.rebuildTree()
return m, nil
}
func (m Model) clearSolo() (tea.Model, tea.Cmd) {
if len(m.solo) == 0 {
return m, nil
}
m.solo = map[string]bool{}
m.setStatus("solo off: the whole plan applies again")
m.rebuildTree()
return m, nil
}
// markNewsRead records that the news has been seen, so the next run only
// reports what arrived after this point. Confirmed, because there is no undo.
func (m Model) markNewsRead() (tea.Model, tea.Cmd) {
if !m.hasNewsRow() {
m.setStatus("there is no news to mark as read")
return m, nil
}
m.pendingNews = true
m.modal = newConfirm(
[]string{"mark the arch news as read"},
[]string{"items published after now will still be reported"}, nil)
return m, nil
}
func (m Model) hasNewsRow() bool {
for _, n := range m.visible {
if n.Kind == KindCheck && strings.EqualFold(n.Text, "news") {
return true
}
}
return false
}
// removeNode drops a node from the tree, so a decision takes effect immediately
// rather than after a reload that would go back to the network.
func (m *Model) removeNode(target *Node) {
var walk func(nodes []*Node) []*Node
walk = func(nodes []*Node) []*Node {
out := nodes[:0]
for _, n := range nodes {
if n == target {
continue
}
n.Children = walk(n.Children)
out = append(out, n)
}
return out
}
m.roots = walk(m.roots)
}
func (m Model) undoLast() (tea.Model, tea.Cmd) {
if len(m.undo) == 0 {
m.setStatus("nothing to undo")
return m, nil
}
entry := m.undo[len(m.undo)-1]
m.undo = m.undo[:len(m.undo)-1]
switch entry.kind {
case undoSkip:
if entry.repo {
m.held[entry.name] = entry.wasOn
} else {
m.skipped[entry.name] = entry.wasOn
}
m.setStatus("undid the decision on " + entry.name)
case undoIgnore:
m.cfg.Toggle(entry.name)
if err := m.cfg.Save(); err != nil {
m.setStatus("could not save config: " + err.Error())
} else {
m.setStatus("undid the ignore on " + entry.name)
}
}
m.applyDecisions()
return m, nil
}
// needsRoot reports whether this run will invoke anything privileged.
//
// Flatpak is left out on purpose: user installations do not need root, and
// requiring it would block a run that would have worked.
func (m Model) needsRoot() bool {
if m.doc == nil {
return false
}
for _, u := range m.doc.Repo {
if m.willApply(u.Name) {
return true
}
}
for _, a := range m.doc.Aur {
if m.willApply(a.Name) {
return true
}
}
for _, v := range m.doc.Vcs {
if m.willApply(v.Name) {
return true
}
}
return false
}
// soloCount and totalPackages describe the pick, so the header can say how much
// of the plan is still in play.
func (m Model) soloCount() int {
n := 0
for _, u := range m.doc.Repo {
if m.willApply(u.Name) {
n++
}
}
for _, a := range m.doc.Aur {
if m.willApply(a.Name) {
n++
}
}
for _, v := range m.doc.Vcs {
if m.willApply(v.Name) {
n++
}
}
return n
}
func (m Model) totalPackages() int {
if m.doc == nil {
return 0
}
return len(m.doc.Repo) + len(m.doc.Aur) + len(m.doc.Vcs)
}
// willApply is the one place that decides whether a package is part of this
// run. Every count, the plan, the advisories and the warnings all ask this, so
// they cannot disagree with each other or with the apply.
func (m Model) willApply(name string) bool {
if m.cfg.IsIgnored(name) {
return false
}
if len(m.solo) > 0 {
return m.solo[name]
}
return !m.skipped[name] && !m.held[name]
}
// applyDecisions pushes the current state onto the tree and redraws.
func (m *Model) applyDecisions() {
var mark func(nodes []*Node)
mark = func(nodes []*Node) {
for _, n := range nodes {
if n.Kind == KindPackage {
n.Ignored = m.cfg.IsIgnored(n.Name)
n.Skipped = !m.willApply(n.Name)
n.Soloed = m.solo[n.Name]
n.SoloActive = len(m.solo) > 0
}
mark(n.Children)
}
}
mark(m.roots)
m.applyFilter()
}
// -- action keys ----------------------------------------------------------
func (m Model) drillDown() (tea.Model, tea.Cmd) {
node := m.selected()
if node == nil {
return m, nil
}
switch node.Kind {
case KindPackage:
m.modal = newDetail(node.Name, m.width)
return m, m.fetchDetail(node.Name)
case KindAdvisory:
m.modal = newTextLoading(node.Name, "fetching the advisory from the tracker")
return m, tea.Batch(m.fetchAdvisory(node.Name), spinnerTick())
default:
// The warnings page is built from the plan's warning kinds, not from
// the row's detail, so it has to be reachable before the guard that
// skips rows with nothing to show.
if node.Kind == KindCheck && node.Text == "warning" && len(m.doc.Warnings) > 0 {
m.modal = newTabs("warnings", m.warningTabs())
return m, nil
}
if len(node.Detail) == 0 && len(node.Queue) == 0 {
return m, nil
}
title := node.Name
if title == "" {
title = node.Text
}
if title == "" {
title = node.Command
}
if len(node.Queue) > 0 {
// The queue is the answer; prose summarising the same commands
// would be a second place to read them.
m.modal = newQueue(node.Command, node.Queue)
return m, nil
}
modal := newText(title)
modal.Update(textBodyMsg{title: title, lines: node.Detail})
m.modal = modal
return m, nil
}
}
// fetchAdvisory pulls one advisory's full record, so the link is a last resort
// rather than the only thing we can show.
func (m Model) fetchAdvisory(id string) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
detail, err := m.backend.LoadAdvisory(ctx, id)
if err != nil {
return textBodyMsg{title: id, err: err}
}
return textBodyMsg{title: id, lines: advisoryLines(detail), foot: detail.URL}
}
}
// fetchSecurity runs arch-audit again and shows the result.
func (m Model) fetchSecurity() tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel()
report, err := m.backend.RunSecurity(ctx)
if err != nil {
return textBodyMsg{title: "arch-audit", err: err}
}
return textBodyMsg{title: "arch-audit", lines: securityLines(report)}
}
}
func (m Model) fetchDetail(pkg string) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
d, err := m.backend.LoadDetail(ctx, pkg)
return detailMsg{pkg: pkg, detail: d, err: err}
}
}
func (m Model) confirmApply() (tea.Model, tea.Cmd) {
if m.doc == nil {
return m, nil
}
commands, notes, warnings := m.planLines()
if len(commands) == 0 {
m.modal = newMessage("NOTHING TO DO", Muted.Render("no updates are selected"))
return m, nil
}
m.modal = newConfirm(commands, notes, warnings)
return m, nil
}
// planLines describes exactly what would run, and refuses to hide a partial
// upgrade behind a cheerful summary.
func (m Model) planLines() (commands, notes, warnings []string) {
// Everything below asks willApply, so what this shows and what the apply
// does cannot drift apart.
var repoHeld, repoLive, aurLive int
var heldNames []string
for _, u := range m.doc.Repo {
if m.willApply(u.Name) {
repoLive++
} else {
repoHeld++
heldNames = append(heldNames, u.Name)
}
}
for _, a := range m.doc.Aur {
if m.willApply(a.Name) {
aurLive++
}
}
for _, v := range m.doc.Vcs {
if m.willApply(v.Name) {
aurLive++
}
}
if repoLive > 0 || repoHeld > 0 {
cmd := "pacman -Syu"
if repoHeld > 0 {
cmd += " --ignore=" + strings.Join(heldNames, ",")
}
commands = append(commands, cmd)
detail := fmt.Sprintf("%d package(s)", repoLive)
if repoHeld > 0 {
detail += fmt.Sprintf(", %d held back", repoHeld)
}
notes = append(notes, detail)
}
if aurLive > 0 {
commands = append(commands, "paru -Sua")
detail := fmt.Sprintf("%d AUR package(s)", aurLive)
notes = append(notes, detail)
}
// Holding repository packages back is a partial upgrade, which Arch does
// not support: the held packages keep their old libraries while everything
// else moves to the new ones. That is worth saying out loud, not burying.
if repoHeld > 0 {
warnings = append(warnings, fmt.Sprintf(
"%d repository package(s) held back: this is a partial upgrade, "+
"which is not supported and can leave libraries mismatched",
repoHeld))
}
if len(m.solo) > 0 {
notes = append(notes, fmt.Sprintf("solo: %d of %d package(s) selected",
m.soloCount(), m.totalPackages()))
}
return commands, notes, warnings
}
// startApply launches the backend and switches to the live view.
func (m Model) startApply() (tea.Model, tea.Cmd) {
commands, _, _ := m.planLines()
if len(commands) == 0 {
m.modal = newMessage("NOTHING TO DO", Muted.Render("no updates are selected"))
return m, nil
}
// sudo cannot prompt through a pipe, and the phases run with their input
// closed so that paru cannot steal the keyboard. Without this the run
// exited 1 in under a second having done nothing, with the reason buried in
// a log file. Suspending once here caches the credentials in the real
// terminal, where the password prompt actually works.
if m.needsRoot() && !m.sudoReady {
if _, err := exec.LookPath("sudo"); err != nil {
m.modal = newMessage("NO SUDO", Err.Render("sudo is not installed"),
"", Muted.Render("This update needs root and there is no way to ask for it."))
return m, nil
}
m.pendingApply = true
return m, tea.ExecProcess(exec.Command("sudo", "-v"),
func(err error) tea.Msg { return sudoMsg{err: err} })
}
skips := make([]string, 0, len(m.skipped))
for name, on := range m.skipped {
if on {
skips = append(skips, name)
}
}
// Solo is expressed as holds rather than as a new backend flag: "apply
// only these" and "hold everything else" are the same instruction, and the
// apply already understands the second.
holds := make([]string, 0, len(m.held))
if len(m.solo) > 0 {
for _, u := range m.doc.Repo {
if !m.solo[u.Name] {
holds = append(holds, u.Name)
}
}
for _, a := range m.doc.Aur {
if !m.solo[a.Name] {
holds = append(holds, a.Name)
}
}
for _, v := range m.doc.Vcs {
if !m.solo[v.Name] {
holds = append(holds, v.Name)
}
}
} else {
for name, on := range m.held {
if on {
holds = append(holds, name)
}
}
}
ctx, cancel := context.WithCancel(context.Background())
m.cancelApply = cancel
live, err := m.backend.StartApply(ctx, skips, holds)
if err != nil {
m.modal = newMessage("COULD NOT START",
Err.Render("the backend did not start"),
"",
Muted.Render(err.Error()),
Muted.Render(m.hint))
return m, nil
}
m.stream = live
m.build = NewBuildView(nil, 0)
m.abortArmed = false
return m, waitForEvent(live)
}
// -- clipboard and pager --------------------------------------------------
func (m Model) copyLine() (tea.Model, tea.Cmd) {
node := m.selected()
if node == nil {
return m, nil
}
text := ansi.Strip(node.render(m.columns()))
return m, func() tea.Msg {
if err := writeClipboard(text); err != nil {
return copiedMsg{what: "(failed: " + err.Error() + ")"}
}
return copiedMsg{what: "line"}
}
}
// writeClipboard shells out, which is what the rest of this tree does. OSC52
// would also work over SSH but needs raw access to the terminal.
func writeClipboard(text string) error {
for _, candidate := range [][]string{
{"wl-copy"}, {"xclip", "-selection", "clipboard"}, {"xsel", "-ib"},
} {
path, err := exec.LookPath(candidate[0])
if err != nil {
continue
}
cmd := exec.Command(path, candidate[1:]...)
cmd.Stdin = strings.NewReader(text)
if err := cmd.Run(); err == nil {
return nil
}
}
return fmt.Errorf("no clipboard helper (wl-copy, xclip or xsel)")
}
// openDiff writes the diff to a temp file for the configured pager.
func (m Model) openDiff() (tea.Model, tea.Cmd) {
node := m.selected()
if node == nil || node.Kind != KindPackage {
return m, nil
}
return m, func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
d, err := m.backend.LoadDetail(ctx, node.Name)
if err != nil {
return copiedMsg{what: "(could not load the diff)"}
}
text := renderDiffText(d)
if strings.TrimSpace(text) == "" {
return copiedMsg{what: "(no diff to open)"}
}
path := filepath.Join(os.TempDir(), "updater-"+node.Name+".diff")
if err := os.WriteFile(path, []byte(text), 0o600); err != nil {
return copiedMsg{what: "(could not write the diff)"}
}
return pagerMsg{path: path}
}
}
// pagerMsg asks the runtime to suspend the TUI and run a pager on a file.
type pagerMsg struct{ path string }
func renderDiffText(d *plan.Detail) string {
var b strings.Builder
if d.BaselineNote != "" {
b.WriteString("# " + d.BaselineNote + "\n")
}
for _, line := range d.Diff.Lines {
switch line.Kind {
case "gap":
continue
case "add":
b.WriteString("+" + line.Text + "\n")
case "del":
b.WriteString("-" + line.Text + "\n")
default:
b.WriteString(" " + line.Text + "\n")
}
}
if b.Len() == 0 {
for _, line := range d.Pkgbuild {
b.WriteString(" " + line + "\n")
}
}
return b.String()
}
// -- tree plumbing --------------------------------------------------------
func (m *Model) rebuildTree() {
threshold := 8
if m.cfg != nil {
threshold = m.cfg.ExpandThreshold()
}
// Rebuilding produces fresh nodes, which would snap everything back to its
// default. A reload should show updated facts, not undo the reader's
// navigation, so the open/closed state is carried across by identity.
was := m.captureExpansion()
m.roots = BuildTree(m.doc, m.doc.Audits, threshold, m.willApply)
m.restoreExpansion(was)
m.applyDecisions()
}
// nodeKey identifies a row across rebuilds.
func nodeKey(n *Node) string {
if n.Name != "" {
return fmt.Sprintf("%d|%s", n.Kind, n.Name)
}
return fmt.Sprintf("%d|%s", n.Kind, n.Title)
}
func (m *Model) captureExpansion() map[string]bool {
out := map[string]bool{}
var walk func([]*Node)
walk = func(nodes []*Node) {
for _, n := range nodes {
if n.HasChildren() {
out[nodeKey(n)] = n.Expanded
}
walk(n.Children)
}
}
walk(m.roots)
return out
}
func (m *Model) restoreExpansion(was map[string]bool) {
if len(was) == 0 {
return
}
var walk func([]*Node)
walk = func(nodes []*Node) {
for _, n := range nodes {
if n.HasChildren() {
if expanded, ok := was[nodeKey(n)]; ok {
n.Expanded = expanded
}
}
walk(n.Children)
}
}
walk(m.roots)
}
// rebuildView re-flattens without touching the decisions.
func (m *Model) rebuildView() {
m.applyFilter()
}
// applyFilter recomputes the visible list from the filter and the tree.
func (m *Model) applyFilter() {
m.all = Flatten(m.roots)
if strings.TrimSpace(m.filter) == "" {
m.visible = m.all
} else {
needle := strings.ToLower(m.filter)
keep := map[*Node]bool{}
var walk func(nodes []*Node) bool
walk = func(nodes []*Node) bool {
any := false
for _, n := range nodes {
hit := strings.Contains(strings.ToLower(n.Name), needle) ||
strings.Contains(strings.ToLower(n.Text), needle) ||
strings.Contains(strings.ToLower(n.Title), needle)
if len(n.Children) > 0 && walk(n.Children) {
hit = true
}
if hit {
keep[n] = true
any = true
}
}
return any
}
walk(m.roots)
filtered := make([]*Node, 0, len(m.all))
for _, n := range m.all {
if n.Kind == KindSpacer {
continue
}
if keep[n] {
filtered = append(filtered, n)
}
}
if len(filtered) == 0 {
filtered = append(filtered, &Node{Kind: KindCheck, Glyph: "·",
GlyphRole: "muted", Text: "no matches", Evidence: m.filter})
}
m.visible = filtered
}
m.cursor = clamp(m.cursor, 0, max(0, len(m.visible)-1))
if m.cursor < len(m.visible) && !m.visible[m.cursor].Selectable() {
m.cursor = m.firstSelectable(m.cursor, +1)
}
m.offset = clamp(m.offset, 0, max(0, len(m.visible)-1))
}
func (m *Model) move(delta int) {
if len(m.visible) == 0 {
return
}
step := 1
if delta < 0 {
step, delta = -1, -delta
}
for i := 0; i < delta; i++ {
next := m.cursor + step
for next >= 0 && next < len(m.visible) && !m.visible[next].Selectable() {
next += step
}
if next < 0 || next >= len(m.visible) {
break
}
m.cursor = next
}
view := m.listHeight()
if m.cursor < m.offset {
m.offset = m.cursor
}
if m.cursor >= m.offset+view {
m.offset = m.cursor - view + 1
}
}
// firstSelectable finds the first row the cursor may rest on.
func (m *Model) firstSelectable(from, step int) int {
for i := from; i >= 0 && i < len(m.visible); i += step {
if m.visible[i].Selectable() {
return i
}
}
return 0
}
// enter is the only structural key. What it does depends on the row:
//
// section collapse or expand
// package expand (to show findings), then open the overlay
// check, advisory open
//
// It never dead-ends: a collapsed node opens, an expanded one goes deeper, and
// a leaf goes straight to the overlay. A section has no overlay, so its rows
// are reached by collapsing and expanding it.
func (m Model) enter() (tea.Model, tea.Cmd) {
node := m.selected()
if node == nil {
return m, nil
}
if node.Kind == KindSection && node.HasChildren() {
return m.toggleExpand()
}
if node.HasChildren() && !node.Expanded {
return m.toggleExpand()
}
return m.drillDown()
}
// enterHint describes what enter will do on the selected row, so the footer
// never offers a key that does nothing there.
func (m Model) enterHint() (string, bool) {
node := m.selected()
if node == nil {
return "", false
}
switch {
case node.Kind == KindSection && node.HasChildren():
if node.Expanded {
return "collapse", true
}
return "expand", true
case node.HasChildren() && !node.Expanded:
return "expand", true
case node.Kind == KindPackage, node.Kind == KindAdvisory,
node.Kind == KindCheck, node.Kind == KindStep:
return "open", true
case len(node.Detail) > 0:
return "open", true
}
return "", false
}
func (m Model) toggleExpand() (tea.Model, tea.Cmd) {
node := m.selected()
if node == nil || !node.HasChildren() {
return m, nil
}
node.Toggle()
m.rebuildView()
for i, n := range m.visible {
if n == node {
m.cursor = i
break
}
}
return m, nil
}
func (m Model) collapse() (tea.Model, tea.Cmd) {
node := m.selected()
if node != nil && node.Expanded {
node.Toggle()
m.rebuildView()
}
return m, nil
}
func (m *Model) setStatus(text string) {
m.status = text
m.statusAt = time.Now()
}
// -- layout ---------------------------------------------------------------
func (m Model) listHeight() int { return max(1, m.height-5) }
func (m Model) columns() Columns {
return NewColumns(m.width)
}
// -- rendering ------------------------------------------------------------
// showStatus is the single owner of whether the status line is on screen.
func (m Model) showStatus() bool {
return m.status != "" && time.Since(m.statusAt) < 6*time.Second
}
// buildHeight is what is left for the dashboard once the status has its lines.
func (m Model) buildHeight() int {
if m.showStatus() {
return max(1, m.height-2)
}
return m.height
}
// clipLines is the vertical backstop, matching the one place() gives overlays.
func clipLines(body string, height int) string {
if height <= 0 {
return body
}
lines := strings.Split(body, "\n")
if len(lines) <= height {
return body
}
return strings.Join(lines[:height], "\n")
}
func (m Model) View() string {
if m.width < 50 || m.height < 10 {
return Muted.Render("terminal too small — need at least 50×10")
}
if m.doc == nil && m.loading {
return m.loadingView()
}
// modals first, matching Update: one can be open over the dashboard
if m.modal != nil {
return m.modal.View(m.width, m.height)
}
if m.build != nil {
// The status is given its two lines up front. Appending it to a frame
// already budgeted to exactly the height pushed the top of the screen
// away -- and it appears during a run, when the reader is trying to
// stop one.
body := m.build.View(m.width, m.cfg.HideHints(), m.buildHeight())
if m.showStatus() {
body += "\n\n" + Muted.Render(m.status)
}
return body
}
// renderFooter owns the status line; appending it here as well printed it
// twice and pushed the filter prompt off the bottom while typing.
return clipLines(
m.renderHeader()+"\n\n"+m.renderBody()+"\n\n"+m.renderFooter(),
m.height)
}
// loadingView is the first thing you see. It names what is being waited on, so
// a slow `checkupdates` sync does not look like a frozen program.
func (m Model) loadingView() string {
// Everything is centred on the track: a word hanging off the left of a bar
// reads as two unrelated things rather than one.
const track = 26
bar := sweep(track, m.spin)
mid := func(line string) string {
pad := (track - ansi.StringWidth(line)) / 2
if pad < 0 {
pad = 0
}
return strings.Repeat(" ", pad) + line
}
word := mid(Text.Render("loading"))
clock := ""
if !m.loadingAt.IsZero() {
if d := time.Since(m.loadingAt); d >= time.Second {
clock = seconds(d.Seconds())
}
}
// The composition is chosen to fit rather than left to be clipped.
// `place` keeps the centre of an over-tall box, and on a bordered box that
// means losing the border, which reads as a rendering fault rather than as
// a small terminal. Least important first: the clock, the wordmark, the
// breathing room.
avail := max(5, m.height-2)
room := avail - 4 // the box's own border and padding
var body []string
switch {
case room >= 7:
body = []string{mid(Accent.Render("updater")), "", word, "", bar}
if clock != "" {
body = append(body, "", mid(Muted.Render(clock)))
}
case room >= 5:
body = []string{mid(Accent.Render("updater")), "", word, "", bar}
case room >= 3:
body = []string{word, "", bar}
default:
body = []string{word}
}
return "\n\n" + place(Box.Render(strings.Join(body, "\n")),
m.width, avail)
}
// sweep is an indeterminate track with a highlight travelling along it.
//
// It says work is happening without claiming to know how much is left, which
// is the only thing true before the plan has been read. The screen used to
// list the phases it expected to run as static dim lines that never changed,
// so they read as progress while measuring nothing.
func sweep(width, frame int) string {
const head = 4
pos := frame % (width + head*2)
var b strings.Builder
for i := 0; i < width; i++ {
d := i - pos + head
switch {
case d >= 0 && d < head:
b.WriteString(Accent.Render("━"))
case d == -1 || d == head:
b.WriteString(Muted.Render("─"))
default:
b.WriteString(Rule.Render("─"))
}
}
return b.String()
}
func (m Model) renderHeader() string {
left := Accent.Render("updater")
right := Muted.Render(m.statusLine())
if m.loading {
right = Muted.Render("loading…")
}
return spread(left, right, m.width)
}
// statusLine summarises what the plan will do, counting only live decisions.
func (m Model) statusLine() string {
if m.doc == nil {
return ""
}
repo, aur := 0, 0
for _, u := range m.doc.Repo {
if m.willApply(u.Name) {
repo++
}
}
for _, a := range m.doc.Aur {
if m.willApply(a.Name) {
aur++
}
}
// VCS packages are AUR packages now, and the count has to match what the
// backend will actually be asked to build
for _, v := range m.doc.Vcs {
if m.willApply(v.Name) {
aur++
}
}
parts := []string{fmt.Sprintf("%d repo", repo), fmt.Sprintf("%d AUR", aur)}
if len(m.solo) > 0 {
parts = append(parts, Accent.Render(
fmt.Sprintf("solo %d of %d", m.soloCount(), m.totalPackages())))
}
// Counted from the rows that are actually in the plan, not from the maps.
// A record left behind for a package that is no longer offered would
// otherwise show up as a skip the user never made.
if len(m.solo) == 0 {
if n := m.decisionCount(); n > 0 {
parts = append(parts, Warn.Render(fmt.Sprintf("%d skipped", n)))
}
}
if n := len(m.cfg.Ignored); n > 0 {
parts = append(parts, Muted.Render(fmt.Sprintf("%d ignored", n)))
}
if m.filter != "" {
parts = append(parts, Ver.Render("filter:"+m.filter))
}
return strings.Join(parts, " · ")
}
// decisionCount is how many rows in this plan have been decided on.
func (m Model) decisionCount() int {
// the footer renders even when the backend failed and there is no plan
if m.doc == nil {
return 0
}
count := 0
for _, u := range m.doc.Repo {
if m.held[u.Name] {
count++
}
}
for _, a := range m.doc.Aur {
if m.skipped[a.Name] {
count++
}
}
for _, v := range m.doc.Vcs {
if m.skipped[v.Name] {
count++
}
}
return count
}
func (m Model) renderBody() string {
cols := m.columns()
height := m.listHeight()
if m.err != nil {
return Err.Render("backend error") + "\n\n" +
Muted.Render(m.err.Error()) + "\n\n" + Muted.Render(m.hint)
}
end := min(len(m.visible), m.offset+height)
rows := make([]string, 0, height)
for i := m.offset; i < end; i++ {
rows = append(rows, m.visible[i].Render(cols, i == m.cursor))
}
for len(rows) < height {
rows = append(rows, "")
}
return strings.Join(rows, "\n")
}
// renderFooter shows keys relevant to where you are, plus any transient status.
func (m Model) renderFooter() string {
var hints []keyDesc
switch {
case m.filtering:
hints = []keyDesc{{"/", "type to filter"}, {"enter", "keep"}, {"esc", "clear"}}
case m.selected() != nil && m.selected().Kind == KindPackage:
what := "open"
if n := m.selected(); n.HasChildren() && !n.Expanded {
what = "expand"
}
hints = []keyDesc{{"space", "skip"}, {"i", "ignore"}, {"enter", what},
{"a", "apply"}, {"?", "help"}, {"q", "quit"}}
case m.selected() != nil && m.selected().Kind == KindAdvisory:
hints = []keyDesc{{"enter", "open"}, {"c", "acknowledge"}}
if len(m.cfg.AckedList()) > 0 {
hints = append(hints, keyDesc{"C", "clear acks"})
}
if sel := m.selected(); sel != nil && sel.Kind == KindPackage {
if len(m.solo) > 0 {
hints = append(hints, keyDesc{"s", "drop from solo"})
hints = append(hints, keyDesc{"S", "solo off"})
} else {
hints = append(hints, keyDesc{"s", "solo this"})
}
}
hints = append(hints,
keyDesc{"a", "apply"}, keyDesc{"?", "help"}, keyDesc{"q", "quit"})
default:
hints = []keyDesc{}
if what, ok := m.enterHint(); ok {
hints = append(hints, keyDesc{"enter", what})
}
if len(m.cfg.AckedList()) > 0 {
hints = append(hints, keyDesc{"C", "clear acks"})
}
if len(m.solo) > 0 {
hints = append(hints, keyDesc{"S", "solo off"})
} else if m.decisionCount() > 0 {
hints = append(hints, keyDesc{"u", "undo the last skip"})
}
if sel := m.selected(); sel != nil && sel.Kind == KindPackage && len(m.solo) == 0 {
hints = append(hints, keyDesc{"s", "solo this"})
}
if sel := m.selected(); sel != nil {
if sel.Kind == KindCheck && strings.EqualFold(sel.Text, "news") {
hints = append(hints, keyDesc{"n", "mark read"})
}
if len(sel.Run) > 0 {
hints = append(hints, keyDesc{"R", "run the fix"})
}
}
hints = append(hints,
keyDesc{"a", "apply"}, keyDesc{"?", "help"}, keyDesc{"q", "quit"})
}
parts := make([]string, 0, len(hints))
for _, h := range hints {
parts = append(parts, Key.Render(h.key)+" "+Desc.Render(h.desc))
}
line := strings.Join(parts, Desc.Render(" · "))
pos := Muted.Render(fmt.Sprintf("[%d/%d]", m.cursor+1, max(1, len(m.visible))))
out := spread(line, pos, m.width)
if m.status != "" && time.Since(m.statusAt) < 6*time.Second {
out += "\n" + Muted.Render(m.status)
}
if m.filtering {
out += "\n" + Ver.Render("filter: ") + Text.Render(m.filter) + Accent.Render("█")
}
return out
}
// lipglossJoin keeps the import honest without pulling in extra helpers.
var _ = lipgloss.JoinVertical
func clamp(v, lo, hi int) int {
if hi < lo {
return lo
}
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
// ErrNothingToShow is what a preview returns when the overlay it was asked for
// has no content on this machine.
//
// Not a failure. An up-to-date system has no command queue, and "there is
// nothing here" is a useful answer to "show me the queue" rather than an error
// to exit on.
var ErrNothingToShow = errors.New("nothing to show")
// RenderOnce loads, lays out at the given size and returns a single frame.
//
// Equivalent to running the TUI and taking one screenshot, without a terminal.
// `show` optionally opens a modal first, which is the only way to inspect the
// overlays from outside: a headless capture of a TUI cannot otherwise reach
// them.
func (m Model) RenderOnce(width, height int, show, pkg string) (string, error) {
next, _ := m.Update(tea.WindowSizeMsg{Width: width, Height: height})
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel()
doc, err := m.backend.Load(ctx)
next, _ = next.(Model).Update(loadedMsg{plan: doc, err: err})
final := next.(Model)
if final.err != nil {
return "", final.err
}
switch show {
case "":
case "help":
final.modal = newHelp()
case "confirm":
updated, _ := final.confirmApply()
final = updated.(Model)
case "detail":
if pkg == "" {
pkg = firstPackage(final.visible)
}
if pkg == "" {
return "", fmt.Errorf("no package to show")
}
modal := newDetail(pkg, width)
d, derr := final.backend.LoadDetail(ctx, pkg)
modal.Update(detailMsg{pkg: pkg, detail: d, err: derr})
final.modal = modal
case "advisory":
modal := newTextLoading(advisoryFor(final.visible), "fetching the advisory")
if id := advisoryFor(final.visible); id != "" {
d, derr := final.backend.LoadAdvisory(ctx, id)
if derr == nil {
modal.Update(textBodyMsg{title: id, lines: advisoryLines(d), foot: d.URL})
} else {
modal.Update(textBodyMsg{title: id, err: derr})
}
}
final.modal = modal
case "plan":
// the first plan step, opened on its command queue
var step *Node
for _, n := range final.visible {
if n.Kind == KindStep && len(n.Queue) > 0 {
step = n
break
}
}
if step == nil {
return "", fmt.Errorf("%w: nothing to queue — every pending "+
"change is a notice rather than a command", ErrNothingToShow)
}
final.modal = newQueue(step.Command, step.Queue)
case "warnings":
tabs := final.warningTabs()
if len(tabs) == 0 {
return "", fmt.Errorf("%w: no warnings on this machine",
ErrNothingToShow)
}
final.modal = newTabs("warnings", tabs)
case "security":
modal := newTextLoading("arch-audit", "running arch-audit")
report, rerr := final.backend.RunSecurity(ctx)
if rerr == nil {
modal.Update(textBodyMsg{title: "arch-audit", lines: securityLines(report)})
} else {
modal.Update(textBodyMsg{title: "arch-audit", err: rerr})
}
final.modal = modal
case "loading":
final.doc, final.loading = nil, true
default:
return "", fmt.Errorf("unknown overlay %q", show)
}
return final.View(), nil
}
// advisoryFor finds the first advisory in the tree, so a preview can open one.
func advisoryFor(nodes []*Node) string {
for _, n := range nodes {
if n.Kind == KindAdvisory {
return n.Name
}
}
return ""
}
func firstPackage(nodes []*Node) string {
for _, n := range nodes {
if n.Kind == KindPackage {
return n.Name
}
}
return ""
}