mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
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:
17 files changed
+1253
-103
No files matched your search
+81
-10
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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\u009b31mtxt.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\u009b31mtxt.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\u009b31mit\x7f\n\x00done",
|
||||
"Steps:\n]52;c;cHduZWQ=run[2J31mit\ndone",
|
||||
"Steps: ]52;c;cHduZWQ=run[2J31mit done",
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Reference in new issue
Block a user