mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
refactor: add terminal output and forms package (#1124)
- `internal/terminal`: streams and the prompting policy, interactive forms (issue, PR, review, merge, milestone), editor / pager / browser launching, JSON and `--jq` output. - An aligned table renderer and a record renderer for human output; both strip control and escape characters from server-supplied text so titles and bodies cannot inject terminal sequences. Reviewed-on: https://gitea.com/gitea/tea/pulls/1124 Reviewed-by: TheFox0x7 <95654+thefox0x7@noreply.gitea.com>
This commit is contained in:
1 parent
88b91faa54
commit
faea86cd94
27 files changed
+1941
-1
No files matched your search
@@ -0,0 +1,119 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Row returns unstyled cells.
|
||||
func Row(texts ...string) []Cell {
|
||||
cells := make([]Cell, len(texts))
|
||||
for i, text := range texts {
|
||||
cells[i].Text = text
|
||||
}
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
widths[i] = max(widths[i], runewidth.StringWidth(texts[r][i]))
|
||||
}
|
||||
}
|
||||
var b strings.Builder
|
||||
for r, row := range rows {
|
||||
var line strings.Builder
|
||||
for i, cell := range row {
|
||||
text := texts[r][i]
|
||||
if color {
|
||||
text = cell.style(text)
|
||||
}
|
||||
line.WriteString(text)
|
||||
if i < len(row)-1 {
|
||||
line.WriteString(strings.Repeat(" ", widths[i]-runewidth.StringWidth(texts[r][i])+2))
|
||||
}
|
||||
}
|
||||
b.WriteString(strings.TrimRight(line.String(), " "))
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// 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 {
|
||||
Heading []Cell
|
||||
Lines []string
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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.WriteByte('\n')
|
||||
for _, line := range r.Lines {
|
||||
b.WriteString(SafeText(line))
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// 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"))
|
||||
}
|
||||
Reference in new issue
Block a user