383 lines
11 KiB
Go
383 lines
11 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"charm.land/bubbles/v2/viewport"
|
|
"charm.land/lipgloss/v2"
|
|
|
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/gh"
|
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/logs"
|
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/task"
|
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/ui"
|
|
)
|
|
|
|
type sidebarSource int
|
|
|
|
const (
|
|
// sourceLogs follows the selected (or pinned) container.
|
|
sourceLogs sidebarSource = iota
|
|
// sourceTask follows a background pull or build.
|
|
sourceTask
|
|
// sourceCI follows the GitHub CI checks for the selected PR.
|
|
sourceCI
|
|
)
|
|
|
|
var sidebarTabNames = map[sidebarSource]string{
|
|
sourceLogs: "logs",
|
|
sourceTask: "build",
|
|
sourceCI: "ci",
|
|
}
|
|
|
|
var tabOrder = []sidebarSource{sourceLogs, sourceTask, sourceCI}
|
|
|
|
// sidebar is the right-hand panel. It has two jobs — live container output and
|
|
// live build output — and both are just text, so one scrollable viewport backs
|
|
// them and the source switch only changes what fills it.
|
|
type sidebar struct {
|
|
visible bool
|
|
source sidebarSource
|
|
viewport viewport.Model
|
|
follow bool
|
|
pinned string
|
|
taskID int
|
|
width int
|
|
height int
|
|
title string
|
|
subtitle string
|
|
}
|
|
|
|
func newSidebar() sidebar {
|
|
view := viewport.New(viewport.WithWidth(40), viewport.WithHeight(10))
|
|
view.SoftWrap = true
|
|
view.Style = lipgloss.NewStyle().Foreground(ui.Text)
|
|
return sidebar{visible: true, follow: true, viewport: view, width: 40, height: 10}
|
|
}
|
|
|
|
// resize sets the panel's outer dimensions; the viewport is inset for the
|
|
// border and the two header lines.
|
|
// resize sets the panel's outer size in terminal cells. In Lip Gloss v2 a
|
|
// style's Width includes its border, so the text inside loses two columns to
|
|
// the border and two more to the horizontal padding. Vertically the border
|
|
// costs two rows and the title and subtitle one each.
|
|
func (s *sidebar) resize(width, height int) {
|
|
s.width, s.height = width, height
|
|
s.viewport.SetWidth(s.textWidth())
|
|
s.viewport.SetHeight(max(height-4, 3))
|
|
}
|
|
|
|
// textWidth is the usable width inside the border and padding. Content wider
|
|
// than this soft-wraps, which turns a progress list into a ragged mess, so
|
|
// every renderer budgets against it.
|
|
func (s sidebar) textWidth() int { return max(s.width-4, 10) }
|
|
|
|
func (s *sidebar) setContent(content string) {
|
|
atBottom := s.viewport.AtBottom()
|
|
s.viewport.SetContent(content)
|
|
if s.follow || atBottom {
|
|
s.viewport.GotoBottom()
|
|
}
|
|
}
|
|
|
|
func (s *sidebar) toggleFollow() {
|
|
s.follow = !s.follow
|
|
if s.follow {
|
|
s.viewport.GotoBottom()
|
|
}
|
|
}
|
|
|
|
func (s *sidebar) pageUp() {
|
|
s.follow = false
|
|
s.viewport.PageUp()
|
|
}
|
|
|
|
func (s *sidebar) pageDown() {
|
|
s.viewport.PageDown()
|
|
s.follow = s.viewport.AtBottom()
|
|
}
|
|
|
|
// view renders the panel, or nothing when it is hidden.
|
|
func (s sidebar) view() string {
|
|
if !s.visible || s.width < 24 {
|
|
return ""
|
|
}
|
|
text := s.textWidth()
|
|
header := ui.Heading.Render(ui.Truncate(s.title, text))
|
|
mode := "paused"
|
|
if s.follow {
|
|
mode = "following"
|
|
}
|
|
subtitle := s.subtitle
|
|
if subtitle == "" {
|
|
subtitle = mode
|
|
} else {
|
|
subtitle += " · " + mode
|
|
}
|
|
body := lipgloss.JoinVertical(lipgloss.Left,
|
|
header,
|
|
ui.Help.Render(ui.Truncate(subtitle, text)),
|
|
s.viewport.View(),
|
|
)
|
|
return ui.Panel("", s.source == sourceTask).Width(s.width).Height(s.height - 2).Render(body)
|
|
}
|
|
|
|
var logStateLabels = map[logs.State]string{
|
|
logs.StateStarting: "attaching",
|
|
logs.StateStopped: "stream ended",
|
|
logs.StateFailed: "stream failed",
|
|
}
|
|
|
|
// renderLogSnapshot turns a live log buffer into panel content.
|
|
func renderLogSnapshot(name string, snapshot logs.Snapshot, attached bool) (title, subtitle, content string) {
|
|
title = ui.IconTerminal + " " + name
|
|
if name == "" {
|
|
return ui.IconTerminal + " logs", "no container selected", ui.Help.Render("Select a running instance to stream its logs.")
|
|
}
|
|
if !attached {
|
|
return title, "not streaming", ui.Help.Render("Container is not running.")
|
|
}
|
|
if snapshot.State == logs.StateStreaming {
|
|
subtitle = fmt.Sprintf("%d lines", len(snapshot.Lines))
|
|
} else if label, found := logStateLabels[snapshot.State]; found {
|
|
subtitle = label
|
|
}
|
|
content = tailLines(snapshot.Lines, len(snapshot.Lines))
|
|
if snapshot.Err != nil {
|
|
content += "\n" + ui.Alert.Render(snapshot.Err.Error())
|
|
}
|
|
if strings.TrimSpace(content) == "" {
|
|
content = ui.Help.Render("waiting for output…")
|
|
}
|
|
return title, subtitle, content
|
|
}
|
|
|
|
var taskKindIcons = map[task.Kind]string{
|
|
task.KindBuild: ui.IconBuild,
|
|
task.KindPull: ui.IconCloud,
|
|
}
|
|
|
|
// renderTaskSnapshot lays out a background job: a progress bar per unit of
|
|
// work, then the tail of its output. This is the panel that answers "what is my
|
|
// build doing right now" without leaving the interface.
|
|
func renderTaskSnapshot(snapshot task.Snapshot, width int) (title, subtitle, content string) {
|
|
kindIcon := taskKindIcons[snapshot.Kind]
|
|
if kindIcon == "" {
|
|
kindIcon = ui.IconBuild
|
|
}
|
|
title = kindIcon + " " + snapshot.Kind.String() + " · " + snapshot.Target
|
|
subtitle = snapshot.State.String()
|
|
if snapshot.Detail != "" {
|
|
subtitle += " · " + snapshot.Detail
|
|
}
|
|
subtitle += fmt.Sprintf(" · %s", formatDuration(snapshot.Elapsed()))
|
|
|
|
var out strings.Builder
|
|
labelWidth := stepLabelWidth(snapshot.Steps, width)
|
|
for _, step := range snapshot.Steps {
|
|
out.WriteString(renderStep(step, labelWidth, width))
|
|
out.WriteString("\n")
|
|
}
|
|
if snapshot.Kind == task.KindBuild {
|
|
if tail := tailLines(snapshot.Lines, 60); tail != "" {
|
|
if out.Len() > 0 {
|
|
out.WriteString(ui.Rule("output", width) + "\n")
|
|
}
|
|
// Left unstyled so the build tool's own colours survive.
|
|
out.WriteString(tail + "\n")
|
|
}
|
|
}
|
|
if snapshot.Err != nil {
|
|
out.WriteString("\n" + ui.Alert.Render(snapshot.Err.Error()))
|
|
}
|
|
if strings.TrimSpace(out.String()) == "" {
|
|
out.WriteString(ui.Help.Render("starting…"))
|
|
}
|
|
return title, subtitle, out.String()
|
|
}
|
|
|
|
// renderStep lays out one progress row inside an exact width. The panel wraps
|
|
// long lines, which would turn a layer list into a ragged mess, so each row is
|
|
// budgeted rather than trimmed after the fact.
|
|
// stepLabelWidth sizes the label column to the widest label, so a pull of
|
|
// twelve-character layer IDs does not leave a gap the width of a Dockerfile
|
|
// instruction.
|
|
func stepLabelWidth(steps []task.Step, width int) int {
|
|
longest := 0
|
|
for _, step := range steps {
|
|
longest = max(longest, lipgloss.Width(step.Label))
|
|
}
|
|
bar := clampInt(width/3, 6, 22)
|
|
available := max(width-bar-stepFixedWidth-8, 8)
|
|
return clampInt(longest, 8, available)
|
|
}
|
|
|
|
func renderStep(step task.Step, labelWidth, width int) string {
|
|
icon := ui.Pending.Render(ui.IconPending)
|
|
switch {
|
|
case step.Failed:
|
|
icon = ui.Alert.Render(ui.IconFail)
|
|
case step.Done:
|
|
icon = ui.OK.Render(ui.IconPass)
|
|
}
|
|
colour := ui.Warning
|
|
if step.Done {
|
|
colour = ui.Success
|
|
}
|
|
|
|
note := step.Note
|
|
if note == "" {
|
|
note = strings.ToLower(step.Status)
|
|
}
|
|
bar := clampInt(width/3, 6, 22)
|
|
noteWidth := max(width-bar-labelWidth-stepFixedWidth, 0)
|
|
|
|
rendered := fmt.Sprintf("%s %-*s %s %s",
|
|
icon,
|
|
labelWidth, ui.Truncate(step.Label, labelWidth),
|
|
ui.Bar(step.Percent, bar, colour),
|
|
ui.Percent(step.Percent),
|
|
)
|
|
if noteWidth > 1 && note != "" {
|
|
rendered += " " + ui.Help.Render(ui.Truncate(note, noteWidth-1))
|
|
}
|
|
return rendered
|
|
}
|
|
|
|
// stepFixedWidth is the icon, the percentage, and the three separating spaces.
|
|
const stepFixedWidth = 1 + 4 + 3
|
|
|
|
// tailLines keeps the last count meaningful lines of output.
|
|
func tailLines(lines []string, count int) string {
|
|
kept := make([]string, 0, min(len(lines), count))
|
|
for _, line := range lines {
|
|
if !ui.Blank(line) {
|
|
kept = append(kept, line)
|
|
}
|
|
}
|
|
if len(kept) > count {
|
|
kept = kept[len(kept)-count:]
|
|
}
|
|
return strings.Join(kept, "\n")
|
|
}
|
|
|
|
func clampInt(value, low, high int) int {
|
|
if value < low {
|
|
return low
|
|
}
|
|
if value > high {
|
|
return high
|
|
}
|
|
return value
|
|
}
|
|
|
|
type checkGlyphSpec struct {
|
|
symbol string
|
|
style lipgloss.Style
|
|
}
|
|
|
|
var checkGlyphs = map[string]checkGlyphSpec{
|
|
"pass": {ui.IconPass, ui.OK},
|
|
"fail": {ui.IconFail, ui.Alert},
|
|
"pending": {ui.IconPending, ui.Pending},
|
|
"skipping": {ui.IconStopped, ui.Dim},
|
|
"cancel": {ui.IconFail, ui.Dim},
|
|
}
|
|
|
|
func renderCheckLine(c gh.Check, width int) string {
|
|
spec, found := checkGlyphs[c.BucketKey()]
|
|
if !found {
|
|
spec = checkGlyphs["pending"]
|
|
}
|
|
icon := spec.style.Render(spec.symbol)
|
|
|
|
dur := c.Duration()
|
|
durStyle := ui.Dim
|
|
if dur == "" {
|
|
if c.IsSkipped() {
|
|
dur = "skipped"
|
|
} else if c.IsPending() {
|
|
dur = "running"
|
|
durStyle = ui.Pending
|
|
} else if c.IsFailure() {
|
|
dur = "failed"
|
|
durStyle = ui.Alert
|
|
} else {
|
|
dur = "—"
|
|
}
|
|
} else if c.IsFailure() {
|
|
durStyle = ui.Alert
|
|
} else if c.IsPending() {
|
|
durStyle = ui.Pending
|
|
}
|
|
styledDur := durStyle.Render(dur)
|
|
durLen := lipgloss.Width(styledDur)
|
|
|
|
name := c.Name
|
|
if c.Workflow != "" && c.Workflow != c.Name && c.Workflow != "CI" {
|
|
name += " " + ui.Dim.Render("("+c.Workflow+")")
|
|
}
|
|
|
|
maxNameWidth := max(width-durLen-4, 10)
|
|
styledName := ui.Truncate(name, maxNameWidth)
|
|
padding := max(width-lipgloss.Width(icon)-1-lipgloss.Width(styledName)-durLen, 1)
|
|
|
|
line := icon + " " + styledName + strings.Repeat(" ", padding) + styledDur
|
|
if c.Description != "" {
|
|
line += "\n " + ui.Help.Render(ui.Truncate(c.Description, max(width-6, 10)))
|
|
}
|
|
return line
|
|
}
|
|
|
|
func renderCISnapshot(pr int, checks []gh.Check, loading bool, err error, width int) (title, subtitle, content string) {
|
|
if pr <= 0 {
|
|
return "ci", "no PR selected", ui.Help.Render("Select a pull request to inspect its CI checks.")
|
|
}
|
|
title = fmt.Sprintf("ci · PR #%d", pr)
|
|
if loading && len(checks) == 0 {
|
|
return title, "fetching…", ui.Help.Render("Fetching CI checks from GitHub…")
|
|
}
|
|
if err != nil && len(checks) == 0 {
|
|
return title, "error", ui.Alert.Render(err.Error()) + "\n\n" + ui.Help.Render("Check `gh auth status` and internet connection.")
|
|
}
|
|
if len(checks) == 0 {
|
|
return title, "no checks", ui.Help.Render("No CI checks reported for this pull request.")
|
|
}
|
|
|
|
pass, fail, pending, skipping := 0, 0, 0, 0
|
|
for _, c := range checks {
|
|
switch {
|
|
case c.IsSuccess():
|
|
pass++
|
|
case c.IsFailure():
|
|
fail++
|
|
case c.IsPending():
|
|
pending++
|
|
default:
|
|
skipping++
|
|
}
|
|
}
|
|
|
|
var parts []string
|
|
if pass > 0 {
|
|
parts = append(parts, fmt.Sprintf("%s %d pass", ui.IconPass, pass))
|
|
}
|
|
if fail > 0 {
|
|
parts = append(parts, fmt.Sprintf("%s %d fail", ui.IconFail, fail))
|
|
}
|
|
if pending > 0 {
|
|
parts = append(parts, fmt.Sprintf("%s %d pending", ui.IconPending, pending))
|
|
}
|
|
if skipping > 0 {
|
|
parts = append(parts, fmt.Sprintf("%s %d skipped", ui.IconStopped, skipping))
|
|
}
|
|
subtitle = strings.Join(parts, " · ")
|
|
|
|
var out strings.Builder
|
|
for _, c := range checks {
|
|
out.WriteString(renderCheckLine(c, width))
|
|
out.WriteString("\n")
|
|
}
|
|
return title, subtitle, strings.TrimRight(out.String(), "\n")
|
|
}
|