mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
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>
128 lines
3.6 KiB
Go
128 lines
3.6 KiB
Go
// 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()
|
|
}
|