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