feat: fit list tables to the terminal and write TSV when piped

Lists had no header, wrapped every row once a title was longer than
the terminal, and were space-padded when piped, so cut -f and awk -F'\t'
could not split them and empty cells collapsed.

terminal.Table now takes column specs and a Display. On a terminal it
prints an uppercase header, aligns columns two spaces apart and shrinks
flexible columns, widest first, to Streams.Width (the terminal, then
$COLUMNS, then 80), cutting by display width with "…". States use the
terminal's ANSI 16 colors, hex colors fall back to the 256-color palette
unless COLORTERM advertises 24-bit, and OSC 8 links appear only on the
first column, independently of color (TEA_HYPERLINKS=auto|always|never).
Piped tables are tab-separated without header, color or links, and times
are RFC3339 UTC instead of "about 2 hours ago".

terminal.View lays out single resources (title, badges, metadata, fields,
body, footer URL) and renders markdown bodies with glamour on a terminal
only (TEA_MARKDOWN=raw keeps them raw). Shared formatters cover relative
times, timestamps, sizes, durations and status icons.

In the app, writeTable and writeView are the new list and view writers:
a "Showing N of M …" context line, an empty-state message on stderr and
the next-page hint. writeList, writeItem and batch keep working and now
render through the new table: tab-separated when piped, truncated at the
widest column on a terminal, with headers for issue list and pr list.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-11 22:43:10 +02:00
1 parent 1c9cc32d28
commit 55574a9fd0
17 files changed
+1253 -103

No files matched your search

