fix: address verified review findings (terminal-output-1)

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-18 17:38:52 +02:00
1 parent 6b847058eb
commit a47dbc413c
25 files changed
+175 -24

No files matched your search

+3 -3
View File
@@ -382,9 +382,9 @@ func runRecord(run services.Run, d terminal.Display, now time.Time) string {
{Label: "Event", Value: run.Event},
{Label: "Actor", Value: run.Actor},
{Label: "Attempt", Value: strconv.FormatInt(run.Attempt, 10)},
{Label: "Created_at", Value: run.CreatedAt},
{Label: "Started_at", Value: optional(run.StartedAt)},
{Label: "Completed_at", Value: optional(run.CompletedAt)},
{Label: "Created_at", Value: pipedTime(run.CreatedAt)},
{Label: "Started_at", Value: pipedTime(optional(run.StartedAt))},
{Label: "Completed_at", Value: pipedTime(optional(run.CompletedAt))},
{Label: "Elapsed", Value: runElapsedText(run.StartedAt, run.CompletedAt, now)},
}
rows := make([][]terminal.Cell, len(run.Jobs))
+1
View File
@@ -129,6 +129,7 @@ $ tea admin user view bob --json email,admin`,
return terminal.View{
Title: user.Username, ID: fmt.Sprintf("(ID %d)", user.ID), Badges: []terminal.Cell{role, status}, Meta: meta,
Fields: []terminal.Field{
{Label: "ID", Value: strconv.FormatInt(user.ID, 10), PipedOnly: true},
{Label: "Name", Value: user.FullName},
{Label: "Email", Value: user.Email},
{Label: "Role", Value: role.Text, PipedOnly: true},
+1 -1
View File
@@ -146,7 +146,7 @@ func (a *application) branchViewCommand() *cobra.Command {
meta = "updated " + terminal.RelativeTime(a.deps.Now(), updated)
}
return terminal.View{
Title: item.Name, Badges: badges, Meta: meta, Body: item.Message, Kind: "branch", URL: branchURL(repository, item.Name),
Title: item.Name, Badges: badges, Meta: meta, Body: item.Message, Plain: true, Kind: "branch", URL: branchURL(repository, item.Name),
Fields: []terminal.Field{{Label: "Commit", Value: item.SHA}, {Label: "Protection", Value: protection}, {Label: "You can push", Value: yesNo(item.CanPush)}},
}
})
+21 -1
View File
@@ -8,12 +8,14 @@ import (
"errors"
"fmt"
"io"
"regexp"
"strings"
"time"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal"
"github.com/mattn/go-runewidth"
"github.com/spf13/cobra"
)
@@ -153,8 +155,26 @@ func (a *application) checksRedraw(cmd *cobra.Command, interval time.Duration) f
if lines > 0 {
clear = fmt.Sprintf("\x1b[%dA\x1b[J", lines)
}
lines = strings.Count(text, "\n")
lines = screenRows(text, a.deps.Streams.Width)
_, err := io.WriteString(cmd.OutOrStdout(), clear+text)
return err
}
}
// displayEscapes matches the color and hyperlink sequences that take no room on screen.
var displayEscapes = regexp.MustCompile("\x1b\\[[0-9;]*m|\x1b\\]8;[^\x07\x1b]*(?:\x07|\x1b\\\\)")
// screenRows counts the rows the cursor sits below the start of text on a terminal width columns wide,
// where a line wider than the terminal wraps onto more rows; a width of 0 means unlimited.
func screenRows(text string, width int) int {
rows := strings.Count(text, "\n")
if width <= 0 {
return rows
}
for _, line := range strings.Split(text, "\n") {
if cells := runewidth.StringWidth(displayEscapes.ReplaceAllString(line, "")); cells > width {
rows += (cells - 1) / width
}
}
return rows
}
+2 -2
View File
@@ -203,7 +203,7 @@ func commentView(t target, now time.Time) func(services.Comment) terminal.View {
Fields: []terminal.Field{
{Label: "ID", Value: strconv.FormatInt(comment.ID, 10), PipedOnly: true},
{Label: "Author", Value: comment.Author, PipedOnly: true},
{Label: "Created_at", Value: comment.CreatedAt, PipedOnly: true},
{Label: "Created_at", Value: pipedTime(comment.CreatedAt), PipedOnly: true},
},
Body: comment.Body, Kind: "comment", URL: comment.URL,
}
@@ -228,7 +228,7 @@ func (a *application) writeComments(cmd *cobra.Command, t target, n int64, items
var text strings.Builder
for i, comment := range items {
if !d.TTY {
text.WriteString(fmt.Sprintf("id:\t%d\nauthor:\t%s\ncreated_at:\t%s\nurl:\t%s\n--\n", comment.ID, terminal.SafeLine(comment.Author), comment.CreatedAt, terminal.SafeLine(comment.URL)))
text.WriteString(fmt.Sprintf("id:\t%d\nauthor:\t%s\ncreated_at:\t%s\nurl:\t%s\n--\n", comment.ID, terminal.SafeLine(comment.Author), pipedTime(comment.CreatedAt), terminal.SafeLine(comment.URL)))
text.WriteString(strings.TrimRight(terminal.SafeText(comment.Body), "\n") + "\n--\n")
continue
}
+1 -1
View File
@@ -291,7 +291,7 @@ func (a *application) writeDiscussion(cmd *cobra.Command, target target, v discu
var b strings.Builder
b.WriteString(view.Render(d))
for _, comment := range comments {
fmt.Fprintf(&b, "--\nauthor:\t%s\ncreated_at:\t%s\n", terminal.SafeLine(comment.author), terminal.SafeLine(comment.createdAt))
fmt.Fprintf(&b, "--\nauthor:\t%s\ncreated_at:\t%s\n", terminal.SafeLine(comment.author), pipedTime(comment.createdAt))
if comment.id > 0 {
fmt.Fprintf(&b, "id:\t%d\n", comment.id)
}
+1 -1
View File
@@ -51,7 +51,7 @@ func (a *application) writeHuman(cmd *cobra.Command, text string) error {
// writeJSON writes v as JSON, filtered by --json fields and --jq.
func (a *application) writeJSON(cmd *cobra.Command, v any) error {
pretty := a.deps.Streams.OutTTY
return terminal.JSONOutput{Fields: a.options.json, Query: a.options.jq, Pretty: pretty, Color: pretty && a.colorEnabled()}.Write(cmd.OutOrStdout(), v)
return terminal.JSONOutput{Fields: a.options.json, Allowed: commandOutputFields(cmd), Query: a.options.jq, Pretty: pretty, Color: pretty && a.colorEnabled()}.Write(cmd.OutOrStdout(), v)
}
// listSpec describes how a list command shows its items to people.
+4 -3
View File
@@ -31,13 +31,13 @@ func attachmentRow(item services.IssueAttachment) []terminal.Cell {
}
}
func attachmentView(item services.IssueAttachment) terminal.View {
func attachmentView(item services.IssueAttachment, d terminal.Display) terminal.View {
return terminal.View{
Title: item.Name, ID: "#" + strconv.FormatInt(item.ID, 10),
Fields: []terminal.Field{
{Label: "Size", Value: terminal.Bytes(item.Size)},
{Label: "Downloads", Value: strconv.FormatInt(item.DownloadCount, 10)},
{Label: "Created", Value: item.CreatedAt},
timeField("Created", item.CreatedAt, d),
},
Kind: "attachment", URL: item.URL,
}
@@ -93,7 +93,8 @@ are addressed by the %s number and the attachment ID that attachment list shows.
if err != nil {
return err
}
return writeView(a, cmd, item, attachmentView)
d := a.display()
return writeView(a, cmd, item, func(item services.IssueAttachment) terminal.View { return attachmentView(item, d) })
}}
var uploadName string
+1 -1
View File
@@ -33,7 +33,7 @@ $ tea label export -R alice/old | tea label import - -R alice/new`, Args: argsEx
return err
}
if a.options.json != "" {
return terminal.WriteJSON(destination, items, a.options.json, a.options.jq)
return terminal.JSONOutput{Fields: a.options.json, Query: a.options.jq, Safe: output == "" && a.deps.Streams.OutTTY}.Write(destination, items)
}
var text strings.Builder
for _, item := range items {
+1 -1
View File
@@ -222,7 +222,7 @@ With --json it prints one JSON object per line.`,
err = s.Watch(ctx, listed, interval, func(items []services.Notification) error {
if a.options.json != "" {
for _, item := range items {
if err := (terminal.JSONOutput{Fields: a.options.json, Query: a.options.jq}).Write(cmd.OutOrStdout(), item); err != nil {
if err := (terminal.JSONOutput{Fields: a.options.json, Allowed: commandOutputFields(cmd), Query: a.options.jq, Safe: d.TTY}).Write(cmd.OutOrStdout(), item); err != nil {
return err
}
}
+9
View File
@@ -37,6 +37,15 @@ func timeField(label, raw string, d terminal.Display) terminal.Field {
return terminal.Field{Label: label, Value: terminal.RelativeTime(now, t)}
}
// pipedTime normalizes a server timestamp to RFC 3339 UTC like the times of piped tables, and keeps
// a value that is not a timestamp as it is.
func pipedTime(raw string) string {
if t := terminal.ParseTime(raw); !t.IsZero() {
return terminal.Timestamp(t)
}
return raw
}
func (a *application) organizationCommand() *cobra.Command {
group := &cobra.Command{Use: "org", Short: "Manage organizations", Long: `Work with Gitea organizations: list the ones you belong to, view, create, edit and delete
them, and manage their members.
+6
View File
@@ -95,3 +95,9 @@ func TestOrganizationViewShowsDetailsAndCounts(t *testing.T) {
require.Equal(t, "title:\tACME Corp\nname:\tacme\nvisibility:\tpublic\nwebsite:\thttps://acme.example\nlocation:\t\n"+
"repo admins manage team access:\tno\nmembers:\t2\nteams:\t3\nrepositories:\t1\nurl:\t"+server.URL+"/acme\n--\nTools\n", out.String())
}
func TestPipedTimeNormalizesToUTC(t *testing.T) {
require.Equal(t, "2026-01-02T01:04:05Z", pipedTime("2026-01-02T03:04:05+02:00"))
require.Equal(t, "", pipedTime(""))
require.Equal(t, "soon", pipedTime("soon"))
}
+18
View File
@@ -0,0 +1,18 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestScreenRowsCountsWrappedLines(t *testing.T) {
require.Equal(t, 2, screenRows("one\ntwo\n", 80))
require.Equal(t, 2, screenRows("one\ntwo\n", 0))
require.Equal(t, 4, screenRows(strings.Repeat("x", 25)+"\nab\n", 10), "25 cells take 3 rows of 10")
require.Equal(t, 2, screenRows("\x1b[31m"+strings.Repeat("x", 10)+"\x1b[0m\n\x1b]8;;http://a/b\x1b\\link\x1b]8;;\x1b\\\n", 10), "escapes take no room")
}
+1
View File
@@ -212,6 +212,7 @@ $ tea team view 2 --json units`,
return terminal.View{
Title: team.Slug(), ID: fmt.Sprintf("(ID %d)", team.ID), Badges: []terminal.Cell{{Text: team.Access(), Bold: true}},
Fields: []terminal.Field{
{Label: "ID", Value: strconv.FormatInt(team.ID, 10), PipedOnly: true},
{Label: "Access", Value: team.Access(), PipedOnly: true},
{Label: "Units", Value: strings.Join(units, ", ")},
{Label: "Members", Value: memberNames},
+21
View File
@@ -131,6 +131,27 @@ func TestTeamsAreAddressedAsOrganizationAndName(t *testing.T) {
require.EqualError(t, err, `team "ops" not found in acme`)
}
func TestTeamViewPrintsTheIDWhenPiped(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/orgs/acme/teams/search":
if r.URL.Query().Get("page") != "1" {
_, _ = w.Write([]byte(`{"ok":true,"data":[]}`))
return
}
_, _ = w.Write([]byte(`{"ok":true,"data":[{"id":2,"name":"developers","permission":"none"}]}`))
case "/api/v1/teams/2/members", "/api/v1/teams/2/repos":
_, _ = w.Write([]byte(`[]`))
default:
t.Errorf("unexpected request %s", r.URL.Path)
}
}))
defer server.Close()
deps, out, _ := serverDeps(t, server.URL)
require.NoError(t, Execute(context.Background(), deps, []string{"team", "view", "acme/developers"}))
require.Contains(t, out.String(), "\nid:\t2\n")
}
func TestTeamListShowsAccessAndSizes(t *testing.T) {
totals := map[string]string{"/api/v1/teams/1/members": "1", "/api/v1/teams/1/repos": "4", "/api/v1/teams/2/members": "3", "/api/v1/teams/2/repos": "2"}
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {