From bddadef5740558caf40ebd0487c3de4a0b508f4b Mon Sep 17 00:00:00 2001 From: Heath Stewart Date: Sat, 12 Oct 2024 02:03:47 -0700 Subject: [PATCH] Highlight matches in table and content When `--filter` is passed, matches will be highlighted in the existing table. If file names match, the "n file(s)" cell will be highlighted. When `--include-content` is additionally passed, the file name, description, and/or content will be printed like `search code` with matches highlighted. --- pkg/cmd/gist/list/list.go | 70 +++++++++---- pkg/cmd/gist/list/list_test.go | 185 +++++++++++++++++++++++++++++++-- 2 files changed, 227 insertions(+), 28 deletions(-) diff --git a/pkg/cmd/gist/list/list.go b/pkg/cmd/gist/list/list.go index e5d55d8c1..2975a55df 100644 --- a/pkg/cmd/gist/list/list.go +++ b/pkg/cmd/gist/list/list.go @@ -49,7 +49,8 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman or even the content of files in the gist using %[1]s--filter%[1]s. Use %[1]s--include-content%[1]s to include content of files, noting that - this will be slower and increase the rate limit used. + this will be slower and increase the rate limit used. Instead of printing a table, + code will be printed with highlights. For supported regular expression syntax, see https://pkg.go.dev/regexp/syntax `, "`"), @@ -136,17 +137,39 @@ func listRun(opts *ListOptions) error { fmt.Fprintf(opts.IO.ErrOut, "failed to start pager: %v\n", err) } - if opts.Filter != nil { - return printHighlights(opts.IO, gists, opts.Filter) + if opts.Filter != nil && opts.IncludeContent { + return printContent(opts.IO, gists, opts.Filter) } - return printTable(opts.IO, gists) + return printTable(opts.IO, gists, opts.Filter) } -func printTable(io *iostreams.IOStreams, gists []shared.Gist) error { +func printTable(io *iostreams.IOStreams, gists []shared.Gist, filter *regexp.Regexp) error { cs := io.ColorScheme() tp := tableprinter.New(io, tableprinter.WithHeader("ID", "DESCRIPTION", "FILES", "VISIBILITY", "UPDATED")) + // Highlight filter matches in the description when printing the table. + highlightDescription := func(s string) string { + if filter != nil { + if str, err := highlightMatch(s, filter, nil, cs.Bold, cs.Highlight); err == nil { + return str + } + } + return cs.Bold(s) + } + + // Highlight the files column when any file name matches the filter. + highlightFilesFunc := func(gist *shared.Gist) func(string) string { + if filter != nil { + for _, file := range gist.Files { + if filter.MatchString(file.Filename) { + return cs.Highlight + } + } + } + return normal + } + for _, gist := range gists { fileCount := len(gist.Files) @@ -170,9 +193,12 @@ func printTable(io *iostreams.IOStreams, gists []shared.Gist) error { tp.AddField(gist.ID) tp.AddField( text.RemoveExcessiveWhitespace(description), - tableprinter.WithColor(cs.Bold), + tableprinter.WithColor(highlightDescription), + ) + tp.AddField( + text.Pluralize(fileCount, "file"), + tableprinter.WithColor(highlightFilesFunc(&gist)), ) - tp.AddField(text.Pluralize(fileCount, "file")) tp.AddField(visibility, tableprinter.WithColor(visColor)) tp.AddTimeField(time.Now(), gist.UpdatedAt, cs.Gray) tp.EndRow() @@ -181,7 +207,7 @@ func printTable(io *iostreams.IOStreams, gists []shared.Gist) error { return tp.Render() } -func printHighlights(io *iostreams.IOStreams, gists []shared.Gist, filter *regexp.Regexp) error { +func printContent(io *iostreams.IOStreams, gists []shared.Gist, filter *regexp.Regexp) error { const tab string = " " cs := io.ColorScheme() @@ -191,39 +217,36 @@ func printHighlights(io *iostreams.IOStreams, gists []shared.Gist, filter *regex split := func(r rune) bool { return r == '\n' || r == '\r' } - text := func(s string) string { - return s - } for _, gist := range gists { for _, file := range gist.Files { - found := false + matched := false out.Reset() - if filename, err = highlightMatch(file.Filename, filter, &found, cs.Green, cs.Highlight); err != nil { + if filename, err = highlightMatch(file.Filename, filter, &matched, cs.Green, cs.Highlight); err != nil { return err } fmt.Fprintln(out, cs.Blue(gist.ID), filename) if gist.Description != "" { - if description, err = highlightMatch(gist.Description, filter, &found, cs.Bold, cs.Highlight); err != nil { + if description, err = highlightMatch(gist.Description, filter, &matched, cs.Bold, cs.Highlight); err != nil { return err } - fmt.Fprintln(out, tab, description) + fmt.Fprintf(out, "%s%s\n", tab, description) } if file.Content != "" { for _, line := range strings.FieldsFunc(file.Content, split) { if filter.MatchString(line) { - if line, err = highlightMatch(line, filter, &found, text, cs.Highlight); err != nil { + if line, err = highlightMatch(line, filter, &matched, normal, cs.Highlight); err != nil { return err } - fmt.Fprintln(out, tab, tab, line) + fmt.Fprintf(out, "%[1]s%[1]s%[2]s\n", tab, line) } } } - if found { + if matched { fmt.Fprintln(io.Out, out.String()) } } @@ -232,7 +255,7 @@ func printHighlights(io *iostreams.IOStreams, gists []shared.Gist, filter *regex return nil } -func highlightMatch(s string, filter *regexp.Regexp, found *bool, color, highlight func(string) string) (string, error) { +func highlightMatch(s string, filter *regexp.Regexp, matched *bool, color, highlight func(string) string) (string, error) { matches := filter.FindAllStringIndex(s, -1) if matches == nil { return color(s), nil @@ -252,6 +275,13 @@ func highlightMatch(s string, filter *regexp.Regexp, found *bool, color, highlig } } - *found = *found || true + if matched != nil { + *matched = *matched || true + } + return out.String(), nil } + +func normal(s string) string { + return s +} diff --git a/pkg/cmd/gist/list/list_test.go b/pkg/cmd/gist/list/list_test.go index 0b88caf83..b20c0efeb 100644 --- a/pkg/cmd/gist/list/list_test.go +++ b/pkg/cmd/gist/list/list_test.go @@ -141,6 +141,7 @@ func Test_listRun(t *testing.T) { wantErr bool wantOut string stubs func(*httpmock.Registry) + color bool nontty bool }{ { @@ -390,12 +391,62 @@ func Test_listRun(t *testing.T) { nontty: true, }, { - name: "with content filter", + name: "filtered", opts: &ListOptions{ - Filter: regexp.MustCompile("octo"), - IncludeContent: true, - Visibility: "all", + Filter: regexp.MustCompile("octo"), + Visibility: "all", }, + nontty: true, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(query), + httpmock.StringResponse(fmt.Sprintf( + `{ "data": { "viewer": { "gists": { "nodes": [ + { + "name": "1234", + "files": [ + { "name": "main.txt", "text": "foo" } + ], + "description": "octo match in the description", + "updatedAt": "%[1]v", + "isPublic": true + }, + { + "name": "2345", + "files": [ + { "name": "main.txt", "text": "foo" }, + { "name": "octo.txt", "text": "bar" } + ], + "description": "match in the file name", + "updatedAt": "%[1]v", + "isPublic": false + }, + { + "name": "3456", + "files": [ + { "name": "main.txt", "text": "octo in the text" } + ], + "description": "match in the file text", + "updatedAt": "%[1]v", + "isPublic": true + } + ] } } } }`, + absTime.Format(time.RFC3339), + )), + ) + }, + wantOut: heredoc.Docf(` + 1234%[1]socto match in the description%[1]s1 file%[1]spublic%[1]s2020-07-30T15:24:28Z + 2345%[1]smatch in the file name%[1]s2 files%[1]ssecret%[1]s2020-07-30T15:24:28Z + `, "\t"), + }, + { + name: "filtered (tty)", + opts: &ListOptions{ + Filter: regexp.MustCompile("octo"), + Visibility: "all", + }, + color: true, stubs: func(reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(query), @@ -434,13 +485,130 @@ func Test_listRun(t *testing.T) { )), ) }, + wantOut: heredoc.Docf(` + %[1]s[0;2;4;37mID %[1]s[0m %[1]s[0;2;4;37mDESCRIPTION %[1]s[0m %[1]s[0;2;4;37mFILES %[1]s[0m %[1]s[0;2;4;37mVISIBILITY%[1]s[0m %[1]s[0;2;4;37mUPDATED %[1]s[0m + 1234 %[1]s[0;30;43mocto%[1]s[0m%[1]s[0;1;39m match in the description%[1]s[0m 1 file %[1]s[0;32mpublic %[1]s[0m %[1]s[38;5;242mabout 6 hours ago%[1]s[m + 2345 %[1]s[0;1;39mmatch in the file name %[1]s[0m %[1]s[0;30;43m2 files%[1]s[0m %[1]s[0;31msecret %[1]s[0m %[1]s[38;5;242mabout 6 hours ago%[1]s[m + `, "\x1b"), + }, + { + name: "filtered with content", + opts: &ListOptions{ + Filter: regexp.MustCompile("octo"), + IncludeContent: true, + Visibility: "all", + }, + nontty: true, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(query), + httpmock.StringResponse(fmt.Sprintf( + `{ "data": { "viewer": { "gists": { "nodes": [ + { + "name": "1234", + "files": [ + { "name": "main.txt", "text": "foo" } + ], + "description": "octo match in the description", + "updatedAt": "%[1]v", + "isPublic": true + }, + { + "name": "2345", + "files": [ + { "name": "main.txt", "text": "foo" }, + { "name": "octo.txt", "text": "bar" } + ], + "description": "match in the file name", + "updatedAt": "%[1]v", + "isPublic": false + }, + { + "name": "3456", + "files": [ + { "name": "main.txt", "text": "octo in the text" } + ], + "description": "match in the file text", + "updatedAt": "%[1]v", + "isPublic": true + } + ] } } } }`, + absTime.Format(time.RFC3339), + )), + ) + }, wantOut: heredoc.Doc(` - ID DESCRIPTION FILES VISIBILITY UPDATED - 1234 octo match in the description 1 file public about 6 hours ago - 2345 match in the file name 2 files secret about 6 hours ago - 3456 match in the file text 1 file public about 6 hours ago + 1234 main.txt + octo match in the description + + 2345 octo.txt + match in the file name + + 3456 main.txt + match in the file text + octo in the text + `), }, + { + name: "filtered with content (tty)", + opts: &ListOptions{ + Filter: regexp.MustCompile("octo"), + IncludeContent: true, + Visibility: "all", + }, + color: true, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(query), + httpmock.StringResponse(fmt.Sprintf( + `{ "data": { "viewer": { "gists": { "nodes": [ + { + "name": "1234", + "files": [ + { "name": "main.txt", "text": "foo" } + ], + "description": "octo match in the description", + "updatedAt": "%[1]v", + "isPublic": true + }, + { + "name": "2345", + "files": [ + { "name": "main.txt", "text": "foo" }, + { "name": "octo.txt", "text": "bar" } + ], + "description": "match in the file name", + "updatedAt": "%[1]v", + "isPublic": false + }, + { + "name": "3456", + "files": [ + { "name": "main.txt", "text": "octo in the text" } + ], + "description": "match in the file text", + "updatedAt": "%[1]v", + "isPublic": true + } + ] } } } }`, + sixHoursAgo.Format(time.RFC3339), + )), + ) + }, + wantOut: heredoc.Docf(` + %[1]s[0;34m1234%[1]s[0m %[1]s[0;32mmain.txt%[1]s[0m + %[1]s[0;30;43mocto%[1]s[0m%[1]s[0;1;39m match in the description%[1]s[0m + + %[1]s[0;34m2345%[1]s[0m %[1]s[0;30;43mocto%[1]s[0m%[1]s[0;32m.txt%[1]s[0m + %[1]s[0;1;39mmatch in the file name%[1]s[0m + + %[1]s[0;34m3456%[1]s[0m %[1]s[0;32mmain.txt%[1]s[0m + %[1]s[0;1;39mmatch in the file text%[1]s[0m + %[1]s[0;30;43mocto%[1]s[0m in the text + + `, "\x1b"), + }, } for _, tt := range tests { @@ -456,6 +624,7 @@ func Test_listRun(t *testing.T) { } ios, _, stdout, _ := iostreams.Test() + ios.SetColorEnabled(tt.color) ios.SetStdoutTTY(!tt.nontty) tt.opts.IO = ios