Merge pull request #3008 from ganboonhong/interactive-gist-view
Add interactive select in gist view
This commit is contained in:
commit
953855c1c3
5 changed files with 309 additions and 104 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue