ShortenID and SmallSHA sliced their input to a fixed length without checking it was long enough, panicking on shorter strings. Return the input unchanged when it is already shorter than the cut. Also replace the blank Commit placeholder with an explicit "unknown-commit" sentinel. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
282 lines
6.0 KiB
Go
282 lines
6.0 KiB
Go
package formatter
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"coopcloud.tech/abra/pkg/i18n"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/charmbracelet/lipgloss/table"
|
|
"github.com/docker/go-units"
|
|
"golang.org/x/term"
|
|
|
|
"coopcloud.tech/abra/pkg/config"
|
|
"coopcloud.tech/abra/pkg/log"
|
|
"github.com/schollz/progressbar/v3"
|
|
)
|
|
|
|
var BoldStyle = lipgloss.NewStyle().
|
|
Bold(true)
|
|
|
|
var BoldUnderlineStyle = lipgloss.NewStyle().
|
|
Bold(true).
|
|
Underline(true)
|
|
|
|
func ShortenID(str string) string {
|
|
if len(str) < 12 {
|
|
return str
|
|
}
|
|
return str[:12]
|
|
}
|
|
|
|
func SmallSHA(hash string) string {
|
|
if len(hash) < 8 {
|
|
return hash
|
|
}
|
|
return hash[:8]
|
|
}
|
|
|
|
// RemoveSha remove image sha from a string that are added in some docker outputs
|
|
func RemoveSha(str string) string {
|
|
return strings.Split(str, "@")[0]
|
|
}
|
|
|
|
// HumanDuration from docker/cli RunningFor() to be accessible outside of the class
|
|
func HumanDuration(timestamp int64) string {
|
|
date := time.Unix(timestamp, 0)
|
|
now := time.Now().UTC()
|
|
return units.HumanDuration(now.Sub(date)) + i18n.G(" ago")
|
|
}
|
|
|
|
// CreateTable prepares a table layout for output.
|
|
func CreateTable() (*table.Table, error) {
|
|
var (
|
|
renderer = lipgloss.NewRenderer(os.Stdout)
|
|
headerStyle = renderer.NewStyle().Bold(true).Align(lipgloss.Center)
|
|
cellStyle = renderer.NewStyle().Padding(0, 1)
|
|
borderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("63"))
|
|
)
|
|
|
|
table := table.New().
|
|
Border(lipgloss.ThickBorder()).
|
|
BorderStyle(borderStyle).
|
|
StyleFunc(func(row, col int) lipgloss.Style {
|
|
var style lipgloss.Style
|
|
|
|
switch {
|
|
case row == table.HeaderRow:
|
|
return headerStyle
|
|
default:
|
|
style = cellStyle
|
|
}
|
|
|
|
return style
|
|
})
|
|
|
|
return table, nil
|
|
}
|
|
|
|
func PrintTable(t *table.Table) error {
|
|
if isAbraCI, ok := os.LookupEnv("ABRA_CI"); ok && isAbraCI == "1" {
|
|
// NOTE(d1): no width limits for CI testing since we test against outputs
|
|
log.Debug(i18n.G("detected ABRA_CI=1"))
|
|
fmt.Println(t)
|
|
return nil
|
|
}
|
|
|
|
tWidth, _ := lipgloss.Size(t.String())
|
|
|
|
width, _, err := term.GetSize(0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if tWidth > width {
|
|
t.Width(width - 10)
|
|
}
|
|
|
|
fmt.Println(t)
|
|
|
|
return nil
|
|
}
|
|
|
|
// horizontal is a JoinHorizontal helper function.
|
|
func horizontal(left, mid, right string) string {
|
|
return lipgloss.JoinHorizontal(lipgloss.Top, left, mid, right)
|
|
}
|
|
|
|
func CreateOverview(header string, rows [][]string) string {
|
|
var borderStyle = lipgloss.NewStyle().
|
|
BorderStyle(lipgloss.ThickBorder()).
|
|
Padding(0, 1, 0, 1).
|
|
BorderForeground(lipgloss.Color("63"))
|
|
|
|
var headerStyle = lipgloss.NewStyle().
|
|
Underline(true).
|
|
Bold(true).
|
|
PaddingBottom(1)
|
|
|
|
var leftStyle = lipgloss.NewStyle()
|
|
var rightStyle = lipgloss.NewStyle()
|
|
|
|
var longest int
|
|
for _, row := range rows {
|
|
if len(row[0]) > longest {
|
|
longest = len(row[0])
|
|
}
|
|
}
|
|
|
|
var renderedRows []string
|
|
for _, row := range rows {
|
|
if len(row) < 2 {
|
|
continue
|
|
}
|
|
|
|
if len(row) > 2 {
|
|
panic(i18n.G("CreateOverview: only accepts rows of len == 2"))
|
|
}
|
|
|
|
lenOffset := 4
|
|
if len(row[0]) < longest {
|
|
lenOffset += longest - len(row[0])
|
|
}
|
|
|
|
offset := ""
|
|
for range lenOffset {
|
|
offset = offset + " "
|
|
}
|
|
|
|
rendered := horizontal(leftStyle.Render(row[0]), offset, rightStyle.Render(row[1]))
|
|
|
|
if row[1] == "---" {
|
|
rendered = horizontal(
|
|
leftStyle.
|
|
Bold(true).
|
|
Underline(true).
|
|
PaddingTop(1).
|
|
Render(row[0]),
|
|
offset,
|
|
rightStyle.Render(""),
|
|
)
|
|
}
|
|
|
|
renderedRows = append(renderedRows, rendered)
|
|
}
|
|
|
|
body := strings.Builder{}
|
|
body.WriteString(
|
|
borderStyle.Render(
|
|
lipgloss.JoinVertical(
|
|
lipgloss.Center,
|
|
headerStyle.Render(header),
|
|
lipgloss.JoinVertical(
|
|
lipgloss.Left,
|
|
renderedRows...,
|
|
),
|
|
),
|
|
),
|
|
)
|
|
|
|
return body.String()
|
|
}
|
|
|
|
// ToJSON converts a lipgloss.Table to JSON representation. It's not a robust
|
|
// implementation and mainly caters for our current use case which is basically
|
|
// a bunch of strings. See https://github.com/charmbracelet/lipgloss/issues/335
|
|
// for the real thing (hopefully).
|
|
func ToJSON(headers []string, rows [][]string) (string, error) {
|
|
var buff bytes.Buffer
|
|
|
|
buff.Write([]byte("["))
|
|
|
|
for idx, row := range rows {
|
|
payload := make(map[string]string)
|
|
|
|
for idx, header := range headers {
|
|
payload[strings.ToLower(header)] = row[idx]
|
|
}
|
|
|
|
serialized, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
buff.Write(serialized)
|
|
|
|
if idx < (len(rows) - 1) {
|
|
buff.Write([]byte(","))
|
|
}
|
|
}
|
|
|
|
buff.Write([]byte("]"))
|
|
|
|
return buff.String(), nil
|
|
}
|
|
|
|
// CreateProgressbar generates a progress bar
|
|
func CreateProgressbar(length int, title string) *progressbar.ProgressBar {
|
|
return progressbar.NewOptions(
|
|
length,
|
|
progressbar.OptionClearOnFinish(),
|
|
progressbar.OptionSetPredictTime(false),
|
|
progressbar.OptionShowCount(),
|
|
progressbar.OptionSetDescription(title),
|
|
)
|
|
}
|
|
|
|
// StripTagMeta strips front-matter image tag data that we don't need for parsing.
|
|
func StripTagMeta(image string) string {
|
|
originalImage := image
|
|
|
|
if strings.Contains(image, "docker.io") {
|
|
image = strings.Split(image, "/")[1]
|
|
}
|
|
|
|
if strings.Contains(image, "library") {
|
|
image = strings.Split(image, "/")[1]
|
|
}
|
|
|
|
if originalImage != image {
|
|
log.Debug(i18n.G("stripped %s to %s for parsing", originalImage, image))
|
|
}
|
|
|
|
return image
|
|
}
|
|
|
|
// ByteCountSI presents a human friendly representation of a byte count. See
|
|
// https://yourbasic.org/golang/formatting-byte-size-to-human-readable-format.
|
|
func ByteCountSI(b uint64) string {
|
|
const unit = 1000
|
|
|
|
if b < unit {
|
|
return fmt.Sprintf("%d B", b)
|
|
}
|
|
|
|
div, exp := uint64(unit), 0
|
|
for n := b / unit; n >= unit; n /= unit {
|
|
div *= unit
|
|
exp++
|
|
}
|
|
|
|
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "kMGTPE"[exp])
|
|
}
|
|
|
|
// BoldDirtyDefault ensures a dirty modifier is rendered in bold.
|
|
func BoldDirtyDefault(v string) string {
|
|
if strings.HasSuffix(v, config.DIRTY_DEFAULT) {
|
|
vBold := BoldStyle.Render(config.DIRTY_DEFAULT)
|
|
v = strings.Replace(v, config.DIRTY_DEFAULT, vBold, 1)
|
|
}
|
|
|
|
return v
|
|
}
|
|
|
|
// AddDirtyMarker adds the dirty marker to a version string.
|
|
func AddDirtyMarker(v string) string {
|
|
return fmt.Sprintf("%s%s", v, config.DIRTY_DEFAULT)
|
|
}
|