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);