Skip to content

Commit 20c7bdc

Browse files
authored
Merge pull request cli#11761 from luxass/gist-edit-large-file
fix(gist): add support for editing & viewing large files
2 parents a722c88 + 7db532a commit 20c7bdc

6 files changed

Lines changed: 418 additions & 15 deletions

File tree

pkg/cmd/gist/edit/edit.go

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -214,11 +214,12 @@ func editRun(opts *EditOptions) error {
214214

215215
// Remove a file from the gist
216216
if opts.RemoveFilename != "" {
217-
err := removeFile(gistToUpdate, opts.RemoveFilename)
217+
files, err := getFilesToRemove(gistToUpdate, opts.RemoveFilename)
218218
if err != nil {
219219
return err
220220
}
221221

222+
gistToUpdate.Files = files
222223
return updateGist(apiClient, host, gistToUpdate)
223224
}
224225

@@ -258,6 +259,20 @@ func editRun(opts *EditOptions) error {
258259
return fmt.Errorf("editing binary files not supported")
259260
}
260261

262+
// If the file is truncated, fetch the full content
263+
// but only if it hasn't already been edited in this session
264+
file := gist.Files[filename]
265+
if file.Truncated {
266+
if _, alreadyEdited := filesToUpdate[filename]; !alreadyEdited {
267+
fullContent, err := shared.GetRawGistFile(client, file.RawURL)
268+
if err != nil {
269+
return err
270+
}
271+
272+
gistFile.Content = fullContent
273+
}
274+
}
275+
261276
var text string
262277
if src := opts.SourceFile; src != "" {
263278
if src == "-" {
@@ -328,6 +343,12 @@ func editRun(opts *EditOptions) error {
328343
return nil
329344
}
330345

346+
updatedFiles := make(map[string]*gistFileToUpdate, len(filesToUpdate))
347+
for filename := range filesToUpdate {
348+
updatedFiles[filename] = gistToUpdate.Files[filename]
349+
}
350+
gistToUpdate.Files = updatedFiles
351+
331352
return updateGist(apiClient, host, gistToUpdate)
332353
}
333354

@@ -385,11 +406,13 @@ func getFilesToAdd(file string, content []byte) (map[string]*gistFileToUpdate, e
385406
}, nil
386407
}
387408

388-
func removeFile(gist gistToUpdate, filename string) error {
409+
func getFilesToRemove(gist gistToUpdate, filename string) (map[string]*gistFileToUpdate, error) {
389410
if _, found := gist.Files[filename]; !found {
390-
return fmt.Errorf("gist has no file %q", filename)
411+
return nil, fmt.Errorf("gist has no file %q", filename)
391412
}
392413

393414
gist.Files[filename] = nil
394-
return nil
415+
return map[string]*gistFileToUpdate{
416+
filename: nil,
417+
}, nil
395418
}

pkg/cmd/gist/edit/edit_test.go

Lines changed: 151 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -230,10 +230,33 @@ func Test_editRun(t *testing.T) {
230230
wantLastRequestParameters: map[string]interface{}{
231231
"description": "catbug",
232232
"files": map[string]interface{}{
233-
"cicada.txt": map[string]interface{}{
234-
"content": "bwhiizzzbwhuiiizzzz",
235-
"filename": "cicada.txt",
233+
"unix.md": map[string]interface{}{
234+
"content": "new file content",
235+
"filename": "unix.md",
236236
},
237+
},
238+
},
239+
},
240+
{
241+
name: "single file edit flag sends only edited file",
242+
opts: &EditOptions{
243+
Selector: "1234",
244+
EditFilename: "unix.md",
245+
},
246+
mockGist: &shared.Gist{
247+
ID: "1234",
248+
Files: map[string]*shared.GistFile{
249+
"cicada.txt": {Filename: "cicada.txt", Content: "bwhiizzzbwhuiiizzzz", Type: "text/plain"},
250+
"unix.md": {Filename: "unix.md", Content: "meow", Type: "text/markdown"},
251+
},
252+
Owner: &shared.GistOwner{Login: "octocat"},
253+
},
254+
httpStubs: func(reg *httpmock.Registry) {
255+
reg.Register(httpmock.REST("POST", "gists/1234"), httpmock.StatusStringResponse(201, "{}"))
256+
},
257+
wantLastRequestParameters: map[string]interface{}{
258+
"description": "",
259+
"files": map[string]interface{}{
237260
"unix.md": map[string]interface{}{
238261
"content": "new file content",
239262
"filename": "unix.md",
@@ -478,10 +501,6 @@ func Test_editRun(t *testing.T) {
478501
wantLastRequestParameters: map[string]interface{}{
479502
"description": "",
480503
"files": map[string]interface{}{
481-
"sample.txt": map[string]interface{}{
482-
"filename": "sample.txt",
483-
"content": "bwhiizzzbwhuiiizzzz",
484-
},
485504
"sample2.txt": nil,
486505
},
487506
},
@@ -581,6 +600,120 @@ func Test_editRun(t *testing.T) {
581600
},
582601
wantErr: "no file in the gist",
583602
},
603+
{
604+
name: "edit gist with truncated file",
605+
opts: &EditOptions{
606+
Selector: "1234",
607+
},
608+
mockGist: &shared.Gist{
609+
ID: "1234",
610+
Files: map[string]*shared.GistFile{
611+
"large.txt": {
612+
Filename: "large.txt",
613+
Content: "This is truncated content...",
614+
Type: "text/plain",
615+
Truncated: true,
616+
RawURL: "https://gist.githubusercontent.com/user/1234/raw/large.txt",
617+
},
618+
},
619+
Owner: &shared.GistOwner{Login: "octocat"},
620+
},
621+
httpStubs: func(reg *httpmock.Registry) {
622+
reg.Register(httpmock.REST("POST", "gists/1234"),
623+
httpmock.StatusStringResponse(201, "{}"))
624+
},
625+
wantLastRequestParameters: map[string]interface{}{
626+
"description": "",
627+
"files": map[string]interface{}{
628+
"large.txt": map[string]interface{}{
629+
"content": "new file content",
630+
"filename": "large.txt",
631+
},
632+
},
633+
},
634+
},
635+
{
636+
name: "edit specific truncated file in gist with multiple truncated files",
637+
opts: &EditOptions{
638+
Selector: "1234",
639+
EditFilename: "large.txt",
640+
},
641+
mockGist: &shared.Gist{
642+
ID: "1234",
643+
Files: map[string]*shared.GistFile{
644+
"large.txt": {
645+
Filename: "large.txt",
646+
Content: "This is truncated content...",
647+
Type: "text/plain",
648+
Truncated: true,
649+
RawURL: "https://gist.githubusercontent.com/user/1234/raw/large.txt",
650+
},
651+
"also-truncated.txt": {
652+
Filename: "also-truncated.txt",
653+
Content: "", // Empty because GitHub truncates subsequent files
654+
Type: "text/plain",
655+
Truncated: true, // Subsequent files are also marked as truncated
656+
RawURL: "https://gist.githubusercontent.com/user/1234/raw/also-truncated.txt",
657+
},
658+
},
659+
Owner: &shared.GistOwner{Login: "octocat"},
660+
},
661+
httpStubs: func(reg *httpmock.Registry) {
662+
reg.Register(httpmock.REST("POST", "gists/1234"),
663+
httpmock.StatusStringResponse(201, "{}"))
664+
},
665+
wantLastRequestParameters: map[string]interface{}{
666+
"description": "",
667+
"files": map[string]interface{}{
668+
"large.txt": map[string]interface{}{
669+
"content": "new file content",
670+
"filename": "large.txt",
671+
},
672+
},
673+
},
674+
},
675+
{
676+
name: "interactive truncated multi-file gist fetches only selected file raw content the first time",
677+
isTTY: true,
678+
opts: &EditOptions{Selector: "1234"},
679+
prompterStubs: func(pm *prompter.MockPrompter) {
680+
pm.RegisterSelect("Edit which file?", []string{"also-truncated.txt", "large.txt"}, func(_, _ string, opts []string) (int, error) {
681+
return prompter.IndexFor(opts, "large.txt")
682+
})
683+
pm.RegisterSelect("What next?", editNextOptions, func(_, _ string, opts []string) (int, error) {
684+
return prompter.IndexFor(opts, "Edit another file")
685+
})
686+
// Editing large.txt twice to ensure that fetch for the raw URL happens only once
687+
pm.RegisterSelect("Edit which file?", []string{"also-truncated.txt", "large.txt"}, func(_, _ string, opts []string) (int, error) {
688+
return prompter.IndexFor(opts, "large.txt")
689+
})
690+
pm.RegisterSelect("What next?", editNextOptions, func(_, _ string, opts []string) (int, error) {
691+
return prompter.IndexFor(opts, "Submit")
692+
})
693+
},
694+
mockGist: &shared.Gist{
695+
ID: "1234",
696+
Files: map[string]*shared.GistFile{
697+
"large.txt": {Filename: "large.txt", Content: "This is truncated content...", Type: "text/plain", Truncated: true, RawURL: "https://gist.githubusercontent.com/user/1234/raw/large.txt"},
698+
"also-truncated.txt": {Filename: "also-truncated.txt", Content: "stuff...", Type: "text/plain", Truncated: true, RawURL: "https://gist.githubusercontent.com/user/1234/raw/also-truncated.txt"},
699+
},
700+
Owner: &shared.GistOwner{Login: "octocat"},
701+
},
702+
httpStubs: func(reg *httpmock.Registry) {
703+
reg.Register(httpmock.REST("POST", "gists/1234"), httpmock.StatusStringResponse(201, "{}"))
704+
// Explicity exclude also-truncated.txt raw URL to ensure it is not fetched since we did not select it.
705+
reg.Exclude(t, httpmock.REST("GET", "user/1234/raw/also-truncated.txt"))
706+
},
707+
wantLastRequestParameters: map[string]interface{}{
708+
"description": "",
709+
"files": map[string]interface{}{
710+
"large.txt": map[string]interface{}{
711+
"content": "new file content",
712+
"filename": "large.txt",
713+
},
714+
},
715+
},
716+
},
584717
}
585718

586719
for _, tt := range tests {
@@ -603,6 +736,17 @@ func Test_editRun(t *testing.T) {
603736
httpmock.JSONResponse(tt.mockGist))
604737
reg.Register(httpmock.GraphQL(`query UserCurrent\b`),
605738
httpmock.StringResponse(`{"data":{"viewer":{"login":"octocat"}}}`))
739+
740+
// Register raw URL mocks for truncated files
741+
for filename, file := range tt.mockGist.Files {
742+
if file.Truncated && file.RawURL != "" {
743+
// Mock the raw URL response for GetRawGistFile calls
744+
if filename == "large.txt" {
745+
reg.Register(httpmock.REST("GET", "user/1234/raw/large.txt"),
746+
httpmock.StringResponse("This is the full content of the large file retrieved from raw URL"))
747+
}
748+
}
749+
}
606750
}
607751
}
608752

pkg/cmd/gist/shared/shared.go

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package shared
33
import (
44
"errors"
55
"fmt"
6+
"io"
67
"net/http"
78
"net/url"
89
"regexp"
@@ -19,10 +20,12 @@ import (
1920
)
2021

2122
type GistFile struct {
22-
Filename string `json:"filename,omitempty"`
23-
Type string `json:"type,omitempty"`
24-
Language string `json:"language,omitempty"`
25-
Content string `json:"content"`
23+
Filename string `json:"filename,omitempty"`
24+
Type string `json:"type,omitempty"`
25+
Language string `json:"language,omitempty"`
26+
Content string `json:"content"`
27+
RawURL string `json:"raw_url,omitempty"`
28+
Truncated bool `json:"truncated,omitempty"`
2629
}
2730

2831
type GistOwner struct {
@@ -244,3 +247,29 @@ func PromptGists(prompter prompter.Prompter, client *http.Client, host string, c
244247

245248
return &gists[result], nil
246249
}
250+
251+
func GetRawGistFile(httpClient *http.Client, rawURL string) (string, error) {
252+
req, err := http.NewRequest("GET", rawURL, nil)
253+
if err != nil {
254+
return "", err
255+
}
256+
257+
resp, err := httpClient.Do(req)
258+
if err != nil {
259+
return "", err
260+
}
261+
262+
defer resp.Body.Close()
263+
264+
if resp.StatusCode != http.StatusOK {
265+
return "", api.HandleHTTPError(resp)
266+
}
267+
268+
body, err := io.ReadAll(resp.Body)
269+
270+
if err != nil {
271+
return "", err
272+
}
273+
274+
return string(body), nil
275+
}

0 commit comments

Comments
 (0)