mirror of
https://github.com/go-gitea/gitea.git
synced 2025-06-19 21:12:32 +00:00
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:  File too large:  GitHub does the same (I've copied the text from there): 
This commit is contained in:
parent
24ce2058e8
commit
3a37d63d61
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
})
|
||||
|
@ -285,10 +285,10 @@ func prepareToRenderFile(ctx *context.Context, entry *git.TreeEntry) {
|
||||
}
|
||||
}
|
||||
|
||||
prepareToRenderButtons(ctx, fInfo.isLFSFile, isRepresentableAsText, lfsLock)
|
||||
prepareToRenderButtons(ctx, lfsLock)
|
||||
}
|
||||
|
||||
func prepareToRenderButtons(ctx *context.Context, isLFSFile, isRepresentableAsText bool, lfsLock *git_model.LFSLock) {
|
||||
func prepareToRenderButtons(ctx *context.Context, lfsLock *git_model.LFSLock) {
|
||||
// archived or mirror repository, the buttons should not be shown
|
||||
if ctx.Repo.Repository.IsArchived || !ctx.Repo.Repository.CanEnableEditor() {
|
||||
return
|
||||
@ -301,33 +301,16 @@ func prepareToRenderButtons(ctx *context.Context, isLFSFile, isRepresentableAsTe
|
||||
return
|
||||
}
|
||||
|
||||
if 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")
|
||||
}
|
||||
|
||||
if !ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName) {
|
||||
if !isLFSFile { // lfs file cannot be edited after fork
|
||||
ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.fork_before_edit")
|
||||
}
|
||||
ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.fork_before_edit")
|
||||
ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_have_write_access")
|
||||
return
|
||||
}
|
||||
|
||||
// it's a lfs file and the user is not the owner of the lock
|
||||
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")
|
||||
return
|
||||
}
|
||||
|
||||
if !isLFSFile { // lfs file cannot be edited
|
||||
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")
|
||||
isLFSLocked := lfsLock != nil && lfsLock.OwnerID != ctx.Doer.ID
|
||||
ctx.Data["CanEditFile"] = !isLFSLocked
|
||||
ctx.Data["EditFileTooltip"] = util.Iif(isLFSLocked, ctx.Tr("repo.editor.this_file_locked"), ctx.Tr("repo.editor.edit_this_file"))
|
||||
ctx.Data["CanDeleteFile"] = !isLFSLocked
|
||||
ctx.Data["DeleteFileTooltip"] = util.Iif(isLFSLocked, ctx.Tr("repo.editor.this_file_locked"), ctx.Tr("repo.editor.delete_this_file"))
|
||||
}
|
||||
|
@ -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)"`
|
||||
|
@ -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)
|
||||
|
@ -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}}
|
||||
|
@ -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>
|
||||
|
@ -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}}
|
||||
{{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 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>
|
||||
<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>
|
||||
{{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>
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
|
Loading…
Reference in New Issue
Block a user