mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11:25 +01:00
Drop additions that the UX plan does not ask for: - the interactive label edit form; label edit now requires a flag - label clone and the skip mode of label creation it needed - repo rename rewriting the Git remote or tea.repository; it now warns that the remote still points to the old name - the extra "source" remote on clones of pull mirrors - run artifact download, which duplicates run download --zip - 256-color and 24-bit label swatches; colors stay ANSI 16 - config list --show-source (config get keeps it) - the membership and access GETs before team member/repo add and remove; Gitea's PUT and DELETE are idempotent label edit/delete keep --by-name: it is the only way to address a label whose name is a number that is also another label's ID. Signed-off-by: bircni <bircni@icloud.com>
223 lines
6.4 KiB
Go
223 lines
6.4 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package terminal
|
|
|
|
import (
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"github.com/mattn/go-runewidth"
|
|
)
|
|
|
|
// 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.
|
|
Color 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
|
|
}
|
|
|
|
// 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(Colorize(color, text, true), c.Bold)
|
|
}
|
|
return Hyperlink(c.Link, text, link && d.Links)
|
|
}
|
|
|
|
// 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))
|
|
for r, row := range rows {
|
|
texts[r] = make([]string, len(shown))
|
|
for j, i := range shown {
|
|
if i < len(row) {
|
|
texts[r][j] = row[i].text(column(i), d)
|
|
}
|
|
}
|
|
}
|
|
var b strings.Builder
|
|
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 j, text := range cells {
|
|
if column(shown[j]).Flex {
|
|
text = runewidth.Truncate(text, widths[j], "…")
|
|
}
|
|
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]--
|
|
}
|
|
}
|
|
|
|
// SafeLine reduces server text to one line: SafeText, with tabs and line breaks as spaces.
|
|
func SafeLine(s string) string {
|
|
return strings.Map(func(r rune) rune {
|
|
if r == '\t' || r == '\n' {
|
|
return ' '
|
|
}
|
|
return r
|
|
}, SafeText(s))
|
|
}
|
|
|
|
// SafeText keeps tabs and line breaks in server text, normalizing CRLF to LF, and drops
|
|
// every other control or format character, such as escape sequences, lone carriage
|
|
// returns and bidirectional overrides, so the text cannot drive the terminal.
|
|
func SafeText(s string) string {
|
|
return strings.Map(func(r rune) rune {
|
|
if r != '\t' && r != '\n' && (unicode.IsControl(r) || unicode.Is(unicode.Cf, r)) {
|
|
return -1
|
|
}
|
|
return r
|
|
}, strings.ReplaceAll(s, "\r\n", "\n"))
|
|
}
|