diff --git a/models/fixtures/branch.yml b/models/fixtures/branch.yml index 6536e1dda7b84..03e21d04b45e4 100644 --- a/models/fixtures/branch.yml +++ b/models/fixtures/branch.yml @@ -201,3 +201,15 @@ is_deleted: false deleted_by_id: 0 deleted_unix: 0 + +- + id: 25 + repo_id: 54 + name: 'master' + commit_id: '73cf03db6ece34e12bf91e8853dc58f678f2f82d' + commit_message: 'Initial commit' + commit_time: 1671663402 + pusher_id: 2 + is_deleted: false + deleted_by_id: 0 + deleted_unix: 0 diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index f7c2e5049bbc2..3390bb7ab2f67 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1332,7 +1332,9 @@ editor.upload_file = Upload File editor.edit_file = Edit File editor.preview_changes = Preview Changes editor.cannot_edit_lfs_files = LFS files cannot be edited in the web interface. +editor.cannot_edit_too_large_file = The file is too large to be edited. editor.cannot_edit_non_text_files = Binary files cannot be edited in the web interface. +editor.file_not_editable_hint = But you can still rename or move it. editor.edit_this_file = Edit File editor.this_file_locked = File is locked editor.must_be_on_a_branch = You must be on a branch to make or propose changes to this file. diff --git a/routers/web/repo/editor.go b/routers/web/repo/editor.go index cbcb3a3b21d87..62bf8b182fad5 100644 --- a/routers/web/repo/editor.go +++ b/routers/web/repo/editor.go @@ -145,10 +145,6 @@ func editFile(ctx *context.Context, isNewFile bool) { } blob := entry.Blob() - if blob.Size() >= setting.UI.MaxDisplayFileSize { - ctx.NotFound(err) - return - } buf, dataRc, fInfo, err := getFileReader(ctx, ctx.Repo.Repository.ID, blob) if err != nil { @@ -162,22 +158,37 @@ func editFile(ctx *context.Context, isNewFile bool) { defer dataRc.Close() - ctx.Data["FileSize"] = blob.Size() - - // Only some file types are editable online as text. - if !fInfo.st.IsRepresentableAsText() || fInfo.isLFSFile { - ctx.NotFound(nil) - return + if fInfo.isLFSFile { + lfsLock, err := git_model.GetTreePathLock(ctx, ctx.Repo.Repository.ID, ctx.Repo.TreePath) + if err != nil { + ctx.ServerError("GetTreePathLock", err) + return + } + if lfsLock != nil && lfsLock.OwnerID != ctx.Doer.ID { + ctx.NotFound(nil) + return + } } - d, _ := io.ReadAll(dataRc) + ctx.Data["FileSize"] = fInfo.fileSize - buf = append(buf, d...) - if content, err := charset.ToUTF8(buf, charset.ConvertOpts{KeepBOM: true}); err != nil { - log.Error("ToUTF8: %v", err) - ctx.Data["FileContent"] = string(buf) + // Only some file types are editable online as text. + if fInfo.isLFSFile { + ctx.Data["NotEditableReason"] = ctx.Tr("repo.editor.cannot_edit_lfs_files") + } else if !fInfo.st.IsRepresentableAsText() { + ctx.Data["NotEditableReason"] = ctx.Tr("repo.editor.cannot_edit_non_text_files") + } else if fInfo.fileSize >= setting.UI.MaxDisplayFileSize { + ctx.Data["NotEditableReason"] = ctx.Tr("repo.editor.cannot_edit_too_large_file") } else { - ctx.Data["FileContent"] = content + d, _ := io.ReadAll(dataRc) + + buf = append(buf, d...) + if content, err := charset.ToUTF8(buf, charset.ConvertOpts{KeepBOM: true}); err != nil { + log.Error("ToUTF8: %v", err) + ctx.Data["FileContent"] = string(buf) + } else { + ctx.Data["FileContent"] = content + } } } else { // Append filename from query, or empty string to allow username the new file. @@ -280,6 +291,10 @@ func editFilePost(ctx *context.Context, form forms.EditRepoFileForm, isNewFile b operation := "update" if isNewFile { operation = "create" + } else if !form.Content.Has() && ctx.Repo.TreePath != form.TreePath { + // The form content only has data if file is representable as text, is not too large and not in lfs. If it doesn't + // have data, the only possible operation is a rename + operation = "rename" } if _, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{ @@ -292,7 +307,7 @@ func editFilePost(ctx *context.Context, form forms.EditRepoFileForm, isNewFile b Operation: operation, FromTreePath: ctx.Repo.TreePath, TreePath: form.TreePath, - ContentReader: strings.NewReader(strings.ReplaceAll(form.Content, "\r", "")), + ContentReader: strings.NewReader(strings.ReplaceAll(form.Content.Value(), "\r", "")), }, }, Signoff: form.Signoff, diff --git a/routers/web/repo/patch.go b/routers/web/repo/patch.go index ca346b7e6c313..3ffd8f89c4e20 100644 --- a/routers/web/repo/patch.go +++ b/routers/web/repo/patch.go @@ -99,7 +99,7 @@ func NewDiffPatchPost(ctx *context.Context) { OldBranch: ctx.Repo.BranchName, NewBranch: branchName, Message: message, - Content: strings.ReplaceAll(form.Content, "\r", ""), + Content: strings.ReplaceAll(form.Content.Value(), "\r", ""), Author: gitCommitter, Committer: gitCommitter, }) diff --git a/routers/web/repo/view_file.go b/routers/web/repo/view_file.go index 3df6051975bc4..a142b7c11190c 100644 --- a/routers/web/repo/view_file.go +++ b/routers/web/repo/view_file.go @@ -140,13 +140,6 @@ func prepareToRenderFile(ctx *context.Context, entry *git.TreeEntry) { ctx.Data["LFSLockHint"] = ctx.Tr("repo.editor.this_file_locked") } - // Assume file is not editable first. - if fInfo.isLFSFile { - ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.cannot_edit_lfs_files") - } else if !isRepresentableAsText { - ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.cannot_edit_non_text_files") - } - // read all needed attributes which will be used later // there should be no performance different between reading 2 or 4 here attrsMap, err := attribute.CheckAttributes(ctx, ctx.Repo.GitRepo, ctx.Repo.CommitID, attribute.CheckAttributeOpts{ @@ -243,21 +236,6 @@ func prepareToRenderFile(ctx *context.Context, entry *git.TreeEntry) { ctx.Data["FileContent"] = fileContent ctx.Data["LineEscapeStatus"] = statuses } - if !fInfo.isLFSFile { - if ctx.Repo.CanEnableEditor(ctx, ctx.Doer) { - if lfsLock != nil && lfsLock.OwnerID != ctx.Doer.ID { - ctx.Data["CanEditFile"] = false - ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.this_file_locked") - } else { - ctx.Data["CanEditFile"] = true - ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.edit_this_file") - } - } else if !ctx.Repo.RefFullName.IsBranch() { - ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.must_be_on_a_branch") - } else if !ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName) { - ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.fork_before_edit") - } - } case fInfo.st.IsPDF(): ctx.Data["IsPDFFile"] = true @@ -309,15 +287,21 @@ func prepareToRenderFile(ctx *context.Context, entry *git.TreeEntry) { if ctx.Repo.CanEnableEditor(ctx, ctx.Doer) { if lfsLock != nil && lfsLock.OwnerID != ctx.Doer.ID { + ctx.Data["CanEditFile"] = false + ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.this_file_locked") ctx.Data["CanDeleteFile"] = false ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.this_file_locked") } else { + ctx.Data["CanEditFile"] = true + ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.edit_this_file") ctx.Data["CanDeleteFile"] = true ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.delete_this_file") } } else if !ctx.Repo.RefFullName.IsBranch() { + ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.must_be_on_a_branch") ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_be_on_a_branch") } else if !ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName) { + ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.fork_before_edit") ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_have_write_access") } } diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index a2827e516a5d8..d11ad0a54c9ec 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -10,6 +10,7 @@ import ( issues_model "code.gitea.io/gitea/models/issues" project_model "code.gitea.io/gitea/models/project" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web/middleware" "code.gitea.io/gitea/services/context" @@ -689,7 +690,7 @@ func (f *NewWikiForm) Validate(req *http.Request, errs binding.Errors) binding.E // EditRepoFileForm form for changing repository file type EditRepoFileForm struct { TreePath string `binding:"Required;MaxSize(500)"` - Content string + Content optional.Option[string] CommitSummary string `binding:"MaxSize(100)"` CommitMessage string CommitChoice string `binding:"Required;MaxSize(50)"` diff --git a/services/repository/files/update.go b/services/repository/files/update.go index 712914a27eb78..e1acf6a92f74f 100644 --- a/services/repository/files/update.go +++ b/services/repository/files/update.go @@ -246,7 +246,7 @@ func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use contentStore := lfs.NewContentStore() for _, file := range opts.Files { switch file.Operation { - case "create", "update": + case "create", "update", "rename": if err := CreateOrUpdateFile(ctx, t, file, contentStore, repo.ID, hasOldBranch); err != nil { return nil, err } @@ -488,31 +488,32 @@ func CreateOrUpdateFile(ctx context.Context, t *TemporaryUploadRepository, file } } - treeObjectContentReader := file.ContentReader - var lfsMetaObject *git_model.LFSMetaObject - if setting.LFS.StartServer && hasOldBranch { - // Check there is no way this can return multiple infos - attributesMap, err := attribute.CheckAttributes(ctx, t.gitRepo, "" /* use temp repo's working dir */, attribute.CheckAttributeOpts{ - Attributes: []string{attribute.Filter}, - Filenames: []string{file.Options.treePath}, - }) + var oldEntry *git.TreeEntry + // Assume that the file.ContentReader of a pure rename operation is invalid. Use the file content how it's present in + // git instead + if file.Operation == "rename" { + lastCommitID, err := t.GetLastCommit(ctx) + if err != nil { + return err + } + commit, err := t.GetCommit(lastCommitID) if err != nil { return err } - if attributesMap[file.Options.treePath] != nil && attributesMap[file.Options.treePath].Get(attribute.Filter).ToString().Value() == "lfs" { - // OK so we are supposed to LFS this data! - pointer, err := lfs.GeneratePointer(treeObjectContentReader) - if err != nil { - return err - } - lfsMetaObject = &git_model.LFSMetaObject{Pointer: pointer, RepositoryID: repoID} - treeObjectContentReader = strings.NewReader(pointer.StringContent()) + if oldEntry, err = commit.GetTreeEntryByPath(file.Options.fromTreePath); err != nil { + return err } } - // Add the object to the database - objectHash, err := t.HashObject(ctx, treeObjectContentReader) + var objectHash string + var lfsPointer *lfs.Pointer + switch file.Operation { + case "create", "update": + objectHash, lfsPointer, err = createOrUpdateFileHash(ctx, t, file, hasOldBranch) + case "rename": + objectHash, lfsPointer, err = renameFileHash(ctx, t, oldEntry, file) + } if err != nil { return err } @@ -528,9 +529,9 @@ func CreateOrUpdateFile(ctx context.Context, t *TemporaryUploadRepository, file } } - if lfsMetaObject != nil { + if lfsPointer != nil { // We have an LFS object - create it - lfsMetaObject, err = git_model.NewLFSMetaObject(ctx, lfsMetaObject.RepositoryID, lfsMetaObject.Pointer) + lfsMetaObject, err := git_model.NewLFSMetaObject(ctx, repoID, *lfsPointer) if err != nil { return err } @@ -539,11 +540,20 @@ func CreateOrUpdateFile(ctx context.Context, t *TemporaryUploadRepository, file return err } if !exist { - _, err := file.ContentReader.Seek(0, io.SeekStart) - if err != nil { - return err + var lfsContentReader io.Reader + if file.Operation != "rename" { + if _, err := file.ContentReader.Seek(0, io.SeekStart); err != nil { + return err + } + lfsContentReader = file.ContentReader + } else { + if lfsContentReader, err = oldEntry.Blob().DataAsync(); err != nil { + return err + } + defer lfsContentReader.(io.ReadCloser).Close() } - if err := contentStore.Put(lfsMetaObject.Pointer, file.ContentReader); err != nil { + + if err := contentStore.Put(lfsMetaObject.Pointer, lfsContentReader); err != nil { if _, err2 := git_model.RemoveLFSMetaObjectByOid(ctx, repoID, lfsMetaObject.Oid); err2 != nil { return fmt.Errorf("unable to remove failed inserted LFS object %s: %v (Prev Error: %w)", lfsMetaObject.Oid, err2, err) } @@ -555,6 +565,99 @@ func CreateOrUpdateFile(ctx context.Context, t *TemporaryUploadRepository, file return nil } +func createOrUpdateFileHash(ctx context.Context, t *TemporaryUploadRepository, file *ChangeRepoFile, hasOldBranch bool) (string, *lfs.Pointer, error) { + treeObjectContentReader := file.ContentReader + var lfsPointer *lfs.Pointer + if setting.LFS.StartServer && hasOldBranch { + // Check there is no way this can return multiple infos + attributesMap, err := attribute.CheckAttributes(ctx, t.gitRepo, "" /* use temp repo's working dir */, attribute.CheckAttributeOpts{ + Attributes: []string{attribute.Filter}, + Filenames: []string{file.Options.treePath}, + }) + if err != nil { + return "", nil, err + } + + if attributesMap[file.Options.treePath] != nil && attributesMap[file.Options.treePath].Get(attribute.Filter).ToString().Value() == "lfs" { + // OK so we are supposed to LFS this data! + pointer, err := lfs.GeneratePointer(treeObjectContentReader) + if err != nil { + return "", nil, err + } + lfsPointer = &pointer + treeObjectContentReader = strings.NewReader(pointer.StringContent()) + } + } + + // Add the object to the database + objectHash, err := t.HashObject(ctx, treeObjectContentReader) + if err != nil { + return "", nil, err + } + + return objectHash, lfsPointer, nil +} + +func renameFileHash(ctx context.Context, t *TemporaryUploadRepository, oldEntry *git.TreeEntry, file *ChangeRepoFile) (string, *lfs.Pointer, error) { + if setting.LFS.StartServer { + attributesMap, err := attribute.CheckAttributes(ctx, t.gitRepo, "" /* use temp repo's working dir */, attribute.CheckAttributeOpts{ + Attributes: []string{attribute.Filter}, + Filenames: []string{file.Options.treePath, file.Options.fromTreePath}, + }) + if err != nil { + return "", nil, err + } + + oldIsLfs := attributesMap[file.Options.fromTreePath] != nil && attributesMap[file.Options.fromTreePath].Get(attribute.Filter).ToString().Value() == "lfs" + newIsLfs := attributesMap[file.Options.treePath] != nil && attributesMap[file.Options.treePath].Get(attribute.Filter).ToString().Value() == "lfs" + + // If the old and new paths are both in lfs or both not in lfs, the object hash of the old file can be used directly + // as the object doesn't change + if oldIsLfs == newIsLfs { + return oldEntry.ID.String(), nil, nil + } + + oldEntryReader, err := oldEntry.Blob().DataAsync() + if err != nil { + return "", nil, err + } + defer oldEntryReader.Close() + + var treeObjectContentReader io.Reader + var lfsPointer *lfs.Pointer + // If the old path is in lfs but the new isn't, read the content from lfs and add it as normal git object + // If the new path is in lfs but the old isn't, read the content from the git object and generate a lfs + // pointer of it + if oldIsLfs { + pointer, err := lfs.ReadPointer(oldEntryReader) + if err != nil { + return "", nil, err + } + treeObjectContentReader, err = lfs.ReadMetaObject(pointer) + if err != nil { + return "", nil, err + } + defer treeObjectContentReader.(io.ReadCloser).Close() + } else { + pointer, err := lfs.GeneratePointer(oldEntryReader) + if err != nil { + return "", nil, err + } + treeObjectContentReader = strings.NewReader(pointer.StringContent()) + lfsPointer = &pointer + } + + // Add the object to the database + objectID, err := t.HashObject(ctx, treeObjectContentReader) + if err != nil { + return "", nil, err + } + return objectID, lfsPointer, nil + } + + return oldEntry.ID.String(), nil, nil +} + // VerifyBranchProtection verify the branch protection for modifying the given treePath on the given branch func VerifyBranchProtection(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, branchName string, treePaths []string) error { protectedBranch, err := git_model.GetFirstMatchProtectedBranchRule(ctx, repo.ID, branchName) diff --git a/templates/repo/diff/box.tmpl b/templates/repo/diff/box.tmpl index fa96d2f0e217c..22abf9a2193cc 100644 --- a/templates/repo/diff/box.tmpl +++ b/templates/repo/diff/box.tmpl @@ -148,7 +148,7 @@ <a class="item" rel="nofollow" href="{{$.BeforeSourcePath}}/{{PathEscapeSegments .Name}}">{{ctx.Locale.Tr "repo.diff.view_file"}}</a> {{else}} <a class="item" rel="nofollow" href="{{$.SourcePath}}/{{PathEscapeSegments .Name}}">{{ctx.Locale.Tr "repo.diff.view_file"}}</a> - {{if and $.Repository.CanEnableEditor $.CanEditFile (not $file.IsLFSFile) (not $file.IsBin)}} + {{if and $.Repository.CanEnableEditor $.CanEditFile}} <a class="item" rel="nofollow" href="{{$.HeadRepoLink}}/_edit/{{PathEscapeSegments $.HeadBranchName}}/{{PathEscapeSegments $file.Name}}?return_uri={{print $.BackToLink "#diff-" $file.NameHash | QueryEscape}}">{{ctx.Locale.Tr "repo.editor.edit_this_file"}}</a> {{end}} {{end}} diff --git a/templates/repo/editor/commit_form.tmpl b/templates/repo/editor/commit_form.tmpl index 8f46c47b96bf2..7efed773492c6 100644 --- a/templates/repo/editor/commit_form.tmpl +++ b/templates/repo/editor/commit_form.tmpl @@ -77,7 +77,7 @@ </div> {{end}} </div> - <button id="commit-button" type="submit" class="ui primary button"> + <button id="commit-button" type="submit" class="ui primary button" {{if .PageIsEdit}}disabled{{end}}> {{if eq .commit_choice "commit-to-new-branch"}}{{ctx.Locale.Tr "repo.editor.propose_file_change"}}{{else}}{{ctx.Locale.Tr "repo.editor.commit_changes"}}{{end}} </button> <a class="ui button red" href="{{if .ReturnURI}}{{.ReturnURI}}{{else}}{{$.BranchLink}}/{{PathEscapeSegments .TreePath}}{{end}}">{{ctx.Locale.Tr "repo.editor.cancel"}}</a> diff --git a/templates/repo/editor/edit.tmpl b/templates/repo/editor/edit.tmpl index ae8a60c20c116..e1bf46d53d356 100644 --- a/templates/repo/editor/edit.tmpl +++ b/templates/repo/editor/edit.tmpl @@ -28,31 +28,40 @@ <input type="hidden" id="tree_path" name="tree_path" value="{{.TreePath}}" required> </div> </div> - <div class="field"> - <div class="ui top attached header"> - <div class="ui compact small menu small-menu-items repo-editor-menu"> - <a class="active item" data-tab="write">{{svg "octicon-code"}} {{if .IsNewFile}}{{ctx.Locale.Tr "repo.editor.new_file"}}{{else}}{{ctx.Locale.Tr "repo.editor.edit_file"}}{{end}}</a> - <a class="item" data-tab="preview" data-preview-url="{{.Repository.Link}}/markup" data-preview-context-ref="{{.RepoLink}}/src/{{.RefTypeNameSubURL}}">{{svg "octicon-eye"}} {{ctx.Locale.Tr "preview"}}</a> - {{if not .IsNewFile}} - <a class="item" data-tab="diff" hx-params="context,content" hx-vals='{"context":"{{.BranchLink}}"}' hx-include="#edit_area" hx-swap="innerHTML" hx-target=".tab[data-tab='diff']" hx-indicator=".tab[data-tab='diff']" hx-post="{{.RepoLink}}/_preview/{{.BranchName | PathEscapeSegments}}/{{.TreePath | PathEscapeSegments}}">{{svg "octicon-diff"}} {{ctx.Locale.Tr "repo.editor.preview_changes"}}</a> - {{end}} - </div> - </div> - <div class="ui bottom attached segment tw-p-0"> - <div class="ui active tab tw-rounded-b" data-tab="write"> - <textarea id="edit_area" name="content" class="tw-hidden" data-id="repo-{{.Repository.Name}}-{{.TreePath}}" - data-previewable-extensions="{{.PreviewableExtensions}}" - data-line-wrap-extensions="{{.LineWrapExtensions}}">{{.FileContent}}</textarea> - <div class="editor-loading is-loading"></div> + {{if not .NotEditableReason}} + <div class="field"> + <div class="ui top attached header"> + <div class="ui compact small menu small-menu-items repo-editor-menu"> + <a class="active item" data-tab="write">{{svg "octicon-code"}} {{if .IsNewFile}}{{ctx.Locale.Tr "repo.editor.new_file"}}{{else}}{{ctx.Locale.Tr "repo.editor.edit_file"}}{{end}}</a> + <a class="item" data-tab="preview" data-preview-url="{{.Repository.Link}}/markup" data-preview-context-ref="{{.RepoLink}}/src/{{.RefTypeNameSubURL}}">{{svg "octicon-eye"}} {{ctx.Locale.Tr "preview"}}</a> + {{if not .IsNewFile}} + <a class="item" data-tab="diff" hx-params="context,content" hx-vals='{"context":"{{.BranchLink}}"}' hx-include="#edit_area" hx-swap="innerHTML" hx-target=".tab[data-tab='diff']" hx-indicator=".tab[data-tab='diff']" hx-post="{{.RepoLink}}/_preview/{{.BranchName | PathEscapeSegments}}/{{.TreePath | PathEscapeSegments}}">{{svg "octicon-diff"}} {{ctx.Locale.Tr "repo.editor.preview_changes"}}</a> + {{end}} + </div> </div> - <div class="ui tab tw-px-4 tw-py-3" data-tab="preview"> - {{ctx.Locale.Tr "loading"}} + <div class="ui bottom attached segment tw-p-0"> + <div class="ui active tab tw-rounded-b" data-tab="write"> + <textarea id="edit_area" name="content" class="tw-hidden" data-id="repo-{{.Repository.Name}}-{{.TreePath}}" + data-previewable-extensions="{{.PreviewableExtensions}}" + data-line-wrap-extensions="{{.LineWrapExtensions}}">{{.FileContent}}</textarea> + <div class="editor-loading is-loading"></div> + </div> + <div class="ui tab tw-px-4 tw-py-3" data-tab="preview"> + {{ctx.Locale.Tr "loading"}} + </div> + <div class="ui tab" data-tab="diff"> + <div class="tw-p-16"></div> + </div> </div> - <div class="ui tab" data-tab="diff"> - <div class="tw-p-16"></div> + </div> + {{else}} + <div class="field"> + <div class="ui segment tw-text-center"> + <h4 class="tw-font-semibold tw-mb-2">{{.NotEditableReason}}</h4> + <p>{{ctx.Locale.Tr "repo.editor.file_not_editable_hint"}}</p> </div> </div> - </div> + {{end}} {{template "repo/editor/commit_form" .}} </form> </div> diff --git a/tests/integration/repofiles_change_test.go b/tests/integration/repofiles_change_test.go index ce55a2f94347a..4678e52a9c5e1 100644 --- a/tests/integration/repofiles_change_test.go +++ b/tests/integration/repofiles_change_test.go @@ -58,6 +58,50 @@ func getUpdateRepoFilesOptions(repo *repo_model.Repository) *files_service.Chang } } +func getUpdateRepoFilesRenameOptions(repo *repo_model.Repository) *files_service.ChangeRepoFilesOptions { + return &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + // move normally + { + Operation: "rename", + FromTreePath: "README.md", + TreePath: "README.txt", + SHA: "", + ContentReader: nil, + }, + // move from in lfs + { + Operation: "rename", + FromTreePath: "crypt.bin", + TreePath: "crypt1.bin", + SHA: "", + ContentReader: nil, + }, + // move from lfs to normal + { + Operation: "rename", + FromTreePath: "jpeg.jpg", + TreePath: "jpeg.jpeg", + SHA: "", + ContentReader: nil, + }, + // move from normal to lfs + { + Operation: "rename", + FromTreePath: "CONTRIBUTING.md", + TreePath: "CONTRIBUTING.md.bin", + SHA: "", + ContentReader: nil, + }, + }, + OldBranch: repo.DefaultBranch, + NewBranch: repo.DefaultBranch, + Message: "Rename files", + Author: nil, + Committer: nil, + } +} + func getDeleteRepoFilesOptions(repo *repo_model.Repository) *files_service.ChangeRepoFilesOptions { return &files_service.ChangeRepoFilesOptions{ Files: []*files_service.ChangeRepoFile{ @@ -248,6 +292,109 @@ func getExpectedFileResponseForRepoFilesUpdate(commitID, filename, lastCommitSHA } } +func getExpectedFileResponseForRepoFilesUpdateRename(commitID, lastCommitSHA string, lastCommitterWhen, lastAuthorWhen time.Time) *api.FilesResponse { + details := []map[string]any{ + { + "filename": "README.txt", + "sha": "8276d2a29779af982c0afa976bdb793b52d442a8", + "size": 22, + "content": "IyBBbiBMRlMtZW5hYmxlZCByZXBvCg==", + }, + { + "filename": "crypt1.bin", + "sha": "d4a41a0d4db4949e129bd22f871171ea988103ef", + "size": 129, + "content": "dmVyc2lvbiBodHRwczovL2dpdC1sZnMuZ2l0aHViLmNvbS9zcGVjL3YxCm9pZCBzaGEyNTY6MmVjY2RiNDM4MjVkMmE0OWQ5OWQ1NDJkYWEyMDA3NWNmZjFkOTdkOWQyMzQ5YTg5NzdlZmU5YzAzNjYxNzM3YwpzaXplIDIwNDgK", + }, + { + "filename": "jpeg.jpeg", + "sha": "71911bf48766c7181518c1070911019fbb00b1fc", + "size": 107, + "content": "/9j/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/yQALCAABAAEBAREA/8wABgAQEAX/2gAIAQEAAD8A0s8g/9k=", + }, + { + "filename": "CONTRIBUTING.md.bin", + "sha": "2b6c6c4eaefa24b22f2092c3d54b263ff26feb58", + "size": 127, + "content": "dmVyc2lvbiBodHRwczovL2dpdC1sZnMuZ2l0aHViLmNvbS9zcGVjL3YxCm9pZCBzaGEyNTY6N2I2YjJjODhkYmE5Zjc2MGExYTU4NDY5YjY3ZmVlMmI2OThlZjdlOTM5OWM0Y2E0ZjM0YTE0Y2NiZTM5ZjYyMwpzaXplIDI3Cg==", + }, + } + + var responses []*api.ContentsResponse + for _, detail := range details { + encoding := "base64" + content := detail["content"].(string) + selfURL := setting.AppURL + "api/v1/repos/user2/lfs/contents/" + detail["filename"].(string) + "?ref=master" + htmlURL := setting.AppURL + "user2/lfs/src/branch/master/" + detail["filename"].(string) + gitURL := setting.AppURL + "api/v1/repos/user2/lfs/git/blobs/" + detail["sha"].(string) + downloadURL := setting.AppURL + "user2/lfs/raw/branch/master/" + detail["filename"].(string) + + responses = append(responses, &api.ContentsResponse{ + Name: detail["filename"].(string), + Path: detail["filename"].(string), + SHA: detail["sha"].(string), + LastCommitSHA: lastCommitSHA, + LastCommitterDate: lastCommitterWhen, + LastAuthorDate: lastAuthorWhen, + Type: "file", + Size: int64(detail["size"].(int)), + Encoding: &encoding, + Content: &content, + URL: &selfURL, + HTMLURL: &htmlURL, + GitURL: &gitURL, + DownloadURL: &downloadURL, + Links: &api.FileLinksResponse{ + Self: &selfURL, + GitURL: &gitURL, + HTMLURL: &htmlURL, + }, + }) + } + + return &api.FilesResponse{ + Files: responses, + Commit: &api.FileCommitResponse{ + CommitMeta: api.CommitMeta{ + URL: setting.AppURL + "api/v1/repos/user2/lfs/git/commits/" + commitID, + SHA: commitID, + }, + HTMLURL: setting.AppURL + "user2/lfs/commit/" + commitID, + Author: &api.CommitUser{ + Identity: api.Identity{ + Name: "User Two", + Email: "user2@noreply.example.org", + }, + Date: time.Now().UTC().Format(time.RFC3339), + }, + Committer: &api.CommitUser{ + Identity: api.Identity{ + Name: "User Two", + Email: "user2@noreply.example.org", + }, + Date: time.Now().UTC().Format(time.RFC3339), + }, + Parents: []*api.CommitMeta{ + { + URL: setting.AppURL + "api/v1/repos/user2/lfs/git/commits/73cf03db6ece34e12bf91e8853dc58f678f2f82d", + SHA: "73cf03db6ece34e12bf91e8853dc58f678f2f82d", + }, + }, + Message: "Rename files\n", + Tree: &api.CommitMeta{ + URL: setting.AppURL + "api/v1/repos/user2/lfs/git/trees/5307376dc3a5557dc1c403c29a8984668ca9ecb5", + SHA: "5307376dc3a5557dc1c403c29a8984668ca9ecb5", + }, + }, + Verification: &api.PayloadCommitVerification{ + Verified: false, + Reason: "gpg.error.not_signed_commit", + Signature: "", + Payload: "", + }, + } +} + func TestChangeRepoFilesForCreate(t *testing.T) { // setup onGiteaRun(t, func(t *testing.T, u *url.URL) { @@ -369,6 +516,35 @@ func TestChangeRepoFilesForUpdateWithFileMove(t *testing.T) { }) } +func TestChangeRepoFilesForUpdateWithFileRename(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + ctx, _ := contexttest.MockContext(t, "user2/lfs") + ctx.SetPathParam("id", "54") + contexttest.LoadRepo(t, ctx, 54) + contexttest.LoadRepoCommit(t, ctx) + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadGitRepo(t, ctx) + defer ctx.Repo.GitRepo.Close() + + repo := ctx.Repo.Repository + doer := ctx.Doer + opts := getUpdateRepoFilesRenameOptions(repo) + + // test + filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts) + + // asserts + assert.NoError(t, err) + gitRepo, _ := gitrepo.OpenRepository(git.DefaultContext, repo) + defer gitRepo.Close() + + commit, _ := gitRepo.GetBranchCommit(repo.DefaultBranch) + lastCommit, _ := commit.GetCommitByPath(opts.Files[0].TreePath) + expectedFileResponse := getExpectedFileResponseForRepoFilesUpdateRename(commit.ID.String(), lastCommit.ID.String(), lastCommit.Committer.When, lastCommit.Author.When) + assert.Equal(t, expectedFileResponse, filesResponse) + }) +} + // Test opts with branch names removed, should get same results as above test func TestChangeRepoFilesWithoutBranchNames(t *testing.T) { // setup diff --git a/web_src/js/features/repo-editor.ts b/web_src/js/features/repo-editor.ts index 0f77508f70d8a..acf4127399513 100644 --- a/web_src/js/features/repo-editor.ts +++ b/web_src/js/features/repo-editor.ts @@ -141,38 +141,39 @@ export function initRepoEditor() { } }); + const elForm = document.querySelector<HTMLFormElement>('.repository.editor .edit.form'); + + // Using events from https://github.com/codedance/jquery.AreYouSure#advanced-usage + // to enable or disable the commit button + const commitButton = document.querySelector<HTMLButtonElement>('#commit-button'); + const dirtyFileClass = 'dirty-file'; + + // Enabling the button at the start if the page has posted + if (document.querySelector<HTMLInputElement>('input[name="page_has_posted"]')?.value === 'true') { + commitButton.disabled = false; + } + + // Registering a custom listener for the file path and the file content + // FIXME: it is not quite right here (old bug), it causes double-init, the global areYouSure "dirty" class will also be added + applyAreYouSure(elForm, { + silent: true, + dirtyClass: dirtyFileClass, + fieldSelector: ':input:not(.commit-form-wrapper :input)', + change($form: any) { + const dirty = $form[0]?.classList.contains(dirtyFileClass); + commitButton.disabled = !dirty; + }, + }); + // on the upload page, there is no editor(textarea) const editArea = document.querySelector<HTMLTextAreaElement>('.page-content.repository.editor textarea#edit_area'); if (!editArea) return; - const elForm = document.querySelector<HTMLFormElement>('.repository.editor .edit.form'); initEditPreviewTab(elForm); (async () => { const editor = await createCodeEditor(editArea, filenameInput); - // Using events from https://github.com/codedance/jquery.AreYouSure#advanced-usage - // to enable or disable the commit button - const commitButton = document.querySelector<HTMLButtonElement>('#commit-button'); - const dirtyFileClass = 'dirty-file'; - - // Disabling the button at the start - if (document.querySelector<HTMLInputElement>('input[name="page_has_posted"]').value !== 'true') { - commitButton.disabled = true; - } - - // Registering a custom listener for the file path and the file content - // FIXME: it is not quite right here (old bug), it causes double-init, the global areYouSure "dirty" class will also be added - applyAreYouSure(elForm, { - silent: true, - dirtyClass: dirtyFileClass, - fieldSelector: ':input:not(.commit-form-wrapper :input)', - change($form: any) { - const dirty = $form[0]?.classList.contains(dirtyFileClass); - commitButton.disabled = !dirty; - }, - }); - // Update the editor from query params, if available, // only after the dirtyFileClass initialization const params = new URLSearchParams(window.location.search);