Merge pull request #3008 from ganboonhong/interactive-gist-view

Add interactive select in gist view
This commit is contained in:
Nate Smith 2021-03-01 16:10:05 -06:00 committed by GitHub
commit 953855c1c3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 309 additions and 104 deletions

View file

@ -1,88 +0,0 @@
package list
import (
"context"
"net/http"
"strings"
"time"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/pkg/cmd/gist/shared"
"github.com/shurcooL/githubv4"
"github.com/shurcooL/graphql"
)
func listGists(client *http.Client, hostname string, limit int, visibility string) ([]shared.Gist, error) {
type response struct {
Viewer struct {
Gists struct {
Nodes []struct {
Description string
Files []struct {
Name string
}
IsPublic bool
Name string
UpdatedAt time.Time
}
PageInfo struct {
HasNextPage bool
EndCursor string
}
} `graphql:"gists(first: $per_page, after: $endCursor, privacy: $visibility, orderBy: {field: CREATED_AT, direction: DESC})"`
}
}
perPage := limit
if perPage > 100 {
perPage = 100
}
variables := map[string]interface{}{
"per_page": githubv4.Int(perPage),
"endCursor": (*githubv4.String)(nil),
"visibility": githubv4.GistPrivacy(strings.ToUpper(visibility)),
}
gql := graphql.NewClient(ghinstance.GraphQLEndpoint(hostname), client)
gists := []shared.Gist{}
pagination:
for {
var result response
err := gql.QueryNamed(context.Background(), "GistList", &result, variables)
if err != nil {
return nil, err
}
for _, gist := range result.Viewer.Gists.Nodes {
files := map[string]*shared.GistFile{}
for _, file := range gist.Files {
files[file.Name] = &shared.GistFile{
Filename: file.Name,
}
}
gists = append(
gists,
shared.Gist{
ID: gist.Name,
Description: gist.Description,
Files: files,
UpdatedAt: gist.UpdatedAt,
Public: gist.IsPublic,
},
)
if len(gists) == limit {
break pagination
}
}
if !result.Viewer.Gists.PageInfo.HasNextPage {
break
}
variables["endCursor"] = githubv4.String(result.Viewer.Gists.PageInfo.EndCursor)
}
return gists, nil
}

View file

