From 55574a9fd06707f8f71092533b2ce072af1f09e2 Mon Sep 17 00:00:00 2001 From: bircni Date: Sun, 11 Oct 2026 22:39:00 +0200 Subject: [PATCH] feat: fit list tables to the terminal and write TSV when piped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- go.mod | 15 ++- go.sum | 41 +++++- internal/app/human_output.go | 155 ++++++++++++++++++++-- internal/app/human_output_test.go | 165 ++++++++++++++++++++++- internal/app/issue_filters_test.go | 7 +- internal/app/labelfile_test.go | 2 +- internal/terminal/color.go | 91 +++++++++++-- internal/terminal/color_test.go | 54 ++++++-- internal/terminal/format.go | 55 ++++++++ internal/terminal/format_test.go | 67 ++++++++++ internal/terminal/markdown.go | 54 ++++++++ internal/terminal/streams.go | 21 ++- internal/terminal/table.go | 205 ++++++++++++++++++++++++----- internal/terminal/table_test.go | 137 +++++++++++++++---- internal/terminal/time.go | 57 ++++++++ internal/terminal/view.go | 127 ++++++++++++++++++ internal/terminal/view_test.go | 103 +++++++++++++++ 17 files changed, 1253 insertions(+), 103 deletions(-) create mode 100644 internal/terminal/format.go create mode 100644 internal/terminal/format_test.go create mode 100644 internal/terminal/markdown.go create mode 100644 internal/terminal/time.go create mode 100644 internal/terminal/view.go create mode 100644 internal/terminal/view_test.go diff --git a/go.mod b/go.mod index d41aa65e..b76d3770 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.27.0 toolchain go1.27.1 require ( + charm.land/glamour/v2 v2.0.1 charm.land/huh/v2 v2.0.3 code.gitea.io/gitea-vet v0.2.3 gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c @@ -31,12 +32,15 @@ require ( charm.land/bubbles/v2 v2.1.0 // indirect charm.land/bubbletea/v2 v2.0.6 // indirect charm.land/lipgloss/v2 v2.0.5 // indirect + github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260511121909-c840852527f3 // indirect github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/exp/strings v0.1.0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect @@ -45,21 +49,28 @@ require ( github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/danieljoos/wincred v1.2.3 // indirect + github.com/dlclark/regexp2 v1.11.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/go-version v1.9.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/itchyny/timefmt-go v0.1.6 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark v1.7.8 // indirect + github.com/yuin/goldmark-emoji v1.0.5 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect - golang.org/x/sync v0.22.0 // indirect - golang.org/x/tools v0.48.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/text v0.42.0 // indirect + golang.org/x/tools v0.49.0 // indirect ) retract v1.3.3 // accidental release, tag deleted diff --git a/go.sum b/go.sum index ee6445a2..9f1366a3 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo= charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g= +charm.land/glamour/v2 v2.0.1 h1:xl+r00A4aJWU0z8fgwKd9fQQ4rsphqGUzuEiXZP5n+c= +charm.land/glamour/v2 v2.0.1/go.mod h1:jo9z8XqVKPeEFMVdvCRLGk++RyJ3CdUwgNr7EvXLw3k= charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY= @@ -18,12 +20,20 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= +github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= +github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= +github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA= github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= @@ -40,6 +50,8 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6g github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA= github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= @@ -64,14 +76,20 @@ github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg= @@ -85,6 +103,8 @@ github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -111,6 +131,11 @@ github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUm github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= +github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= +github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= @@ -124,18 +149,20 @@ golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZ golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= -golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -146,11 +173,13 @@ golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200325010219-a49f79bcc224/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= -golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/app/human_output.go b/internal/app/human_output.go index 6938d6b6..ee711fa3 100644 --- a/internal/app/human_output.go +++ b/internal/app/human_output.go @@ -6,10 +6,12 @@ package app import ( "errors" "fmt" + "slices" "strings" "gitea.dev/tea/internal/settings" "gitea.dev/tea/internal/terminal" + "github.com/mattn/go-runewidth" "github.com/spf13/cobra" ) @@ -28,6 +30,22 @@ func (a *application) colorEnabled() bool { return terminal.ColorEnabled(preferences.Color, a.deps.Streams.OutTTY, a.deps.Getenv) } +// display describes stdout for tables and views. The hyperlinks (auto, always, never) and markdown +// (auto, raw) preferences are read from TEA_HYPERLINKS and TEA_MARKDOWN until config.yml stores them. +func (a *application) display() terminal.Display { + preferences, _ := a.preferences() + streams := a.deps.Streams + return terminal.Display{ + TTY: streams.OutTTY, + Color: terminal.ColorEnabled(preferences.Color, streams.OutTTY, a.deps.Getenv), + TrueColor: terminal.TrueColor(a.deps.Getenv), + Links: terminal.HyperlinksEnabled(a.deps.Getenv("TEA_HYPERLINKS"), streams.OutTTY, a.deps.Getenv), + Markdown: terminal.MarkdownEnabled(a.deps.Getenv("TEA_MARKDOWN"), streams.OutTTY), + Width: streams.Width, + Now: a.deps.Now(), + } +} + // writeHuman pages multi-line human list/view text. JSON callers must not use it. // --no-input does not disable paging. func (a *application) writeHuman(cmd *cobra.Command, text string) error { @@ -40,6 +58,81 @@ func (a *application) writeJSON(cmd *cobra.Command, v any) error { return terminal.WriteJSON(cmd.OutOrStdout(), v, a.options.json, a.options.jq) } +// listSpec describes how a list command shows its items to people. +type listSpec[T any] struct { + Columns []terminal.Column + Row func(T) []terminal.Cell + // Empty, such as "no open issues in alice/x", goes to stderr on a TTY when nothing is listed. + Empty string + // Context names the listed items, such as "open issues in alice/x", for the line that heads the + // table on a TTY: "Showing 30 of 112 open issues in alice/x". + Context string +} + +// listPage places a page of items in the whole listing. +type listPage struct { + // Number is the 1-based --page value. + Number int + // More reports later pages. Total is the size of the whole listing when Counted. + More bool + Total int + Counted bool +} + +// writeTable writes items as a JSON array, or as a table through the pager. On a TTY the table has +// a header row and, with a spec Context, a context line that also points at the next page; an empty +// list prints the spec's Empty message to stderr instead. Piped tables are tab-separated, and the +// next-page hint goes to stderr. +func writeTable[T any](a *application, cmd *cobra.Command, items []T, spec listSpec[T], page listPage) error { + if a.options.json != "" { + return a.writeJSON(cmd, items) + } + d := a.display() + if len(items) == 0 { + if !d.TTY || spec.Empty == "" { + return nil + } + _, err := fmt.Fprintln(cmd.ErrOrStderr(), spec.Empty) + return err + } + rows := make([][]terminal.Cell, len(items)) + for i, item := range items { + rows[i] = spec.Row(item) + } + text := terminal.Table(spec.Columns, rows, d) + if d.TTY && spec.Context != "" { + return a.writeHuman(cmd, showing(len(items), page, spec.Context)+"\n\n"+text) + } + if err := a.writeHuman(cmd, text); err != nil { + return err + } + return a.morePagesHint(cmd, page.More, page.Number) +} + +// showing is the context line above a table: "Showing 30 of 112 open issues in alice/x · next: --page 2". +func showing(shown int, page listPage, what string) string { + line := fmt.Sprintf("Showing %d", shown) + switch { + case page.Counted: + line += fmt.Sprintf(" of %d", page.Total) + case !page.More && page.Number <= 1: + line += fmt.Sprintf(" of %d", shown) + } + line += " " + what + if page.More { + line += fmt.Sprintf(" · next: --page %d", page.Number+1) + } + return line +} + +// writeView writes item as a JSON object, or as a view through the pager. +func writeView[T any](a *application, cmd *cobra.Command, item T, view func(T) terminal.View) error { + if a.options.json != "" { + return a.writeJSON(cmd, item) + } + return a.writeHuman(cmd, view(item).Render(a.display())) +} + // abortBatch stops a batch instead of recording an item failure. type abortBatch struct{ error } @@ -70,7 +163,7 @@ func batch[K, T any](a *application, cmd *cobra.Command, keys []K, label func(K) var err error switch { case a.options.json == "": - err = a.writeHuman(cmd, table(results, row, a.colorEnabled())) + err = a.writeHuman(cmd, legacyTable(a, cmd, results, row)) case len(keys) != 1: err = a.writeJSON(cmd, results) case len(results) == 1: @@ -79,20 +172,64 @@ func batch[K, T any](a *application, cmd *cobra.Command, keys []K, label func(K) return errors.Join(append(failures, err)...) } -// writeList writes items as a JSON array, or as one aligned table through the pager. +// writeList writes items as a JSON array, or as one table through the pager. List commands move +// to writeTable. func writeList[T any](a *application, cmd *cobra.Command, items []T, row func(T) []terminal.Cell) error { if a.options.json != "" { return a.writeJSON(cmd, items) } - return a.writeHuman(cmd, table(items, row, a.colorEnabled())) + return a.writeHuman(cmd, legacyTable(a, cmd, items, row)) } -// writeItem writes one item as a JSON object, or as its list row. +// writeItem writes one item as a JSON object, or as its list row. Views move to writeView. func writeItem[T any](a *application, cmd *cobra.Command, item T, row func(T) []terminal.Cell) error { if a.options.json != "" { return a.writeJSON(cmd, item) } - return a.writeHuman(cmd, table([]T{item}, row, a.colorEnabled())) + return a.writeHuman(cmd, legacyTable(a, cmd, []T{item}, row)) +} + +// legacyColumns gives lists that still render through writeList a header row and a flexible +// column, keyed by command path without the root. Their owners replace an entry with a listSpec. +var legacyColumns = map[string][]terminal.Column{ + "issue list": numberedColumns, + "pr list": numberedColumns, +} + +// numberedColumns head the rows of issueRow and pullRow. +var numberedColumns = []terminal.Column{{Header: "ID", Prefix: "#"}, {Header: "Title", Flex: true}, {Header: "State"}, {Header: "Author"}} + +// legacyTable renders rows of commands that predate listSpec: tab-separated when piped, and on a +// TTY fit to the terminal width by truncating their widest column, unless legacyColumns names +// their columns. +func legacyTable[T any](a *application, cmd *cobra.Command, items []T, row func(T) []terminal.Cell) string { + rows := make([][]terminal.Cell, len(items)) + for i, item := range items { + rows[i] = row(item) + } + columns, ok := legacyColumns[strings.TrimPrefix(cmd.CommandPath(), cmd.Root().Name()+" ")] + if !ok { + columns = widestFlex(rows) + } + return terminal.Table(columns, rows, a.display()) +} + +// widestFlex makes the column with the widest cell flexible. +func widestFlex(rows [][]terminal.Cell) []terminal.Column { + var widths []int + for _, row := range rows { + for i, cell := range row { + if i == len(widths) { + widths = append(widths, 0) + } + widths[i] = max(widths[i], runewidth.StringWidth(terminal.SafeLine(cell.Text))) + } + } + columns := make([]terminal.Column, len(widths)) + if len(widths) > 0 { + columns[slices.Index(widths, slices.Max(widths))].Flex = true + } + return columns } // writeRecord writes one item as a JSON object, or as a human-readable record. @@ -121,15 +258,17 @@ func numberedRecord(number int64, title, url string) terminal.Record { return terminal.Record{Heading: []terminal.Cell{{Text: fmt.Sprintf("#%d", number), Bold: true}, {Text: title}}, Lines: []string{url}} } -// stateColors colors issue, pull request and milestone states on color terminals. -var stateColors = map[string]string{"open": "2da44e", "closed": "cf222e", "merged": "8250df"} +// stateColors colors issue, pull request and milestone states with the terminal's own palette. +var stateColors = map[string]string{"open": "green", "closed": "red", "merged": "magenta", "draft": "muted"} +// table renders rows aligned like a TTY table without header or truncation, for commands that +// predate listSpec and compose their own output. func table[T any](items []T, row func(T) []terminal.Cell, color bool) string { rows := make([][]terminal.Cell, len(items)) for i, item := range items { rows[i] = row(item) } - return terminal.Table(rows, color) + return terminal.Table(nil, rows, terminal.Display{TTY: true, Color: color, TrueColor: true, Links: color}) } // morePagesHint tells human readers on stderr that the listing continues past this page. diff --git a/internal/app/human_output_test.go b/internal/app/human_output_test.go index 9a225682..c52ece08 100644 --- a/internal/app/human_output_test.go +++ b/internal/app/human_output_test.go @@ -12,8 +12,11 @@ import ( "strconv" "strings" "testing" + "time" "gitea.dev/tea/internal/terminal" + "github.com/mattn/go-runewidth" + "github.com/spf13/cobra" "github.com/stretchr/testify/require" ) @@ -56,7 +59,7 @@ func TestLabelListAppliesColorAndPagesWithNoInput(t *testing.T) { _, _ = w.Write([]byte(`[{"id":1,"name":"bug","color":"ff0000","description":"Defect"},{"id":2,"name":"docs","color":"00ff00","description":"Docs"}]`)) })) defer server.Close() - deps, out, diagnostics, pager := pagedDeps(t, server.URL, map[string]string{"PAGER": "fake-pager"}, "color", "always") + deps, out, diagnostics, pager := pagedDeps(t, server.URL, map[string]string{"PAGER": "fake-pager", "COLORTERM": "truecolor"}, "color", "always") require.NoError(t, Execute(context.Background(), deps, []string{"label", "list", "-R", "alice/project", "--no-input"})) require.Equal(t, 1, pager.calls) require.Equal(t, "fake-pager", pager.command) @@ -114,7 +117,7 @@ func TestTeaPagerAndColorEnvironmentOverrides(t *testing.T) { deps, out, _, pager := pagedDeps(t, server.URL, env, "pager", "configured-pager", "color", "never") require.NoError(t, Execute(context.Background(), deps, []string{"label", "list", "-R", "alice/project", "--no-input"})) require.Equal(t, "tea-pager", pager.command) - require.Contains(t, pager.input, "\x1b[38;2;255;0;0mbug\x1b[0m") + require.Contains(t, pager.input, "\x1b[38;5;196mbug\x1b[0m") out.Reset() require.NoError(t, Execute(context.Background(), deps, []string{"config", "get", "pager", "--show-source", "--json"})) require.JSONEq(t, `{"pager":{"value":"tea-pager","source":"TEA_PAGER"}}`, out.String()) @@ -131,13 +134,12 @@ func TestColorAutoUsesStdoutTTY(t *testing.T) { deps, out, _ := serverDeps(t, server.URL) deps.Streams.OutTTY = false require.NoError(t, Execute(context.Background(), deps, []string{"label", "list", "-R", "alice/project", "--no-input"})) - require.Equal(t, "1 bug ff0000\n", out.String()) - require.NotContains(t, out.String(), "\x1b[") + require.Equal(t, "1\tbug\tff0000\t\n", out.String()) out.Reset() deps.Streams.OutTTY = true require.NoError(t, Execute(context.Background(), deps, []string{"label", "list", "-R", "alice/project", "--no-input"})) - require.Contains(t, out.String(), "\x1b[38;2;255;0;0mbug\x1b[0m") + require.Contains(t, out.String(), "\x1b[38;5;196mbug\x1b[0m") } func TestMultiItemHumanOutputPagesOnce(t *testing.T) { @@ -171,7 +173,7 @@ func TestMultiItemHumanOutputPagesOnce(t *testing.T) { {[]string{"issue", "comment", "list", "5"}, "1 alice\none\n\n2 bob\ntwo\n\n"}, } { t.Run(tc.args[0]+" "+tc.args[1], func(t *testing.T) { - deps, out, _, pager := pagedDeps(t, server.URL, nil, "pager", "fake-pager", "color", "never") + deps, out, _, pager := pagedDeps(t, server.URL, map[string]string{"TEA_HYPERLINKS": "never"}, "pager", "fake-pager", "color", "never") require.NoError(t, Execute(context.Background(), deps, append(tc.args, "-R", "alice/project", "--yes"))) require.Equal(t, 1, pager.calls) require.Equal(t, tc.want, pager.input) @@ -210,3 +212,154 @@ func TestListsHintWhenMorePagesExist(t *testing.T) { require.Empty(t, diagnostics.String(), args) } } + +func TestListsFitTheTerminalAndSplitWhenPiped(t *testing.T) { + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`[{"number":6,"title":"A very long issue title that keeps going and going to see how list output handles wrapping","state":"open","user":{"login":"alice"}},` + + `{"number":5,"title":"支持中文路径 (support CJK paths)","state":"open","user":{"login":""}}]`)) + })) + defer server.Close() + for _, group := range []string{"issue", "pr"} { + t.Run(group, func(t *testing.T) { + deps, out, _ := serverDeps(t, server.URL) + args := []string{group, "list", "-R", "alice/project"} + require.NoError(t, Execute(context.Background(), deps, args)) + require.Equal(t, "6\tA very long issue title that keeps going and going to see how list output handles wrapping\topen\talice\n"+ + "5\t支持中文路径 (support CJK paths)\topen\t\n", out.String()) + + out.Reset() + deps.Streams.OutTTY, deps.Streams.Width = true, 50 + deps.Getenv = serverEnv(server.URL, map[string]string{"NO_COLOR": "1"}) + require.NoError(t, Execute(context.Background(), deps, args)) + require.Equal(t, "ID TITLE STATE AUTHOR\n"+ + "#6 A very long issue title that k… open alice\n"+ + "#5 支持中文路径 (support CJK path… open\n", stripLinks(out.String())) + for _, line := range strings.Split(strings.TrimSpace(out.String()), "\n") { + require.LessOrEqual(t, runewidth.StringWidth(stripLinks(line)), 50, line) + } + }) + } +} + +// stripLinks removes OSC 8 hyperlinks from rendered output. +func stripLinks(s string) string { + for { + start := strings.Index(s, "\x1b]8;") + if start < 0 { + return s + } + end := strings.Index(s[start:], "\x1b\\") + s = s[:start] + s[start+end+2:] + } +} + +// outputApplication returns an application over deps and a command writing to its streams. +func outputApplication(deps Dependencies, json string) (*application, *cobra.Command) { + deps.Now = func() time.Time { return time.Date(2026, 10, 11, 20, 0, 0, 0, time.UTC) } + a := &application{deps: deps, options: options{json: json}} + cmd := &cobra.Command{Use: "list"} + cmd.SetOut(deps.Streams.Out) + cmd.SetErr(deps.Streams.ErrOut) + cmd.SetContext(context.Background()) + return a, cmd +} + +type listed struct { + Number int64 `json:"number"` + Title string `json:"title"` + Updated time.Time `json:"updated"` +} + +var listedSpec = listSpec[listed]{ + Columns: []terminal.Column{{Header: "ID", Prefix: "#"}, {Header: "Title", Flex: true}, {Header: "Updated"}}, + Row: func(item listed) []terminal.Cell { + return []terminal.Cell{{Text: strconv.FormatInt(item.Number, 10)}, {Text: item.Title}, {Time: item.Updated}} + }, + Empty: "no open issues in alice/x", + Context: "open issues in alice/x", +} + +func TestWriteTable(t *testing.T) { + updated := time.Date(2026, 10, 11, 18, 0, 0, 0, time.UTC) + items := []listed{{12, "Emoji in title 🚀", updated}, {1, "Login fails", updated}} + for _, tc := range []struct { + name string + tty bool + json string + items []listed + page listPage + out, stderr string + pagedHeadings bool + }{ + { + name: "context line and header on a terminal", tty: true, items: items, page: listPage{Number: 1, More: true, Total: 112, Counted: true}, + out: "Showing 2 of 112 open issues in alice/x · next: --page 2\n\n" + + "ID TITLE UPDATED\n#12 Emoji in title 🚀 about 2 hours ago\n#1 Login fails about 2 hours ago\n", + }, + { + name: "counts a single page without a total", tty: true, items: items[:1], page: listPage{Number: 1}, + out: "Showing 1 of 1 open issues in alice/x\n\nID TITLE UPDATED\n#12 Emoji in title 🚀 about 2 hours ago\n", + }, + { + name: "tab-separated with the page hint on stderr when piped", items: items, page: listPage{Number: 2, More: true}, + out: "12\tEmoji in title 🚀\t2026-10-11T18:00:00Z\n1\tLogin fails\t2026-10-11T18:00:00Z\n", + stderr: "more results available; use --page 3\n", + }, + {name: "empty message on a terminal", tty: true, page: listPage{Number: 1}, stderr: "no open issues in alice/x\n"}, + {name: "nothing when empty and piped", page: listPage{Number: 1}}, + {name: "JSON", tty: true, json: "number", items: items[:1], page: listPage{Number: 1, More: true}, out: `[{"number":12}]` + "\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + deps, out, diagnostics := testDeps(t) + deps.Streams.OutTTY = tc.tty + deps.Getenv = envFunc(map[string]string{"NO_COLOR": "1"}) + a, cmd := outputApplication(deps, tc.json) + require.NoError(t, writeTable(a, cmd, tc.items, listedSpec, tc.page)) + require.Equal(t, tc.out, out.String()) + require.Equal(t, tc.stderr, diagnostics.String()) + }) + } +} + +func TestWriteView(t *testing.T) { + view := func(item listed) terminal.View { + return terminal.View{ + Title: item.Title, ID: fmt.Sprintf("#%d", item.Number), Badges: []terminal.Cell{{Text: "Open", Color: "green"}}, + Meta: "alice opened " + terminal.RelativeTime(time.Date(2026, 10, 11, 20, 0, 0, 0, time.UTC), item.Updated), Body: "Steps", Kind: "issue", URL: "https://example.org/1", + } + } + item := listed{1, "Login fails", time.Date(2026, 10, 11, 18, 0, 0, 0, time.UTC)} + for _, tc := range []struct { + name, json, want string + tty bool + }{ + {name: "terminal", tty: true, want: "Login fails #1\nOpen • alice opened about 2 hours ago\n\nSteps\n\nView this issue on Gitea: https://example.org/1\n"}, + {name: "piped", want: "title:\tLogin fails\nurl:\thttps://example.org/1\n--\nSteps\n"}, + {name: "JSON", tty: true, json: "title", want: `{"title":"Login fails"}` + "\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + deps, out, _ := testDeps(t) + deps.Streams.OutTTY = tc.tty + deps.Getenv = envFunc(map[string]string{"NO_COLOR": "1", "TEA_MARKDOWN": "raw", "TEA_HYPERLINKS": "never"}) + a, cmd := outputApplication(deps, tc.json) + require.NoError(t, writeView(a, cmd, item, view)) + require.Equal(t, tc.want, out.String()) + }) + } +} + +func TestShowing(t *testing.T) { + for _, tc := range []struct { + shown int + page listPage + want string + }{ + {30, listPage{Number: 1, More: true, Total: 112, Counted: true}, "Showing 30 of 112 open issues · next: --page 2"}, + {11, listPage{Number: 1, Total: 11, Counted: true}, "Showing 11 of 11 open issues"}, + {11, listPage{Number: 1}, "Showing 11 of 11 open issues"}, + {30, listPage{Number: 1, More: true}, "Showing 30 open issues · next: --page 2"}, + {5, listPage{Number: 3}, "Showing 5 open issues"}, + } { + require.Equal(t, tc.want, showing(tc.shown, tc.page, "open issues"), "%+v", tc.page) + } +} diff --git a/internal/app/issue_filters_test.go b/internal/app/issue_filters_test.go index 2ba15819..9798c7cf 100644 --- a/internal/app/issue_filters_test.go +++ b/internal/app/issue_filters_test.go @@ -97,12 +97,13 @@ func TestIssueListLinksNumbersOnColorTerminals(t *testing.T) { deps, out, _ := serverDeps(t, server.URL) args := []string{"issue", "list", "-R", "alice/project"} require.NoError(t, Execute(context.Background(), deps, args)) - require.Equal(t, "7 Short open alice\n12 Evil]52;c;aGk= Title closed bob\n", out.String()) + require.Equal(t, "7\tShort\topen\talice\n12\tEvil]52;c;aGk= Title\tclosed\tbob\n", out.String()) out.Reset() deps.Streams.OutTTY = true require.NoError(t, Execute(context.Background(), deps, args)) - require.Equal(t, "\x1b]8;;https://example.org/i/7\x1b\\7\x1b]8;;\x1b\\ Short \x1b[38;2;45;164;78mopen\x1b[0m alice\n"+ - "\x1b]8;;https://example.org/i/12\x1b\\12\x1b]8;;\x1b\\ Evil]52;c;aGk= Title \x1b[38;2;207;34;46mclosed\x1b[0m bob\n", out.String()) + require.Equal(t, "\x1b[90mID\x1b[0m \x1b[90mTITLE\x1b[0m \x1b[90mSTATE\x1b[0m \x1b[90mAUTHOR\x1b[0m\n"+ + "\x1b]8;;https://example.org/i/7\x1b\\#7\x1b]8;;\x1b\\ Short \x1b[32mopen\x1b[0m alice\n"+ + "\x1b]8;;https://example.org/i/12\x1b\\#12\x1b]8;;\x1b\\ Evil]52;c;aGk= Title \x1b[31mclosed\x1b[0m bob\n", out.String()) } func TestIssueListKindFilter(t *testing.T) { diff --git a/internal/app/labelfile_test.go b/internal/app/labelfile_test.go index 9b0e98c0..35fe0d94 100644 --- a/internal/app/labelfile_test.go +++ b/internal/app/labelfile_test.go @@ -66,5 +66,5 @@ func TestLabelImportAcceptsColorPrefixedRows(t *testing.T) { require.Len(t, created, 2) requirePayload(t, `{"name":"bug","color":"ee0701","description":"Something is not working"}`, strings.NewReader(created[0])) requirePayload(t, `{"name":"enhancement","color":"84b6eb"}`, strings.NewReader(created[1])) - require.Equal(t, "1 bug\n2 enhancement\n", out.String()) + require.Equal(t, "1\tbug\t\t\n2\tenhancement\t\t\n", out.String()) } diff --git a/internal/terminal/color.go b/internal/terminal/color.go index f1761360..af75323b 100644 --- a/internal/terminal/color.go +++ b/internal/terminal/color.go @@ -26,22 +26,93 @@ func ColorEnabled(preference string, outTTY bool, getenv func(string) string) bo } } -// Colorize applies a 24-bit foreground color from a six-digit RGB hex value. -func Colorize(hex, text string, enabled bool) string { - if !enabled || text == "" { +// HyperlinksEnabled resolves the hyperlinks preference like ColorEnabled, but independently of color: +// NO_COLOR keeps links, and auto links only on TTY stdout whose TERM is not dumb. +func HyperlinksEnabled(preference string, outTTY bool, getenv func(string) string) bool { + switch preference { + case "always": + return true + case "never": + return false + default: + return outTTY && (getenv == nil || getenv("TERM") != "dumb") + } +} + +// TrueColor reports whether the terminal advertises 24-bit color through COLORTERM. +func TrueColor(getenv func(string) string) bool { + if getenv == nil { + return false + } + value := getenv("COLORTERM") + return value == "truecolor" || value == "24bit" +} + +// namedColors are the ANSI 16 colors for states and status, so they follow the terminal's own theme. +var namedColors = map[string]string{"red": "31", "green": "32", "yellow": "33", "blue": "34", "magenta": "35", "cyan": "36", "muted": "90"} + +// Colorize colors text by name (red, green, yellow, blue, magenta, cyan or muted) from the terminal's palette, +// or by six-digit RGB hex with 24-bit color. Unknown colors leave the text plain. +func Colorize(color, text string, enabled bool) string { + if !enabled { return text } + return paint(color, text, true) +} + +// paint is Colorize for enabled color; without trueColor, hex colors use the nearest 256-color index. +func paint(color, text string, trueColor bool) string { + if text == "" { + return text + } + if code, ok := namedColors[color]; ok { + return "\x1b[" + code + "m" + text + "\x1b[0m" + } + r, g, b, ok := parseHex(color) + switch { + case !ok: + return text + case trueColor: + return fmt.Sprintf("\x1b[38;2;%d;%d;%dm%s\x1b[0m", r, g, b, text) + default: + return fmt.Sprintf("\x1b[38;5;%dm%s\x1b[0m", ansi256(r, g, b), text) + } +} + +func parseHex(hex string) (r, g, b int, ok bool) { hex = strings.TrimPrefix(strings.TrimSpace(hex), "#") if len(hex) != 6 { - return text + return 0, 0, 0, false } - r, errR := strconv.ParseUint(hex[0:2], 16, 8) - g, errG := strconv.ParseUint(hex[2:4], 16, 8) - b, errB := strconv.ParseUint(hex[4:6], 16, 8) - if errR != nil || errG != nil || errB != nil { - return text + value, err := strconv.ParseUint(hex, 16, 24) + if err != nil { + return 0, 0, 0, false } - return fmt.Sprintf("\x1b[38;2;%d;%d;%dm%s\x1b[0m", r, g, b, text) + return int(value >> 16), int(value >> 8 & 0xff), int(value & 0xff), true +} + +// ansi256 returns the 256-color palette index nearest to an RGB color: one of the 6×6×6 cube colors +// (16–231) or one of the 24 grays (232–255). +func ansi256(r, g, b int) int { + levels := [6]int{0, 95, 135, 175, 215, 255} + level := func(v int) int { + switch { + case v < 48: + return 0 + case v < 115: + return 1 + default: + return (v - 35) / 40 + } + } + ri, gi, bi := level(r), level(g), level(b) + gray := min(max((r+g+b)/3-3, 0)/10, 23) + grayValue := 8 + 10*gray + distance := func(cr, cg, cb int) int { return (r-cr)*(r-cr) + (g-cg)*(g-cg) + (b-cb)*(b-cb) } + if distance(levels[ri], levels[gi], levels[bi]) <= distance(grayValue, grayValue, grayValue) { + return 16 + 36*ri + 6*gi + bi + } + return 232 + gray } // Emphasis applies simple bold styling for TTY human tables. diff --git a/internal/terminal/color_test.go b/internal/terminal/color_test.go index e0ad88b4..e004817c 100644 --- a/internal/terminal/color_test.go +++ b/internal/terminal/color_test.go @@ -9,30 +9,62 @@ import ( "github.com/stretchr/testify/require" ) +func env(vars map[string]string) func(string) string { + return func(key string) string { return vars[key] } +} + func TestColorEnabledRespectsPreferenceAndEnvironment(t *testing.T) { - getenv := func(key string) string { - if key == "NO_COLOR" { - return "1" - } - return "" - } + noColor := env(map[string]string{"NO_COLOR": "1"}) require.True(t, ColorEnabled("always", false, nil)) require.False(t, ColorEnabled("never", true, nil)) - require.True(t, ColorEnabled("auto", true, func(string) string { return "" })) - require.False(t, ColorEnabled("auto", false, func(string) string { return "" })) - require.False(t, ColorEnabled("auto", true, getenv)) - require.True(t, ColorEnabled("always", true, getenv)) - require.False(t, ColorEnabled("auto", true, func(key string) string { return map[string]string{"TERM": "dumb"}[key] })) + require.True(t, ColorEnabled("auto", true, env(nil))) + require.False(t, ColorEnabled("auto", false, env(nil))) + require.False(t, ColorEnabled("auto", true, noColor)) + require.True(t, ColorEnabled("always", true, noColor)) + require.False(t, ColorEnabled("auto", true, env(map[string]string{"TERM": "dumb"}))) +} + +func TestHyperlinksEnabledIgnoresColorPolicy(t *testing.T) { + for _, tc := range []struct { + preference string + tty bool + env map[string]string + want bool + }{ + {"auto", true, nil, true}, + {"", true, map[string]string{"NO_COLOR": "1"}, true}, + {"auto", false, nil, false}, + {"auto", true, map[string]string{"TERM": "dumb"}, false}, + {"always", false, map[string]string{"TERM": "dumb"}, true}, + {"never", true, nil, false}, + } { + require.Equal(t, tc.want, HyperlinksEnabled(tc.preference, tc.tty, env(tc.env)), "%+v", tc) + } +} + +func TestTrueColor(t *testing.T) { + require.True(t, TrueColor(env(map[string]string{"COLORTERM": "truecolor"}))) + require.True(t, TrueColor(env(map[string]string{"COLORTERM": "24bit"}))) + require.False(t, TrueColor(env(map[string]string{"COLORTERM": "yes"}))) + require.False(t, TrueColor(nil)) } func TestColorizeAndEmphasis(t *testing.T) { require.Equal(t, "bug", Colorize("ff0000", "bug", false)) require.Equal(t, "bug", Colorize("bad", "bug", true)) require.Equal(t, "\x1b[38;2;255;0;0mbug\x1b[0m", Colorize("#ff0000", "bug", true)) + require.Equal(t, "\x1b[32mopen\x1b[0m", Colorize("green", "open", true)) + require.Equal(t, "\x1b[90mdraft\x1b[0m", Colorize("muted", "draft", true)) require.Equal(t, "open", Emphasis("open", false)) require.Equal(t, "\x1b[1mopen\x1b[0m", Emphasis("open", true)) } +func TestPaintApproximatesHexColorsWithoutTrueColor(t *testing.T) { + for hex, index := range map[string]string{"ff0000": "196", "00ff00": "46", "000000": "16", "ffffff": "231", "808080": "244", "d73a4a": "167", "7057ff": "63"} { + require.Equal(t, "\x1b[38;5;"+index+"mx\x1b[0m", paint(hex, "x", false), hex) + } +} + func TestHyperlink(t *testing.T) { require.Equal(t, "#7", Hyperlink("https://example.org/i/7", "#7", false)) require.Equal(t, "\x1b]8;;https://example.org/i/7\x1b\\#7\x1b]8;;\x1b\\", Hyperlink("https://example.org/i/7", "#7", true)) diff --git a/internal/terminal/format.go b/internal/terminal/format.go new file mode 100644 index 00000000..79d69ec1 --- /dev/null +++ b/internal/terminal/format.go @@ -0,0 +1,55 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terminal + +import ( + "fmt" + "time" +) + +// Bytes formats a size in binary units: "13 B", "2.9 MiB". +func Bytes(n int64) string { + if n < 1024 { + return fmt.Sprintf("%d B", n) + } + units := []string{"KiB", "MiB", "GiB", "TiB"} + value, unit := float64(n)/1024, 0 + for value >= 1024 && unit < len(units)-1 { + value /= 1024 + unit++ + } + return fmt.Sprintf("%.1f %s", value, units[unit]) +} + +// Duration formats an elapsed time by its two largest units: "1h 30m", "1m 12s", "45s". +func Duration(d time.Duration) string { + d = d.Round(time.Second) + hours, minutes, seconds := int(d/time.Hour), int(d%time.Hour/time.Minute), int(d%time.Minute/time.Second) + switch { + case hours > 0: + return fmt.Sprintf("%dh %dm", hours, minutes) + case minutes > 0: + return fmt.Sprintf("%dm %ds", minutes, seconds) + default: + return fmt.Sprintf("%ds", seconds) + } +} + +// StatusIcon summarizes a check or run in one cell: ✓ succeeded, X failed, - skipped or canceled, and * still +// pending. A conclusion, when present, takes precedence over the status. +func StatusIcon(status, conclusion string) Cell { + if conclusion != "" { + status = conclusion + } + switch status { + case "success": + return Cell{Text: "✓", Color: "green"} + case "failure", "error": + return Cell{Text: "X", Color: "red"} + case "cancelled", "canceled", "skipped": //nolint:misspell // Gitea API spelling. + return Cell{Text: "-", Muted: true} + default: + return Cell{Text: "*", Color: "yellow"} + } +} diff --git a/internal/terminal/format_test.go b/internal/terminal/format_test.go new file mode 100644 index 00000000..2ecae9f4 --- /dev/null +++ b/internal/terminal/format_test.go @@ -0,0 +1,67 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terminal + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestRelativeTime(t *testing.T) { + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + for _, tc := range []struct { + at time.Time + want string + }{ + {now.Add(-30 * time.Second), "less than a minute ago"}, + {now.Add(-time.Minute), "about 1 minute ago"}, + {now.Add(-3 * time.Hour), "about 3 hours ago"}, + {now.AddDate(0, -2, 0), "about 2 months ago"}, + {now.AddDate(-2, -1, 0), "about 2 years ago"}, + {now.Add(10 * time.Second), "in less than a minute"}, + {now.AddDate(0, 0, 3), "in about 3 days"}, + } { + require.Equal(t, tc.want, RelativeTime(now, tc.at), tc.at) + } +} + +func TestTimestamp(t *testing.T) { + at := time.Date(2026, 10, 11, 20, 48, 23, 588823137, time.FixedZone("CEST", 2*3600)) + require.Equal(t, "2026-10-11T18:48:23Z", Timestamp(at)) + require.Equal(t, at.Unix(), ParseTime("2026-10-11T20:48:23+02:00").Unix()) + require.True(t, ParseTime("").IsZero()) + require.True(t, ParseTime("yesterday").IsZero()) +} + +func TestBytes(t *testing.T) { + for n, want := range map[int64]string{0: "0 B", 13: "13 B", 1023: "1023 B", 1536: "1.5 KiB", 3040870: "2.9 MiB", 5 << 30: "5.0 GiB", 3 << 50: "3072.0 TiB"} { + require.Equal(t, want, Bytes(n), n) + } +} + +func TestDuration(t *testing.T) { + for d, want := range map[time.Duration]string{0: "0s", 45 * time.Second: "45s", 72 * time.Second: "1m 12s", 90 * time.Minute: "1h 30m", 2 * time.Hour: "2h 0m", 1499 * time.Millisecond: "1s"} { + require.Equal(t, want, Duration(d), d) + } +} + +func TestStatusIcon(t *testing.T) { + for _, tc := range []struct { + status, conclusion string + want Cell + }{ + {"completed", "success", Cell{Text: "✓", Color: "green"}}, + {"success", "", Cell{Text: "✓", Color: "green"}}, + {"completed", "failure", Cell{Text: "X", Color: "red"}}, + {"error", "", Cell{Text: "X", Color: "red"}}, + {"completed", "cancelled", Cell{Text: "-", Muted: true}}, //nolint:misspell // Gitea API spelling. + {"skipped", "", Cell{Text: "-", Muted: true}}, + {"queued", "", Cell{Text: "*", Color: "yellow"}}, + {"pending", "", Cell{Text: "*", Color: "yellow"}}, + } { + require.Equal(t, tc.want, StatusIcon(tc.status, tc.conclusion), "%s/%s", tc.status, tc.conclusion) + } +} diff --git a/internal/terminal/markdown.go b/internal/terminal/markdown.go new file mode 100644 index 00000000..0ed8024f --- /dev/null +++ b/internal/terminal/markdown.go @@ -0,0 +1,54 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terminal + +import ( + "regexp" + "strings" + + "charm.land/glamour/v2" + "charm.land/glamour/v2/styles" +) + +// MarkdownEnabled resolves the markdown preference: raw never renders, and auto renders on TTY stdout. +func MarkdownEnabled(preference string, outTTY bool) bool { + return outTTY && preference != "raw" +} + +// glamourLinks matches the OSC 8 hyperlinks that glamour writes for markdown links. +var glamourLinks = regexp.MustCompile("\x1b\\]8;[^\x07\x1b]*(?:\x07|\x1b\\\\)") + +// Markdown renders sanitized markdown for d: wrapped to its width (80 when unknown), colored or plain, +// with links only when d allows them. It keeps line breaks like Gitea does, and returns the sanitized +// text unrendered if rendering fails. +func Markdown(text string, d Display) string { + text = SafeText(text) + width := d.Width + if width <= 0 { + width = 80 + } + style := styles.NoTTYStyleConfig + if d.Color { + style = styles.DarkStyleConfig + // The terminal's own text color keeps body text readable on light backgrounds too. + style.Document.Color = nil + } + renderer, err := glamour.NewTermRenderer(glamour.WithStyles(style), glamour.WithWordWrap(width), glamour.WithPreservedNewLines()) + if err != nil { + return text + } + rendered, err := renderer.Render(text) + if err != nil { + return text + } + if !d.Links { + rendered = glamourLinks.ReplaceAllString(rendered, "") + } + // glamour pads every line to the full width. + lines := strings.Split(rendered, "\n") + for i, line := range lines { + lines[i] = strings.TrimRight(line, " ") + } + return strings.Trim(strings.Join(lines, "\n"), "\n") +} diff --git a/internal/terminal/streams.go b/internal/terminal/streams.go index d5c9c3d0..724e81bc 100644 --- a/internal/terminal/streams.go +++ b/internal/terminal/streams.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "os" + "strconv" "strings" "charm.land/huh/v2" @@ -24,11 +25,29 @@ type Streams struct { InTTY bool OutTTY bool ErrTTY bool + // Width is the number of columns human output on Out fits in; 0 means unlimited. + Width int } // System binds streams to the current process and detects each terminal. func System() Streams { - return Streams{In: os.Stdin, Out: os.Stdout, ErrOut: os.Stderr, InTTY: term.IsTerminal(int(os.Stdin.Fd())), OutTTY: term.IsTerminal(int(os.Stdout.Fd())), ErrTTY: term.IsTerminal(int(os.Stderr.Fd()))} + return Streams{ + In: os.Stdin, Out: os.Stdout, ErrOut: os.Stderr, + InTTY: term.IsTerminal(int(os.Stdin.Fd())), OutTTY: term.IsTerminal(int(os.Stdout.Fd())), ErrTTY: term.IsTerminal(int(os.Stderr.Fd())), + Width: terminalWidth(int(os.Stdout.Fd()), os.Getenv), + } +} + +// terminalWidth returns the width of the terminal at fd, then $COLUMNS, then 80. A pty without a +// window size, such as `script` run without a terminal, reports a width of 0. +func terminalWidth(fd int, getenv func(string) string) int { + if width, _, err := term.GetSize(fd); err == nil && width > 0 { + return width + } + if width, err := strconv.Atoi(getenv("COLUMNS")); err == nil && width > 0 { + return width + } + return 80 } // Policy controls input interaction independently of supplied flag count. diff --git a/internal/terminal/table.go b/internal/terminal/table.go index 4686ce44..c1584957 100644 --- a/internal/terminal/table.go +++ b/internal/terminal/table.go @@ -5,22 +5,83 @@ package terminal import ( "strings" + "time" "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 +// 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; TrueColor writes hex colors in 24-bit instead + // of the nearest of 256. + Color, TrueColor 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 } -// 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) +// 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(paint(color, text, d.TrueColor), c.Bold) + } + return Hyperlink(c.Link, text, link && d.Links) } // Row returns unstyled cells. @@ -32,41 +93,122 @@ func Row(texts ...string) []Cell { 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 { +// 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)) - 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) + texts[r] = make([]string, len(shown)) + for j, i := range shown { + if i < len(row) { + texts[r][j] = row[i].text(column(i), d) } - widths[i] = max(widths[i], runewidth.StringWidth(texts[r][i])) } } var b strings.Builder - for r, row := range rows { + 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 i, cell := range row { - text := texts[r][i] - if color { - text = cell.style(text) + for j, text := range cells { + if column(shown[j]).Flex { + text = runewidth.Truncate(text, widths[j], "…") } - line.WriteString(text) - if i < len(row)-1 { - line.WriteString(strings.Repeat(" ", widths[i]-runewidth.StringWidth(texts[r][i])+2)) + 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]-- + } +} + // 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 { @@ -77,16 +219,13 @@ type Record struct { // 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 { + d := Display{TTY: true, Color: color, TrueColor: true, Links: color} 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.WriteString(cell.style(SafeLine(cell.Text), d, true)) } b.WriteByte('\n') for _, line := range r.Lines { diff --git a/internal/terminal/table_test.go b/internal/terminal/table_test.go index 70f5f2b4..ee113460 100644 --- a/internal/terminal/table_test.go +++ b/internal/terminal/table_test.go @@ -4,47 +4,140 @@ package terminal import ( + "strings" "testing" + "time" + "github.com/mattn/go-runewidth" "github.com/stretchr/testify/require" ) func TestTable(t *testing.T) { + now := time.Date(2026, 10, 11, 20, 0, 0, 0, time.UTC) + tty := Display{TTY: true, Now: now} + issues := []Column{{Header: "ID", Prefix: "#"}, {Header: "Title", Flex: true}, {Header: "Labels", Flex: true}, {Header: "Updated"}} + issueRows := [][]Cell{ + {{Text: "12", Link: "https://example.org/12"}, {Text: "Emoji in title 🚀 works?"}, {}, {Time: now.Add(-2 * time.Hour)}}, + {{Text: "1", Link: "https://example.org/1"}, {Text: "Login fails with special characters in password"}, {Text: "bug, priority/high"}, {Time: now.Add(-90 * time.Minute)}}, + } for _, tc := range []struct { - name string - rows [][]Cell - color bool - want string + name string + columns []Column + rows [][]Cell + d Display + want string }{ - {"empty", nil, false, ""}, - {"aligns columns", [][]Cell{Row("7", "Fix login", "open"), Row("12", "Crash", "closed")}, false, "7 Fix login open\n12 Crash closed\n"}, - {"counts runes, not bytes", [][]Cell{Row("1", "äöü", "x"), Row("2", "abcd", "y")}, false, "1 äöü x\n2 abcd y\n"}, - {"counts wide runes twice", [][]Cell{Row("1", "修复", "x"), Row("2", "abcd", "y")}, false, "1 修复 x\n2 abcd y\n"}, - {"trims empty trailing cells", [][]Cell{Row("1", "bug", ""), Row("22", "feature", "new work")}, false, "1 bug\n22 feature new work\n"}, - {"pads ragged rows", [][]Cell{Row("1"), Row("2", "two")}, false, "1\n2 two\n"}, + {"empty", issues, nil, tty, ""}, + {"aligns legacy rows without header", nil, [][]Cell{Row("7", "Fix login", "open"), Row("12", "Crash", "closed")}, tty, "7 Fix login open\n12 Crash closed\n"}, + {"counts runes, not bytes", nil, [][]Cell{Row("1", "äöü", "x"), Row("2", "abcd", "y")}, tty, "1 äöü x\n2 abcd y\n"}, + {"counts wide runes twice", nil, [][]Cell{Row("1", "修复", "x"), Row("2", "abcd", "y")}, tty, "1 修复 x\n2 abcd y\n"}, + {"trims empty trailing cells", nil, [][]Cell{Row("1", "bug", ""), Row("22", "feature", "new work")}, tty, "1 bug\n22 feature new work\n"}, + {"pads ragged rows", nil, [][]Cell{Row("1"), Row("2", "two")}, tty, "1\n2 two\n"}, { - "strips hostile title", - [][]Cell{Row("1", "a\x1b]52;c;cHduZWQ=\x07b\x1b[2J\u009b31m‮txt.exe\r\ntail\tend", "open")}, - false, - "1 a]52;c;cHduZWQ=b[2J31mtxt.exe tail end open\n", + "heads columns and formats cells for people", + issues, issueRows, tty, + "ID TITLE LABELS UPDATED\n" + + "#12 Emoji in title 🚀 works? about 2 hours ago\n" + + "#1 Login fails with special characters in password bug, priority/high about 1 hour ago\n", }, - {"ignores styles without color", [][]Cell{{{Text: "7", Link: "https://example.org/7"}, {Text: "bug", Color: "ff0000"}, {Text: "open", Bold: true}}}, false, "7 bug open\n"}, { - "styles after alignment", - [][]Cell{{{Text: "7", Link: "https://example.org/7"}, {Text: "bug", Color: "ff0000"}, {Text: "open", Bold: true}}, Row("12", "feature", "closed")}, - true, - "\x1b]8;;https://example.org/7\x1b\\7\x1b]8;;\x1b\\ \x1b[38;2;255;0;0mbug\x1b[0m \x1b[1mopen\x1b[0m\n12 feature closed\n", + "shrinks the widest flexible column first", + issues, issueRows, + Display{TTY: true, Now: now, Width: 72}, + "ID TITLE LABELS UPDATED\n" + + "#12 Emoji in title 🚀 works? about 2 hours ago\n" + + "#1 Login fails with special ch… bug, priority/high about 1 hour ago\n", + }, + { + "keeps flexible columns at their floor", + issues, issueRows, + Display{TTY: true, Now: now, Width: 40}, + "ID TITLE LABELS UPDATED\n" + + "#12 Emoji i… about 2 hours ago\n" + + "#1 Login f… bug, pr… about 1 hour ago\n", + }, + { + "cuts wide runes by display width", + []Column{{Header: "Title", Flex: true}, {Header: "By"}}, + [][]Cell{Row("支持中文路径 (support CJK paths)", "alice")}, + Display{TTY: true, Width: 20}, + "TITLE BY\n支持中文路径… alice\n", + }, + { + "writes tab-separated values when piped", + issues, issueRows, + Display{Width: 40, Color: true, Links: true}, + "12\tEmoji in title 🚀 works?\t\t2026-10-11T18:00:00Z\n" + + "1\tLogin fails with special characters in password\tbug, priority/high\t2026-10-11T18:30:00Z\n", + }, + { + "keeps columns to their output", + []Column{{Header: "ID"}, {Header: "State", PipedOnly: true}, {Header: "Link", TTYOnly: true}}, + [][]Cell{Row("1", "open", "→")}, + tty, + "ID LINK\n1 →\n", + }, + { + "keeps piped-only columns when piped", + []Column{{Header: "ID"}, {Header: "State", PipedOnly: true}, {Header: "Link", TTYOnly: true}}, + [][]Cell{Row("1", "open", "→")}, + Display{}, + "1\topen\n", + }, + { + "strips hostile text in both outputs", + nil, + [][]Cell{Row("1", "a\x1b]52;c;cHduZWQ=\x07b\x1b[2J\u009b31m‮txt.exe\r\ntail\tend", "open")}, + Display{}, + "1\ta]52;c;cHduZWQ=b[2J31mtxt.exe tail end\topen\n", + }, + {"ignores styles without color", nil, [][]Cell{{{Text: "7", Link: "https://example.org/7"}, {Text: "bug", Color: "ff0000"}, {Text: "open", Bold: true}}}, tty, "7 bug open\n"}, + { + "styles after alignment and links only the first column", + []Column{{Header: "ID"}, {Header: "Name"}, {Header: "State"}}, + [][]Cell{{{Text: "7", Link: "https://example.org/7"}, {Text: "bug", Color: "ff0000", Link: "https://example.org/bug"}, {Text: "open", Color: "green", Bold: true}}, Row("12", "feature", "closed")}, + Display{TTY: true, Color: true, Links: true}, + "\x1b[90mID\x1b[0m \x1b[90mNAME\x1b[0m \x1b[90mSTATE\x1b[0m\n" + + "\x1b]8;;https://example.org/7\x1b\\7\x1b]8;;\x1b\\ \x1b[38;5;196mbug\x1b[0m \x1b[1m\x1b[32mopen\x1b[0m\x1b[0m\n" + + "12 feature closed\n", + }, + { + "links without color", + nil, + [][]Cell{{{Text: "7", Link: "https://example.org/7"}, {Text: "bug", Muted: true}}}, + Display{TTY: true, Links: true}, + "\x1b]8;;https://example.org/7\x1b\\7\x1b]8;;\x1b\\ bug\n", + }, + { + "writes 24-bit color on true-color terminals", + nil, + [][]Cell{{{Text: "bug", Color: "#ff0000"}, {Text: "old", Muted: true}}}, + Display{TTY: true, Color: true, TrueColor: true}, + "\x1b[38;2;255;0;0mbug\x1b[0m \x1b[90mold\x1b[0m\n", }, } { t.Run(tc.name, func(t *testing.T) { - require.Equal(t, tc.want, Table(tc.rows, tc.color)) + require.Equal(t, tc.want, Table(tc.columns, tc.rows, tc.d)) }) } } +func TestTableFitsWidthWithWideRunes(t *testing.T) { + titles := []string{"Emoji in title 🚀 works?", "支持中文路径 (support CJK paths)", strings.Repeat("long ", 40)} + rows := make([][]Cell, len(titles)) + for i, title := range titles { + rows[i] = Row("#1", title, "alice") + } + for _, width := range []int{30, 50, 80} { + for _, line := range strings.Split(strings.TrimSuffix(Table([]Column{{}, {Flex: true}, {}}, rows, Display{TTY: true, Width: width}), "\n"), "\n") { + require.LessOrEqual(t, runewidth.StringWidth(line), width, line) + } + } +} + func TestRecord(t *testing.T) { record := Record{ - Heading: []Cell{{Text: "#7", Bold: true}, {Text: "Crash\x1b[2J\nnow"}, {Text: "bob\u202e"}}, + Heading: []Cell{{Text: "#7", Bold: true}, {Text: "Crash\x1b[2J\nnow"}, {Text: "bob‮"}}, Lines: []string{"Steps:\r\n\x1b]52;c;aGk=\x07run\n", "https://example.org/7"}, } require.Equal(t, "#7 Crash[2J now bob\nSteps:\n]52;c;aGk=run\n\nhttps://example.org/7\n", record.Render(false)) @@ -58,7 +151,7 @@ func TestSafeText(t *testing.T) { {"drops lone carriage returns", "safe\rFAKE", "safeFAKE", "safeFAKE"}, { "strips hostile body", - "Steps:\r\n\x1b]52;c;cHduZWQ=\x07run\x1b[2J\u009b31m\u202e\u200bit\x7f\n\x00done", + "Steps:\r\n\x1b]52;c;cHduZWQ=\x07run\x1b[2J\u009b31m‮​it\x7f\n\x00done", "Steps:\n]52;c;cHduZWQ=run[2J31mit\ndone", "Steps: ]52;c;cHduZWQ=run[2J31mit done", }, diff --git a/internal/terminal/time.go b/internal/terminal/time.go new file mode 100644 index 00000000..5df2485a --- /dev/null +++ b/internal/terminal/time.go @@ -0,0 +1,57 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terminal + +import ( + "fmt" + "time" +) + +// RelativeTime describes t relative to now like gh: "less than a minute ago", "about 3 hours ago", +// and "in about 2 months" for future times such as due dates. +func RelativeTime(now, t time.Time) string { + d, prefix, suffix := now.Sub(t), "", " ago" + if d < 0 { + d, prefix, suffix = -d, "in ", "" + } + const day = 24 * time.Hour + var n time.Duration + var unit string + switch { + case d < time.Minute: + return prefix + "less than a minute" + suffix + case d < time.Hour: + n, unit = d/time.Minute, "minute" + case d < day: + n, unit = d/time.Hour, "hour" + case d < 30*day: + n, unit = d/day, "day" + case d < 365*day: + n, unit = d/(30*day), "month" + default: + n, unit = d/(365*day), "year" + } + return prefix + "about " + plural(int(n), unit) + suffix +} + +// Timestamp formats t for scripts: RFC3339 in UTC at second precision. +func Timestamp(t time.Time) string { + return t.UTC().Truncate(time.Second).Format(time.RFC3339) +} + +// ParseTime parses an API timestamp, returning the zero time for an empty or malformed value. +func ParseTime(raw string) time.Time { + t, err := time.Parse(time.RFC3339, raw) + if err != nil { + return time.Time{} + } + return t +} + +func plural(n int, unit string) string { + if n == 1 { + return "1 " + unit + } + return fmt.Sprintf("%d %ss", n, unit) +} diff --git a/internal/terminal/view.go b/internal/terminal/view.go new file mode 100644 index 00000000..e13f2527 --- /dev/null +++ b/internal/terminal/view.go @@ -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 on Gitea: ". 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() +} diff --git a/internal/terminal/view_test.go b/internal/terminal/view_test.go new file mode 100644 index 00000000..18a8856c --- /dev/null +++ b/internal/terminal/view_test.go @@ -0,0 +1,103 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terminal + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func issueView() View { + return View{ + Title: "Login fails\x1b[2J", ID: "#1", + Badges: []Cell{{Text: "Open", Color: "green"}}, + Meta: "alice opened about 1 hour ago • 2 comments", + Fields: []Field{ + {Label: "State", Value: "open", PipedOnly: true}, + {Label: "Labels", Cells: []Cell{{Text: "bug", Color: "ee0701"}, {Text: "priority/high"}}}, + {Label: "Milestone", Value: "v1.0"}, + {Label: "Blocked by"}, + {Label: "Comments", Value: "2", TTYOnly: true}, + }, + Body: "Steps:\r\n1. Use `p@ss!`\x1b]52;c;aGk=\x07\n", + Kind: "issue", URL: "http://localhost:3001/alice/project/issues/1", + } +} + +func TestViewRender(t *testing.T) { + for _, tc := range []struct { + name string + view View + d Display + want string + }{ + { + "key and value lines when piped", + issueView(), + Display{Color: true}, + "title:\tLogin fails[2J\nstate:\topen\nlabels:\tbug, priority/high\nmilestone:\tv1.0\nblocked by:\t\n" + + "url:\thttp://localhost:3001/alice/project/issues/1\n--\nSteps:\n1. Use `p@ss!`]52;c;aGk=\n", + }, + { + "summary for people", + issueView(), + Display{TTY: true}, + "Login fails[2J #1\nOpen • alice opened about 1 hour ago • 2 comments\n" + + "Labels: bug, priority/high\nMilestone: v1.0\nComments: 2\n\n" + + "Steps:\n1. Use `p@ss!`]52;c;aGk=\n\nView this issue on Gitea: http://localhost:3001/alice/project/issues/1\n", + }, + { + "styled for color terminals", + View{Title: "v1.0", Badges: []Cell{{Text: "Latest", Color: "green"}}, Fields: []Field{{Label: "Labels", Cells: []Cell{{Text: "bug", Color: "ee0701"}}}}, URL: "https://example.org/r"}, + Display{TTY: true, Color: true, TrueColor: true}, + "\x1b[1mv1.0\x1b[0m\n\x1b[32mLatest\x1b[0m\n\x1b[1mLabels:\x1b[0m \x1b[38;2;238;7;1mbug\x1b[0m\n\n" + + "\x1b[90mNo description provided\x1b[0m\n\n\x1b[90mView on Gitea: https://example.org/r\x1b[0m\n", + }, + { + "minimal", + View{Title: "Home"}, + Display{TTY: true}, + "Home\n\nNo description provided\n", + }, + } { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, tc.view.Render(tc.d)) + }) + } +} + +func TestViewRendersMarkdownOnlyWhenEnabled(t *testing.T) { + view := View{Title: "Docs", Body: "# Setup\n\nRun **tea** [now](https://example.org).\n\n- one\n- two"} + require.Contains(t, view.Render(Display{TTY: true}), "\n# Setup\n\nRun **tea** [now](https://example.org).\n") + require.Contains(t, view.Render(Display{Markdown: true}), "--\n# Setup\n\nRun **tea** [now](https://example.org).\n") + + plain := view.Render(Display{TTY: true, Markdown: true, Width: 40}) + require.Contains(t, plain, "\n # Setup\n\n Run **tea** now https://example.org.\n\n • one\n • two\n") + require.NotContains(t, plain, "\x1b") + + colored := view.Render(Display{TTY: true, Markdown: true, Color: true}) + require.Contains(t, colored, "Run \x1b[1mtea\x1b[m") + require.NotContains(t, colored, "\x1b]8;") + require.Contains(t, view.Render(Display{TTY: true, Markdown: true, Color: true, Links: true}), "\x1b]8;id=") +} + +func TestMarkdownSanitizesServerText(t *testing.T) { + rendered := Markdown("Hello \x1b]0;PWNED\x07world\n\n`\x1b[2Jcode`", Display{Color: true, Links: true}) + require.NotContains(t, rendered, "\x1b]0;") + require.NotContains(t, rendered, "\x1b[2J") + require.Contains(t, rendered, "]0;PWNEDworld") + for _, line := range strings.Split(Markdown(strings.Repeat("word ", 50), Display{Width: 40}), "\n") { + require.LessOrEqual(t, len(line), 40, line) + require.Equal(t, strings.TrimRight(line, " "), line) + } +} + +func TestMarkdownEnabled(t *testing.T) { + require.True(t, MarkdownEnabled("auto", true)) + require.True(t, MarkdownEnabled("", true)) + require.False(t, MarkdownEnabled("raw", true)) + require.False(t, MarkdownEnabled("auto", false)) +}