Add helper template functions for rendering tables (#3519)
Co-authored-by: Mislav Marohnić <mislav@github.com>
This commit is contained in:
parent
a53ea0c655
commit
e2973453b5
21 changed files with 517 additions and 65 deletions
|
|
@ -294,6 +294,8 @@ func apiRun(opts *ApiOptions) error {
|
|||
host = opts.Hostname
|
||||
}
|
||||
|
||||
template := export.NewTemplate(opts.IO, opts.Template)
|
||||
|
||||
hasNextPage := true
|
||||
for hasNextPage {
|
||||
resp, err := httpRequest(httpClient, host, method, requestPath, requestBody, requestHeaders)
|
||||
|
|
@ -301,7 +303,7 @@ func apiRun(opts *ApiOptions) error {
|
|||
return err
|
||||
}
|
||||
|
||||
endCursor, err := processResponse(resp, opts, headersOutputStream)
|
||||
endCursor, err := processResponse(resp, opts, headersOutputStream, &template)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -324,10 +326,10 @@ func apiRun(opts *ApiOptions) error {
|
|||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return template.End()
|
||||
}
|
||||
|
||||
func processResponse(resp *http.Response, opts *ApiOptions, headersOutputStream io.Writer) (endCursor string, err error) {
|
||||
func processResponse(resp *http.Response, opts *ApiOptions, headersOutputStream io.Writer, template *export.Template) (endCursor string, err error) {
|
||||
if opts.ShowResponseHeaders {
|
||||
fmt.Fprintln(headersOutputStream, resp.Proto, resp.Status)
|
||||
printHeaders(headersOutputStream, resp.Header, opts.IO.ColorEnabled())
|
||||
|
|
@ -365,7 +367,7 @@ func processResponse(resp *http.Response, opts *ApiOptions, headersOutputStream
|
|||
}
|
||||
} else if opts.Template != "" {
|
||||
// TODO: reuse parsed template across pagination invocations
|
||||
err = export.ExecuteTemplate(opts.IO.Out, responseBody, opts.Template, opts.IO.ColorEnabled())
|
||||
err = template.Execute(responseBody)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import (
|
|||
"github.com/cli/cli/internal/config"
|
||||
"github.com/cli/cli/internal/ghrepo"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/cli/cli/pkg/export"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/google/shlex"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
|
@ -671,6 +672,101 @@ func Test_apiRun_paginationGraphQL(t *testing.T) {
|
|||
assert.Equal(t, "PAGE1_END", endCursor)
|
||||
}
|
||||
|
||||
func Test_apiRun_paginated_template(t *testing.T) {
|
||||
io, _, stdout, stderr := iostreams.Test()
|
||||
io.SetStdoutTTY(true)
|
||||
|
||||
requestCount := 0
|
||||
responses := []*http.Response{
|
||||
{
|
||||
StatusCode: 200,
|
||||
Header: http.Header{"Content-Type": []string{`application/json`}},
|
||||
Body: ioutil.NopCloser(bytes.NewBufferString(`{
|
||||
"data": {
|
||||
"nodes": [
|
||||
{
|
||||
"page": 1,
|
||||
"caption": "page one"
|
||||
}
|
||||
],
|
||||
"pageInfo": {
|
||||
"endCursor": "PAGE1_END",
|
||||
"hasNextPage": true
|
||||
}
|
||||
}
|
||||
}`)),
|
||||
},
|
||||
{
|
||||
StatusCode: 200,
|
||||
Header: http.Header{"Content-Type": []string{`application/json`}},
|
||||
Body: ioutil.NopCloser(bytes.NewBufferString(`{
|
||||
"data": {
|
||||
"nodes": [
|
||||
{
|
||||
"page": 20,
|
||||
"caption": "page twenty"
|
||||
}
|
||||
],
|
||||
"pageInfo": {
|
||||
"endCursor": "PAGE20_END",
|
||||
"hasNextPage": false
|
||||
}
|
||||
}
|
||||
}`)),
|
||||
},
|
||||
}
|
||||
|
||||
options := ApiOptions{
|
||||
IO: io,
|
||||
HttpClient: func() (*http.Client, error) {
|
||||
var tr roundTripper = func(req *http.Request) (*http.Response, error) {
|
||||
resp := responses[requestCount]
|
||||
resp.Request = req
|
||||
requestCount++
|
||||
return resp, nil
|
||||
}
|
||||
return &http.Client{Transport: tr}, nil
|
||||
},
|
||||
Config: func() (config.Config, error) {
|
||||
return config.NewBlankConfig(), nil
|
||||
},
|
||||
|
||||
RequestMethod: "POST",
|
||||
RequestPath: "graphql",
|
||||
Paginate: true,
|
||||
// test that templates executed per page properly render a table.
|
||||
Template: `{{range .data.nodes}}{{tablerow .page .caption}}{{end}}`,
|
||||
}
|
||||
|
||||
err := apiRun(&options)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, heredoc.Doc(`
|
||||
1 page one
|
||||
20 page twenty
|
||||
`), stdout.String(), "stdout")
|
||||
assert.Equal(t, "", stderr.String(), "stderr")
|
||||
|
||||
var requestData struct {
|
||||
Variables map[string]interface{}
|
||||
}
|
||||
|
||||
bb, err := ioutil.ReadAll(responses[0].Request.Body)
|
||||
require.NoError(t, err)
|
||||
err = json.Unmarshal(bb, &requestData)
|
||||
require.NoError(t, err)
|
||||
_, hasCursor := requestData.Variables["endCursor"].(string)
|
||||
assert.Equal(t, false, hasCursor)
|
||||
|
||||
bb, err = ioutil.ReadAll(responses[1].Request.Body)
|
||||
require.NoError(t, err)
|
||||
err = json.Unmarshal(bb, &requestData)
|
||||
require.NoError(t, err)
|
||||
endCursor, hasCursor := requestData.Variables["endCursor"].(string)
|
||||
assert.Equal(t, true, hasCursor)
|
||||
assert.Equal(t, "PAGE1_END", endCursor)
|
||||
}
|
||||
|
||||
func Test_apiRun_inputFile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
@ -1167,10 +1263,15 @@ func Test_processResponse_template(t *testing.T) {
|
|||
]`)),
|
||||
}
|
||||
|
||||
_, err := processResponse(&resp, &ApiOptions{
|
||||
opts := ApiOptions{
|
||||
IO: io,
|
||||
Template: `{{range .}}{{.title}} ({{.labels | pluck "name" | join ", " }}){{"\n"}}{{end}}`,
|
||||
}, ioutil.Discard)
|
||||
}
|
||||
template := export.NewTemplate(io, opts.Template)
|
||||
_, err := processResponse(&resp, &opts, ioutil.Discard, &template)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = template.End()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, heredoc.Doc(`
|
||||
|
|
|
|||
|
|
@ -163,7 +163,7 @@ func listRun(opts *ListOptions) error {
|
|||
defer opts.IO.StopPager()
|
||||
|
||||
if opts.Exporter != nil {
|
||||
return opts.Exporter.Write(opts.IO.Out, listResult.Issues, opts.IO.ColorEnabled())
|
||||
return opts.Exporter.Write(opts.IO, listResult.Issues)
|
||||
}
|
||||
|
||||
if isTerminal {
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ func statusRun(opts *StatusOptions) error {
|
|||
"assigned": issuePayload.Assigned.Issues,
|
||||
"mentioned": issuePayload.Mentioned.Issues,
|
||||
}
|
||||
return opts.Exporter.Write(opts.IO.Out, data, opts.IO.ColorEnabled())
|
||||
return opts.Exporter.Write(opts.IO, data)
|
||||
}
|
||||
|
||||
out := opts.IO.Out
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ func viewRun(opts *ViewOptions) error {
|
|||
defer opts.IO.StopPager()
|
||||
|
||||
if opts.Exporter != nil {
|
||||
return opts.Exporter.Write(opts.IO.Out, issue, opts.IO.ColorEnabled())
|
||||
return opts.Exporter.Write(opts.IO, issue)
|
||||
}
|
||||
|
||||
if opts.IO.IsStdoutTTY() {
|
||||
|
|
|
|||
|
|
@ -163,7 +163,7 @@ func listRun(opts *ListOptions) error {
|
|||
defer opts.IO.StopPager()
|
||||
|
||||
if opts.Exporter != nil {
|
||||
return opts.Exporter.Write(opts.IO.Out, listResult.PullRequests, opts.IO.ColorEnabled())
|
||||
return opts.Exporter.Write(opts.IO, listResult.PullRequests)
|
||||
}
|
||||
|
||||
if opts.IO.IsStdoutTTY() {
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ func statusRun(opts *StatusOptions) error {
|
|||
if prPayload.CurrentPR != nil {
|
||||
data["currentBranch"] = prPayload.CurrentPR
|
||||
}
|
||||
return opts.Exporter.Write(opts.IO.Out, data, opts.IO.ColorEnabled())
|
||||
return opts.Exporter.Write(opts.IO, data)
|
||||
}
|
||||
|
||||
out := opts.IO.Out
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ func viewRun(opts *ViewOptions) error {
|
|||
defer opts.IO.StopPager()
|
||||
|
||||
if opts.Exporter != nil {
|
||||
return opts.Exporter.Write(opts.IO.Out, pr, opts.IO.ColorEnabled())
|
||||
return opts.Exporter.Write(opts.IO, pr)
|
||||
}
|
||||
|
||||
if connectedToTerminal {
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ func viewRun(opts *ViewOptions) error {
|
|||
defer opts.IO.StopPager()
|
||||
|
||||
if opts.Exporter != nil {
|
||||
return opts.Exporter.Write(opts.IO.Out, release, opts.IO.ColorEnabled())
|
||||
return opts.Exporter.Write(opts.IO, release)
|
||||
}
|
||||
|
||||
if opts.IO.IsStdoutTTY() {
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@ func listRun(opts *ListOptions) error {
|
|||
defer opts.IO.StopPager()
|
||||
|
||||
if opts.Exporter != nil {
|
||||
return opts.Exporter.Write(opts.IO.Out, listResult.Repositories, opts.IO.ColorEnabled())
|
||||
return opts.Exporter.Write(opts.IO, listResult.Repositories)
|
||||
}
|
||||
|
||||
cs := opts.IO.ColorScheme()
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ func viewRun(opts *ViewOptions) error {
|
|||
defer opts.IO.StopPager()
|
||||
|
||||
if opts.Exporter != nil {
|
||||
return opts.Exporter.Write(opts.IO.Out, repo, opts.IO.ColorEnabled())
|
||||
return opts.Exporter.Write(opts.IO, repo)
|
||||
}
|
||||
|
||||
fullName := ghrepo.FullName(toView)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package view
|
|||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
|
|
@ -669,9 +668,9 @@ func (e *testExporter) Fields() []string {
|
|||
return e.fields
|
||||
}
|
||||
|
||||
func (e *testExporter) Write(w io.Writer, data interface{}, colorize bool) error {
|
||||
func (e *testExporter) Write(io *iostreams.IOStreams, data interface{}) error {
|
||||
r := data.(*api.Repository)
|
||||
fmt.Fprintf(w, "name: %s\n", r.Name)
|
||||
fmt.Fprintf(w, "defaultBranchRef: %s\n", r.DefaultBranchRef.Name)
|
||||
fmt.Fprintf(io.Out, "name: %s\n", r.Name)
|
||||
fmt.Fprintf(io.Out, "defaultBranchRef: %s\n", r.DefaultBranchRef.Name)
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,12 +95,30 @@ var HelpTopics = map[string]map[string]string{
|
|||
For the syntax of Go templates, see: https://golang.org/pkg/text/template/
|
||||
|
||||
The following functions are available in templates:
|
||||
- %[1]scolor <style>, <input>%[1]s: colorize input using https://github.com/mgutz/ansi
|
||||
- %[1]sautocolor%[1]s: like %[1]scolor%[1]s, but only emits color to terminals
|
||||
- %[1]stimefmt <format> <time>%[1]s: formats a timestamp using Go's Time.Format function
|
||||
- %[1]stimeago <time>%[1]s: renders a timestamp as relative to now
|
||||
- %[1]spluck <field> <list>%[1]s: collects values of a field from all items in the input
|
||||
- %[1]scolor <style> <input>%[1]s: colorize input using https://github.com/mgutz/ansi
|
||||
- %[1]sjoin <sep> <list>%[1]s: joins values in the list using a separator
|
||||
- %[1]spluck <field> <list>%[1]s: collects values of a field from all items in the input
|
||||
- %[1]stablerow <fields>...%[1]s: aligns fields in output vertically as a table
|
||||
- %[1]stablerender%[1]s: renders fields added by tablerow in place
|
||||
- %[1]stimeago <time>%[1]s: renders a timestamp as relative to now
|
||||
- %[1]stimefmt <format> <time>%[1]s: formats a timestamp using Go's Time.Format function
|
||||
- %[1]struncate <length> <input>%[1]s: ensures input fits within length
|
||||
|
||||
EXAMPLES
|
||||
# format issues as table
|
||||
$ gh issue list --json number,title --template \
|
||||
'{{range .}}{{tablerow (printf "#%%v" .number | autocolor "green") .title}}{{end}}'
|
||||
|
||||
# format a pull request using multiple tables with headers
|
||||
$ gh pr view 3519 --json number,title,body,reviews,assignees --template \
|
||||
'{{printf "#%%v" .number}} {{.title}}
|
||||
|
||||
{{.body}}
|
||||
|
||||
{{tablerow "ASSIGNEE" "NAME"}}{{range .assignees}}{{tablerow .login .name}}{{end}}{{tablerender}}
|
||||
{{tablerow "REVIEWER" "STATE" "COMMENT"}}{{range .reviews}}{{tablerow .author.login .state .body}}{{end}}
|
||||
'
|
||||
`, "`"),
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/cli/cli/pkg/export"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/cli/cli/pkg/jsoncolor"
|
||||
"github.com/cli/cli/pkg/set"
|
||||
"github.com/spf13/cobra"
|
||||
|
|
@ -107,7 +108,7 @@ func checkJSONFlags(cmd *cobra.Command) (*exportFormat, error) {
|
|||
|
||||
type Exporter interface {
|
||||
Fields() []string
|
||||
Write(w io.Writer, data interface{}, colorEnabled bool) error
|
||||
Write(io *iostreams.IOStreams, data interface{}) error
|
||||
}
|
||||
|
||||
type exportFormat struct {
|
||||
|
|
@ -123,7 +124,7 @@ func (e *exportFormat) Fields() []string {
|
|||
// Write serializes data into JSON output written to w. If the object passed as data implements exportable,
|
||||
// or if data is a map or slice of exportable object, ExportData() will be called on each object to obtain
|
||||
// raw data for serialization.
|
||||
func (e *exportFormat) Write(w io.Writer, data interface{}, colorEnabled bool) error {
|
||||
func (e *exportFormat) Write(ios *iostreams.IOStreams, data interface{}) error {
|
||||
buf := bytes.Buffer{}
|
||||
encoder := json.NewEncoder(&buf)
|
||||
encoder.SetEscapeHTML(false)
|
||||
|
|
@ -131,11 +132,12 @@ func (e *exportFormat) Write(w io.Writer, data interface{}, colorEnabled bool) e
|
|||
return err
|
||||
}
|
||||
|
||||
w := ios.Out
|
||||
if e.filter != "" {
|
||||
return export.FilterJSON(w, &buf, e.filter)
|
||||
} else if e.template != "" {
|
||||
return export.ExecuteTemplate(w, &buf, e.template, colorEnabled)
|
||||
} else if colorEnabled {
|
||||
return export.ExecuteTemplate(ios, &buf, e.template)
|
||||
} else if ios.ColorEnabled() {
|
||||
return jsoncolor.Write(w, &buf, " ")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"io/ioutil"
|
||||
"testing"
|
||||
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
|
@ -118,8 +119,7 @@ func TestAddJSONFlags(t *testing.T) {
|
|||
|
||||
func Test_exportFormat_Write(t *testing.T) {
|
||||
type args struct {
|
||||
data interface{}
|
||||
colorEnabled bool
|
||||
data interface{}
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
@ -132,8 +132,7 @@ func Test_exportFormat_Write(t *testing.T) {
|
|||
name: "regular JSON output",
|
||||
exporter: exportFormat{},
|
||||
args: args{
|
||||
data: map[string]string{"name": "hubot"},
|
||||
colorEnabled: false,
|
||||
data: map[string]string{"name": "hubot"},
|
||||
},
|
||||
wantW: "{\"name\":\"hubot\"}\n",
|
||||
wantErr: false,
|
||||
|
|
@ -142,8 +141,7 @@ func Test_exportFormat_Write(t *testing.T) {
|
|||
name: "call ExportData",
|
||||
exporter: exportFormat{fields: []string{"field1", "field2"}},
|
||||
args: args{
|
||||
data: &exportableItem{"item1"},
|
||||
colorEnabled: false,
|
||||
data: &exportableItem{"item1"},
|
||||
},
|
||||
wantW: "{\"field1\":\"item1:field1\",\"field2\":\"item1:field2\"}\n",
|
||||
wantErr: false,
|
||||
|
|
@ -156,7 +154,6 @@ func Test_exportFormat_Write(t *testing.T) {
|
|||
"s1": []exportableItem{{"i1"}, {"i2"}},
|
||||
"s2": []exportableItem{{"i3"}},
|
||||
},
|
||||
colorEnabled: false,
|
||||
},
|
||||
wantW: "{\"s1\":[{\"f1\":\"i1:f1\",\"f2\":\"i1:f2\"},{\"f1\":\"i2:f1\",\"f2\":\"i2:f2\"}],\"s2\":[{\"f1\":\"i3:f1\",\"f2\":\"i3:f2\"}]}\n",
|
||||
wantErr: false,
|
||||
|
|
@ -165,8 +162,7 @@ func Test_exportFormat_Write(t *testing.T) {
|
|||
name: "with jq filter",
|
||||
exporter: exportFormat{filter: ".name"},
|
||||
args: args{
|
||||
data: map[string]string{"name": "hubot"},
|
||||
colorEnabled: false,
|
||||
data: map[string]string{"name": "hubot"},
|
||||
},
|
||||
wantW: "hubot\n",
|
||||
wantErr: false,
|
||||
|
|
@ -175,8 +171,7 @@ func Test_exportFormat_Write(t *testing.T) {
|
|||
name: "with Go template",
|
||||
exporter: exportFormat{template: "{{.name}}"},
|
||||
args: args{
|
||||
data: map[string]string{"name": "hubot"},
|
||||
colorEnabled: false,
|
||||
data: map[string]string{"name": "hubot"},
|
||||
},
|
||||
wantW: "hubot",
|
||||
wantErr: false,
|
||||
|
|
@ -185,7 +180,10 @@ func Test_exportFormat_Write(t *testing.T) {
|
|||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
w := &bytes.Buffer{}
|
||||
if err := tt.exporter.Write(w, tt.args.data, tt.args.colorEnabled); (err != nil) != tt.wantErr {
|
||||
io := &iostreams.IOStreams{
|
||||
Out: w,
|
||||
}
|
||||
if err := tt.exporter.Write(io, tt.args.data); (err != nil) != tt.wantErr {
|
||||
t.Errorf("exportFormat.Write() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,16 +11,32 @@ import (
|
|||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/cli/cli/pkg/text"
|
||||
"github.com/cli/cli/utils"
|
||||
"github.com/mgutz/ansi"
|
||||
)
|
||||
|
||||
func parseTemplate(tpl string, colorEnabled bool) (*template.Template, error) {
|
||||
type Template struct {
|
||||
io *iostreams.IOStreams
|
||||
tablePrinter utils.TablePrinter
|
||||
template *template.Template
|
||||
templateStr string
|
||||
}
|
||||
|
||||
func NewTemplate(io *iostreams.IOStreams, template string) Template {
|
||||
return Template{
|
||||
io: io,
|
||||
templateStr: template,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Template) parseTemplate(tpl string) (*template.Template, error) {
|
||||
now := time.Now()
|
||||
|
||||
templateFuncs := map[string]interface{}{
|
||||
"color": templateColor,
|
||||
"autocolor": templateColor,
|
||||
"color": t.color,
|
||||
"autocolor": t.color,
|
||||
|
||||
"timefmt": func(format, input string) (string, error) {
|
||||
t, err := time.Parse(time.RFC3339, input)
|
||||
|
|
@ -37,11 +53,14 @@ func parseTemplate(tpl string, colorEnabled bool) (*template.Template, error) {
|
|||
return timeAgo(now.Sub(t)), nil
|
||||
},
|
||||
|
||||
"pluck": templatePluck,
|
||||
"join": templateJoin,
|
||||
"pluck": templatePluck,
|
||||
"join": templateJoin,
|
||||
"tablerow": t.tableRow,
|
||||
"tablerender": t.tableRender,
|
||||
"truncate": text.Truncate,
|
||||
}
|
||||
|
||||
if !colorEnabled {
|
||||
if !t.io.ColorEnabled() {
|
||||
templateFuncs["autocolor"] = func(colorName string, input interface{}) (string, error) {
|
||||
return jsonScalarToString(input)
|
||||
}
|
||||
|
|
@ -50,10 +69,16 @@ func parseTemplate(tpl string, colorEnabled bool) (*template.Template, error) {
|
|||
return template.New("").Funcs(templateFuncs).Parse(tpl)
|
||||
}
|
||||
|
||||
func ExecuteTemplate(w io.Writer, input io.Reader, templateStr string, colorEnabled bool) error {
|
||||
t, err := parseTemplate(templateStr, colorEnabled)
|
||||
if err != nil {
|
||||
return err
|
||||
func (t *Template) Execute(input io.Reader) error {
|
||||
w := t.io.Out
|
||||
|
||||
if t.template == nil {
|
||||
template, err := t.parseTemplate(t.templateStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.template = template
|
||||
}
|
||||
|
||||
jsonData, err := ioutil.ReadAll(input)
|
||||
|
|
@ -66,7 +91,15 @@ func ExecuteTemplate(w io.Writer, input io.Reader, templateStr string, colorEnab
|
|||
return err
|
||||
}
|
||||
|
||||
return t.Execute(w, data)
|
||||
return t.template.Execute(w, data)
|
||||
}
|
||||
|
||||
func ExecuteTemplate(io *iostreams.IOStreams, input io.Reader, template string) error {
|
||||
t := NewTemplate(io, template)
|
||||
if err := t.Execute(input); err != nil {
|
||||
return err
|
||||
}
|
||||
return t.End()
|
||||
}
|
||||
|
||||
func jsonScalarToString(input interface{}) (string, error) {
|
||||
|
|
@ -88,7 +121,7 @@ func jsonScalarToString(input interface{}) (string, error) {
|
|||
}
|
||||
}
|
||||
|
||||
func templateColor(colorName string, input interface{}) (string, error) {
|
||||
func (t *Template) color(colorName string, input interface{}) (string, error) {
|
||||
text, err := jsonScalarToString(input)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
|
@ -117,6 +150,40 @@ func templateJoin(sep string, input []interface{}) (string, error) {
|
|||
return strings.Join(results, sep), nil
|
||||
}
|
||||
|
||||
func (t *Template) tableRow(fields ...interface{}) (string, error) {
|
||||
if t.tablePrinter == nil {
|
||||
t.tablePrinter = utils.NewTablePrinterWithOptions(t.io, utils.TablePrinterOptions{IsTTY: true})
|
||||
}
|
||||
for _, e := range fields {
|
||||
s, err := jsonScalarToString(e)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to write table row: %v", err)
|
||||
}
|
||||
t.tablePrinter.AddField(s, text.TruncateColumn, nil)
|
||||
}
|
||||
t.tablePrinter.EndRow()
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (t *Template) tableRender() (string, error) {
|
||||
if t.tablePrinter != nil {
|
||||
err := t.tablePrinter.Render()
|
||||
t.tablePrinter = nil
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to render table: %v", err)
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (t *Template) End() error {
|
||||
// Finalize any template actions.
|
||||
if _, err := t.tableRender(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func timeAgo(ago time.Duration) string {
|
||||
if ago < time.Minute {
|
||||
return "just now"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package export
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
|
@ -9,6 +8,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
)
|
||||
|
||||
func Test_jsonScalarToString(t *testing.T) {
|
||||
|
|
@ -85,7 +85,6 @@ func Test_executeTemplate(t *testing.T) {
|
|||
args: args{
|
||||
json: strings.NewReader(`{}`),
|
||||
template: `{{color "blue+h" "songs are like tattoos"}}`,
|
||||
colorize: false,
|
||||
},
|
||||
wantW: "\x1b[0;94msongs are like tattoos\x1b[0m",
|
||||
},
|
||||
|
|
@ -103,7 +102,6 @@ func Test_executeTemplate(t *testing.T) {
|
|||
args: args{
|
||||
json: strings.NewReader(`{}`),
|
||||
template: `{{autocolor "red" "go"}}`,
|
||||
colorize: false,
|
||||
},
|
||||
wantW: "go",
|
||||
},
|
||||
|
|
@ -112,7 +110,6 @@ func Test_executeTemplate(t *testing.T) {
|
|||
args: args{
|
||||
json: strings.NewReader(`{"created_at":"2008-02-25T20:18:33Z"}`),
|
||||
template: `{{.created_at | timefmt "Mon Jan 2, 2006"}}`,
|
||||
colorize: false,
|
||||
},
|
||||
wantW: "Mon Feb 25, 2008",
|
||||
},
|
||||
|
|
@ -121,7 +118,6 @@ func Test_executeTemplate(t *testing.T) {
|
|||
args: args{
|
||||
json: strings.NewReader(fmt.Sprintf(`{"created_at":"%s"}`, time.Now().Add(-5*time.Minute).Format(time.RFC3339))),
|
||||
template: `{{.created_at | timeago}}`,
|
||||
colorize: false,
|
||||
},
|
||||
wantW: "5 minutes ago",
|
||||
},
|
||||
|
|
@ -134,7 +130,6 @@ func Test_executeTemplate(t *testing.T) {
|
|||
{"name": "chore"}
|
||||
]`)),
|
||||
template: `{{range(pluck "name" .)}}{{. | printf "%s\n"}}{{end}}`,
|
||||
colorize: false,
|
||||
},
|
||||
wantW: "bug\nfeature request\nchore\n",
|
||||
},
|
||||
|
|
@ -143,15 +138,137 @@ func Test_executeTemplate(t *testing.T) {
|
|||
args: args{
|
||||
json: strings.NewReader(`[ "bug", "feature request", "chore" ]`),
|
||||
template: `{{join "\t" .}}`,
|
||||
colorize: false,
|
||||
},
|
||||
wantW: "bug\tfeature request\tchore",
|
||||
},
|
||||
{
|
||||
name: "table",
|
||||
args: args{
|
||||
json: strings.NewReader(heredoc.Doc(`[
|
||||
{"number": 1, "title": "One"},
|
||||
{"number": 20, "title": "Twenty"},
|
||||
{"number": 3000, "title": "Three thousand"}
|
||||
]`)),
|
||||
template: `{{range .}}{{tablerow (.number | printf "#%v") .title}}{{end}}`,
|
||||
},
|
||||
wantW: heredoc.Doc(`#1 One
|
||||
#20 Twenty
|
||||
#3000 Three thousand
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "table with multiline text",
|
||||
args: args{
|
||||
json: strings.NewReader(heredoc.Doc(`[
|
||||
{"number": 1, "title": "One\ranother line of text"},
|
||||
{"number": 20, "title": "Twenty\nanother line of text"},
|
||||
{"number": 3000, "title": "Three thousand\r\nanother line of text"}
|
||||
]`)),
|
||||
template: `{{range .}}{{tablerow (.number | printf "#%v") .title}}{{end}}`,
|
||||
},
|
||||
wantW: heredoc.Doc(`#1 One...
|
||||
#20 Twenty...
|
||||
#3000 Three thousand...
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "table with mixed value types",
|
||||
args: args{
|
||||
json: strings.NewReader(heredoc.Doc(`[
|
||||
{"number": 1, "title": null, "float": false},
|
||||
{"number": 20.1, "title": "Twenty-ish", "float": true},
|
||||
{"number": 3000, "title": "Three thousand", "float": false}
|
||||
]`)),
|
||||
template: `{{range .}}{{tablerow .number .title .float}}{{end}}`,
|
||||
},
|
||||
wantW: heredoc.Doc(`1 false
|
||||
20.10 Twenty-ish true
|
||||
3000 Three thousand false
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "table with color",
|
||||
args: args{
|
||||
json: strings.NewReader(heredoc.Doc(`[
|
||||
{"number": 1, "title": "One"}
|
||||
]`)),
|
||||
template: `{{range .}}{{tablerow (.number | color "green") .title}}{{end}}`,
|
||||
},
|
||||
wantW: "\x1b[0;32m1\x1b[0m One\n",
|
||||
},
|
||||
{
|
||||
name: "table with header and footer",
|
||||
args: args{
|
||||
json: strings.NewReader(heredoc.Doc(`[
|
||||
{"number": 1, "title": "One"},
|
||||
{"number": 2, "title": "Two"}
|
||||
]`)),
|
||||
template: heredoc.Doc(`HEADER
|
||||
{{range .}}{{tablerow .number .title}}{{end}}FOOTER
|
||||
`),
|
||||
},
|
||||
wantW: heredoc.Doc(`HEADER
|
||||
FOOTER
|
||||
1 One
|
||||
2 Two
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "table with header and footer using endtable",
|
||||
args: args{
|
||||
json: strings.NewReader(heredoc.Doc(`[
|
||||
{"number": 1, "title": "One"},
|
||||
{"number": 2, "title": "Two"}
|
||||
]`)),
|
||||
template: heredoc.Doc(`HEADER
|
||||
{{range .}}{{tablerow .number .title}}{{end}}{{tablerender}}FOOTER
|
||||
`),
|
||||
},
|
||||
wantW: heredoc.Doc(`HEADER
|
||||
1 One
|
||||
2 Two
|
||||
FOOTER
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "multiple tables with different columns",
|
||||
args: args{
|
||||
json: strings.NewReader(heredoc.Doc(`{
|
||||
"issues": [
|
||||
{"number": 1, "title": "One"},
|
||||
{"number": 2, "title": "Two"}
|
||||
],
|
||||
"prs": [
|
||||
{"number": 3, "title": "Three", "reviewDecision": "REVIEW_REQUESTED"},
|
||||
{"number": 4, "title": "Four", "reviewDecision": "CHANGES_REQUESTED"}
|
||||
]
|
||||
}`)),
|
||||
template: heredoc.Doc(`{{tablerow "ISSUE" "TITLE"}}{{range .issues}}{{tablerow .number .title}}{{end}}{{tablerender}}
|
||||
{{tablerow "PR" "TITLE" "DECISION"}}{{range .prs}}{{tablerow .number .title .reviewDecision}}{{end}}`),
|
||||
},
|
||||
wantW: heredoc.Docf(`ISSUE TITLE
|
||||
1 One
|
||||
2 Two
|
||||
|
||||
PR TITLE DECISION
|
||||
3 Three REVIEW_REQUESTED
|
||||
4 Four CHANGES_REQUESTED
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "truncate",
|
||||
args: args{
|
||||
json: strings.NewReader(`{"title": "This is a long title"}`),
|
||||
template: `{{truncate 13 .title}}`,
|
||||
},
|
||||
wantW: "This is a ...",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
w := &bytes.Buffer{}
|
||||
if err := ExecuteTemplate(w, tt.args.json, tt.args.template, tt.args.colorize); (err != nil) != tt.wantErr {
|
||||
io, _, w, _ := iostreams.Test()
|
||||
io.SetColorEnabled(tt.args.colorize)
|
||||
if err := ExecuteTemplate(io, tt.args.json, tt.args.template); (err != nil) != tt.wantErr {
|
||||
t.Errorf("executeTemplate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import (
|
|||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
const DefaultWidth = 80
|
||||
|
||||
type IOStreams struct {
|
||||
In io.ReadCloser
|
||||
Out io.Writer
|
||||
|
|
@ -99,6 +101,10 @@ func (s *IOStreams) TerminalTheme() string {
|
|||
return s.terminalTheme
|
||||
}
|
||||
|
||||
func (s *IOStreams) SetColorEnabled(colorEnabled bool) {
|
||||
s.colorEnabled = colorEnabled
|
||||
}
|
||||
|
||||
func (s *IOStreams) SetStdinTTY(isTTY bool) {
|
||||
s.stdinTTYOverride = true
|
||||
s.stdinIsTTY = isTTY
|
||||
|
|
@ -239,12 +245,14 @@ func (s *IOStreams) StopProgressIndicator() {
|
|||
s.progressIndicator = nil
|
||||
}
|
||||
|
||||
// TerminalWidth returns the width of the terminal that stdout is attached to.
|
||||
// TODO: investigate whether ProcessTerminalWidth could replace all this.
|
||||
func (s *IOStreams) TerminalWidth() int {
|
||||
if s.termWidthOverride > 0 {
|
||||
return s.termWidthOverride
|
||||
}
|
||||
|
||||
defaultWidth := 80
|
||||
defaultWidth := DefaultWidth
|
||||
out := s.Out
|
||||
if s.originalOut != nil {
|
||||
out = s.originalOut
|
||||
|
|
@ -271,6 +279,15 @@ func (s *IOStreams) TerminalWidth() int {
|
|||
return defaultWidth
|
||||
}
|
||||
|
||||
// ProcessTerminalWidth returns the width of the terminal that the process is attached to.
|
||||
func (s *IOStreams) ProcessTerminalWidth() int {
|
||||
w, _, err := s.ttySize()
|
||||
if err != nil {
|
||||
return DefaultWidth
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func (s *IOStreams) ForceTerminal(spec string) {
|
||||
s.colorEnabled = !EnvColorDisabled()
|
||||
s.SetStdoutTTY(true)
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
package text
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/muesli/reflow/ansi"
|
||||
"github.com/muesli/reflow/truncate"
|
||||
)
|
||||
|
||||
const (
|
||||
ellipsis = "..."
|
||||
minWidthForEllipsis = 5
|
||||
minWidthForEllipsis = len(ellipsis) + 2
|
||||
)
|
||||
|
||||
// DisplayWidth calculates what the rendered width of a string may be
|
||||
|
|
@ -34,3 +36,12 @@ func Truncate(maxWidth int, s string) string {
|
|||
|
||||
return r
|
||||
}
|
||||
|
||||
// TruncateColumn replaces the first new line character with an ellipsis
|
||||
// and shortens a string to fit the maximum display width
|
||||
func TruncateColumn(maxWidth int, s string) string {
|
||||
if i := strings.IndexAny(s, "\r\n"); i >= 0 {
|
||||
s = s[:i] + ellipsis
|
||||
}
|
||||
return Truncate(maxWidth, s)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,22 @@ func TestTruncate(t *testing.T) {
|
|||
args args
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "Short enough",
|
||||
args: args{
|
||||
max: 5,
|
||||
s: "short",
|
||||
},
|
||||
want: "short",
|
||||
},
|
||||
{
|
||||
name: "Too short",
|
||||
args: args{
|
||||
max: 4,
|
||||
s: "short",
|
||||
},
|
||||
want: "shor",
|
||||
},
|
||||
{
|
||||
name: "Japanese",
|
||||
args: args{
|
||||
|
|
@ -78,6 +94,14 @@ func TestTruncate(t *testing.T) {
|
|||
},
|
||||
want: "é́́é́́é́́é́́é́́é́́é́́é́́...",
|
||||
},
|
||||
{
|
||||
name: "Red accented characters",
|
||||
args: args{
|
||||
max: 11,
|
||||
s: "\x1b[0;31mé́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́\x1b[0m",
|
||||
},
|
||||
want: "\x1b[0;31mé́́é́́é́́é́́é́́é́́é́́é́́...\x1b[0m",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
@ -88,6 +112,82 @@ func TestTruncate(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestTruncateColumn(t *testing.T) {
|
||||
type args struct {
|
||||
max int
|
||||
s string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "exactly minimum width",
|
||||
args: args{
|
||||
max: 5,
|
||||
s: "short",
|
||||
},
|
||||
want: "short",
|
||||
},
|
||||
{
|
||||
name: "exactly minimum width with new line",
|
||||
args: args{
|
||||
max: 5,
|
||||
s: "short\n",
|
||||
},
|
||||
want: "sh...",
|
||||
},
|
||||
{
|
||||
name: "less than minimum width",
|
||||
args: args{
|
||||
max: 4,
|
||||
s: "short",
|
||||
},
|
||||
want: "shor",
|
||||
},
|
||||
{
|
||||
name: "less than minimum width with new line",
|
||||
args: args{
|
||||
max: 4,
|
||||
s: "short\n",
|
||||
},
|
||||
want: "shor",
|
||||
},
|
||||
{
|
||||
name: "first line of multiple is short enough",
|
||||
args: args{
|
||||
max: 80,
|
||||
s: "short\n\nthis is a new line",
|
||||
},
|
||||
want: "short...",
|
||||
},
|
||||
{
|
||||
name: "using Windows line endings",
|
||||
args: args{
|
||||
max: 80,
|
||||
s: "short\r\n\r\nthis is a new line",
|
||||
},
|
||||
want: "short...",
|
||||
},
|
||||
{
|
||||
name: "using older MacOS line endings",
|
||||
args: args{
|
||||
max: 80,
|
||||
s: "short\r\rthis is a new line",
|
||||
},
|
||||
want: "short...",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := TruncateColumn(tt.args.max, tt.args.s); got != tt.want {
|
||||
t.Errorf("TruncateColumn() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayWidth(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
@ -149,6 +249,11 @@ func TestDisplayWidth(t *testing.T) {
|
|||
text: `é́́`,
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "color codes",
|
||||
text: "\x1b[0;31mred\x1b[0m",
|
||||
want: 3,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -17,11 +17,27 @@ type TablePrinter interface {
|
|||
Render() error
|
||||
}
|
||||
|
||||
type TablePrinterOptions struct {
|
||||
IsTTY bool
|
||||
}
|
||||
|
||||
func NewTablePrinter(io *iostreams.IOStreams) TablePrinter {
|
||||
if io.IsStdoutTTY() {
|
||||
return NewTablePrinterWithOptions(io, TablePrinterOptions{
|
||||
IsTTY: io.IsStdoutTTY(),
|
||||
})
|
||||
}
|
||||
|
||||
func NewTablePrinterWithOptions(io *iostreams.IOStreams, opts TablePrinterOptions) TablePrinter {
|
||||
if opts.IsTTY {
|
||||
var maxWidth int
|
||||
if io.IsStdoutTTY() {
|
||||
maxWidth = io.TerminalWidth()
|
||||
} else {
|
||||
maxWidth = io.ProcessTerminalWidth()
|
||||
}
|
||||
return &ttyTablePrinter{
|
||||
out: io.Out,
|
||||
maxWidth: io.TerminalWidth(),
|
||||
maxWidth: maxWidth,
|
||||
}
|
||||
}
|
||||
return &tsvTablePrinter{
|
||||
|
|
@ -49,7 +65,6 @@ func (t ttyTablePrinter) IsTTY() bool {
|
|||
return true
|
||||
}
|
||||
|
||||
// Never pass pre-colorized text to AddField; always specify colorFunc. Otherwise, the table printer can't correctly compute the width of its columns.
|
||||
func (t *ttyTablePrinter) AddField(s string, truncateFunc func(int, string) string, colorFunc func(string) string) {
|
||||
if truncateFunc == nil {
|
||||
truncateFunc = text.Truncate
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue