Skip to content

Commit 3a37d63

Browse files
authored
Allow renaming/moving binary/LFS files in the UI (#34350)
Adds the ability to rename/move binary files like binary blobs or images and files that are too large in the web ui. This was purposed in #24722, along with the ability edit images via an upload of a new image, which I didn't implement here (could be done in a separate PR). Binary file content: ![binary](https://github.com/user-attachments/assets/61d9ff71-25d3-4832-9288-452cdefc7283) File too large: ![toolarge](https://github.com/user-attachments/assets/3b42dbd0-e76a-4c3c-92d2-52ebffedea64) GitHub does the same (I've copied the text from there): ![gh](https://github.com/user-attachments/assets/e1499813-fb71-4544-9d58-086046a5f13e)
1 parent 24ce205 commit 3a37d63

File tree

12 files changed

+417
-115
lines changed

12 files changed

+417
-115
lines changed

models/fixtures/branch.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,3 +201,15 @@
201201
is_deleted: false
202202
deleted_by_id: 0
203203
deleted_unix: 0
204+
205+
-
206+
id: 25
207+
repo_id: 54
208+
name: 'master'
209+
commit_id: '73cf03db6ece34e12bf91e8853dc58f678f2f82d'
210+
commit_message: 'Initial commit'
211+
commit_time: 1671663402
212+
pusher_id: 2
213+
is_deleted: false
214+
deleted_by_id: 0
215+
deleted_unix: 0

options/locale/locale_en-US.ini

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1332,7 +1332,9 @@ editor.upload_file = Upload File
13321332
editor.edit_file = Edit File
13331333
editor.preview_changes = Preview Changes
13341334
editor.cannot_edit_lfs_files = LFS files cannot be edited in the web interface.
1335+
editor.cannot_edit_too_large_file = The file is too large to be edited.
13351336
editor.cannot_edit_non_text_files = Binary files cannot be edited in the web interface.
1337+
editor.file_not_editable_hint = But you can still rename or move it.
13361338
editor.edit_this_file = Edit File
13371339
editor.this_file_locked = File is locked
13381340
editor.must_be_on_a_branch = You must be on a branch to make or propose changes to this file.

routers/web/repo/editor.go

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -145,10 +145,6 @@ func editFile(ctx *context.Context, isNewFile bool) {
145145
}
146146

147147
blob := entry.Blob()
148-
if blob.Size() >= setting.UI.MaxDisplayFileSize {
149-
ctx.NotFound(err)
150-
return
151-
}
152148

153149
buf, dataRc, fInfo, err := getFileReader(ctx, ctx.Repo.Repository.ID, blob)
154150
if err != nil {
@@ -162,22 +158,37 @@ func editFile(ctx *context.Context, isNewFile bool) {
162158

163159
defer dataRc.Close()
164160

165-
ctx.Data["FileSize"] = blob.Size()
166-
167-
// Only some file types are editable online as text.
168-
if !fInfo.st.IsRepresentableAsText() || fInfo.isLFSFile {
169-
ctx.NotFound(nil)
170-
return
161+
if fInfo.isLFSFile {
162+
lfsLock, err := git_model.GetTreePathLock(ctx, ctx.Repo.Repository.ID, ctx.Repo.TreePath)
163+
if err != nil {
164+
ctx.ServerError("GetTreePathLock", err)
165+
return
166+
}
167+
if lfsLock != nil && lfsLock.OwnerID != ctx.Doer.ID {
168+
ctx.NotFound(nil)
169+
return
170+
}
171171
}
172172

173-
d, _ := io.ReadAll(dataRc)
173+
ctx.Data["FileSize"] = fInfo.fileSize
174174

175-
buf = append(buf, d...)
176-
if content, err := charset.ToUTF8(buf, charset.ConvertOpts{KeepBOM: true}); err != nil {
177-
log.Error("ToUTF8: %v", err)
178-
ctx.Data["FileContent"] = string(buf)
175+
// Only some file types are editable online as text.
176+
if fInfo.isLFSFile {
177+
ctx.Data["NotEditableReason"] = ctx.Tr("repo.editor.cannot_edit_lfs_files")
178+
} else if !fInfo.st.IsRepresentableAsText() {
179+
ctx.Data["NotEditableReason"] = ctx.Tr("repo.editor.cannot_edit_non_text_files")
180+
} else if fInfo.fileSize >= setting.UI.MaxDisplayFileSize {
181+
ctx.Data["NotEditableReason"] = ctx.Tr("repo.editor.cannot_edit_too_large_file")
179182
} else {
180-
ctx.Data["FileContent"] = content
183+
d, _ := io.ReadAll(dataRc)
184+
185+
buf = append(buf, d...)
186+
if content, err := charset.ToUTF8(buf, charset.ConvertOpts{KeepBOM: true}); err != nil {
187+
log.Error("ToUTF8: %v", err)
188+
ctx.Data["FileContent"] = string(buf)
189+
} else {
190+
ctx.Data["FileContent"] = content
191+
}
181192
}
182193
} else {
183194
// 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
280291
operation := "update"
281292
if isNewFile {
282293
operation = "create"
294+
} else if !form.Content.Has() && ctx.Repo.TreePath != form.TreePath {
295+
// The form content only has data if file is representable as text, is not too large and not in lfs. If it doesn't
296+
// have data, the only possible operation is a rename
297+
operation = "rename"
283298
}
284299

285300
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
292307
Operation: operation,
293308
FromTreePath: ctx.Repo.TreePath,
294309
TreePath: form.TreePath,
295-
ContentReader: strings.NewReader(strings.ReplaceAll(form.Content, "\r", "")),
310+
ContentReader: strings.NewReader(strings.ReplaceAll(form.Content.Value(), "\r", "")),
296311
},
297312
},
298313
Signoff: form.Signoff,

routers/web/repo/patch.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ func NewDiffPatchPost(ctx *context.Context) {
9999
OldBranch: ctx.Repo.BranchName,
100100
NewBranch: branchName,
101101
Message: message,
102-
Content: strings.ReplaceAll(form.Content, "\r", ""),
102+
Content: strings.ReplaceAll(form.Content.Value(), "\r", ""),
103103
Author: gitCommitter,
104104
Committer: gitCommitter,
105105
})

routers/web/repo/view_file.go

Lines changed: 8 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -285,10 +285,10 @@ func prepareToRenderFile(ctx *context.Context, entry *git.TreeEntry) {
285285
}
286286
}
287287

288-
prepareToRenderButtons(ctx, fInfo.isLFSFile, isRepresentableAsText, lfsLock)
288+
prepareToRenderButtons(ctx, lfsLock)
289289
}
290290