+81 -10
View File
@@ -26,22 +26,93 @@ func ColorEnabled(preference string, outTTY bool, getenv func(string) string) bo
}
}
// Colorize applies a 24-bit foreground color from a six-digit RGB hex value.
func Colorize(hex, text string, enabled bool) string {
if !enabled || text == "" {
// HyperlinksEnabled resolves the hyperlinks preference like ColorEnabled, but independently of color:
// NO_COLOR keeps links, and auto links only on TTY stdout whose TERM is not dumb.
func HyperlinksEnabled(preference string, outTTY bool, getenv func(string) string) bool {
switch preference {
case "always":
return true
case "never":
return false
default:
return outTTY && (getenv == nil || getenv("TERM") != "dumb")
}
}
// TrueColor reports whether the terminal advertises 24-bit color through COLORTERM.
func TrueColor(getenv func(string) string) bool {
if getenv == nil {
return false
}
value := getenv("COLORTERM")
return value == "truecolor" || value == "24bit"
}
// namedColors are the ANSI 16 colors for states and status, so they follow the terminal's own theme.
var namedColors = map[string]string{"red": "31", "green": "32", "yellow": "33", "blue": "34", "magenta": "35", "cyan": "36", "muted": "90"}
// Colorize colors text by name (red, green, yellow, blue, magenta, cyan or muted) from the terminal's palette,
// or by six-digit RGB hex with 24-bit color. Unknown colors leave the text plain.
func Colorize(color, text string, enabled bool) string {
if !enabled {
return text
}
return paint(color, text, true)
}
// paint is Colorize for enabled color; without trueColor, hex colors use the nearest 256-color index.
func paint(color, text string, trueColor bool) string {
if text == "" {
return text
}
if code, ok := namedColors[color]; ok {
return "\x1b[" + code + "m" + text + "\x1b[0m"
}
r, g, b, ok := parseHex(color)
switch {
case !ok:
return text
case trueColor:
return fmt.Sprintf("\x1b[38;2;%d;%d;%dm%s\x1b[0m", r, g, b, text)
default:
return fmt.Sprintf("\x1b[38;5;%dm%s\x1b[0m", ansi256(r, g, b), text)
}
}
func parseHex(hex string) (r, g, b int, ok bool) {
hex = strings.TrimPrefix(strings.TrimSpace(hex), "#")
if len(hex) != 6 {
return text
return 0, 0, 0, false
}
r, errR := strconv.ParseUint(hex[0:2], 16, 8)
g, errG := strconv.ParseUint(hex[2:4], 16, 8)
b, errB := strconv.ParseUint(hex[4:6], 16, 8)
if errR != nil || errG != nil || errB != nil {
return text
value, err := strconv.ParseUint(hex, 16, 24)
if err != nil {
return 0, 0, 0, false
}
return fmt.Sprintf("\x1b[38;2;%d;%d;%dm%s\x1b[0m", r, g, b, text)
return int(value >> 16), int(value >> 8 & 0xff), int(value & 0xff), true
}
// ansi256 returns the 256-color palette index nearest to an RGB color: one of the 6×6×6 cube colors
// (16–231) or one of the 24 grays (232–255).
func ansi256(r, g, b int) int {
levels := [6]int{0, 95, 135, 175, 215, 255}
level := func(v int) int {
switch {
case v < 48:
return 0
case v < 115:
return 1
default:
return (v - 35) / 40
}
}
ri, gi, bi := level(r), level(g), level(b)
gray := min(max((r+g+b)/3-3, 0)/10, 23)
grayValue := 8 + 10*gray
distance := func(cr, cg, cb int) int { return (r-cr)*(r-cr) + (g-cg)*(g-cg) + (b-cb)*(b-cb) }
if distance(levels[ri], levels[gi], levels[bi]) <= distance(grayValue, grayValue, grayValue) {
return 16 + 36*ri + 6*gi + bi
}
return 232 + gray
}
// Emphasis applies simple bold styling for TTY human tables.
+43 -11
View File
@@ -9,30 +9,62 @@ import (
"github.com/stretchr/testify/require"
)
func env(vars map[string]string) func(string) string {
return func(key string) string { return vars[key] }
}
func TestColorEnabledRespectsPreferenceAndEnvironment(t *testing.T) {
getenv := func(key string) string {
if key == "NO_COLOR" {
return "1"
}
return ""
}
noColor := env(map[string]string{"NO_COLOR": "1"})
require.True(t, ColorEnabled("always", false, nil))
require.False(t, ColorEnabled("never", true, nil))
require.True(t, ColorEnabled("auto", true, func(string) string { return "" }))
require.False(t, ColorEnabled("auto", false, func(string) string { return "" }))
require.False(t, ColorEnabled("auto", true, getenv))
require.True(t, ColorEnabled("always", true, getenv))
require.False(t, ColorEnabled("auto", true, func(key string) string { return map[string]string{"TERM": "dumb"}[key] }))
require.True(t, ColorEnabled("auto", true, env(nil)))
require.False(t, ColorEnabled("auto", false, env(nil)))
require.False(t, ColorEnabled("auto", true, noColor))
require.True(t, ColorEnabled("always", true, noColor))
require.False(t, ColorEnabled("auto", true, env(map[string]string{"TERM": "dumb"})))
}
func TestHyperlinksEnabledIgnoresColorPolicy(t *testing.T) {
for _, tc := range []struct {
preference string
tty bool
env map[string]string
want bool
}{
{"auto", true, nil, true},
{"", true, map[string]string{"NO_COLOR": "1"}, true},
{"auto", false, nil, false},
{"auto", true, map[string]string{"TERM": "dumb"}, false},
{"always", false, map[string]string{"TERM": "dumb"}, true},
{"never", true, nil, false},
} {
require.Equal(t, tc.want, HyperlinksEnabled(tc.preference, tc.tty, env(tc.env)), "%+v", tc)
}
}
func TestTrueColor(t *testing.T) {
require.True(t, TrueColor(env(map[string]string{"COLORTERM": "truecolor"})))
require.True(t, TrueColor(env(map[string]string{"COLORTERM": "24bit"})))
require.False(t, TrueColor(env(map[string]string{"COLORTERM": "yes"})))
require.False(t, TrueColor(nil))
}
func TestColorizeAndEmphasis(t *testing.T) {
require.Equal(t, "bug", Colorize("ff0000", "bug", false))
require.Equal(t, "bug", Colorize("bad", "bug", true))
require.Equal(t, "\x1b[38;2;255;0;0mbug\x1b[0m", Colorize("#ff0000", "bug", true))
require.Equal(t, "\x1b[32mopen\x1b[0m", Colorize("green", "open", true))
require.Equal(t, "\x1b[90mdraft\x1b[0m", Colorize("muted", "draft", true))
require.Equal(t, "open", Emphasis("open", false))
require.Equal(t, "\x1b[1mopen\x1b[0m", Emphasis("open", true))
}
func TestPaintApproximatesHexColorsWithoutTrueColor(t *testing.T) {
for hex, index := range map[string]string{"ff0000": "196", "00ff00": "46", "000000": "16", "ffffff": "231", "808080": "244", "d73a4a": "167", "7057ff": "63"} {
require.Equal(t, "\x1b[38;5;"+index+"mx\x1b[0m", paint(hex, "x", false), hex)
}
}
func TestHyperlink(t *testing.T) {
require.Equal(t, "#7", Hyperlink("https://example.org/i/7", "#7", false))
require.Equal(t, "\x1b]8;;https://example.org/i/7\x1b\\#7\x1b]8;;\x1b\\", Hyperlink("https://example.org/i/7", "#7", true))
+55
View File
@@ -0,0 +1,55 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package terminal
import (
"fmt"
"time"
)
// Bytes formats a size in binary units: "13 B", "2.9 MiB".
func Bytes(n int64) string {
if n < 1024 {
return fmt.Sprintf("%d B", n)
}
units := []string{"KiB", "MiB", "GiB", "TiB"}
value, unit := float64(n)/1024, 0
for value >= 1024 && unit < len(units)-1 {
value /= 1024
unit++
}
return fmt.Sprintf("%.1f %s", value, units[unit])
}
// Duration formats an elapsed time by its two largest units: "1h 30m", "1m 12s", "45s".
func Duration(d time.Duration) string {
d = d.Round(time.Second)
hours, minutes, seconds := int(d/time.Hour), int(d%time.Hour/time.Minute), int(d%time.Minute/time.Second)
switch {
case hours > 0:
return fmt.Sprintf("%dh %dm", hours, minutes)
case minutes > 0:
return fmt.Sprintf("%dm %ds", minutes, seconds)
default:
return fmt.Sprintf("%ds", seconds)
}
}
// StatusIcon summarizes a check or run in one cell: ✓ succeeded, X failed, - skipped or canceled, and * still
// pending. A conclusion, when present, takes precedence over the status.
func StatusIcon(status, conclusion string) Cell {
if conclusion != "" {
status = conclusion
}
switch status {
case "success":
return Cell{Text: "✓", Color: "green"}
case "failure", "error":
return Cell{Text: "X", Color: "red"}
case "cancelled", "canceled", "skipped": //nolint:misspell // Gitea API spelling.
return Cell{Text: "-", Muted: true}
default:
return Cell{Text: "*", Color: "yellow"}
}
}
+67
View File
@@ -0,0 +1,67 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package terminal
import (
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestRelativeTime(t *testing.T) {
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
for _, tc := range []struct {
at time.Time
want string
}{
{now.Add(-30 * time.Second), "less than a minute ago"},
{now.Add(-time.Minute), "about 1 minute ago"},
{now.Add(-3 * time.Hour), "about 3 hours ago"},
{now.AddDate(0, -2, 0), "about 2 months ago"},
{now.AddDate(-2, -1, 0), "about 2 years ago"},
{now.Add(10 * time.Second), "in less than a minute"},
{now.AddDate(0, 0, 3), "in about 3 days"},
} {
require.Equal(t, tc.want, RelativeTime(now, tc.at), tc.at)
}
}
func TestTimestamp(t *testing.T) {
at := time.Date(2026, 10, 11, 20, 48, 23, 588823137, time.FixedZone("CEST", 2*3600))
require.Equal(t, "2026-10-11T18:48:23Z", Timestamp(at))
require.Equal(t, at.Unix(), ParseTime("2026-10-11T20:48:23+02:00").Unix())
require.True(t, ParseTime("").IsZero())
require.True(t, ParseTime("yesterday").IsZero())
}
func TestBytes(t *testing.T) {
for n, want := range map[int64]string{0: "0 B", 13: "13 B", 1023: "1023 B", 1536: "1.5 KiB", 3040870: "2.9 MiB", 5 << 30: "5.0 GiB", 3 << 50: "3072.0 TiB"} {
require.Equal(t, want, Bytes(n), n)
}
}
func TestDuration(t *testing.T) {
for d, want := range map[time.Duration]string{0: "0s", 45 * time.Second: "45s", 72 * time.Second: "1m 12s", 90 * time.Minute: "1h 30m", 2 * time.Hour: "2h 0m", 1499 * time.Millisecond: "1s"} {
require.Equal(t, want, Duration(d), d)
}
}
func TestStatusIcon(t *testing.T) {
for _, tc := range []struct {
status, conclusion string
want Cell
}{
{"completed", "success", Cell{Text: "✓", Color: "green"}},
{"success", "", Cell{Text: "✓", Color: "green"}},
{"completed", "failure", Cell{Text: "X", Color: "red"}},
{"error", "", Cell{Text: "X", Color: "red"}},
{"completed", "cancelled", Cell{Text: "-", Muted: true}}, //nolint:misspell // Gitea API spelling.
{"skipped", "", Cell{Text: "-", Muted: true}},
{"queued", "", Cell{Text: "*", Color: "yellow"}},
{"pending", "", Cell{Text: "*", Color: "yellow"}},
} {
require.Equal(t, tc.want, StatusIcon(tc.status, tc.conclusion), "%s/%s", tc.status, tc.conclusion)
}
}
+54
View File
@@ -0,0 +1,54 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package terminal
import (
"regexp"
"strings"
"charm.land/glamour/v2"
"charm.land/glamour/v2/styles"
)
// MarkdownEnabled resolves the markdown preference: raw never renders, and auto renders on TTY stdout.
func MarkdownEnabled(preference string, outTTY bool) bool {
return outTTY && preference != "raw"
}
// glamourLinks matches the OSC 8 hyperlinks that glamour writes for markdown links.
var glamourLinks = regexp.MustCompile("\x1b\\]8;[^\x07\x1b]*(?:\x07|\x1b\\\\)")
// Markdown renders sanitized markdown for d: wrapped to its width (80 when unknown), colored or plain,
// with links only when d allows them. It keeps line breaks like Gitea does, and returns the sanitized
// text unrendered if rendering fails.
func Markdown(text string, d Display) string {
text = SafeText(text)
width := d.Width
if width <= 0 {
width = 80
}
style := styles.NoTTYStyleConfig
if d.Color {
style = styles.DarkStyleConfig
// The terminal's own text color keeps body text readable on light backgrounds too.
style.Document.Color = nil
}
renderer, err := glamour.NewTermRenderer(glamour.WithStyles(style), glamour.WithWordWrap(width), glamour.WithPreservedNewLines())
if err != nil {
return text
}
rendered, err := renderer.Render(text)
if err != nil {
return text
}
if !d.Links {
rendered = glamourLinks.ReplaceAllString(rendered, "")
}
// glamour pads every line to the full width.
lines := strings.Split(rendered, "\n")
for i, line := range lines {
lines[i] = strings.TrimRight(line, " ")
}
return strings.Trim(strings.Join(lines, "\n"), "\n")
}
+20 -1
View File
@@ -9,6 +9,7 @@ import (
"fmt"
"io"
"os"
"strconv"
"strings"
"charm.land/huh/v2"
@@ -24,11 +25,29 @@ type Streams struct {
InTTY bool
OutTTY bool
ErrTTY bool
// Width is the number of columns human output on Out fits in; 0 means unlimited.
Width int
}
// System binds streams to the current process and detects each terminal.
func System() Streams {
return Streams{In: os.Stdin, Out: os.Stdout, ErrOut: os.Stderr, InTTY: term.IsTerminal(int(os.Stdin.Fd())), OutTTY: term.IsTerminal(int(os.Stdout.Fd())), ErrTTY: term.IsTerminal(int(os.Stderr.Fd()))}
return Streams{
In: os.Stdin, Out: os.Stdout, ErrOut: os.Stderr,
InTTY: term.IsTerminal(int(os.Stdin.Fd())), OutTTY: term.IsTerminal(int(os.Stdout.Fd())), ErrTTY: term.IsTerminal(int(os.Stderr.Fd())),
Width: terminalWidth(int(os.Stdout.Fd()), os.Getenv),
}
}
// terminalWidth returns the width of the terminal at fd, then $COLUMNS, then 80. A pty without a
// window size, such as `script` run without a terminal, reports a width of 0.
func terminalWidth(fd int, getenv func(string) string) int {
if width, _, err := term.GetSize(fd); err == nil && width > 0 {
return width
}
if width, err := strconv.Atoi(getenv("COLUMNS")); err == nil && width > 0 {
return width
}
return 80
}
// Policy controls input interaction independently of supplied flag count.
+172 -33
View File
@@ -5,22 +5,83 @@ package terminal
import (
"strings"
"time"
"unicode"
"github.com/mattn/go-runewidth"
)
// Cell is one value in a Table. On color terminals Link, Color (six-digit RGB hex)
// and Bold style the text after alignment, so escape sequences never count toward
// the column width.
type Cell struct {
Text, Link, Color string
Bold bool
// Display describes the output that tables and views render for. On a terminal (TTY) they align,
// truncate and style text for people; otherwise they write plain tab-separated or key/value text
// for scripts, without header, color or links.
type Display struct {
TTY bool
// Color enables SGR colors and emphasis on a TTY; TrueColor writes hex colors in 24-bit instead
// of the nearest of 256.
Color, TrueColor bool
// Links enables OSC 8 links on the first table column on a TTY.
Links bool
// Markdown renders view bodies on a TTY.
Markdown bool
// Width is the terminal width that table rows fit in; 0 disables truncation.
Width int
// Now is the reference for relative times; zero means the current time.
Now time.Time
}
// style applies the cell's link, color and emphasis to its already safe text.
func (c Cell) style(text string) string {
return Hyperlink(c.Link, Emphasis(Colorize(c.Color, text, true), c.Bold), true)
// Column describes one table column.
type Column struct {
// Header names the column; a TTY shows it uppercased above the rows.
Header string
// Flex columns shrink to fit the terminal width, widest first, cutting text with "…", down to
// Min, the header width or eight cells, whichever is wider. Other columns are never cut.
Flex bool
Min int
// TTYOnly and PipedOnly restrict the column to one kind of output.
TTYOnly, PipedOnly bool
// Prefix precedes non-empty cells on a TTY, such as "#" before issue numbers.
Prefix string
}
// Cell is one value in a Table. On color terminals Color (a name such as green, or six-digit
// RGB hex), Bold and Muted style the text after alignment, so escape sequences never count
// toward the column width. Link applies to the first column only.
type Cell struct {
Text, Link, Color string
Bold, Muted bool
// Time, when set, replaces Text: relative on a TTY, RFC3339 UTC otherwise.
Time time.Time
}
// text returns the cell's safe, unstyled text for the display.
func (c Cell) text(column Column, d Display) string {
if !c.Time.IsZero() {
if !d.TTY {
return Timestamp(c.Time)
}
now := d.Now
if now.IsZero() {
now = time.Now()
}
return RelativeTime(now, c.Time)
}
text := SafeLine(c.Text)
if d.TTY && text != "" {
text = column.Prefix + text
}
return text
}
// style applies the cell's color, emphasis and, when link is set, its hyperlink to already safe text.
func (c Cell) style(text string, d Display, link bool) string {
if d.Color {
color := c.Color
if c.Muted {
color = "muted"
}
text = Emphasis(paint(color, text, d.TrueColor), c.Bold)
}
return Hyperlink(c.Link, text, link && d.Links)
}
// Row returns unstyled cells.
@@ -32,41 +93,122 @@ func Row(texts ...string) []Cell {
return cells
}
// Table renders one line per row with columns aligned and separated by two spaces.
// Cell text passes through SafeLine, so server data can neither break the layout
// nor reach the terminal as escape sequences.
func Table(rows [][]Cell, color bool) string {
// Table renders rows under columns; rows may have more cells than there are columns. On a TTY it
// prints an uppercase header row when any column has a header, aligns columns two spaces apart,
// and shrinks Flex columns until rows fit the display width. Otherwise it writes one line of
// tab-separated cells per row. Cell text passes through SafeLine either way, so server data can
// neither break the layout nor reach the terminal as escape sequences. No rows render as nothing.
func Table(columns []Column, rows [][]Cell, d Display) string {
if len(rows) == 0 {
return ""
}
column := func(i int) Column {
if i < len(columns) {
return columns[i]
}
return Column{}
}
count := len(columns)
for _, row := range rows {
count = max(count, len(row))
}
var shown []int
for i := range count {
if c := column(i); !(c.TTYOnly && !d.TTY) && !(c.PipedOnly && d.TTY) {
shown = append(shown, i)
}
}
texts := make([][]string, len(rows))
var widths []int
for r, row := range rows {
texts[r] = make([]string, len(row))
for i, cell := range row {
texts[r][i] = SafeLine(cell.Text)
if i == len(widths) {
widths = append(widths, 0)
texts[r] = make([]string, len(shown))
for j, i := range shown {
if i < len(row) {
texts[r][j] = row[i].text(column(i), d)
}
widths[i] = max(widths[i], runewidth.StringWidth(texts[r][i]))
}
}
var b strings.Builder
for r, row := range rows {
if !d.TTY {
for _, line := range texts {
b.WriteString(strings.Join(line, "\t"))
b.WriteByte('\n')
}
return b.String()
}
headers := make([]string, len(shown))
hasHeader := false
for j, i := range shown {
headers[j] = strings.ToUpper(SafeLine(column(i).Header))
hasHeader = hasHeader || headers[j] != ""
}
widths := make([]int, len(shown))
for j := range shown {
if hasHeader {
widths[j] = runewidth.StringWidth(headers[j])
}
for _, line := range texts {
widths[j] = max(widths[j], runewidth.StringWidth(line[j]))
}
}
fitWidth(widths, d.Width, func(j int) (bool, int) {
c := column(shown[j])
return c.Flex, max(c.Min, runewidth.StringWidth(headers[j]), 8)
})
writeLine := func(cells []string, style func(j int, text string) string) {
var line strings.Builder
for i, cell := range row {
text := texts[r][i]
if color {
text = cell.style(text)
for j, text := range cells {
if column(shown[j]).Flex {
text = runewidth.Truncate(text, widths[j], "…")
}
line.WriteString(text)
if i < len(row)-1 {
line.WriteString(strings.Repeat(" ", widths[i]-runewidth.StringWidth(texts[r][i])+2))
line.WriteString(style(j, text))
if j < len(cells)-1 {
line.WriteString(strings.Repeat(" ", widths[j]-runewidth.StringWidth(text)+2))
}
}
b.WriteString(strings.TrimRight(line.String(), " "))
b.WriteByte('\n')
}
if hasHeader {
writeLine(headers, func(_ int, text string) string { return Cell{Muted: true}.style(text, d, false) })
}
for r, row := range rows {
writeLine(texts[r], func(j int, text string) string {
if i := shown[j]; i < len(row) {
return row[i].style(text, d, j == 0)
}
return text
})
}
return b.String()
}
// fitWidth narrows flexible columns, always the widest one first, until the row and its two-space
// gaps fit limit or every flexible column reached its floor. A limit of 0 disables fitting.
func fitWidth(widths []int, limit int, flex func(j int) (bool, int)) {
if limit <= 0 {
return
}
excess := 2 * (len(widths) - 1)
for _, w := range widths {
excess += w
}
excess -= limit
for ; excess > 0; excess-- {
widest := -1
for j, w := range widths {
if ok, floor := flex(j); ok && w > floor && (widest < 0 || w > widths[widest]) {
widest = j
}
}
if widest < 0 {
return
}
widths[widest]--
}
}
// Record is one item for human readers: a heading line of cells separated by spaces,
// followed by text lines such as a body and a URL.
type Record struct {
@@ -77,16 +219,13 @@ type Record struct {
// Render formats the record. Heading cells pass through SafeLine and are styled like
// Table cells; lines pass through SafeText, so they keep their own line breaks.
func (r Record) Render(color bool) string {
d := Display{TTY: true, Color: color, TrueColor: true, Links: color}
var b strings.Builder
for i, cell := range r.Heading {
if i > 0 {
b.WriteByte(' ')
}
text := SafeLine(cell.Text)
if color {
text = cell.style(text)
}
b.WriteString(text)
b.WriteString(cell.style(SafeLine(cell.Text), d, true))
}
b.WriteByte('\n')
for _, line := range r.Lines {
+115 -22
View File
@@ -4,47 +4,140 @@
package terminal
import (
"strings"
"testing"
"time"
"github.com/mattn/go-runewidth"
"github.com/stretchr/testify/require"
)
func TestTable(t *testing.T) {
now := time.Date(2026, 10, 11, 20, 0, 0, 0, time.UTC)
tty := Display{TTY: true, Now: now}
issues := []Column{{Header: "ID", Prefix: "#"}, {Header: "Title", Flex: true}, {Header: "Labels", Flex: true}, {Header: "Updated"}}
issueRows := [][]Cell{
{{Text: "12", Link: "https://example.org/12"}, {Text: "Emoji in title 🚀 works?"}, {}, {Time: now.Add(-2 * time.Hour)}},
{{Text: "1", Link: "https://example.org/1"}, {Text: "Login fails with special characters in password"}, {Text: "bug, priority/high"}, {Time: now.Add(-90 * time.Minute)}},
}
for _, tc := range []struct {
name string
rows [][]Cell
color bool
want string
name string
columns []Column
rows [][]Cell
d Display
want string
}{
{"empty", nil, false, ""},
{"aligns columns", [][]Cell{Row("7", "Fix login", "open"), Row("12", "Crash", "closed")}, false, "7 Fix login open\n12 Crash closed\n"},
{"counts runes, not bytes", [][]Cell{Row("1", "äöü", "x"), Row("2", "abcd", "y")}, false, "1 äöü x\n2 abcd y\n"},
{"counts wide runes twice", [][]Cell{Row("1", "修复", "x"), Row("2", "abcd", "y")}, false, "1 修复 x\n2 abcd y\n"},
{"trims empty trailing cells", [][]Cell{Row("1", "bug", ""), Row("22", "feature", "new work")}, false, "1 bug\n22 feature new work\n"},
{"pads ragged rows", [][]Cell{Row("1"), Row("2", "two")}, false, "1\n2 two\n"},
{"empty", issues, nil, tty, ""},
{"aligns legacy rows without header", nil, [][]Cell{Row("7", "Fix login", "open"), Row("12", "Crash", "closed")}, tty, "7 Fix login open\n12 Crash closed\n"},
{"counts runes, not bytes", nil, [][]Cell{Row("1", "äöü", "x"), Row("2", "abcd", "y")}, tty, "1 äöü x\n2 abcd y\n"},
{"counts wide runes twice", nil, [][]Cell{Row("1", "修复", "x"), Row("2", "abcd", "y")}, tty, "1 修复 x\n2 abcd y\n"},
{"trims empty trailing cells", nil, [][]Cell{Row("1", "bug", ""), Row("22", "feature", "new work")}, tty, "1 bug\n22 feature new work\n"},
{"pads ragged rows", nil, [][]Cell{Row("1"), Row("2", "two")}, tty, "1\n2 two\n"},
{
"strips hostile title",
[][]Cell{Row("1", "a\x1b]52;c;cHduZWQ=\x07b\x1b[2J\u009b31m‮txt.exe\r\ntail\tend", "open")},
false,
"1 a]52;c;cHduZWQ=b[2J31mtxt.exe tail end open\n",
"heads columns and formats cells for people",
issues, issueRows, tty,
"ID TITLE LABELS UPDATED\n" +
"#12 Emoji in title 🚀 works? about 2 hours ago\n" +
"#1 Login fails with special characters in password bug, priority/high about 1 hour ago\n",
},
{"ignores styles without color", [][]Cell{{{Text: "7", Link: "https://example.org/7"}, {Text: "bug", Color: "ff0000"}, {Text: "open", Bold: true}}}, false, "7 bug open\n"},
{
"styles after alignment",
[][]Cell{{{Text: "7", Link: "https://example.org/7"}, {Text: "bug", Color: "ff0000"}, {Text: "open", Bold: true}}, Row("12", "feature", "closed")},
true,
"\x1b]8;;https://example.org/7\x1b\\7\x1b]8;;\x1b\\ \x1b[38;2;255;0;0mbug\x1b[0m \x1b[1mopen\x1b[0m\n12 feature closed\n",
"shrinks the widest flexible column first",
issues, issueRows,
Display{TTY: true, Now: now, Width: 72},
"ID TITLE LABELS UPDATED\n" +
"#12 Emoji in title 🚀 works? about 2 hours ago\n" +
"#1 Login fails with special ch… bug, priority/high about 1 hour ago\n",
},
{
"keeps flexible columns at their floor",
issues, issueRows,
Display{TTY: true, Now: now, Width: 40},
"ID TITLE LABELS UPDATED\n" +
"#12 Emoji i… about 2 hours ago\n" +
"#1 Login f… bug, pr… about 1 hour ago\n",
},
{
"cuts wide runes by display width",
[]Column{{Header: "Title", Flex: true}, {Header: "By"}},
[][]Cell{Row("支持中文路径 (support CJK paths)", "alice")},
Display{TTY: true, Width: 20},
"TITLE BY\n支持中文路径… alice\n",
},
{
"writes tab-separated values when piped",
issues, issueRows,
Display{Width: 40, Color: true, Links: true},
"12\tEmoji in title 🚀 works?\t\t2026-10-11T18:00:00Z\n" +
"1\tLogin fails with special characters in password\tbug, priority/high\t2026-10-11T18:30:00Z\n",
},
{
"keeps columns to their output",
[]Column{{Header: "ID"}, {Header: "State", PipedOnly: true}, {Header: "Link", TTYOnly: true}},
[][]Cell{Row("1", "open", "→")},
tty,
"ID LINK\n1 →\n",
},
{
"keeps piped-only columns when piped",
[]Column{{Header: "ID"}, {Header: "State", PipedOnly: true}, {Header: "Link", TTYOnly: true}},
[][]Cell{Row("1", "open", "→")},
Display{},
"1\topen\n",
},
{
"strips hostile text in both outputs",
nil,
[][]Cell{Row("1", "a\x1b]52;c;cHduZWQ=\x07b\x1b[2J\u009b31m‮txt.exe\r\ntail\tend", "open")},
Display{},
"1\ta]52;c;cHduZWQ=b[2J31mtxt.exe tail end\topen\n",
},
{"ignores styles without color", nil, [][]Cell{{{Text: "7", Link: "https://example.org/7"}, {Text: "bug", Color: "ff0000"}, {Text: "open", Bold: true}}}, tty, "7 bug open\n"},
{
"styles after alignment and links only the first column",
[]Column{{Header: "ID"}, {Header: "Name"}, {Header: "State"}},
[][]Cell{{{Text: "7", Link: "https://example.org/7"}, {Text: "bug", Color: "ff0000", Link: "https://example.org/bug"}, {Text: "open", Color: "green", Bold: true}}, Row("12", "feature", "closed")},
Display{TTY: true, Color: true, Links: true},
"\x1b[90mID\x1b[0m \x1b[90mNAME\x1b[0m \x1b[90mSTATE\x1b[0m\n" +
"\x1b]8;;https://example.org/7\x1b\\7\x1b]8;;\x1b\\ \x1b[38;5;196mbug\x1b[0m \x1b[1m\x1b[32mopen\x1b[0m\x1b[0m\n" +
"12 feature closed\n",
},
{
"links without color",
nil,
[][]Cell{{{Text: "7", Link: "https://example.org/7"}, {Text: "bug", Muted: true}}},
Display{TTY: true, Links: true},
"\x1b]8;;https://example.org/7\x1b\\7\x1b]8;;\x1b\\ bug\n",
},
{
"writes 24-bit color on true-color terminals",
nil,
[][]Cell{{{Text: "bug", Color: "#ff0000"}, {Text: "old", Muted: true}}},
Display{TTY: true, Color: true, TrueColor: true},
"\x1b[38;2;255;0;0mbug\x1b[0m \x1b[90mold\x1b[0m\n",
},
} {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.want, Table(tc.rows, tc.color))
require.Equal(t, tc.want, Table(tc.columns, tc.rows, tc.d))
})
}
}
func TestTableFitsWidthWithWideRunes(t *testing.T) {
titles := []string{"Emoji in title 🚀 works?", "支持中文路径 (support CJK paths)", strings.Repeat("long ", 40)}
rows := make([][]Cell, len(titles))
for i, title := range titles {
rows[i] = Row("#1", title, "alice")
}
for _, width := range []int{30, 50, 80} {
for _, line := range strings.Split(strings.TrimSuffix(Table([]Column{{}, {Flex: true}, {}}, rows, Display{TTY: true, Width: width}), "\n"), "\n") {
require.LessOrEqual(t, runewidth.StringWidth(line), width, line)
}
}
}
func TestRecord(t *testing.T) {
record := Record{
Heading: []Cell{{Text: "#7", Bold: true}, {Text: "Crash\x1b[2J\nnow"}, {Text: "bob\u202e"}},
Heading: []Cell{{Text: "#7", Bold: true}, {Text: "Crash\x1b[2J\nnow"}, {Text: "bob‮"}},
Lines: []string{"Steps:\r\n\x1b]52;c;aGk=\x07run\n", "https://example.org/7"},
}
require.Equal(t, "#7 Crash[2J now bob\nSteps:\n]52;c;aGk=run\n\nhttps://example.org/7\n", record.Render(false))
@@ -58,7 +151,7 @@ func TestSafeText(t *testing.T) {
{"drops lone carriage returns", "safe\rFAKE", "safeFAKE", "safeFAKE"},
{
"strips hostile body",
"Steps:\r\n\x1b]52;c;cHduZWQ=\x07run\x1b[2J\u009b31m\u202e\u200bit\x7f\n\x00done",
"Steps:\r\n\x1b]52;c;cHduZWQ=\x07run\x1b[2J\u009b31m‮​it\x7f\n\x00done",
"Steps:\n]52;c;cHduZWQ=run[2J31mit\ndone",
"Steps: ]52;c;cHduZWQ=run[2J31mit done",
},
+57
View File
@@ -0,0 +1,57 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package terminal
import (
"fmt"
"time"
)
// RelativeTime describes t relative to now like gh: "less than a minute ago", "about 3 hours ago",
// and "in about 2 months" for future times such as due dates.
func RelativeTime(now, t time.Time) string {
d, prefix, suffix := now.Sub(t), "", " ago"
if d < 0 {
d, prefix, suffix = -d, "in ", ""
}
const day = 24 * time.Hour
var n time.Duration
var unit string
switch {
case d < time.Minute:
return prefix + "less than a minute" + suffix
case d < time.Hour:
n, unit = d/time.Minute, "minute"
case d < day:
n, unit = d/time.Hour, "hour"
case d < 30*day:
n, unit = d/day, "day"
case d < 365*day:
n, unit = d/(30*day), "month"
default:
n, unit = d/(365*day), "year"
}
return prefix + "about " + plural(int(n), unit) + suffix
}
// Timestamp formats t for scripts: RFC3339 in UTC at second precision.
func Timestamp(t time.Time) string {
return t.UTC().Truncate(time.Second).Format(time.RFC3339)
}
// ParseTime parses an API timestamp, returning the zero time for an empty or malformed value.
func ParseTime(raw string) time.Time {
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
return time.Time{}
}
return t
}
func plural(n int, unit string) string {
if n == 1 {
return "1 " + unit
}
return fmt.Sprintf("%d %ss", n, unit)
}
+127
View File
@@ -0,0 +1,127 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package terminal
import (
"strings"
"github.com/mattn/go-runewidth"
)
// Field is one labeled fact in a View.
type Field struct {
Label string
// Value is plain text. Cells, when set, replace it with styled values joined by commas, such
// as colored labels.
Value string
Cells []Cell
// TTYOnly and PipedOnly restrict the field to one kind of output, like table columns.
TTYOnly, PipedOnly bool
}
// text returns the field's safe value, styled for d when it is made of cells.
func (f Field) text(d Display) string {
if len(f.Cells) == 0 {
return SafeLine(f.Value)
}
values := make([]string, len(f.Cells))
for i, cell := range f.Cells {
values[i] = SafeLine(cell.Text)
if d.TTY {
values[i] = cell.style(values[i], d, false)
}
}
return strings.Join(values, ", ")
}
// View is one resource for human readers, such as an issue, a release or a repository.
//
// On a TTY it shows the bold title with its muted ID, a line of badges and muted metadata, the
// aligned non-empty fields, the body (markdown rendered when the display allows), and the footer
// "View this <kind> on Gitea: <url>". Otherwise it writes "key:\tvalue" lines for the title, the
// fields and the URL, then "--" and the raw body, for scripts.
type View struct {
Title, ID string
// Badges, such as the state, start the line under the title; Meta completes it, for example
// "alice opened about 2 hours ago • 2 comments".
Badges []Cell
Meta string
Fields []Field
Body string
// Kind names the resource in the footer, such as "issue".
Kind, URL string
}
// Render formats the view for d. All server text is sanitized.
func (v View) Render(d Display) string {
var b strings.Builder
if !d.TTY {
if v.Title != "" {
b.WriteString("title:\t" + SafeLine(v.Title) + "\n")
}
for _, field := range v.Fields {
if !field.TTYOnly {
b.WriteString(strings.ToLower(SafeLine(field.Label)) + ":\t" + field.text(d) + "\n")
}
}
if v.URL != "" {
b.WriteString("url:\t" + SafeLine(v.URL) + "\n")
}
body := SafeText(v.Body)
b.WriteString("--\n" + body)
if !strings.HasSuffix(body, "\n") {
b.WriteByte('\n')
}
return b.String()
}
muted := func(text string) string { return Cell{Muted: true}.style(text, d, false) }
b.WriteString(Cell{Bold: true}.style(SafeLine(v.Title), d, false))
if v.ID != "" {
b.WriteString(" " + muted(SafeLine(v.ID)))
}
b.WriteByte('\n')
var summary []string
for _, badge := range v.Badges {
summary = append(summary, badge.style(SafeLine(badge.Text), d, false))
}
if meta := SafeLine(v.Meta); meta != "" {
summary = append(summary, muted(meta))
}
if len(summary) > 0 {
b.WriteString(strings.Join(summary, " • ") + "\n")
}
var fields []Field
labelWidth := 0
for _, field := range v.Fields {
if !field.PipedOnly && (field.Value != "" || len(field.Cells) > 0) {
fields = append(fields, field)
labelWidth = max(labelWidth, runewidth.StringWidth(SafeLine(field.Label)))
}
}
for _, field := range fields {
label := SafeLine(field.Label) + ":"
b.WriteString(Cell{Bold: true}.style(label, d, false) + strings.Repeat(" ", labelWidth+2-runewidth.StringWidth(label)) + field.text(d) + "\n")
}
body := strings.TrimSpace(v.Body)
switch {
case body == "":
body = muted("No description provided")
case d.Markdown:
body = Markdown(body, d)
default:
body = SafeText(body)
}
b.WriteString("\n" + body + "\n")
if v.URL != "" {
kind := "View on Gitea: "
if v.Kind != "" {
kind = "View this " + v.Kind + " on Gitea: "
}
b.WriteString("\n" + muted(kind+SafeLine(v.URL)) + "\n")
}
return b.String()
}
+103
View File
@@ -0,0 +1,103 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package terminal
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func issueView() View {
return View{
Title: "Login fails\x1b[2J", ID: "#1",
Badges: []Cell{{Text: "Open", Color: "green"}},
Meta: "alice opened about 1 hour ago • 2 comments",
Fields: []Field{
{Label: "State", Value: "open", PipedOnly: true},
{Label: "Labels", Cells: []Cell{{Text: "bug", Color: "ee0701"}, {Text: "priority/high"}}},
{Label: "Milestone", Value: "v1.0"},
{Label: "Blocked by"},
{Label: "Comments", Value: "2", TTYOnly: true},
},
Body: "Steps:\r\n1. Use `p@ss!`\x1b]52;c;aGk=\x07\n",
Kind: "issue", URL: "http://localhost:3001/alice/project/issues/1",
}
}
func TestViewRender(t *testing.T) {
for _, tc := range []struct {
name string
view View
d Display
want string
}{
{
"key and value lines when piped",
issueView(),
Display{Color: true},
"title:\tLogin fails[2J\nstate:\topen\nlabels:\tbug, priority/high\nmilestone:\tv1.0\nblocked by:\t\n" +
"url:\thttp://localhost:3001/alice/project/issues/1\n--\nSteps:\n1. Use `p@ss!`]52;c;aGk=\n",
},
{
"summary for people",
issueView(),
Display{TTY: true},
"Login fails[2J #1\nOpen • alice opened about 1 hour ago • 2 comments\n" +
"Labels: bug, priority/high\nMilestone: v1.0\nComments: 2\n\n" +
"Steps:\n1. Use `p@ss!`]52;c;aGk=\n\nView this issue on Gitea: http://localhost:3001/alice/project/issues/1\n",
},
{
"styled for color terminals",
View{Title: "v1.0", Badges: []Cell{{Text: "Latest", Color: "green"}}, Fields: []Field{{Label: "Labels", Cells: []Cell{{Text: "bug", Color: "ee0701"}}}}, URL: "https://example.org/r"},
Display{TTY: true, Color: true, TrueColor: true},
"\x1b[1mv1.0\x1b[0m\n\x1b[32mLatest\x1b[0m\n\x1b[1mLabels:\x1b[0m \x1b[38;2;238;7;1mbug\x1b[0m\n\n" +
"\x1b[90mNo description provided\x1b[0m\n\n\x1b[90mView on Gitea: https://example.org/r\x1b[0m\n",
},
{
"minimal",
View{Title: "Home"},
Display{TTY: true},
"Home\n\nNo description provided\n",
},
} {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.want, tc.view.Render(tc.d))
})
}
}
func TestViewRendersMarkdownOnlyWhenEnabled(t *testing.T) {
view := View{Title: "Docs", Body: "# Setup\n\nRun **tea** [now](https://example.org).\n\n- one\n- two"}
require.Contains(t, view.Render(Display{TTY: true}), "\n# Setup\n\nRun **tea** [now](https://example.org).\n")
require.Contains(t, view.Render(Display{Markdown: true}), "--\n# Setup\n\nRun **tea** [now](https://example.org).\n")
plain := view.Render(Display{TTY: true, Markdown: true, Width: 40})
require.Contains(t, plain, "\n # Setup\n\n Run **tea** now https://example.org.\n\n • one\n • two\n")
require.NotContains(t, plain, "\x1b")
colored := view.Render(Display{TTY: true, Markdown: true, Color: true})
require.Contains(t, colored, "Run \x1b[1mtea\x1b[m")
require.NotContains(t, colored, "\x1b]8;")
require.Contains(t, view.Render(Display{TTY: true, Markdown: true, Color: true, Links: true}), "\x1b]8;id=")
}
func TestMarkdownSanitizesServerText(t *testing.T) {
rendered := Markdown("Hello \x1b]0;PWNED\x07world\n\n`\x1b[2Jcode`", Display{Color: true, Links: true})
require.NotContains(t, rendered, "\x1b]0;")
require.NotContains(t, rendered, "\x1b[2J")
require.Contains(t, rendered, "]0;PWNEDworld")
for _, line := range strings.Split(Markdown(strings.Repeat("word ", 50), Display{Width: 40}), "\n") {
require.LessOrEqual(t, len(line), 40, line)
require.Equal(t, strings.TrimRight(line, " "), line)
}
}
func TestMarkdownEnabled(t *testing.T) {
require.True(t, MarkdownEnabled("auto", true))
require.True(t, MarkdownEnabled("", true))
require.False(t, MarkdownEnabled("raw", true))
require.False(t, MarkdownEnabled("auto", false))
}