Merge pull request #11761 from luxass/gist-edit-large-file

fix(gist): add support for editing & viewing large files
This commit is contained in:
Kynan Ware 2025-10-30 20:35:23 -06:00 committed by GitHub
commit 20c7bdc2a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 418 additions and 15 deletions

View file

@ -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
}

View file

@ -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"))
}
}
}
}
}

View file

@ -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
}

View file

@ -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: "<!DOCTYPE html><html><head><title>Test</title></head><body><h1>Hello</h1></body></html>",
statusCode: http.StatusOK,
want: "<!DOCTYPE html><html><head><title>Test</title></head><body><h1>Hello</h1></body></html>",
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)
})
}
}

View file

@ -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")

View file

@ -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 {