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.
This commit is contained in:
Heath Stewart 2024-10-12 02:03:47 -07:00
parent 86f045ef0e
commit bddadef574
No known key found for this signature in database
2 changed files with 227 additions and 28 deletions

View file

@ -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
}

View file

@ -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