Merge pull request #9728 from heaths/issue9704
Supporting filtering on `gist list`
This commit is contained in:
commit
7aef6ec391
5 changed files with 578 additions and 38 deletions
|
|
@ -3,9 +3,11 @@ package list
|
|||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/internal/gh"
|
||||
"github.com/cli/cli/v2/internal/tableprinter"
|
||||
"github.com/cli/cli/v2/internal/text"
|
||||
|
|
@ -20,8 +22,10 @@ type ListOptions struct {
|
|||
Config func() (gh.Config, error)
|
||||
HttpClient func() (*http.Client, error)
|
||||
|
||||
Limit int
|
||||
Visibility string // all, secret, public
|
||||
Limit int
|
||||
Filter *regexp.Regexp
|
||||
IncludeContent bool
|
||||
Visibility string // all, secret, public
|
||||
}
|
||||
|
||||
func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
|
||||
|
|
@ -33,10 +37,36 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
|
|||
|
||||
var flagPublic bool
|
||||
var flagSecret bool
|
||||
var flagFilter string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List your gists",
|
||||
Use: "list",
|
||||
Short: "List your gists",
|
||||
Long: heredoc.Docf(`
|
||||
List gists from your user account.
|
||||
|
||||
You can use a regular expression to filter the description, file names,
|
||||
or even the content of files in the gist using %[1]s--filter%[1]s.
|
||||
|
||||
For supported regular expression syntax, see <https://pkg.go.dev/regexp/syntax>.
|
||||
|
||||
Use %[1]s--include-content%[1]s to include content of files, noting that
|
||||
this will be slower and increase the rate limit used. Instead of printing a table,
|
||||
code will be printed with highlights similar to %[1]sgh search code%[1]s:
|
||||
|
||||
{{gist ID}} {{file name}}
|
||||
{{description}}
|
||||
{{matching lines from content}}
|
||||
|
||||
No highlights or other color is printed when output is redirected.
|
||||
`, "`"),
|
||||
Example: heredoc.Doc(`
|
||||
# list all secret gists from your user account
|
||||
$ gh gist list --secret
|
||||
|
||||
# find all gists from your user account mentioning "octo" anywhere
|
||||
$ gh gist list --filter octo --include-content
|
||||
`),
|
||||
Aliases: []string{"ls"},
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
|
|
@ -44,6 +74,18 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
|
|||
return cmdutil.FlagErrorf("invalid limit: %v", opts.Limit)
|
||||
}
|
||||
|
||||
if flagFilter == "" {
|
||||
if opts.IncludeContent {
|
||||
return cmdutil.FlagErrorf("cannot use --include-content without --filter")
|
||||
}
|
||||
} else {
|
||||
if filter, err := regexp.CompilePOSIX(flagFilter); err != nil {
|
||||
return err
|
||||
} else {
|
||||
opts.Filter = filter
|
||||
}
|
||||
}
|
||||
|
||||
opts.Visibility = "all"
|
||||
if flagSecret {
|
||||
opts.Visibility = "secret"
|
||||
|
|
@ -61,6 +103,8 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
|
|||
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 10, "Maximum number of gists to fetch")
|
||||
cmd.Flags().BoolVar(&flagPublic, "public", false, "Show only public gists")
|
||||
cmd.Flags().BoolVar(&flagSecret, "secret", false, "Show only secret gists")
|
||||
cmd.Flags().StringVar(&flagFilter, "filter", "", "Filter gists using a regular `expression`")
|
||||
cmd.Flags().BoolVar(&opts.IncludeContent, "include-content", false, "Include gists' file content when filtering")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
|
@ -78,7 +122,13 @@ func listRun(opts *ListOptions) error {
|
|||
|
||||
host, _ := cfg.Authentication().DefaultHost()
|
||||
|
||||
gists, err := shared.ListGists(client, host, opts.Limit, opts.Visibility)
|
||||
// Filtering can take a while so start the progress indicator. StopProgressIndicator will no-op if not running.
|
||||
if opts.Filter != nil {
|
||||
opts.IO.StartProgressIndicator()
|
||||
}
|
||||
gists, err := shared.ListGists(client, host, opts.Limit, opts.Filter, opts.IncludeContent, opts.Visibility)
|
||||
opts.IO.StopProgressIndicator()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -93,8 +143,38 @@ func listRun(opts *ListOptions) error {
|
|||
fmt.Fprintf(opts.IO.ErrOut, "failed to start pager: %v\n", err)
|
||||
}
|
||||
|
||||
cs := opts.IO.ColorScheme()
|
||||
tp := tableprinter.New(opts.IO, tableprinter.WithHeader("ID", "DESCRIPTION", "FILES", "VISIBILITY", "UPDATED"))
|
||||
if opts.Filter != nil && opts.IncludeContent {
|
||||
return printContent(opts.IO, gists, opts.Filter)
|
||||
}
|
||||
|
||||
return printTable(opts.IO, gists, opts.Filter)
|
||||
}
|
||||
|
||||
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)
|
||||
|
|
@ -119,9 +199,12 @@ func listRun(opts *ListOptions) 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()
|
||||
|
|
@ -129,3 +212,98 @@ func listRun(opts *ListOptions) error {
|
|||
|
||||
return tp.Render()
|
||||
}
|
||||
|
||||
// printContent prints a gist with optional description and content similar to `gh search code`
|
||||
// including highlighted matches in the form:
|
||||
//
|
||||
// {{gist ID}} {{file name}}
|
||||
// {{description, if any}}
|
||||
// {{content lines with matches, if any}}
|
||||
//
|
||||
// If printing to a non-TTY stream the format will be the same but without highlights.
|
||||
func printContent(io *iostreams.IOStreams, gists []shared.Gist, filter *regexp.Regexp) error {
|
||||
const tab string = " "
|
||||
cs := io.ColorScheme()
|
||||
|
||||
out := &strings.Builder{}
|
||||
var filename, description string
|
||||
var err error
|
||||
split := func(r rune) bool {
|
||||
return r == '\n' || r == '\r'
|
||||
}
|
||||
|
||||
for _, gist := range gists {
|
||||
for _, file := range gist.Files {
|
||||
matched := false
|
||||
out.Reset()
|
||||
|
||||
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, &matched, cs.Bold, cs.Highlight); err != nil {
|
||||
return err
|
||||
}
|
||||
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, &matched, normal, cs.Highlight); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(out, "%[1]s%[1]s%[2]s\n", tab, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if matched {
|
||||
fmt.Fprintln(io.Out, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
out := strings.Builder{}
|
||||
|
||||
// Color up to the first match. If an empty string, no ANSI color sequence is added.
|
||||
if _, err := out.WriteString(color(s[:matches[0][0]])); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Highlight each match, then color the remaining text which, if an empty string, no ANSI color sequence is added.
|
||||
for i, match := range matches {
|
||||
if _, err := out.WriteString(highlight(s[match[0]:match[1]])); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
text := s[match[1]:]
|
||||
if i+1 < len(matches) {
|
||||
text = s[match[1]:matches[i+1][0]]
|
||||
}
|
||||
if _, err := out.WriteString(color(text)); err != nil {
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
if matched != nil {
|
||||
*matched = *matched || true
|
||||
}
|
||||
|
||||
return out.String(), nil
|
||||
}
|
||||
|
||||
func normal(s string) string {
|
||||
return s
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -19,9 +20,10 @@ import (
|
|||
|
||||
func TestNewCmdList(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cli string
|
||||
wants ListOptions
|
||||
name string
|
||||
cli string
|
||||
wants ListOptions
|
||||
wantsErr bool
|
||||
}{
|
||||
{
|
||||
name: "no arguments",
|
||||
|
|
@ -70,6 +72,31 @@ func TestNewCmdList(t *testing.T) {
|
|||
Visibility: "all",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid limit",
|
||||
cli: "--limit 0",
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "filter and include-content",
|
||||
cli: "--filter octo --include-content",
|
||||
wants: ListOptions{
|
||||
Limit: 10,
|
||||
Filter: regexp.MustCompilePOSIX("octo"),
|
||||
IncludeContent: true,
|
||||
Visibility: "all",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid filter",
|
||||
cli: "--filter octo(",
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "include content without filter",
|
||||
cli: "--include-content",
|
||||
wantsErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -90,6 +117,10 @@ func TestNewCmdList(t *testing.T) {
|
|||
cmd.SetErr(&bytes.Buffer{})
|
||||
|
||||
_, err = cmd.ExecuteC()
|
||||
if tt.wantsErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tt.wants.Visibility, gotOpts.Visibility)
|
||||
|
|
@ -110,6 +141,7 @@ func Test_listRun(t *testing.T) {
|
|||
wantErr bool
|
||||
wantOut string
|
||||
stubs func(*httpmock.Registry)
|
||||
color bool
|
||||
nontty bool
|
||||
}{
|
||||
{
|
||||
|
|
@ -358,6 +390,225 @@ func Test_listRun(t *testing.T) {
|
|||
`),
|
||||
nontty: true,
|
||||
},
|
||||
{
|
||||
name: "filtered",
|
||||
opts: &ListOptions{
|
||||
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),
|
||||
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;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(`
|
||||
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 {
|
||||
|
|
@ -373,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
|
||||
|
||||
|
|
@ -396,3 +648,59 @@ func Test_listRun(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_highlightMatch(t *testing.T) {
|
||||
regex := regexp.MustCompilePOSIX(`[Oo]cto`)
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
color bool
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "single match",
|
||||
input: "Octo",
|
||||
want: "Octo",
|
||||
},
|
||||
{
|
||||
name: "single match (color)",
|
||||
input: "Octo",
|
||||
color: true,
|
||||
want: "\x1b[0;30;43mOcto\x1b[0m",
|
||||
},
|
||||
{
|
||||
name: "single match with extra",
|
||||
input: "Hello, Octocat!",
|
||||
want: "Hello, Octocat!",
|
||||
},
|
||||
{
|
||||
name: "single match with extra (color)",
|
||||
input: "Hello, Octocat!",
|
||||
color: true,
|
||||
want: "\x1b[0;34mHello, \x1b[0m\x1b[0;30;43mOcto\x1b[0m\x1b[0;34mcat!\x1b[0m",
|
||||
},
|
||||
{
|
||||
name: "multiple matches",
|
||||
input: "Octocat/octo",
|
||||
want: "Octocat/octo",
|
||||
},
|
||||
{
|
||||
name: "multiple matches (color)",
|
||||
input: "Octocat/octo",
|
||||
color: true,
|
||||
want: "\x1b[0;30;43mOcto\x1b[0m\x1b[0;34mcat/\x1b[0m\x1b[0;30;43mocto\x1b[0m",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cs := iostreams.NewColorScheme(tt.color, false, false)
|
||||
|
||||
matched := false
|
||||
got, err := highlightMatch(tt.input, regex, &matched, cs.Blue, cs.Highlight)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, matched)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -74,7 +75,9 @@ func GistIDFromURL(gistURL string) (string, error) {
|
|||
return "", fmt.Errorf("Invalid gist URL %s", u)
|
||||
}
|
||||
|
||||
func ListGists(client *http.Client, hostname string, limit int, visibility string) ([]Gist, error) {
|
||||
const maxPerPage = 100
|
||||
|
||||
func ListGists(client *http.Client, hostname string, limit int, filter *regexp.Regexp, includeContent bool, visibility string) ([]Gist, error) {
|
||||
type response struct {
|
||||
Viewer struct {
|
||||
Gists struct {
|
||||
|
|
@ -82,6 +85,7 @@ func ListGists(client *http.Client, hostname string, limit int, visibility strin
|
|||
Description string
|
||||
Files []struct {
|
||||
Name string
|
||||
Text string `graphql:"text @include(if: $includeContent)"`
|
||||
}
|
||||
IsPublic bool
|
||||
Name string
|
||||
|
|
@ -96,14 +100,33 @@ func ListGists(client *http.Client, hostname string, limit int, visibility strin
|
|||
}
|
||||
|
||||
perPage := limit
|
||||
if perPage > 100 {
|
||||
perPage = 100
|
||||
if perPage > maxPerPage {
|
||||
perPage = maxPerPage
|
||||
}
|
||||
|
||||
variables := map[string]interface{}{
|
||||
"per_page": githubv4.Int(perPage),
|
||||
"endCursor": (*githubv4.String)(nil),
|
||||
"visibility": githubv4.GistPrivacy(strings.ToUpper(visibility)),
|
||||
"per_page": githubv4.Int(perPage),
|
||||
"endCursor": (*githubv4.String)(nil),
|
||||
"visibility": githubv4.GistPrivacy(strings.ToUpper(visibility)),
|
||||
"includeContent": githubv4.Boolean(includeContent),
|
||||
}
|
||||
|
||||
filterFunc := func(gist *Gist) bool {
|
||||
if filter.MatchString(gist.Description) {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, file := range gist.Files {
|
||||
if filter.MatchString(file.Filename) {
|
||||
return true
|
||||
}
|
||||
|
||||
if includeContent && filter.MatchString(file.Content) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
gql := api.NewClientFromHTTP(client)
|
||||
|
|
@ -122,19 +145,22 @@ pagination:
|
|||
for _, file := range gist.Files {
|
||||
files[file.Name] = &GistFile{
|
||||
Filename: file.Name,
|
||||
Content: file.Text,
|
||||
}
|
||||
}
|
||||
|
||||
gists = append(
|
||||
gists,
|
||||
Gist{
|
||||
ID: gist.Name,
|
||||
Description: gist.Description,
|
||||
Files: files,
|
||||
UpdatedAt: gist.UpdatedAt,
|
||||
Public: gist.IsPublic,
|
||||
},
|
||||
)
|
||||
gist := Gist{
|
||||
ID: gist.Name,
|
||||
Description: gist.Description,
|
||||
Files: files,
|
||||
UpdatedAt: gist.UpdatedAt,
|
||||
Public: gist.IsPublic,
|
||||
}
|
||||
|
||||
if filter == nil || filterFunc(&gist) {
|
||||
gists = append(gists, gist)
|
||||
}
|
||||
|
||||
if len(gists) == limit {
|
||||
break pagination
|
||||
}
|
||||
|
|
@ -177,7 +203,7 @@ func IsBinaryContents(contents []byte) bool {
|
|||
}
|
||||
|
||||
func PromptGists(prompter prompter.Prompter, client *http.Client, host string, cs *iostreams.ColorScheme) (gistID string, err error) {
|
||||
gists, err := ListGists(client, host, 10, "all")
|
||||
gists, err := ListGists(client, host, 10, nil, false, "all")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ func displayResults(io *iostreams.IOStreams, results search.CodeResult) error {
|
|||
}
|
||||
fmt.Fprintf(io.Out, "%s %s\n", cs.Blue(code.Repository.FullName), cs.GreenBold(code.Path))
|
||||
for _, match := range code.TextMatches {
|
||||
lines := formatMatch(match.Fragment, match.Matches, io.ColorEnabled())
|
||||
lines := formatMatch(match.Fragment, match.Matches, io)
|
||||
for _, line := range lines {
|
||||
fmt.Fprintf(io.Out, "\t%s\n", strings.TrimSpace(line))
|
||||
}
|
||||
|
|
@ -153,7 +153,7 @@ func displayResults(io *iostreams.IOStreams, results search.CodeResult) error {
|
|||
}
|
||||
for _, code := range results.Items {
|
||||
for _, match := range code.TextMatches {
|
||||
lines := formatMatch(match.Fragment, match.Matches, io.ColorEnabled())
|
||||
lines := formatMatch(match.Fragment, match.Matches, io)
|
||||
for _, line := range lines {
|
||||
fmt.Fprintf(io.Out, "%s:%s: %s\n", cs.Blue(code.Repository.FullName), cs.GreenBold(code.Path), strings.TrimSpace(line))
|
||||
}
|
||||
|
|
@ -162,7 +162,9 @@ func displayResults(io *iostreams.IOStreams, results search.CodeResult) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func formatMatch(t string, matches []search.Match, colorize bool) []string {
|
||||
func formatMatch(t string, matches []search.Match, io *iostreams.IOStreams) []string {
|
||||
cs := io.ColorScheme()
|
||||
|
||||
startIndices := map[int]struct{}{}
|
||||
endIndices := map[int]struct{}{}
|
||||
for _, m := range matches {
|
||||
|
|
@ -186,14 +188,10 @@ func formatMatch(t string, matches []search.Match, colorize bool) []string {
|
|||
continue
|
||||
}
|
||||
if _, ok := startIndices[i]; ok {
|
||||
if colorize {
|
||||
b.WriteString("\x1b[30;43m") // black text on yellow background
|
||||
}
|
||||
b.WriteString(cs.HighlightStart())
|
||||
found = true
|
||||
} else if _, ok := endIndices[i]; ok {
|
||||
if colorize {
|
||||
b.WriteString("\x1b[m") // color reset
|
||||
}
|
||||
b.WriteString(cs.Reset())
|
||||
}
|
||||
b.WriteRune(c)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ import (
|
|||
"github.com/mgutz/ansi"
|
||||
)
|
||||
|
||||
const (
|
||||
highlightStyle = "black:yellow"
|
||||
)
|
||||
|
||||
var (
|
||||
magenta = ansi.ColorFunc("magenta")
|
||||
cyan = ansi.ColorFunc("cyan")
|
||||
|
|
@ -20,6 +24,8 @@ var (
|
|||
bold = ansi.ColorFunc("default+b")
|
||||
cyanBold = ansi.ColorFunc("cyan+b")
|
||||
greenBold = ansi.ColorFunc("green+b")
|
||||
highlightStart = ansi.ColorCode(highlightStyle)
|
||||
highlight = ansi.ColorFunc(highlightStyle)
|
||||
|
||||
gray256 = func(t string) string {
|
||||
return fmt.Sprintf("\x1b[%d;5;%dm%s\x1b[m", 38, 242, t)
|
||||
|
|
@ -176,6 +182,30 @@ func (c *ColorScheme) FailureIconWithColor(colo func(string) string) string {
|
|||
return colo("X")
|
||||
}
|
||||
|
||||
func (c *ColorScheme) HighlightStart() string {
|
||||
if !c.enabled {
|
||||
return ""
|
||||
}
|
||||
|
||||
return highlightStart
|
||||
}
|
||||
|
||||
func (c *ColorScheme) Highlight(t string) string {
|
||||
if !c.enabled {
|
||||
return t
|
||||
}
|
||||
|
||||
return highlight(t)
|
||||
}
|
||||
|
||||
func (c *ColorScheme) Reset() string {
|
||||
if !c.enabled {
|
||||
return ""
|
||||
}
|
||||
|
||||
return ansi.Reset
|
||||
}
|
||||
|
||||
func (c *ColorScheme) ColorFromString(s string) func(string) string {
|
||||
s = strings.ToLower(s)
|
||||
var fn func(string) string
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue