Files
homarr/tools/homarr-dev/internal/tui/sidebar.go
T

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