291-
func prepareToRenderButtons(ctx *context.Context, isLFSFile, isRepresentableAsText bool, lfsLock *git_model.LFSLock) {
291+
func prepareToRenderButtons(ctx *context.Context, lfsLock *git_model.LFSLock) {
292292
// archived or mirror repository, the buttons should not be shown
293293
if ctx.Repo.Repository.IsArchived || !ctx.Repo.Repository.CanEnableEditor() {
294294
return
@@ -301,33 +301,16 @@ func prepareToRenderButtons(ctx *context.Context, isLFSFile, isRepresentableAsTe
301301
return
302302
}
303303

304-
if isLFSFile {
305-
ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.cannot_edit_lfs_files")
306-
} else if !isRepresentableAsText {
307-
ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.cannot_edit_non_text_files")
308-
}
309-
310304
if !ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName) {
311-
if !isLFSFile { // lfs file cannot be edited after fork
312-
ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.fork_before_edit")
313-
}
305+
ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.fork_before_edit")
314306
ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_have_write_access")
315307
return
316308
}
317309

318310
// it's a lfs file and the user is not the owner of the lock
319-
if lfsLock != nil && lfsLock.OwnerID != ctx.Doer.ID {
320-
ctx.Data["CanEditFile"] = false
321-
ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.this_file_locked")
322-
ctx.Data["CanDeleteFile"] = false
323-
ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.this_file_locked")
324-
return
325-
}
326-
327-
if !isLFSFile { // lfs file cannot be edited
328-
ctx.Data["CanEditFile"] = true
329-
ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.edit_this_file")
330-
}
331-
ctx.Data["CanDeleteFile"] = true
332-
ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.delete_this_file")
311+
isLFSLocked := lfsLock != nil && lfsLock.OwnerID != ctx.Doer.ID
312+
ctx.Data["CanEditFile"] = !isLFSLocked
313+
ctx.Data["EditFileTooltip"] = util.Iif(isLFSLocked, ctx.Tr("repo.editor.this_file_locked"), ctx.Tr("repo.editor.edit_this_file"))
314+
ctx.Data["CanDeleteFile"] = !isLFSLocked
315+
ctx.Data["DeleteFileTooltip"] = util.Iif(isLFSLocked, ctx.Tr("repo.editor.this_file_locked"), ctx.Tr("repo.editor.delete_this_file"))
333316
}

services/forms/repo_form.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
issues_model "code.gitea.io/gitea/models/issues"
1212
project_model "code.gitea.io/gitea/models/project"
13+
"code.gitea.io/gitea/modules/optional"
1314
"code.gitea.io/gitea/modules/structs"
1415
"code.gitea.io/gitea/modules/web/middleware"
1516
"code.gitea.io/gitea/services/context"
@@ -689,7 +690,7 @@ func (f *NewUncycloForm) Validate(req *http.Request, errs binding.Errors) binding.E
689690
// EditRepoFileForm form for changing repository file
690691
type EditRepoFileForm struct {
691692
TreePath string `binding:"Required;MaxSize(500)"`
692-
Content string
693+
Content optional.Option[string]
693694
CommitSummary string `binding:"MaxSize(100)"`
694695
CommitMessage string
695696
CommitChoice string `binding:"Required;MaxSize(50)"`

services/repository/files/update.go

Lines changed: 128 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use
246246
contentStore := lfs.NewContentStore()
247247
for _, file := range opts.Files {
248248
switch file.Operation {
249-
case "create", "update":
249+
case "create", "update", "rename":
250250
if err := CreateOrUpdateFile(ctx, t, file, contentStore, repo.ID, hasOldBranch); err != nil {
251251
return nil, err
252252
}
@@ -488,31 +488,32 @@ func CreateOrUpdateFile(ctx context.Context, t *TemporaryUploadRepository, file
488488
}
489489
}
490490

491-
treeObjectContentReader := file.ContentReader
492-
var lfsMetaObject *git_model.LFSMetaObject
493-
if setting.LFS.StartServer && hasOldBranch {
494-
// Check there is no way this can return multiple infos
495-
attributesMap, err := attribute.CheckAttributes(ctx, t.gitRepo, "" /* use temp repo's working dir */, attribute.CheckAttributeOpts{
496-
Attributes: []string{attribute.Filter},
497-
Filenames: []string{file.Options.treePath},
498-
})
491+
var oldEntry *git.TreeEntry
492+
// Assume that the file.ContentReader of a pure rename operation is invalid. Use the file content how it's present in
493+
// git instead
494+
if file.Operation == "rename" {
495+
lastCommitID, err := t.GetLastCommit(ctx)
496+
if err != nil {
497+
return err
498+
}
499+
commit, err := t.GetCommit(lastCommitID)
499500
if err != nil {
500501
return err
501502
}
502503

503-
if attributesMap[file.Options.treePath] != nil && attributesMap[file.Options.treePath].Get(attribute.Filter).ToString().Value() == "lfs" {
504-
// OK so we are supposed to LFS this data!
505-
pointer, err := lfs.GeneratePointer(treeObjectContentReader)
506-
if err != nil {
507-
return err
508-
}
509-
lfsMetaObject = &git_model.LFSMetaObject{Pointer: pointer, RepositoryID: repoID}
510-
treeObjectContentReader = strings.NewReader(pointer.StringContent())
504+
if oldEntry, err = commit.GetTreeEntryByPath(file.Options.fromTreePath); err != nil {
505+
return err
511506
}
512507
}
513508

514-
// Add the object to the database
515-
objectHash, err := t.HashObject(ctx, treeObjectContentReader)
509+
var objectHash string
510+
var lfsPointer *lfs.Pointer
511+
switch file.Operation {
512+
case "create", "update":
513+
objectHash, lfsPointer, err = createOrUpdateFileHash(ctx, t, file, hasOldBranch)
514+
case "rename":
515+
objectHash, lfsPointer, err = renameFileHash(ctx, t, oldEntry, file)
516+
}
516517
if err != nil {
517518
return err
518519
}
@@ -528,9 +529,9 @@ func CreateOrUpdateFile(ctx context.Context, t *TemporaryUploadRepository, file
528529
}
529530
}
530531

531-
if lfsMetaObject != nil {
532+
if lfsPointer != nil {
532533
// We have an LFS object - create it
533-
lfsMetaObject, err = git_model.NewLFSMetaObject(ctx, lfsMetaObject.RepositoryID, lfsMetaObject.Pointer)
534+
lfsMetaObject, err := git_model.NewLFSMetaObject(ctx, repoID, *lfsPointer)
534535
if err != nil {
535536
return err
536537
}
@@ -539,11 +540,20 @@ func CreateOrUpdateFile(ctx context.Context, t *TemporaryUploadRepository, file
539540
return err
540541
}
541542
if !exist {
542-
_, err := file.ContentReader.Seek(0, io.SeekStart)
543-
if err != nil {
544-
return err
543+
var lfsContentReader io.Reader
544+
if file.Operation != "rename" {
545+
if _, err := file.ContentReader.Seek(0, io.SeekStart); err != nil {
546+
return err
547+
}
548+
lfsContentReader = file.ContentReader
549+
} else {
550+
if lfsContentReader, err = oldEntry.Blob().DataAsync(); err != nil {
551+
return err
552+
}
553+
defer lfsContentReader.(io.ReadCloser).Close()
545554
}
546-
if err := contentStore.Put(lfsMetaObject.Pointer, file.ContentReader); err != nil {
555+
556+
if err := contentStore.Put(lfsMetaObject.Pointer, lfsContentReader); err != nil {
547557
if _, err2 := git_model.RemoveLFSMetaObjectByOid(ctx, repoID, lfsMetaObject.Oid); err2 != nil {
548558
return fmt.Errorf("unable to remove failed inserted LFS object %s: %v (Prev Error: %w)", lfsMetaObject.Oid, err2, err)
549559
}
@@ -555,6 +565,99 @@ func CreateOrUpdateFile(ctx context.Context, t *TemporaryUploadRepository, file
555565
return nil
556566
}
557567

568+
func createOrUpdateFileHash(ctx context.Context, t *TemporaryUploadRepository, file *ChangeRepoFile, hasOldBranch bool) (string, *lfs.Pointer, error) {
569+
treeObjectContentReader := file.ContentReader
570+
var lfsPointer *lfs.Pointer
571+
if setting.LFS.StartServer && hasOldBranch {
572+
// Check there is no way this can return multiple infos
573+
attributesMap, err := attribute.CheckAttributes(ctx, t.gitRepo, "" /* use temp repo's working dir */, attribute.CheckAttributeOpts{
574+
Attributes: []string{attribute.Filter},
575+
Filenames: []string{file.Options.treePath},
576+
})
577+
if err != nil {
578+
return "", nil, err
579+
}
580+
581+
if attributesMap[file.Options.treePath] != nil && attributesMap[file.Options.treePath].Get(attribute.Filter).ToString().Value() == "lfs" {
582+
// OK so we are supposed to LFS this data!
583+
pointer, err := lfs.GeneratePointer(treeObjectContentReader)
584+
if err != nil {
585+
return "", nil, err
586+
}
587+
lfsPointer = &pointer
588+
treeObjectContentReader = strings.NewReader(pointer.StringContent())
589+
}
590+
}
591+
592+
// Add the object to the database
593+
objectHash, err := t.HashObject(ctx, treeObjectContentReader)
594+
if err != nil {
595+
return "", nil, err
596+
}
597+
598+
return objectHash, lfsPointer, nil
599+
}
600+
601+
func renameFileHash(ctx context.Context, t *TemporaryUploadRepository, oldEntry *git.TreeEntry, file *ChangeRepoFile) (string, *lfs.Pointer, error) {
602+
if setting.LFS.StartServer {
603+
attributesMap, err := attribute.CheckAttributes(ctx, t.gitRepo, "" /* use temp repo's working dir */, attribute.CheckAttributeOpts{
604+
Attributes: []string{attribute.Filter},
605+
Filenames: []string{file.Options.treePath, file.Options.fromTreePath},
606+
})
607+
if err != nil {
608+
return "", nil, err
609+
}
610+
611+
oldIsLfs := attributesMap[file.Options.fromTreePath] != nil && attributesMap[file.Options.fromTreePath].Get(attribute.Filter).ToString().Value() == "lfs"
612+
newIsLfs := attributesMap[file.Options.treePath] != nil && attributesMap[file.Options.treePath].Get(attribute.Filter).ToString().Value() == "lfs"
613+
614+
// 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
615+
// as the object doesn't change
616+
if oldIsLfs == newIsLfs {
617+
return oldEntry.ID.String(), nil, nil
618+
}
619+
620+
oldEntryReader, err := oldEntry.Blob().DataAsync()
621+
if err != nil {
622+
return "", nil, err
623+
}
624+
defer oldEntryReader.Close()
625+
626+
var treeObjectContentReader io.Reader
627+
var lfsPointer *lfs.Pointer
628+
// If the old path is in lfs but the new isn't, read the content from lfs and add it as normal git object
629+
// If the new path is in lfs but the old isn't, read the content from the git object and generate a lfs
630+
// pointer of it
631+
if oldIsLfs {
632+
pointer, err := lfs.ReadPointer(oldEntryReader)
633+
if err != nil {
634+
return "", nil, err
635+
}
636+
treeObjectContentReader, err = lfs.ReadMetaObject(pointer)
637+
if err != nil {
638+
return "", nil, err
639+
}
640+
defer treeObjectContentReader.(io.ReadCloser).Close()
641+
} else {
642+
pointer, err := lfs.GeneratePointer(oldEntryReader)
643+
if err != nil {
644+
return "", nil, err
645+
}
646+
treeObjectContentReader = strings.NewReader(pointer.StringContent())
647+
lfsPointer = &pointer
648+
}
649+
650+
// Add the object to the database
651+
objectID, err := t.HashObject(ctx, treeObjectContentReader)
652+
if err != nil {
653+
return "", nil, err
654+
}
655+
return objectID, lfsPointer, nil
656+
}
657+
658+
return oldEntry.ID.String(), nil, nil
659+
}
660+
558661
// VerifyBranchProtection verify the branch protection for modifying the given treePath on the given branch
559662
func VerifyBranchProtection(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, branchName string, treePaths []string) error {
560663
protectedBranch, err := git_model.GetFirstMatchProtectedBranchRule(ctx, repo.ID, branchName)

templates/repo/diff/box.tmpl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@
148148
<a class="item" rel="nofollow" href="{{$.BeforeSourcePath}}/{{PathEscapeSegments .Name}}">{{ctx.Locale.Tr "repo.diff.view_file"}}</a>
149149
{{else}}
150150
<a class="item" rel="nofollow" href="{{$.SourcePath}}/{{PathEscapeSegments .Name}}">{{ctx.Locale.Tr "repo.diff.view_file"}}</a>
151-
{{if and $.Repository.CanEnableEditor $.CanEditFile (not $file.IsLFSFile) (not $file.IsBin)}}
151+
{{if and $.Repository.CanEnableEditor $.CanEditFile}}
152152
<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>
153153
{{end}}
154154
{{end}}

0 commit comments

Comments
 (0)