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