diff --git a/pkg/cmd/gist/edit/edit.go b/pkg/cmd/gist/edit/edit.go index 9e74adf62..1773a8f83 100644 --- a/pkg/cmd/gist/edit/edit.go +++ b/pkg/cmd/gist/edit/edit.go @@ -214,11 +214,12 @@ func editRun(opts *EditOptions) error { // Remove a file from the gist if opts.RemoveFilename != "" { - err := removeFile(gistToUpdate, opts.RemoveFilename) + files, err := getFilesToRemove(gistToUpdate, opts.RemoveFilename) if err != nil { return err } + gistToUpdate.Files = files return updateGist(apiClient, host, gistToUpdate) } @@ -258,6 +259,20 @@ func editRun(opts *EditOptions) error { return fmt.Errorf("editing binary files not supported") } + // If the file is truncated, fetch the full content + // but only if it hasn't already been edited in this session + file := gist.Files[filename] + if file.Truncated { + if _, alreadyEdited := filesToUpdate[filename]; !alreadyEdited { + fullContent, err := shared.GetRawGistFile(client, file.RawURL) + if err != nil { + return err + } + + gistFile.Content = fullContent + } + } + var text string if src := opts.SourceFile; src != "" { if src == "-" { @@ -328,6 +343,12 @@ func editRun(opts *EditOptions) error { return nil } + updatedFiles := make(map[string]*gistFileToUpdate, len(filesToUpdate)) + for filename := range filesToUpdate { + updatedFiles[filename] = gistToUpdate.Files[filename] + } + gistToUpdate.Files = updatedFiles + return updateGist(apiClient, host, gistToUpdate) } @@ -385,11 +406,13 @@ func getFilesToAdd(file string, content []byte) (map[string]*gistFileToUpdate, e }, nil } -func removeFile(gist gistToUpdate, filename string) error { +func getFilesToRemove(gist gistToUpdate, filename string) (map[string]*gistFileToUpdate, error) { if _, found := gist.Files[filename]; !found { - return fmt.Errorf("gist has no file %q", filename) + return nil, fmt.Errorf("gist has no file %q", filename) } gist.Files[filename] = nil - return nil + return map[string]*gistFileToUpdate{ + filename: nil, + }, nil } diff --git a/pkg/cmd/gist/edit/edit_test.go b/pkg/cmd/gist/edit/edit_test.go index 12cdf8169..ac1555f5c 100644 --- a/pkg/cmd/gist/edit/edit_test.go +++ b/pkg/cmd/gist/edit/edit_test.go @@ -230,10 +230,33 @@ func Test_editRun(t *testing.T) { wantLastRequestParameters: map[string]interface{}{ "description": "catbug", "files": map[string]interface{}{ - "cicada.txt": map[string]interface{}{ - "content": "bwhiizzzbwhuiiizzzz", - "filename": "cicada.txt", + "unix.md": map[string]interface{}{ + "content": "new file content", + "filename": "unix.md", }, + }, + }, + }, + { + name: "single file edit flag sends only edited file", + opts: &EditOptions{ + Selector: "1234", + EditFilename: "unix.md", + }, + mockGist: &shared.Gist{ + ID: "1234", + Files: map[string]*shared.GistFile{ + "cicada.txt": {Filename: "cicada.txt", Content: "bwhiizzzbwhuiiizzzz", Type: "text/plain"}, + "unix.md": {Filename: "unix.md", Content: "meow", Type: "text/markdown"}, + }, + Owner: &shared.GistOwner{Login: "octocat"}, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("POST", "gists/1234"), httpmock.StatusStringResponse(201, "{}")) + }, + wantLastRequestParameters: map[string]interface{}{ + "description": "", + "files": map[string]interface{}{ "unix.md": map[string]interface{}{ "content": "new file content", "filename": "unix.md", @@ -478,10 +501,6 @@ func Test_editRun(t *testing.T) { wantLastRequestParameters: map[string]interface{}{ "description": "", "files": map[string]interface{}{ - "sample.txt": map[string]interface{}{ - "filename": "sample.txt", - "content": "bwhiizzzbwhuiiizzzz", - }, "sample2.txt": nil, }, }, @@ -581,6 +600,120 @@ func Test_editRun(t *testing.T) { }, wantErr: "no file in the gist", }, + { + name: "edit gist with truncated file", + opts: &EditOptions{ + Selector: "1234", + }, + mockGist: &shared.Gist{ + ID: "1234", + Files: map[string]*shared.GistFile{ + "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", + }, + }, + Owner: &shared.GistOwner{Login: "octocat"}, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("POST", "gists/1234"), + httpmock.StatusStringResponse(201, "{}")) + }, + wantLastRequestParameters: map[string]interface{}{ + "description": "", + "files": map[string]interface{}{ + "large.txt": map[string]interface{}{ + "content": "new file content", + "filename": "large.txt", + }, + }, + }, + }, + { + name: "edit specific truncated file in gist with multiple truncated files", + opts: &EditOptions{ + Selector: "1234", + EditFilename: "large.txt", + }, + mockGist: &shared.Gist{ + ID: "1234", + Files: map[string]*shared.GistFile{ + "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", + }, + "also-truncated.txt": { + Filename: "also-truncated.txt", + Content: "", // Empty because GitHub truncates subsequent files + Type: "text/plain", + Truncated: true, // Subsequent files are also marked as truncated + RawURL: "https://gist.githubusercontent.com/user/1234/raw/also-truncated.txt", + }, + }, + Owner: &shared.GistOwner{Login: "octocat"}, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("POST", "gists/1234"), + httpmock.StatusStringResponse(201, "{}")) + }, + wantLastRequestParameters: map[string]interface{}{ + "description": "", + "files": map[string]interface{}{ + "large.txt": map[string]interface{}{ + "content": "new file content", + "filename": "large.txt", + }, + }, + }, + }, + { + name: "interactive truncated multi-file gist fetches only selected file raw content the first time", + isTTY: true, + opts: &EditOptions{Selector: "1234"}, + prompterStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Edit which file?", []string{"also-truncated.txt", "large.txt"}, func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "large.txt") + }) + pm.RegisterSelect("What next?", editNextOptions, func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "Edit another file") + }) + // Editing large.txt twice to ensure that fetch for the raw URL happens only once + pm.RegisterSelect("Edit which file?", []string{"also-truncated.txt", "large.txt"}, func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "large.txt") + }) + pm.RegisterSelect("What next?", editNextOptions, func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "Submit") + }) + }, + mockGist: &shared.Gist{ + ID: "1234", + Files: map[string]*shared.GistFile{ + "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"}, + "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"}, + }, + Owner: &shared.GistOwner{Login: "octocat"}, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("POST", "gists/1234"), httpmock.StatusStringResponse(201, "{}")) + // Explicity exclude also-truncated.txt raw URL to ensure it is not fetched since we did not select it. + reg.Exclude(t, httpmock.REST("GET", "user/1234/raw/also-truncated.txt")) + }, + wantLastRequestParameters: map[string]interface{}{ + "description": "", + "files": map[string]interface{}{ + "large.txt": map[string]interface{}{ + "content": "new file content", + "filename": "large.txt", + }, + }, + }, + }, } for _, tt := range tests { @@ -603,6 +736,17 @@ func Test_editRun(t *testing.T) { httpmock.JSONResponse(tt.mockGist)) reg.Register(httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data":{"viewer":{"login":"octocat"}}}`)) + + // Register raw URL mocks for truncated files + for filename, file := range tt.mockGist.Files { + if file.Truncated && file.RawURL != "" { + // Mock the raw URL response for GetRawGistFile calls + if filename == "large.txt" { + reg.Register(httpmock.REST("GET", "user/1234/raw/large.txt"), + httpmock.StringResponse("This is the full content of the large file retrieved from raw URL")) + } + } + } } } diff --git a/pkg/cmd/gist/shared/shared.go b/pkg/cmd/gist/shared/shared.go index 99a5524ee..305e2f642 100644 --- a/pkg/cmd/gist/shared/shared.go +++ b/pkg/cmd/gist/shared/shared.go @@ -3,6 +3,7 @@ package shared import ( "errors" "fmt" + "io" "net/http" "net/url" "regexp" @@ -19,10 +20,12 @@ import ( ) type GistFile struct { - Filename string `json:"filename,omitempty"` - Type string `json:"type,omitempty"` - Language string `json:"language,omitempty"` - Content string `json:"content"` + Filename string `json:"filename,omitempty"` + Type string `json:"type,omitempty"` + Language string `json:"language,omitempty"` + Content string `json:"content"` + RawURL string `json:"raw_url,omitempty"` + Truncated bool `json:"truncated,omitempty"` } type GistOwner struct { @@ -244,3 +247,29 @@ func PromptGists(prompter prompter.Prompter, client *http.Client, host string, c return &gists[result], nil } + +func GetRawGistFile(httpClient *http.Client, rawURL string) (string, error) { + req, err := http.NewRequest("GET", rawURL, nil) + if err != nil { + return "", err + } + + resp, err := httpClient.Do(req) + if err != nil { + return "", err + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", api.HandleHTTPError(resp) + } + + body, err := io.ReadAll(resp.Body) + + if err != nil { + return "", err + } + + return string(body), nil +} diff --git a/pkg/cmd/gist/shared/shared_test.go b/pkg/cmd/gist/shared/shared_test.go index 0bc1e1f11..d75ebc2b7 100644 --- a/pkg/cmd/gist/shared/shared_test.go +++ b/pkg/cmd/gist/shared/shared_test.go @@ -219,3 +219,98 @@ func TestPromptGists(t *testing.T) { }) } } + +func TestGetRawGistFile(t *testing.T) { + tests := []struct { + name string + response string + statusCode int + want string + wantErr bool + errContains string + }{ + { + name: "successful request", + response: "Hello, World!", + statusCode: http.StatusOK, + want: "Hello, World!", + wantErr: false, + }, + { + name: "empty response", + response: "", + statusCode: http.StatusOK, + want: "", + wantErr: false, + }, + { + name: "not found error", + response: "Not Found", + statusCode: http.StatusNotFound, + want: "", + wantErr: true, + errContains: "HTTP 404", + }, + { + name: "server error", + response: "Internal Server Error", + statusCode: http.StatusInternalServerError, + want: "", + wantErr: true, + errContains: "HTTP 500", + }, + { + name: "large content", + response: "This is a very large file content with multiple lines\nLine 2\nLine 3\nAnd more content...", + statusCode: http.StatusOK, + want: "This is a very large file content with multiple lines\nLine 2\nLine 3\nAnd more content...", + wantErr: false, + }, + { + name: "special characters", + response: "Special chars: àáâãäåæçèéêë 中文 🎉 \"quotes\" 'single'", + statusCode: http.StatusOK, + want: "Special chars: àáâãäåæçèéêë 中文 🎉 \"quotes\" 'single'", + wantErr: false, + }, + { + name: "JSON content", + response: `{"name": "test", "version": "1.0.0", "dependencies": {"lodash": "^4.17.21"}}`, + statusCode: http.StatusOK, + want: `{"name": "test", "version": "1.0.0", "dependencies": {"lodash": "^4.17.21"}}`, + wantErr: false, + }, + { + name: "HTML content", + response: "Test

Hello

", + statusCode: http.StatusOK, + want: "Test

Hello

", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("GET", "raw-url"), + httpmock.StatusStringResponse(tt.statusCode, tt.response), + ) + + client := &http.Client{Transport: reg} + result, err := GetRawGistFile(client, "https://gist.githubusercontent.com/raw-url") + + if tt.wantErr { + assert.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + } else { + assert.NoError(t, err) + assert.Equal(t, tt.want, result) + } + + reg.Verify(t) + }) + } +} diff --git a/pkg/cmd/gist/view/view.go b/pkg/cmd/gist/view/view.go index f789c5b04..9eb906cde 100644 --- a/pkg/cmd/gist/view/view.go +++ b/pkg/cmd/gist/view/view.go @@ -136,6 +136,16 @@ func viewRun(opts *ViewOptions) error { defer opts.IO.StopPager() render := func(gf *shared.GistFile) error { + if gf.Truncated { + fullContent, err := shared.GetRawGistFile(client, gf.RawURL) + + if err != nil { + return err + } + + gf.Content = fullContent + } + if shared.IsBinaryContents([]byte(gf.Content)) { if len(gist.Files) == 1 || opts.Filename != "" { return fmt.Errorf("error: file is binary") diff --git a/pkg/cmd/gist/view/view_test.go b/pkg/cmd/gist/view/view_test.go index 706b85f10..85c2b7ad8 100644 --- a/pkg/cmd/gist/view/view_test.go +++ b/pkg/cmd/gist/view/view_test.go @@ -344,6 +344,96 @@ func Test_viewRun(t *testing.T) { }, wantOut: "cicada.txt\nfoo.md\n", }, + { + name: "truncated file with raw and filename", + isTTY: true, + opts: &ViewOptions{ + Selector: "1234", + Raw: true, + Filename: "large.txt", + }, + mockGist: &shared.Gist{ + Files: map[string]*shared.GistFile{ + "large.txt": { + Content: "This is truncated content...", + Type: "text/plain", + Truncated: true, + RawURL: "https://gist.githubusercontent.com/user/1234/raw/large.txt", + }, + }, + }, + wantOut: "This is the full content of the large file retrieved from raw URL\n", + }, + { + name: "truncated file without raw flag", + isTTY: true, + opts: &ViewOptions{ + Selector: "1234", + Raw: false, + Filename: "large.txt", + }, + mockGist: &shared.Gist{ + Files: map[string]*shared.GistFile{ + "large.txt": { + Content: "This is truncated content...", + Type: "text/plain", + Truncated: true, + RawURL: "https://gist.githubusercontent.com/user/1234/raw/large.txt", + }, + }, + }, + wantOut: "This is the full content of the large file retrieved from raw URL\n", + }, + { + name: "multiple files with one truncated", + isTTY: true, + opts: &ViewOptions{ + Selector: "1234", + Raw: true, + }, + mockGist: &shared.Gist{ + Description: "Mixed files", + Files: map[string]*shared.GistFile{ + "normal.txt": { + Content: "normal content", + Type: "text/plain", + }, + "large.txt": { + Content: "This is truncated content...", + Type: "text/plain", + Truncated: true, + RawURL: "https://gist.githubusercontent.com/user/1234/raw/large.txt", + }, + }, + }, + wantOut: "Mixed files\n\nlarge.txt\n\nThis is the full content of the large file retrieved from raw URL\n\nnormal.txt\n\nnormal content\n", + }, + { + name: "multiple files with subsequent files truncated as empty", + isTTY: true, + opts: &ViewOptions{ + Selector: "1234", + Raw: true, + }, + mockGist: &shared.Gist{ + Description: "Large gist with multiple files", + Files: map[string]*shared.GistFile{ + "large.txt": { + Content: "This is truncated content...", + Type: "text/plain", + Truncated: true, + RawURL: "https://gist.githubusercontent.com/user/1234/raw/large.txt", + }, + "also-truncated.txt": { + Type: "text/plain", + Content: "", // Empty because GitHub truncates subsequent files + Truncated: true, // Subsequent files are also marked as truncated + RawURL: "https://gist.githubusercontent.com/user/1234/raw/also-truncated.txt", + }, + }, + }, + wantOut: "Large gist with multiple files\n\nalso-truncated.txt\n\nThis is the full content of the also-truncated file retrieved from raw URL\n\nlarge.txt\n\nThis is the full content of the large file retrieved from raw URL\n", + }, } for _, tt := range tests { @@ -354,6 +444,18 @@ func Test_viewRun(t *testing.T) { } else { reg.Register(httpmock.REST("GET", "gists/1234"), httpmock.JSONResponse(tt.mockGist)) + + for filename, file := range tt.mockGist.Files { + if file.Truncated && file.RawURL != "" { + if filename == "large.txt" { + reg.Register(httpmock.REST("GET", "user/1234/raw/large.txt"), + httpmock.StringResponse("This is the full content of the large file retrieved from raw URL")) + } else if filename == "also-truncated.txt" { + reg.Register(httpmock.REST("GET", "user/1234/raw/also-truncated.txt"), + httpmock.StringResponse("This is the full content of the also-truncated file retrieved from raw URL")) + } + } + } } if tt.opts == nil {