@ -7,6 +7,7 @@ import (
"time"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/pkg/cmd/gist/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/text"
@ -67,7 +68,7 @@ func listRun(opts *ListOptions) error {
return err
}
gists, err := listGists(client, ghinstance.OverridableDefault(), opts.Limit, opts.Visibility)
gists, err := shared.ListGists(client, ghinstance.OverridableDefault(), opts.Limit, opts.Visibility)
if err != nil {
return err
}

View file

@ -1,6 +1,7 @@
package shared
import (
"context"
"errors"
"fmt"
"net/http"
@ -8,6 +9,10 @@ import (
"strings"
"time"
"github.com/cli/cli/internal/ghinstance"
"github.com/shurcooL/githubv4"
"github.com/shurcooL/graphql"
"github.com/cli/cli/api"
)
@ -67,3 +72,78 @@ 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) {
type response struct {
Viewer struct {
Gists struct {
Nodes []struct {
Description string
Files []struct {
Name string
}
IsPublic bool
Name string
UpdatedAt time.Time
}
PageInfo struct {
HasNextPage bool
EndCursor string
}
} `graphql:"gists(first: $per_page, after: $endCursor, privacy: $visibility, orderBy: {field: CREATED_AT, direction: DESC})"`
}
}
perPage := limit
if perPage > 100 {
perPage = 100
}
variables := map[string]interface{}{
"per_page": githubv4.Int(perPage),
"endCursor": (*githubv4.String)(nil),
"visibility": githubv4.GistPrivacy(strings.ToUpper(visibility)),
}
gql := graphql.NewClient(ghinstance.GraphQLEndpoint(hostname), client)
gists := []Gist{}
pagination:
for {
var result response
err := gql.QueryNamed(context.Background(), "GistList", &result, variables)
if err != nil {
return nil, err
}
for _, gist := range result.Viewer.Gists.Nodes {
files := map[string]*GistFile{}
for _, file := range gist.Files {
files[file.Name] = &GistFile{
Filename: file.Name,
}
}
gists = append(
gists,
Gist{
ID: gist.Name,
Description: gist.Description,
Files: files,
UpdatedAt: gist.UpdatedAt,
Public: gist.IsPublic,
},
)
if len(gists) == limit {
break pagination
}
}
if !result.Viewer.Gists.PageInfo.HasNextPage {
break
}
variables["endCursor"] = githubv4.String(result.Viewer.Gists.PageInfo.EndCursor)
}
return gists, nil
}

View file

@ -5,12 +5,16 @@ import (
"net/http"
"sort"
"strings"
"time"
"github.com/AlecAivazis/survey/v2"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/pkg/cmd/gist/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/markdown"
"github.com/cli/cli/pkg/prompt"
"github.com/cli/cli/pkg/text"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
@ -33,11 +37,14 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
}
cmd := &cobra.Command{
Use: "view {<id> | <url>}",
Use: "view [<id> | <url>]",
Short: "View a gist",
Args: cmdutil.ExactArgs(1, "cannot view: gist argument required"),
Long: `View the given gist or select from recent gists.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
opts.Selector = args[0]
if len(args) == 1 {
opts.Selector = args[0]
}
if !opts.IO.IsStdoutTTY() {
opts.Raw = true
@ -60,6 +67,23 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
func viewRun(opts *ViewOptions) error {
gistID := opts.Selector
client, err := opts.HttpClient()
if err != nil {
return err
}
cs := opts.IO.ColorScheme()
if gistID == "" {
gistID, err = promptGists(client, cs)
if err != nil {
return err
}
if gistID == "" {
fmt.Fprintln(opts.IO.Out, "No gists found.")
return nil
}
}
if opts.Web {
gistURL := gistID
@ -81,11 +105,6 @@ func viewRun(opts *ViewOptions) error {
gistID = id
}
client, err := opts.HttpClient()
if err != nil {
return err
}
gist, err := shared.GetGist(client, ghinstance.OverridableDefault(), gistID)
if err != nil {
return err
@ -126,8 +145,6 @@ func viewRun(opts *ViewOptions) error {
return render(gistFile)
}
cs := opts.IO.ColorScheme()
if gist.Description != "" && !opts.ListFiles {
fmt.Fprintf(opts.IO.Out, "%s\n\n", cs.Bold(gist.Description))
}
@ -160,3 +177,54 @@ func viewRun(opts *ViewOptions) error {
return nil
}
func promptGists(client *http.Client, cs *iostreams.ColorScheme) (gistID string, err error) {
gists, err := shared.ListGists(client, ghinstance.OverridableDefault(), 10, "all")
if err != nil {
return "", err
}
if len(gists) == 0 {
return "", nil
}
var opts []string
var result int
var gistIDs = make([]string, len(gists))
for i, gist := range gists {
gistIDs[i] = gist.ID
description := ""
gistName := ""
if gist.Description != "" {
description = gist.Description
}
filenames := make([]string, 0, len(gist.Files))
for fn := range gist.Files {
filenames = append(filenames, fn)
}
sort.Strings(filenames)
gistName = filenames[0]
gistTime := utils.FuzzyAgo(time.Since(gist.UpdatedAt))
// TODO: support dynamic maxWidth
description = text.Truncate(100, text.ReplaceExcessiveWhitespace(description))
opt := fmt.Sprintf("%s %s %s", cs.Bold(gistName), description, cs.Gray(gistTime))
opts = append(opts, opt)
}
questions := &survey.Select{
Message: "Select a gist",
Options: opts,
}
err = prompt.SurveyAskOne(questions, &result)
if err != nil {
return "", err
}
return gistIDs[result], nil
}

View file

@ -2,13 +2,16 @@ package view
import (
"bytes"
"fmt"
"net/http"
"testing"
"time"
"github.com/cli/cli/pkg/cmd/gist/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/prompt"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
)
@ -60,6 +63,16 @@ func TestNewCmdView(t *testing.T) {
ListFiles: true,
},
},
{
name: "tty no ID supplied",
cli: "",
tty: true,
wants: ViewOptions{
Raw: false,
Selector: "",
ListFiles: true,
},
},
}
for _, tt := range tests {
@ -96,11 +109,12 @@ func TestNewCmdView(t *testing.T) {
func Test_viewRun(t *testing.T) {
tests := []struct {
name string
opts *ViewOptions
wantOut string
gist *shared.Gist
wantErr bool
name string
opts *ViewOptions
wantOut string
gist *shared.Gist
wantErr bool
mockGistList bool
}{
{
name: "no such gist",
@ -126,6 +140,23 @@ func Test_viewRun(t *testing.T) {
},
wantOut: "bwhiizzzbwhuiiizzzz\n",
},
{
name: "one file, no ID supplied",
opts: &ViewOptions{
Selector: "",
ListFiles: false,
},
mockGistList: true,
gist: &shared.Gist{
Files: map[string]*shared.GistFile{
"cicada.txt": {
Content: "test interactive mode",
Type: "text/plain",
},
},
},
wantOut: "test interactive mode\n",
},
{
name: "filename selected",
opts: &ViewOptions{
@ -304,6 +335,30 @@ func Test_viewRun(t *testing.T) {
httpmock.JSONResponse(tt.gist))
}
if tt.mockGistList {
sixHours, _ := time.ParseDuration("6h")
sixHoursAgo := time.Now().Add(-sixHours)
reg.Register(
httpmock.GraphQL(`query GistList\b`),
httpmock.StringResponse(fmt.Sprintf(
`{ "data": { "viewer": { "gists": { "nodes": [
{
"name": "1234",
"files": [{ "name": "cool.txt" }],
"description": "",
"updatedAt": "%s",
"isPublic": true
}
] } } } }`,
sixHoursAgo.Format(time.RFC3339),
)),
)
as, surveyteardown := prompt.InitAskStubber()
defer surveyteardown()
as.StubOne(0)
}
if tt.opts == nil {
tt.opts = &ViewOptions{}
}
@ -328,3 +383,92 @@ func Test_viewRun(t *testing.T) {
})
}
}
func Test_promptGists(t *testing.T) {
tests := []struct {
name string
gistIndex int
response string
wantOut string
gist *shared.Gist
wantErr bool
}{
{
name: "multiple files, select first gist",
gistIndex: 0,
response: `{ "data": { "viewer": { "gists": { "nodes": [
{
"name": "gistid1",
"files": [{ "name": "cool.txt" }],
"description": "",
"updatedAt": "%[1]v",
"isPublic": true
},
{
"name": "gistid2",
"files": [{ "name": "gistfile0.txt" }],
"description": "",
"updatedAt": "%[1]v",
"isPublic": true
}
] } } } }`,
wantOut: "gistid1",
},
{
name: "multiple files, select second gist",
gistIndex: 1,
response: `{ "data": { "viewer": { "gists": { "nodes": [
{
"name": "gistid1",
"files": [{ "name": "cool.txt" }],
"description": "",
"updatedAt": "%[1]v",
"isPublic": true
},
{
"name": "gistid2",
"files": [{ "name": "gistfile0.txt" }],
"description": "",
"updatedAt": "%[1]v",
"isPublic": true
}
] } } } }`,
wantOut: "gistid2",
},
{
name: "no files",
response: `{ "data": { "viewer": { "gists": { "nodes": [] } } } }`,
wantOut: "",
},
}
io, _, _, _ := iostreams.Test()
cs := iostreams.NewColorScheme(io.ColorEnabled(), io.ColorSupport256())
for _, tt := range tests {
reg := &httpmock.Registry{}
const query = `query GistList\b`
sixHours, _ := time.ParseDuration("6h")
sixHoursAgo := time.Now().Add(-sixHours)
reg.Register(
httpmock.GraphQL(query),
httpmock.StringResponse(fmt.Sprintf(
tt.response,
sixHoursAgo.Format(time.RFC3339),
)),
)
client := &http.Client{Transport: reg}
as, surveyteardown := prompt.InitAskStubber()
defer surveyteardown()
as.StubOne(tt.gistIndex)
t.Run(tt.name, func(t *testing.T) {
gistID, err := promptGists(client, cs)
assert.NoError(t, err)
assert.Equal(t, tt.wantOut, gistID)
reg.Verify(t)
})
}
}