mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
feat: fit list tables to the terminal and write TSV when piped
Lists had no header, wrapped every row once a title was longer than the terminal, and were space-padded when piped, so cut -f and awk -F'\t' could not split them and empty cells collapsed. terminal.Table now takes column specs and a Display. On a terminal it prints an uppercase header, aligns columns two spaces apart and shrinks flexible columns, widest first, to Streams.Width (the terminal, then $COLUMNS, then 80), cutting by display width with "…". States use the terminal's ANSI 16 colors, hex colors fall back to the 256-color palette unless COLORTERM advertises 24-bit, and OSC 8 links appear only on the first column, independently of color (TEA_HYPERLINKS=auto|always|never). Piped tables are tab-separated without header, color or links, and times are RFC3339 UTC instead of "about 2 hours ago". terminal.View lays out single resources (title, badges, metadata, fields, body, footer URL) and renders markdown bodies with glamour on a terminal only (TEA_MARKDOWN=raw keeps them raw). Shared formatters cover relative times, timestamps, sizes, durations and status icons. In the app, writeTable and writeView are the new list and view writers: a "Showing N of M …" context line, an empty-state message on stderr and the next-page hint. writeList, writeItem and batch keep working and now render through the new table: tab-separated when piped, truncated at the widest column on a terminal, with headers for issue list and pr list. Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
17 files changed
+1253
-103
No files matched your search
@@ -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
|
||||
@@ -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=
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
+81
-10
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
+172
-33
@@ -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 {
|
||||
|
||||
+115
-22
@@ -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\u009b31mtxt.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\u009b31mtxt.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\u009b31mit\x7f\n\x00done",
|
||||
"Steps:\n]52;c;cHduZWQ=run[2J31mit\ndone",
|
||||
"Steps: ]52;c;cHduZWQ=run[2J31mit done",
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 <kind> on Gitea: <url>". Otherwise it writes "key:\tvalue" lines for the title, the
|
||||
// fields and the URL, then "--" and the raw body, for scripts.
|
||||
type View struct {
|
||||
Title, ID string
|
||||
// Badges, such as the state, start the line under the title; Meta completes it, for example
|
||||
// "alice opened about 2 hours ago • 2 comments".
|
||||
Badges []Cell
|
||||
Meta string
|
||||
Fields []Field
|
||||
Body string
|
||||
// Kind names the resource in the footer, such as "issue".
|
||||
Kind, URL string
|
||||
}
|
||||
|
||||
// Render formats the view for d. All server text is sanitized.
|
||||
func (v View) Render(d Display) string {
|
||||
var b strings.Builder
|
||||
if !d.TTY {
|
||||
if v.Title != "" {
|
||||
b.WriteString("title:\t" + SafeLine(v.Title) + "\n")
|
||||
}
|
||||
for _, field := range v.Fields {
|
||||
if !field.TTYOnly {
|
||||
b.WriteString(strings.ToLower(SafeLine(field.Label)) + ":\t" + field.text(d) + "\n")
|
||||
}
|
||||
}
|
||||
if v.URL != "" {
|
||||
b.WriteString("url:\t" + SafeLine(v.URL) + "\n")
|
||||
}
|
||||
body := SafeText(v.Body)
|
||||
b.WriteString("--\n" + body)
|
||||
if !strings.HasSuffix(body, "\n") {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
muted := func(text string) string { return Cell{Muted: true}.style(text, d, false) }
|
||||
b.WriteString(Cell{Bold: true}.style(SafeLine(v.Title), d, false))
|
||||
if v.ID != "" {
|
||||
b.WriteString(" " + muted(SafeLine(v.ID)))
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
var summary []string
|
||||
for _, badge := range v.Badges {
|
||||
summary = append(summary, badge.style(SafeLine(badge.Text), d, false))
|
||||
}
|
||||
if meta := SafeLine(v.Meta); meta != "" {
|
||||
summary = append(summary, muted(meta))
|
||||
}
|
||||
if len(summary) > 0 {
|
||||
b.WriteString(strings.Join(summary, " • ") + "\n")
|
||||
}
|
||||
|
||||
var fields []Field
|
||||
labelWidth := 0
|
||||
for _, field := range v.Fields {
|
||||
if !field.PipedOnly && (field.Value != "" || len(field.Cells) > 0) {
|
||||
fields = append(fields, field)
|
||||
labelWidth = max(labelWidth, runewidth.StringWidth(SafeLine(field.Label)))
|
||||
}
|
||||
}
|
||||
for _, field := range fields {
|
||||
label := SafeLine(field.Label) + ":"
|
||||
b.WriteString(Cell{Bold: true}.style(label, d, false) + strings.Repeat(" ", labelWidth+2-runewidth.StringWidth(label)) + field.text(d) + "\n")
|
||||
}
|
||||
|
||||
body := strings.TrimSpace(v.Body)
|
||||
switch {
|
||||
case body == "":
|
||||
body = muted("No description provided")
|
||||
case d.Markdown:
|
||||
body = Markdown(body, d)
|
||||
default:
|
||||
body = SafeText(body)
|
||||
}
|
||||
b.WriteString("\n" + body + "\n")
|
||||
if v.URL != "" {
|
||||
kind := "View on Gitea: "
|
||||
if v.Kind != "" {
|
||||
kind = "View this " + v.Kind + " on Gitea: "
|
||||
}
|
||||
b.WriteString("\n" + muted(kind+SafeLine(v.URL)) + "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Reference in new issue
Block a user