diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 9091b6bc4b323..e840b08b90608 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1331,7 +1331,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 03e5b830a0884..c9785bb1e98d9 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 fbf59c40edb97..b93538ea5ea01 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/services/repository/files/update_test.go b/services/repository/files/update_test.go new file mode 100644 index 0000000000000..e1a8b04c7083d --- /dev/null +++ b/services/repository/files/update_test.go @@ -0,0 +1,54 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package files + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/lfs" + "code.gitea.io/gitea/services/contexttest" + + "github.com/stretchr/testify/assert" +) + +func TestUpdateRename(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "user2/repo1") + contexttest.LoadRepo(t, ctx, 1) + contexttest.LoadRepoCommit(t, ctx) + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadGitRepo(t, ctx) + defer ctx.Repo.GitRepo.Close() + + repo := ctx.Repo.Repository + branch := repo.DefaultBranch + + temp, _ := NewTemporaryUploadRepository(repo) + _ = temp.Clone(ctx, branch, true) + _ = temp.SetDefaultIndex(ctx) + + filesBeforeRename, _ := temp.LsFiles(ctx, "README.txt", "README.md") + assert.Equal(t, []string{"README.md", ""}, filesBeforeRename) + + file := &ChangeRepoFile{ + Operation: "rename", + FromTreePath: "README.md", + TreePath: "README.txt", + ContentReader: nil, + SHA: "", + Options: &RepoFileOptions{ + fromTreePath: "README.md", + treePath: "README.txt", + executable: false, + }, + } + contentStore := lfs.NewContentStore() + + err := CreateOrUpdateFile(ctx, temp, file, contentStore, 1, true) + assert.NoError(t, err) + + filesAfterRename, _ := temp.LsFiles(ctx, "README.txt", "README.md") + assert.Equal(t, []string{"README.txt", ""}, filesAfterRename) +} 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 @@ {{ctx.Locale.Tr "repo.diff.view_file"}} {{else}} {{ctx.Locale.Tr "repo.diff.view_file"}} - {{if and $.Repository.CanEnableEditor $.CanEditFile (not $file.IsLFSFile) (not $file.IsBin)}} + {{if and $.Repository.CanEnableEditor $.CanEditFile}} {{ctx.Locale.Tr "repo.editor.edit_this_file"}} {{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 @@ {{end}} - {{ctx.Locale.Tr "repo.editor.cancel"}} 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 @@ -
-
- -
-
-
- -
+ {{if not .NotEditableReason}} +
+ -
- {{ctx.Locale.Tr "loading"}} +
+
+ +
+
+
+ {{ctx.Locale.Tr "loading"}} +
+
+
+
-
-
+
+ {{else}} +
+
+

{{.NotEditableReason}}

+

{{ctx.Locale.Tr "repo.editor.file_not_editable_hint"}}

-
+ {{end}} {{template "repo/editor/commit_form" .}}
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('.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('#commit-button'); + const dirtyFileClass = 'dirty-file'; + + // Enabling the button at the start if the page has posted + if (document.querySelector('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('.page-content.repository.editor textarea#edit_area'); if (!editArea) return; - const elForm = document.querySelector('.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('#commit-button'); - const dirtyFileClass = 'dirty-file'; - - // Disabling the button at the start - if (document.querySelector('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);