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

+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()
}