Navigation Menu
-
Notifications
You must be signed in to change notification settings - Fork 8.6k
Supporting filtering on gist list
#9728
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
8c6d4c9
2fb5687
34203d7
73af9f8
b56353d
1615f2c
4eaeda5
70b1421
86f045e
bddadef
e9d8092
9e00f1e
446f660
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,17 +37,55 @@ 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 { | ||
| if opts.Limit < 1 { | ||
| 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,13 +199,111 @@ 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() | ||
| } | ||
|
|
||
| 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()) | ||
| } | ||
| } | ||
|
Comment on lines
+237
to
+266
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think I'd like to see this refactored out into a new function. Something like
Non-blocking: I'd also like to see the formatting code be more representative of what the output will look like, though I'm not exactly sure how to do that 😅 I find it hard to understand what the output will be from the code aside from reading the tests, and when that happens test expectations often become copy/pasted output. I generally have this complaint throughout the CLI, though, so I'm not going to block on it.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's going to be largely duplicative. The checks for things like This is also taking advantage of a lot of singularly-defined variables including functions we'd have to pass in or keep allocating / redefining in each iteration like a
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let me cut a branch and tinker on it to show you what I'm thinking. You may be right, but I'm gonna give it a try anyway 🙂
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Alright, I tinkered around and I wasn't happy with the lowest hanging fruit. What I would like to see happen (well outside the scope of this) is for fetching and filtering to be hoisted way up the logic stack and set in The problem is that this probably requires a rewrite of how we're fetching, too, and that is waaaay out of scope 😅 As a result, I'm fine to leave it as is in this PR. Thanks for indulging me and your patience while I dig into it myself 🍻 |
||
| } | ||
|
|
||
| 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 | ||
| } | ||

Uh oh!
There was an error while loading. Please reload this page.