package ui
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/ansi"
"updater-tui/internal/plan"
)
// queueModal shows the commands a plan step will run.
//
// Grouped by phase and foldable, because a real AUR queue runs to a couple of
// hundred lines and the question is rarely "read all of it" -- it is "what does
// the build do" or "does this touch /etc". Search answers the second; folding
// answers the first.
type queueModal struct {
title string
stages []plan.Stage
rows []queueRow
cursor int
scroll int // in rendered lines, not rows
height int
inner int
collapsed map[string]bool
searching bool
query string
matchAt int
matches []int
searchErr string
}
// queueRow is one displayed line: either a phase heading or one command.
type queueRow struct {
header bool
phase string
stage plan.Stage
}
// qLine is one rendered line, tagged with the row it came from so the cursor
// and the scroll position can be related to each other.
type qLine struct {
text string
row int
}
func newQueue(title string, stages []plan.Stage) *queueModal {
q := &queueModal{title: title, stages: stages, collapsed: map[string]bool{}}
q.rebuild()
return q
}
// rebuild recomputes the visible rows, skipping anything inside a folded phase.
func (q *queueModal) rebuild() {
q.rows = q.rows[:0]
seen := map[string]bool{}
for _, stage := range q.stages {
if stage.Phase != "" && !seen[stage.Phase] {
seen[stage.Phase] = true
q.rows = append(q.rows, queueRow{header: true, phase: stage.Phase})
}
if stage.Phase != "" && q.collapsed[stage.Phase] {
continue
}
q.rows = append(q.rows, queueRow{phase: stage.Phase, stage: stage})
}
if q.cursor >= len(q.rows) {
q.cursor = max(0, len(q.rows)-1)
}
q.recompute()
}
// phaseCount is how many commands a phase holds, for the folded heading.
func (q *queueModal) phaseCount(phase string) int {
n := 0
for _, stage := range q.stages {
if stage.Phase == phase {
n++
}
}
return n
}
func (q *queueModal) phaseWarnings(phase string) int {
n := 0
for _, stage := range q.stages {
if stage.Phase == phase && stage.IsWarning() {
n++
}
}
return n
}
func (q *queueModal) Update(msg tea.Msg) (modal, tea.Cmd) {
switch msg := msg.(type) {
case spinnerTickMsg:
return q, nil
case tea.KeyMsg:
if q.searching {
return q.updateSearch(msg)
}
switch msg.String() {
case "q", "esc", "ctrl+c":
return nil, nil
case "j", "down":
q.cursor++
case "k", "up":
q.cursor--
case "ctrl+d", "pgdown":
q.cursor += 10
case "ctrl+u", "pgup":
q.cursor -= 10
case "g", "home":
q.cursor = 0
case "G", "end":
q.cursor = len(q.rows) - 1
case " ", "enter":
q.toggle()
case "z":
q.foldAll()
case "Z":
q.collapsed = map[string]bool{}
q.rebuild()
case "/":
q.searching = true
q.query = ""
q.searchErr = ""
case "n":
q.nextMatch(1)
case "N":
q.nextMatch(-1)
}
if q.cursor < 0 {
q.cursor = 0
}
if q.cursor >= len(q.rows) {
q.cursor = max(0, len(q.rows)-1)
}
q.scrollToCursor()
}
return q, nil
}
// toggle folds or unfolds the phase the cursor is on or inside.
func (q *queueModal) toggle() {
if q.cursor >= len(q.rows) {
return
}
phase := q.rows[q.cursor].phase
if phase == "" {
return
}
q.collapsed[phase] = !q.collapsed[phase]
// land the cursor on the heading so it does not jump to a hidden row
q.rebuild()
for i, row := range q.rows {
if row.header && row.phase == phase {
q.cursor = i
break
}
}
}
func (q *queueModal) foldAll() {
for _, stage := range q.stages {
if stage.Phase != "" {
q.collapsed[stage.Phase] = true
}
}
q.rebuild()
q.cursor = 0
}
func (q *queueModal) updateSearch(msg tea.KeyMsg) (modal, tea.Cmd) {
switch msg.Type {
case tea.KeyEsc:
q.searching = false
q.query = ""
q.searchErr = ""
return q, nil
case tea.KeyEnter:
q.searching = false
if q.query != "" && len(q.matches) == 0 {
q.searchErr = "no match"
}
return q, nil
case tea.KeyBackspace:
if q.query != "" {
runes := []rune(q.query)
q.query = string(runes[:len(runes)-1])
}
q.recompute()
return q, nil
case tea.KeyRunes, tea.KeySpace:
q.query += string(msg.Runes)
q.recompute()
return q, nil
}
return q, nil
}
// recompute finds the rows matching the query and unfolds anything hiding them.
func (q *queueModal) recompute() {
q.searchErr = ""
q.matches = q.matches[:0]
if q.query == "" {
return
}
needle := strings.ToLower(q.query)
// a fold must not hide a match: searching is how you find things
unfolded := false
for _, stage := range q.stages {
if !strings.Contains(strings.ToLower(stage.Command), needle) &&
!strings.Contains(strings.ToLower(stage.What), needle) {
continue
}
if q.collapsed[stage.Phase] {
delete(q.collapsed, stage.Phase)
unfolded = true
}
}
if unfolded {
q.rebuild()
return // rebuild recomputes again with the folds open
}
for i, row := range q.rows {
if row.header {
continue
}
if strings.Contains(strings.ToLower(row.stage.Command), needle) ||
strings.Contains(strings.ToLower(row.stage.What), needle) {
q.matches = append(q.matches, i)
}
}
q.matchAt = 0
if len(q.matches) > 0 {
q.cursor = q.matches[0]
q.scrollToCursor()
}
}
func (q *queueModal) nextMatch(step int) {
if len(q.matches) == 0 {
return
}
q.matchAt = (q.matchAt + step + len(q.matches)) % len(q.matches)
q.cursor = q.matches[q.matchAt]
q.scrollToCursor()
}
// visibleLines is how many rendered lines fit the window.
//
// The box contributes a border and a line of padding top and bottom, and the
// title, summary and footer take six lines between them. Getting this wrong
// does not clip the view -- lipgloss.Place pads but never truncates -- it makes
// the whole box taller than the terminal and the top scrolls off.
func (q *queueModal) visibleLines() int {
return max(1, q.height-11)
}
// layout renders every visible row into flat lines.
//
// The window is measured in rendered lines, not rows: a row is one to three
// lines tall, so counting rows would make the body roughly twice the height of
// its window and clip the top of the queue away.
func (q *queueModal) layout(inner int) []qLine {
out := make([]qLine, 0, len(q.rows)*2)
for i, row := range q.rows {
for _, text := range q.rowLines(i, row, inner) {
out = append(out, qLine{text: text, row: i})
}
}
return out
}
func lineOfRow(lines []qLine, row int) int {
for i, line := range lines {
if line.row == row {
return i
}
}
return 0
}
// scrollToCursor brings the whole cursor row into view, not just its first line.
func (q *queueModal) scrollToCursor() {
lines := q.layout(q.inner)
target := lineOfRow(lines, q.cursor)
height := 1
for i := target; i < len(lines) && lines[i].row == q.cursor; i++ {
height = i - target + 1
}
visible := q.visibleLines()
if target < q.scroll {
q.scroll = target
}
if target+height > q.scroll+visible {
q.scroll = target + height - visible
}
if q.scroll < 0 {
q.scroll = 0
}
}
func (q *queueModal) View(width, height int) string {
inner := width - 8
if inner < 24 {
inner = 24
}
q.height = height
q.inner = inner
visible := q.visibleLines()
var b strings.Builder
b.WriteString(Title.Render(q.title))
// a summary line, so a folded queue still says how much is in it
warnings := 0
for _, stage := range q.stages {
if stage.IsWarning() {
warnings++
}
}
summary := fmt.Sprintf("%d commands in %d phases",
len(q.stages), q.distinctPhases())
if warnings > 0 {
summary = Err.Render(fmt.Sprintf("%d command(s) flagged by the audit rules",
warnings)) + " · " + summary
} else {
summary = Muted.Render("Nothing in this queue matched an audit rule") +
" · " + summary
}
b.WriteString("\n\n" + summary + "\n\n")
lines := q.layout(inner)
if q.scroll > len(lines)-visible {
q.scroll = max(0, len(lines)-visible)
}
if q.scroll < 0 {
q.scroll = 0
}
end := min(len(lines), q.scroll+visible)
for _, line := range lines[q.scroll:end] {
b.WriteString(line.text + "\n")
}
for i := end - q.scroll; i < visible; i++ {
b.WriteString("\n")
}
b.WriteString(q.footer(inner))
return place(Box.Render(b.String()), width, height)
}
func (q *queueModal) distinctPhases() int {
seen := map[string]bool{}
for _, stage := range q.stages {
seen[stage.Phase] = true
}
return len(seen)
}
// rowLines renders one row: the command, then its description and the rule that
// flagged it, each on its own line.
func (q *queueModal) rowLines(i int, row queueRow, inner int) []string {
selected := i == q.cursor
if row.header {
arrow := "▾"
if q.collapsed[row.phase] {
arrow = "▸"
}
label := phaseLabel(row.phase)
count := Muted.Render(fmt.Sprintf(" %d", q.phaseCount(row.phase)))
bad := ""
if n := q.phaseWarnings(row.phase); n > 0 {
bad = Err.Render(fmt.Sprintf(" %d flagged", n))
}
// one space, so a heading lines up with the commands beneath it
line := " " + Muted.Render(arrow) + " " + label + count + bad
if selected {
return []string{Selected.Render(ansi.Truncate(ansi.Strip(line), inner, "…"))}
}
return []string{ansi.Truncate(line, inner, "…")}
}
marker, role := queueMarker(row.stage)
style := StylesByRole(role)
body := " " + style.Render(marker) + " " + highlightShell(row.stage.Command)
if selected {
body = Selected.Render(ansi.Truncate(ansi.Strip(body), inner, "…"))
} else {
body = ansi.Truncate(body, inner, "…")
}
out := []string{body}
if row.stage.What != "" {
// the description sits under the command; on the selected row the
// command is already highlighted, so the description stays plain
plain := Desc
if !selected {
plain = Muted
}
out = append(out, " "+plain.Render(
ansi.Truncate(row.stage.What, max(8, inner-6), "…")))
}
// naming the rule is the whole value of the marker: "dangerous" without a
// reason is not actionable
if row.stage.Finding != "" {
finding := Err
if row.stage.Level == "warn" {
finding = Warn
}
out = append(out, " "+finding.Render(
ansi.Truncate("flagged: "+row.stage.Finding, max(8, inner-6), "…")))
}
return out
}
func (q *queueModal) footer(inner int) string {
pos := fmt.Sprintf("[%d/%d]", q.cursor+1, max(1, len(q.rows)))
hint := "[j/k] move • [space] fold • [z] fold all • [/] search • [esc] close"
if q.searching || q.query != "" {
cursor := ""
if q.searching {
cursor = Accent.Render("_")
}
hint = "/" + q.query + cursor
if len(q.matches) > 0 {
hint += Muted.Render(fmt.Sprintf(" %d/%d", q.matchAt+1, len(q.matches)))
} else if q.searchErr != "" {
hint += Warn.Render(" " + q.searchErr)
}
}
gap := inner - ansi.StringWidth(hint) - len(pos)
if gap < 1 {
gap = 1
}
return "\n" + Desc.Render(hint) + strings.Repeat(" ", gap) + Muted.Render(pos)
}