Add "Go to file", "Delete Directory" to repo file list page (#35911)

/claim #35898
Resolves #35898 

### Summary of key changes:

1. Add file name search/Go to file functionality to repo button row.
2. Add backend functionality to delete directory
3. Add context menu for directories with functionality to copy path & delete a directory
4. Move Add/Upload file dropdown to right for parity with Github UI
5. Add tree view to the edit/upload UI

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
Bryan Mutai
2025-11-30 06:58:15 +03:00
committed by GitHub
parent b54af8811e
commit 7d6861ac54
33 changed files with 671 additions and 489 deletions

View File

@@ -1354,8 +1354,11 @@ 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. editor.must_be_on_a_branch = You must be on a branch to make or propose changes to this file.
editor.fork_before_edit = You must fork this repository to make or propose changes to this file. editor.fork_before_edit = You must fork this repository to make or propose changes to this file.
editor.delete_this_file = Delete File editor.delete_this_file = Delete File
editor.delete_this_directory = Delete Directory
editor.must_have_write_access = You must have write access to make or propose changes to this file. editor.must_have_write_access = You must have write access to make or propose changes to this file.
editor.file_delete_success = File "%s" has been deleted. editor.file_delete_success = File "%s" has been deleted.
editor.directory_delete_success = Directory "%s" has been deleted.
editor.delete_directory = Delete directory '%s'
editor.name_your_file = Name your file… editor.name_your_file = Name your file…
editor.filename_help = Add a directory by typing its name followed by a slash ('/'). Remove a directory by typing backspace at the beginning of the input field. editor.filename_help = Add a directory by typing its name followed by a slash ('/'). Remove a directory by typing backspace at the beginning of the input field.
editor.or = or editor.or = or

View File

@@ -610,10 +610,6 @@ func handleChangeRepoFilesError(ctx *context.APIContext, err error) {
ctx.APIError(http.StatusUnprocessableEntity, err) ctx.APIError(http.StatusUnprocessableEntity, err)
return return
} }
if git.IsErrBranchNotExist(err) || files_service.IsErrRepoFileDoesNotExist(err) || git.IsErrNotExist(err) {
ctx.APIError(http.StatusNotFound, err)
return
}
if errors.Is(err, util.ErrNotExist) { if errors.Is(err, util.ErrNotExist) {
ctx.APIError(http.StatusNotFound, err) ctx.APIError(http.StatusNotFound, err)
return return

View File

@@ -10,7 +10,6 @@ import (
"net/url" "net/url"
"path" "path"
"strconv" "strconv"
"strings"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
@@ -42,8 +41,8 @@ type blameRow struct {
// RefBlame render blame page // RefBlame render blame page
func RefBlame(ctx *context.Context) { func RefBlame(ctx *context.Context) {
ctx.Data["PageIsViewCode"] = true
ctx.Data["IsBlame"] = true ctx.Data["IsBlame"] = true
prepareRepoViewContent(ctx, ctx.Repo.RefTypeNameSubURL())
// Get current entry user currently looking at. // Get current entry user currently looking at.
if ctx.Repo.TreePath == "" { if ctx.Repo.TreePath == "" {
@@ -56,17 +55,6 @@ func RefBlame(ctx *context.Context) {
return return
} }
treeNames := strings.Split(ctx.Repo.TreePath, "/")
var paths []string
for i := range treeNames {
paths = append(paths, strings.Join(treeNames[:i+1], "/"))
}
ctx.Data["Paths"] = paths
ctx.Data["TreeNames"] = treeNames
ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL()
ctx.Data["RawFileLink"] = ctx.Repo.RepoLink + "/raw/" + ctx.Repo.RefTypeNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath)
blob := entry.Blob() blob := entry.Blob()
fileSize := blob.Size() fileSize := blob.Size()
ctx.Data["FileSize"] = fileSize ctx.Data["FileSize"] = fileSize

View File

@@ -41,7 +41,12 @@ const (
editorCommitChoiceNewBranch string = "commit-to-new-branch" editorCommitChoiceNewBranch string = "commit-to-new-branch"
) )
func prepareEditorCommitFormOptions(ctx *context.Context, editorAction string) *context.CommitFormOptions { func prepareEditorPage(ctx *context.Context, editorAction string) *context.CommitFormOptions {
prepareHomeTreeSideBarSwitch(ctx)
return prepareEditorPageFormOptions(ctx, editorAction)
}
func prepareEditorPageFormOptions(ctx *context.Context, editorAction string) *context.CommitFormOptions {
cleanedTreePath := files_service.CleanGitTreePath(ctx.Repo.TreePath) cleanedTreePath := files_service.CleanGitTreePath(ctx.Repo.TreePath)
if cleanedTreePath != ctx.Repo.TreePath { if cleanedTreePath != ctx.Repo.TreePath {
redirectTo := fmt.Sprintf("%s/%s/%s/%s", ctx.Repo.RepoLink, editorAction, util.PathEscapeSegments(ctx.Repo.BranchName), util.PathEscapeSegments(cleanedTreePath)) redirectTo := fmt.Sprintf("%s/%s/%s/%s", ctx.Repo.RepoLink, editorAction, util.PathEscapeSegments(ctx.Repo.BranchName), util.PathEscapeSegments(cleanedTreePath))
@@ -283,7 +288,7 @@ func EditFile(ctx *context.Context) {
// on the "New File" page, we should add an empty path field to make end users could input a new name // on the "New File" page, we should add an empty path field to make end users could input a new name
prepareTreePathFieldsAndPaths(ctx, util.Iif(isNewFile, ctx.Repo.TreePath+"/", ctx.Repo.TreePath)) prepareTreePathFieldsAndPaths(ctx, util.Iif(isNewFile, ctx.Repo.TreePath+"/", ctx.Repo.TreePath))
prepareEditorCommitFormOptions(ctx, editorAction) prepareEditorPage(ctx, editorAction)
if ctx.Written() { if ctx.Written() {
return return
} }
@@ -376,15 +381,16 @@ func EditFilePost(ctx *context.Context) {
// DeleteFile render delete file page // DeleteFile render delete file page
func DeleteFile(ctx *context.Context) { func DeleteFile(ctx *context.Context) {
prepareEditorCommitFormOptions(ctx, "_delete") prepareEditorPage(ctx, "_delete")
if ctx.Written() { if ctx.Written() {
return return
} }
ctx.Data["PageIsDelete"] = true ctx.Data["PageIsDelete"] = true
prepareTreePathFieldsAndPaths(ctx, ctx.Repo.TreePath)
ctx.HTML(http.StatusOK, tplDeleteFile) ctx.HTML(http.StatusOK, tplDeleteFile)
} }
// DeleteFilePost response for deleting file // DeleteFilePost response for deleting file or directory
func DeleteFilePost(ctx *context.Context) { func DeleteFilePost(ctx *context.Context) {
parsed := prepareEditorCommitSubmittedForm[*forms.DeleteRepoFileForm](ctx) parsed := prepareEditorCommitSubmittedForm[*forms.DeleteRepoFileForm](ctx)
if ctx.Written() { if ctx.Written() {
@@ -392,7 +398,26 @@ func DeleteFilePost(ctx *context.Context) {
} }
treePath := ctx.Repo.TreePath treePath := ctx.Repo.TreePath
_, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{ if treePath == "" {
ctx.JSONError("cannot delete root directory") // it should not happen unless someone is trying to be malicious
return
}
// Check if the path is a directory
entry, err := ctx.Repo.Commit.GetTreeEntryByPath(treePath)
if err != nil {
ctx.NotFoundOrServerError("GetTreeEntryByPath", git.IsErrNotExist, err)
return
}
var commitMessage string
if entry.IsDir() {
commitMessage = parsed.GetCommitMessage(ctx.Locale.TrString("repo.editor.delete_directory", treePath))
} else {
commitMessage = parsed.GetCommitMessage(ctx.Locale.TrString("repo.editor.delete", treePath))
}
_, err = files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{
LastCommitID: parsed.form.LastCommit, LastCommitID: parsed.form.LastCommit,
OldBranch: parsed.OldBranchName, OldBranch: parsed.OldBranchName,
NewBranch: parsed.NewBranchName, NewBranch: parsed.NewBranchName,
@@ -400,9 +425,10 @@ func DeleteFilePost(ctx *context.Context) {
{ {
Operation: "delete", Operation: "delete",
TreePath: treePath, TreePath: treePath,
DeleteRecursively: true,
}, },
}, },
Message: parsed.GetCommitMessage(ctx.Locale.TrString("repo.editor.delete", treePath)), Message: commitMessage,
Signoff: parsed.form.Signoff, Signoff: parsed.form.Signoff,
Author: parsed.GitCommitter, Author: parsed.GitCommitter,
Committer: parsed.GitCommitter, Committer: parsed.GitCommitter,
@@ -412,7 +438,11 @@ func DeleteFilePost(ctx *context.Context) {
return return
} }
if entry.IsDir() {
ctx.Flash.Success(ctx.Tr("repo.editor.directory_delete_success", treePath))
} else {
ctx.Flash.Success(ctx.Tr("repo.editor.file_delete_success", treePath)) ctx.Flash.Success(ctx.Tr("repo.editor.file_delete_success", treePath))
}
redirectTreePath := getClosestParentWithFiles(ctx.Repo.GitRepo, parsed.NewBranchName, treePath) redirectTreePath := getClosestParentWithFiles(ctx.Repo.GitRepo, parsed.NewBranchName, treePath)
redirectForCommitChoice(ctx, parsed, redirectTreePath) redirectForCommitChoice(ctx, parsed, redirectTreePath)
} }
@@ -420,7 +450,7 @@ func DeleteFilePost(ctx *context.Context) {
func UploadFile(ctx *context.Context) { func UploadFile(ctx *context.Context) {
ctx.Data["PageIsUpload"] = true ctx.Data["PageIsUpload"] = true
prepareTreePathFieldsAndPaths(ctx, ctx.Repo.TreePath) prepareTreePathFieldsAndPaths(ctx, ctx.Repo.TreePath)
opts := prepareEditorCommitFormOptions(ctx, "_upload") opts := prepareEditorPage(ctx, "_upload")
if ctx.Written() { if ctx.Written() {
return return
} }

View File

@@ -14,7 +14,7 @@ import (
) )
func NewDiffPatch(ctx *context.Context) { func NewDiffPatch(ctx *context.Context) {
prepareEditorCommitFormOptions(ctx, "_diffpatch") prepareEditorPage(ctx, "_diffpatch")
if ctx.Written() { if ctx.Written() {
return return
} }

View File

@@ -16,7 +16,7 @@ import (
) )
func CherryPick(ctx *context.Context) { func CherryPick(ctx *context.Context) {
prepareEditorCommitFormOptions(ctx, "_cherrypick") prepareEditorPage(ctx, "_cherrypick")
if ctx.Written() { if ctx.Written() {
return return
} }

View File

@@ -1,24 +0,0 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"net/http"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context"
)
const (
tplFindFiles templates.TplName = "repo/find/files"
)
// FindFiles render the page to find repository files
func FindFiles(ctx *context.Context) {
path := ctx.PathParam("*")
ctx.Data["TreeLink"] = ctx.Repo.RepoLink + "/src/" + util.PathEscapeSegments(path)
ctx.Data["DataLink"] = ctx.Repo.RepoLink + "/tree-list/" + util.PathEscapeSegments(path)
ctx.HTML(http.StatusOK, tplFindFiles)
}

View File

@@ -245,27 +245,17 @@ func LastCommit(ctx *context.Context) {
return return
} }
// The "/lastcommit/" endpoint is used to render the embedded HTML content for the directory file listing with latest commit info
// It needs to construct correct links to the file items, but the route only accepts a commit ID, not a full ref name (branch or tag).
// So we need to get the ref name from the query parameter "refSubUrl".
// TODO: LAST-COMMIT-ASYNC-LOADING: it needs more tests to cover this
refSubURL := path.Clean(ctx.FormString("refSubUrl"))
prepareRepoViewContent(ctx, util.IfZero(refSubURL, ctx.Repo.RefTypeNameSubURL()))
renderDirectoryFiles(ctx, 0) renderDirectoryFiles(ctx, 0)
if ctx.Written() { if ctx.Written() {
return return
} }
var treeNames []string
paths := make([]string, 0, 5)
if len(ctx.Repo.TreePath) > 0 {
treeNames = strings.Split(ctx.Repo.TreePath, "/")
for i := range treeNames {
paths = append(paths, strings.Join(treeNames[:i+1], "/"))
}
ctx.Data["HasParentPath"] = true
if len(paths)-2 >= 0 {
ctx.Data["ParentPath"] = "/" + paths[len(paths)-2]
}
}
branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL()
ctx.Data["BranchLink"] = branchLink
ctx.HTML(http.StatusOK, tplRepoViewList) ctx.HTML(http.StatusOK, tplRepoViewList)
} }
@@ -289,7 +279,9 @@ func renderDirectoryFiles(ctx *context.Context, timeout time.Duration) git.Entri
return nil return nil
} }
ctx.Data["LastCommitLoaderURL"] = ctx.Repo.RepoLink + "/lastcommit/" + url.PathEscape(ctx.Repo.CommitID) + "/" + util.PathEscapeSegments(ctx.Repo.TreePath) // TODO: LAST-COMMIT-ASYNC-LOADING: search this keyword to see more details
lastCommitLoaderURL := ctx.Repo.RepoLink + "/lastcommit/" + url.PathEscape(ctx.Repo.CommitID) + "/" + util.PathEscapeSegments(ctx.Repo.TreePath)
ctx.Data["LastCommitLoaderURL"] = lastCommitLoaderURL + "?refSubUrl=" + url.QueryEscape(ctx.Repo.RefTypeNameSubURL())
// Get current entry user currently looking at. // Get current entry user currently looking at.
entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath) entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath)
@@ -322,6 +314,21 @@ func renderDirectoryFiles(ctx *context.Context, timeout time.Duration) git.Entri
ctx.ServerError("GetCommitsInfo", err) ctx.ServerError("GetCommitsInfo", err)
return nil return nil
} }
{
if timeout != 0 && !setting.IsProd && !setting.IsInTesting {
log.Debug("first call to get directory file commit info")
clearFilesCommitInfo := func() {
log.Warn("clear directory file commit info to force async loading on frontend")
for i := range files {
files[i].Commit = nil
}
}
_ = clearFilesCommitInfo
// clearFilesCommitInfo() // TODO: LAST-COMMIT-ASYNC-LOADING: debug the frontend async latest commit info loading, uncomment this line, and it needs more tests
}
}
ctx.Data["Files"] = files ctx.Data["Files"] = files
prepareDirectoryFileIcons(ctx, files) prepareDirectoryFileIcons(ctx, files)
for _, f := range files { for _, f := range files {
@@ -334,16 +341,6 @@ func renderDirectoryFiles(ctx *context.Context, timeout time.Duration) git.Entri
if !loadLatestCommitData(ctx, latestCommit) { if !loadLatestCommitData(ctx, latestCommit) {
return nil return nil
} }
branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL()
treeLink := branchLink
if len(ctx.Repo.TreePath) > 0 {
treeLink += "/" + util.PathEscapeSegments(ctx.Repo.TreePath)
}
ctx.Data["TreeLink"] = treeLink
return allEntries return allEntries
} }

View File

@@ -362,6 +362,32 @@ func redirectFollowSymlink(ctx *context.Context, treePathEntry *git.TreeEntry) b
return false return false
} }
func prepareRepoViewContent(ctx *context.Context, refTypeNameSubURL string) {
// for: home, file list, file view, blame
ctx.Data["PageIsViewCode"] = true
ctx.Data["RepositoryUploadEnabled"] = setting.Repository.Upload.Enabled // show Upload File button or menu item
// prepare the tree path navigation
var treeNames, paths []string
branchLink := ctx.Repo.RepoLink + "/src/" + refTypeNameSubURL
treeLink := branchLink
if ctx.Repo.TreePath != "" {
treeLink += "/" + util.PathEscapeSegments(ctx.Repo.TreePath)
treeNames = strings.Split(ctx.Repo.TreePath, "/")
for i := range treeNames {
paths = append(paths, strings.Join(treeNames[:i+1], "/"))
}
ctx.Data["HasParentPath"] = true
if len(paths)-2 >= 0 {
ctx.Data["ParentPath"] = "/" + paths[len(paths)-2]
}
}
ctx.Data["Paths"] = paths
ctx.Data["TreeLink"] = treeLink
ctx.Data["TreeNames"] = treeNames
ctx.Data["BranchLink"] = branchLink
}
// Home render repository home page // Home render repository home page
func Home(ctx *context.Context) { func Home(ctx *context.Context) {
if handleRepoHomeFeed(ctx) { if handleRepoHomeFeed(ctx) {
@@ -383,8 +409,7 @@ func Home(ctx *context.Context) {
title += ": " + ctx.Repo.Repository.Description title += ": " + ctx.Repo.Repository.Description
} }
ctx.Data["Title"] = title ctx.Data["Title"] = title
ctx.Data["PageIsViewCode"] = true prepareRepoViewContent(ctx, ctx.Repo.RefTypeNameSubURL())
ctx.Data["RepositoryUploadEnabled"] = setting.Repository.Upload.Enabled // show New File / Upload File buttons
if ctx.Repo.Commit == nil || ctx.Repo.Repository.IsEmpty || ctx.Repo.Repository.IsBroken() { if ctx.Repo.Commit == nil || ctx.Repo.Repository.IsEmpty || ctx.Repo.Repository.IsBroken() {
// empty or broken repositories need to be handled differently // empty or broken repositories need to be handled differently
@@ -405,26 +430,6 @@ func Home(ctx *context.Context) {
return return
} }
// prepare the tree path
var treeNames, paths []string
branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL()
treeLink := branchLink
if ctx.Repo.TreePath != "" {
treeLink += "/" + util.PathEscapeSegments(ctx.Repo.TreePath)
treeNames = strings.Split(ctx.Repo.TreePath, "/")
for i := range treeNames {
paths = append(paths, strings.Join(treeNames[:i+1], "/"))
}
ctx.Data["HasParentPath"] = true
if len(paths)-2 >= 0 {
ctx.Data["ParentPath"] = "/" + paths[len(paths)-2]
}
}
ctx.Data["Paths"] = paths
ctx.Data["TreeLink"] = treeLink
ctx.Data["TreeNames"] = treeNames
ctx.Data["BranchLink"] = branchLink
// some UI components are only shown when the tree path is root // some UI components are only shown when the tree path is root
isTreePathRoot := ctx.Repo.TreePath == "" isTreePathRoot := ctx.Repo.TreePath == ""
@@ -455,7 +460,7 @@ func Home(ctx *context.Context) {
if isViewHomeOnlyContent(ctx) { if isViewHomeOnlyContent(ctx) {
ctx.HTML(http.StatusOK, tplRepoViewContent) ctx.HTML(http.StatusOK, tplRepoViewContent)
} else if len(treeNames) != 0 { } else if ctx.Repo.TreePath != "" {
ctx.HTML(http.StatusOK, tplRepoView) ctx.HTML(http.StatusOK, tplRepoView)
} else { } else {
ctx.HTML(http.StatusOK, tplRepoHome) ctx.HTML(http.StatusOK, tplRepoHome)

View File

@@ -1184,7 +1184,6 @@ func registerWebRoutes(m *web.Router) {
m.Post("/{username}/{reponame}/markup", optSignIn, context.RepoAssignment, reqUnitsWithMarkdown, web.Bind(structs.MarkupOption{}), misc.Markup) m.Post("/{username}/{reponame}/markup", optSignIn, context.RepoAssignment, reqUnitsWithMarkdown, web.Bind(structs.MarkupOption{}), misc.Markup)
m.Group("/{username}/{reponame}", func() { m.Group("/{username}/{reponame}", func() {
m.Get("/find/*", repo.FindFiles)
m.Group("/tree-list", func() { m.Group("/tree-list", func() {
m.Get("/branch/*", context.RepoRefByType(git.RefTypeBranch), repo.TreeList) m.Get("/branch/*", context.RepoRefByType(git.RefTypeBranch), repo.TreeList)
m.Get("/tag/*", context.RepoRefByType(git.RefTypeTag), repo.TreeList) m.Get("/tag/*", context.RepoRefByType(git.RefTypeTag), repo.TreeList)

View File

@@ -135,6 +135,14 @@ func (t *TemporaryUploadRepository) LsFiles(ctx context.Context, filenames ...st
return fileList, nil return fileList, nil
} }
func (t *TemporaryUploadRepository) RemoveRecursivelyFromIndex(ctx context.Context, path string) error {
_, _, err := gitcmd.NewCommand("rm", "--cached", "-r").
AddDynamicArguments(path).
WithDir(t.basePath).
RunStdBytes(ctx)
return err
}
// RemoveFilesFromIndex removes the given files from the index // RemoveFilesFromIndex removes the given files from the index
func (t *TemporaryUploadRepository) RemoveFilesFromIndex(ctx context.Context, filenames ...string) error { func (t *TemporaryUploadRepository) RemoveFilesFromIndex(ctx context.Context, filenames ...string) error {
objFmt, err := t.gitRepo.GetObjectFormat() objFmt, err := t.gitRepo.GetObjectFormat()

View File

@@ -46,7 +46,10 @@ type ChangeRepoFile struct {
FromTreePath string FromTreePath string
ContentReader io.ReadSeeker ContentReader io.ReadSeeker
SHA string SHA string
Options *RepoFileOptions
DeleteRecursively bool // when deleting, work as `git rm -r ...`
Options *RepoFileOptions // FIXME: need to refactor, internal usage only
} }
// ChangeRepoFilesOptions holds the repository files update options // ChangeRepoFilesOptions holds the repository files update options
@@ -69,26 +72,6 @@ type RepoFileOptions struct {
executable bool executable bool
} }
// ErrRepoFileDoesNotExist represents a "RepoFileDoesNotExist" kind of error.
type ErrRepoFileDoesNotExist struct {
Path string
Name string
}
// IsErrRepoFileDoesNotExist checks if an error is a ErrRepoDoesNotExist.
func IsErrRepoFileDoesNotExist(err error) bool {
_, ok := err.(ErrRepoFileDoesNotExist)
return ok
}
func (err ErrRepoFileDoesNotExist) Error() string {
return fmt.Sprintf("repository file does not exist [path: %s]", err.Path)
}
func (err ErrRepoFileDoesNotExist) Unwrap() error {
return util.ErrNotExist
}
type LazyReadSeeker interface { type LazyReadSeeker interface {
io.ReadSeeker io.ReadSeeker
io.Closer io.Closer
@@ -217,24 +200,6 @@ func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use
} }
} }
for _, file := range opts.Files {
if file.Operation == "delete" {
// Get the files in the index
filesInIndex, err := t.LsFiles(ctx, file.TreePath)
if err != nil {
return nil, fmt.Errorf("DeleteRepoFile: %w", err)
}
// Find the file we want to delete in the index
inFilelist := slices.Contains(filesInIndex, file.TreePath)
if !inFilelist {
return nil, ErrRepoFileDoesNotExist{
Path: file.TreePath,
}
}
}
}
if hasOldBranch { if hasOldBranch {
// Get the commit of the original branch // Get the commit of the original branch
commit, err := t.GetBranchCommit(opts.OldBranch) commit, err := t.GetBranchCommit(opts.OldBranch)
@@ -272,9 +237,15 @@ func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use
addedLfsPointers = append(addedLfsPointers, *addedLfsPointer) addedLfsPointers = append(addedLfsPointers, *addedLfsPointer)
} }
case "delete": case "delete":
if file.DeleteRecursively {
if err = t.RemoveRecursivelyFromIndex(ctx, file.TreePath); err != nil {
return nil, err
}
} else {
if err = t.RemoveFilesFromIndex(ctx, file.TreePath); err != nil { if err = t.RemoveFilesFromIndex(ctx, file.TreePath); err != nil {
return nil, err return nil, err
} }
}
default: default:
return nil, fmt.Errorf("invalid file operation: %s %s, supported operations are create, update, delete", file.Operation, file.Options.treePath) return nil, fmt.Errorf("invalid file operation: %s %s, supported operations are create, update, delete", file.Operation, file.Options.treePath)
} }

View File

@@ -1,13 +1,30 @@
{{template "base/head" .}} {{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content repository file editor delete"> <div role="main" aria-label="{{.Title}}" class="page-content repository file editor delete">
{{template "repo/header" .}} {{template "repo/header" .}}
<div class="ui container"> <div class="ui container fluid padded">
{{template "base/alert" .}} {{template "base/alert" .}}
<div class="repo-view-container">
{{template "repo/view_file_tree" .}}
<div class="repo-view-content">
<form class="ui form form-fetch-action" method="post" action="{{.CommitFormOptions.TargetFormAction}}"> <form class="ui form form-fetch-action" method="post" action="{{.CommitFormOptions.TargetFormAction}}">
{{.CsrfTokenHtml}} {{.CsrfTokenHtml}}
{{template "repo/editor/common_top" .}} {{template "repo/editor/common_top" .}}
<div class="repo-editor-header">
{{/* although the UI isn't good enough, this header is necessary for the "left file tree view" toggle button, this button must exist */}}
{{template "repo/view_file_tree_toggle_button" .}}
{{/* then, to make the page looks overall good, add the breadcrumb here to make the toggle button can be shown in a text row, but not a single button*/}}
<div class="breadcrumb">
<a class="section" href="{{$.BranchLink}}">{{.Repository.Name}}</a>
{{range $i, $v := .TreeNames}}
<div class="breadcrumb-divider">/</div>
<span class="section"><a href="{{$.BranchLink}}/{{index $.TreePaths $i | PathEscapeSegments}}">{{$v}}</a></span>
{{end}}
</div>
</div>
{{template "repo/editor/commit_form" .}} {{template "repo/editor/commit_form" .}}
</form> </form>
</div> </div>
</div>
</div>
</div> </div>
{{template "base/footer" .}} {{template "base/footer" .}}

View File

@@ -1,8 +1,11 @@
{{template "base/head" .}} {{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content repository file editor edit"> <div role="main" aria-label="{{.Title}}" class="page-content repository file editor edit">
{{template "repo/header" .}} {{template "repo/header" .}}
<div class="ui container"> <div class="ui container fluid padded">
{{template "base/alert" .}} {{template "base/alert" .}}
<div class="repo-view-container">
{{template "repo/view_file_tree" .}}
<div class="repo-view-content">
<form class="ui edit form form-fetch-action" method="post" action="{{.CommitFormOptions.TargetFormAction}}" <form class="ui edit form form-fetch-action" method="post" action="{{.CommitFormOptions.TargetFormAction}}"
data-text-empty-confirm-header="{{ctx.Locale.Tr "repo.editor.commit_empty_file_header"}}" data-text-empty-confirm-header="{{ctx.Locale.Tr "repo.editor.commit_empty_file_header"}}"
data-text-empty-confirm-content="{{ctx.Locale.Tr "repo.editor.commit_empty_file_text"}}" data-text-empty-confirm-content="{{ctx.Locale.Tr "repo.editor.commit_empty_file_text"}}"
@@ -10,6 +13,7 @@
{{.CsrfTokenHtml}} {{.CsrfTokenHtml}}
{{template "repo/editor/common_top" .}} {{template "repo/editor/common_top" .}}
<div class="repo-editor-header"> <div class="repo-editor-header">
{{template "repo/view_file_tree_toggle_button" .}}
{{template "repo/editor/common_breadcrumb" .}} {{template "repo/editor/common_breadcrumb" .}}
</div> </div>
{{if not .NotEditableReason}} {{if not .NotEditableReason}}
@@ -49,5 +53,7 @@
{{template "repo/editor/commit_form" .}} {{template "repo/editor/commit_form" .}}
</form> </form>
</div> </div>
</div>
</div>
</div> </div>
{{template "base/footer" .}} {{template "base/footer" .}}

View File

@@ -1,12 +1,16 @@
{{template "base/head" .}} {{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content repository file editor upload"> <div role="main" aria-label="{{.Title}}" class="page-content repository file editor upload">
{{template "repo/header" .}} {{template "repo/header" .}}
<div class="ui container"> <div class="ui container fluid padded">
{{template "base/alert" .}} {{template "base/alert" .}}
<div class="repo-view-container">
{{template "repo/view_file_tree" .}}
<div class="repo-view-content">
<form class="ui comment form form-fetch-action" method="post" action="{{.CommitFormOptions.TargetFormAction}}"> <form class="ui comment form form-fetch-action" method="post" action="{{.CommitFormOptions.TargetFormAction}}">
{{.CsrfTokenHtml}} {{.CsrfTokenHtml}}
{{template "repo/editor/common_top" .}} {{template "repo/editor/common_top" .}}
<div class="repo-editor-header"> <div class="repo-editor-header">
{{template "repo/view_file_tree_toggle_button" .}}
{{template "repo/editor/common_breadcrumb" .}} {{template "repo/editor/common_breadcrumb" .}}
</div> </div>
<div class="field"> <div class="field">
@@ -15,5 +19,7 @@
{{template "repo/editor/commit_form" .}} {{template "repo/editor/commit_form" .}}
</form> </form>
</div> </div>
</div>
</div>
</div> </div>
{{template "base/footer" .}} {{template "base/footer" .}}

View File

@@ -1,21 +0,0 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content repository">
{{template "repo/header" .}}
<div class="ui container">
<div class="tw-flex tw-items-center">
<a href="{{$.RepoLink}}">{{.RepoName}}</a>
<span class="tw-mx-2">/</span>
<div class="ui input tw-flex-1">
<input id="repo-file-find-input" type="text" autofocus data-url-data-link="{{.DataLink}}" data-url-tree-link="{{.TreeLink}}">
</div>
</div>
<table id="repo-find-file-table" class="ui single line fixed table">
<tbody>
</tbody>
</table>
<div id="repo-find-file-no-result" class="ui row center tw-mt-8 tw-hidden">
<h3>{{ctx.Locale.Tr "repo.find_file.no_matching"}}</h3>
</div>
</div>
</div>
{{template "base/footer" .}}

View File

@@ -17,9 +17,7 @@
{{template "repo/code/recently_pushed_new_branches" dict "RecentBranchesPromptData" .RecentBranchesPromptData}} {{template "repo/code/recently_pushed_new_branches" dict "RecentBranchesPromptData" .RecentBranchesPromptData}}
<div class="repo-view-container"> <div class="repo-view-container">
<div class="tw-flex tw-flex-col repo-view-file-tree-container not-mobile {{if not .UserSettingCodeViewShowFileTree}}tw-hidden{{end}}" {{if .IsSigned}}data-user-is-signed-in{{end}}>
{{template "repo/view_file_tree" .}} {{template "repo/view_file_tree" .}}
</div>
<div class="repo-view-content"> <div class="repo-view-content">
{{template "repo/view_content" .}} {{template "repo/view_content" .}}
</div> </div>

View File

@@ -5,11 +5,7 @@
<div class="repo-button-row"> <div class="repo-button-row">
<div class="repo-button-row-left"> <div class="repo-button-row-left">
{{if not $isTreePathRoot}} {{if not $isTreePathRoot}}
<button class="repo-view-file-tree-toggle-show ui compact basic button icon not-mobile {{if .UserSettingCodeViewShowFileTree}}tw-hidden{{end}}" {{template "repo/view_file_tree_toggle_button" .}}
data-global-click="onRepoViewFileTreeToggle" data-toggle-action="show"
data-tooltip-content="{{ctx.Locale.Tr "repo.diff.show_file_tree"}}">
{{svg "octicon-sidebar-collapse"}}
</button>
{{end}} {{end}}
{{template "repo/branch_dropdown" dict {{template "repo/branch_dropdown" dict
@@ -37,31 +33,6 @@
</a> </a>
{{end}} {{end}}
<!-- Show go to file if on home page -->
{{if $isTreePathRoot}}
<a href="{{.Repository.Link}}/find/{{.RefTypeNameSubURL}}" class="ui compact basic button">{{ctx.Locale.Tr "repo.find_file.go_to_file"}}</a>
{{end}}
{{if and .RefFullName.IsBranch (not .IsViewFile)}}
<button class="ui dropdown basic compact jump button repo-add-file" {{if not .Repository.CanEnableEditor}}disabled{{end}}>
{{ctx.Locale.Tr "repo.editor.add_file"}}
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu">
<a class="item" href="{{.RepoLink}}/_new/{{.BranchName | PathEscapeSegments}}/{{.TreePath | PathEscapeSegments}}">
{{ctx.Locale.Tr "repo.editor.new_file"}}
</a>
{{if .RepositoryUploadEnabled}}
<a class="item" href="{{.RepoLink}}/_upload/{{.BranchName | PathEscapeSegments}}/{{.TreePath | PathEscapeSegments}}">
{{ctx.Locale.Tr "repo.editor.upload_file"}}
</a>
{{end}}
<a class="item" href="{{.RepoLink}}/_diffpatch/{{.BranchName | PathEscapeSegments}}/{{.TreePath | PathEscapeSegments}}">
{{ctx.Locale.Tr "repo.editor.patch"}}
</a>
</div>
</button>
{{end}}
{{if and $isTreePathRoot .Repository.IsTemplate}} {{if and $isTreePathRoot .Repository.IsTemplate}}
<a role="button" class="ui primary compact button" href="{{AppSubUrl}}/repo/create?template_id={{.Repository.ID}}"> <a role="button" class="ui primary compact button" href="{{AppSubUrl}}/repo/create?template_id={{.Repository.ID}}">
{{ctx.Locale.Tr "repo.use_template"}} {{ctx.Locale.Tr "repo.use_template"}}
@@ -86,12 +57,65 @@
</div> </div>
<div class="repo-button-row-right"> <div class="repo-button-row-right">
<div class="repo-file-search-container"
data-global-init="initRepoFileSearch"
data-repo-link="{{.RepoLink}}"
data-current-ref-name-sub-url="{{.RefTypeNameSubURL}}"
data-tree-list-url="{{.RepoLink}}/tree-list/{{.RefTypeNameSubURL}}"
data-no-results-text="{{ctx.Locale.Tr "repo.find_file.no_matching"}}"
data-placeholder="{{ctx.Locale.Tr "repo.find_file.go_to_file"}}"
></div>
{{if .RefFullName.IsBranch}}
{{$addFilePath := .TreePath}}
{{if .IsViewFile}}
{{if gt (len .TreeNames) 1}}
{{$addFilePath = StringUtils.Join (slice .TreeNames 0 (Eval (len .TreeNames) "-" 1)) "/"}}
{{else}}
{{$addFilePath = ""}}
{{end}}
{{end}}
<button class="ui dropdown basic compact jump button repo-add-file" {{if not .Repository.CanEnableEditor}}disabled{{end}}>
{{ctx.Locale.Tr "repo.editor.add_file"}}
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu">
<a class="item" href="{{.RepoLink}}/_new/{{.BranchName | PathEscapeSegments}}/{{$addFilePath | PathEscapeSegments}}">
{{svg "octicon-file-added" 16 "tw-mr-2"}}{{ctx.Locale.Tr "repo.editor.new_file"}}
</a>
{{if .RepositoryUploadEnabled}}
<a class="item" href="{{.RepoLink}}/_upload/{{.BranchName | PathEscapeSegments}}/{{$addFilePath | PathEscapeSegments}}">
{{svg "octicon-upload" 16 "tw-mr-2"}}{{ctx.Locale.Tr "repo.editor.upload_file"}}
</a>
{{end}}
<a class="item" href="{{.RepoLink}}/_diffpatch/{{.BranchName | PathEscapeSegments}}/{{$addFilePath | PathEscapeSegments}}">
{{svg "octicon-diff" 16 "tw-mr-2"}}{{ctx.Locale.Tr "repo.editor.patch"}}
</a>
</div>
</button>
{{if and (not .IsViewFile) (not $isTreePathRoot)}}
<button class="ui dropdown basic compact jump button tw-px-3" data-tooltip-content="{{ctx.Locale.Tr "repo.more_operations"}}">
{{svg "octicon-kebab-horizontal"}}
<div class="menu">
<a class="item" data-clipboard-text="{{.Repository.Link}}/src/commit/{{.CommitID}}/{{PathEscapeSegments .TreePath}}" data-clipboard-text-type="url">
{{svg "octicon-link" 16}}{{ctx.Locale.Tr "repo.file_copy_permalink"}}
</a>
{{if and (.Permission.CanWrite ctx.Consts.RepoUnitTypeCode) (not .Repository.IsArchived) (not $isTreePathRoot)}}
<div class="divider"></div>
<a class="item tw-text-danger" href="{{.RepoLink}}/_delete/{{.BranchName | PathEscapeSegments}}/{{.TreePath | PathEscapeSegments}}">
{{svg "octicon-trash" 16}}{{ctx.Locale.Tr "repo.editor.delete_this_directory"}}
</a>
{{end}}
</div>
</button>
{{end}}
{{end}}
<!-- Only show clone panel in repository home page --> <!-- Only show clone panel in repository home page -->
{{if $isTreePathRoot}} {{if $isTreePathRoot}}
{{template "repo/clone_panel" .}} {{template "repo/clone_panel" .}}
{{end}} {{end}}
{{if and (not $isTreePathRoot) (not .IsViewFile) (not .IsBlame)}}{{/* IsViewDirectory (not home), TODO: split the templates, avoid using "if" tricks */}} {{if and (not $isTreePathRoot) (not .IsViewFile) (not .IsBlame)}}{{/* IsViewDirectory (not home), TODO: split the templates, avoid using "if" tricks */}}
<a class="ui button" href="{{.RepoLink}}/commits/{{.RefTypeNameSubURL}}/{{.TreePath | PathEscapeSegments}}"> <a class="ui compact button" href="{{.RepoLink}}/commits/{{.RefTypeNameSubURL}}/{{.TreePath | PathEscapeSegments}}">
{{svg "octicon-history" 16 "tw-mr-2"}}{{ctx.Locale.Tr "repo.file_history"}} {{svg "octicon-history" 16 "tw-mr-2"}}{{ctx.Locale.Tr "repo.file_history"}}
</a> </a>
{{end}} {{end}}

View File

@@ -1,15 +1,17 @@
<div class="flex-text-block repo-button-row"> <div class="repo-view-file-tree-container {{if not .UserSettingCodeViewShowFileTree}}tw-hidden{{end}}">
<button class="ui compact basic icon button" <div class="flex-text-block repo-button-row">
<button class="repo-view-file-tree-toggle ui button"
data-global-click="onRepoViewFileTreeToggle" data-toggle-action="hide" data-global-click="onRepoViewFileTreeToggle" data-toggle-action="hide"
data-tooltip-content="{{ctx.Locale.Tr "repo.diff.hide_file_tree"}}"> data-tooltip-content="{{ctx.Locale.Tr "repo.diff.hide_file_tree"}}">
{{svg "octicon-sidebar-expand"}} {{svg "octicon-sidebar-expand"}}
</button> </button>
<b>{{ctx.Locale.Tr "files"}}</b> <b>{{ctx.Locale.Tr "files"}}</b>
</div> </div>
{{/* TODO: Dynamically move components such as refSelector and createPR here */}} {{/* TODO: Dynamically move components such as refSelector and createPR here */}}
<div id="view-file-tree" class="tw-overflow-auto tw-h-full is-loading" <div id="view-file-tree" class="tw-overflow-auto tw-h-full is-loading"
data-repo-link="{{.RepoLink}}" data-repo-link="{{.RepoLink}}"
data-tree-path="{{$.TreePath}}" data-tree-path="{{$.TreePath}}"
data-current-ref-name-sub-url="{{.RefTypeNameSubURL}}" data-current-ref-name-sub-url="{{.RefTypeNameSubURL}}"
></div> ></div>
</div>

View File

@@ -0,0 +1,6 @@
<button type="button"
class="repo-view-file-tree-toggle ui button not-mobile {{if .UserSettingCodeViewShowFileTree}}tw-hidden{{end}}"
data-global-click="onRepoViewFileTreeToggle" data-toggle-action="show" data-tooltip-content="{{ctx.Locale.Tr "repo.diff.show_file_tree"}}"
>
{{svg "octicon-sidebar-collapse"}}
</button>

View File

@@ -47,7 +47,7 @@
{{end}} {{end}}
{{end}} {{end}}
</div> </div>
<div class="repo-file-cell message loading-icon-2px"> <div class="repo-file-cell message commit-summary loading-icon-2px">
{{if $commit}} {{if $commit}}
{{$commitLink := printf "%s/commit/%s" $.RepoLink (PathEscape $commit.ID.String)}} {{$commitLink := printf "%s/commit/%s" $.RepoLink (PathEscape $commit.ID.String)}}
{{ctx.RenderUtils.RenderCommitMessageLinkSubject $commit.Message $commitLink $.Repository}} {{ctx.RenderUtils.RenderCommitMessageLinkSubject $commit.Message $commitLink $.Repository}}

View File

@@ -5,6 +5,7 @@ package integration
import ( import (
"context" "context"
"errors"
"strings" "strings"
"testing" "testing"
@@ -72,7 +73,7 @@ func deleteFileInBranch(user *user_model.User, repo *repo_model.Repository, tree
func createOrReplaceFileInBranch(user *user_model.User, repo *repo_model.Repository, treePath, branchName, content string) error { func createOrReplaceFileInBranch(user *user_model.User, repo *repo_model.Repository, treePath, branchName, content string) error {
_, err := deleteFileInBranch(user, repo, treePath, branchName) _, err := deleteFileInBranch(user, repo, treePath, branchName)
if err != nil && !files_service.IsErrRepoFileDoesNotExist(err) { if err != nil && !errors.Is(err, util.ErrNotExist) {
return err return err
} }

View File

@@ -5,6 +5,7 @@ package integration
import ( import (
"fmt" "fmt"
"net/http"
"net/url" "net/url"
"path" "path"
"strings" "strings"
@@ -12,7 +13,6 @@ import (
"time" "time"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
@@ -22,6 +22,7 @@ import (
files_service "code.gitea.io/gitea/services/repository/files" files_service "code.gitea.io/gitea/services/repository/files"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func getCreateRepoFilesOptions(repo *repo_model.Repository) *files_service.ChangeRepoFilesOptions { func getCreateRepoFilesOptions(repo *repo_model.Repository) *files_service.ChangeRepoFilesOptions {
@@ -93,55 +94,6 @@ func getUpdateRepoFilesRenameOptions(repo *repo_model.Repository) *files_service
} }
} }
func getDeleteRepoFilesOptions(repo *repo_model.Repository) *files_service.ChangeRepoFilesOptions {
return &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{
{
Operation: "delete",
TreePath: "README.md",
SHA: "4b4851ad51df6a7d9f25c979345979eaeb5b349f",
},
},
LastCommitID: "",
OldBranch: repo.DefaultBranch,
NewBranch: repo.DefaultBranch,
Message: "Deletes README.md",
Author: &files_service.IdentityOptions{
GitUserName: "Bob Smith",
GitUserEmail: "bob@smith.com",
},
Committer: nil,
}
}
func getExpectedFileResponseForRepoFilesDelete() *api.FileResponse {
// Just returns fields that don't change, i.e. fields with commit SHAs and dates can't be determined
return &api.FileResponse{
Content: nil,
Commit: &api.FileCommitResponse{
Author: &api.CommitUser{
Identity: api.Identity{
Name: "Bob Smith",
Email: "bob@smith.com",
},
},
Committer: &api.CommitUser{
Identity: api.Identity{
Name: "Bob Smith",
Email: "bob@smith.com",
},
},
Message: "Deletes README.md\n",
},
Verification: &api.PayloadCommitVerification{
Verified: false,
Reason: "gpg.error.not_signed_commit",
Signature: "",
Payload: "",
},
}
}
func getExpectedFileResponseForRepoFilesCreate(commitID string, lastCommit *git.Commit) *api.FileResponse { func getExpectedFileResponseForRepoFilesCreate(commitID string, lastCommit *git.Commit) *api.FileResponse {
treePath := "new/file.txt" treePath := "new/file.txt"
encoding := "base64" encoding := "base64"
@@ -578,12 +530,7 @@ func TestChangeRepoFilesWithoutBranchNames(t *testing.T) {
} }
func TestChangeRepoFilesForDelete(t *testing.T) { func TestChangeRepoFilesForDelete(t *testing.T) {
onGiteaRun(t, testDeleteRepoFiles) onGiteaRun(t, func(t *testing.T, u *url.URL) {
}
func testDeleteRepoFiles(t *testing.T, u *url.URL) {
// setup
unittest.PrepareTestEnv(t)
ctx, _ := contexttest.MockContext(t, "user2/repo1") ctx, _ := contexttest.MockContext(t, "user2/repo1")
ctx.SetPathParam("id", "1") ctx.SetPathParam("id", "1")
contexttest.LoadRepo(t, ctx, 1) contexttest.LoadRepo(t, ctx, 1)
@@ -593,60 +540,78 @@ func testDeleteRepoFiles(t *testing.T, u *url.URL) {
defer ctx.Repo.GitRepo.Close() defer ctx.Repo.GitRepo.Close()
repo := ctx.Repo.Repository repo := ctx.Repo.Repository
doer := ctx.Doer doer := ctx.Doer
opts := getDeleteRepoFilesOptions(repo)
t.Run("Delete README.md file", func(t *testing.T) { t.Run("Delete README.md by commit", func(t *testing.T) {
urlRaw := "/user2/repo1/raw/branch/branch2/README.md"
MakeRequest(t, NewRequest(t, "GET", urlRaw), http.StatusOK)
opts := &files_service.ChangeRepoFilesOptions{
OldBranch: "branch2",
LastCommitID: "985f0301dba5e7b34be866819cd15ad3d8f508ee",
Files: []*files_service.ChangeRepoFile{
{
Operation: "delete",
TreePath: "README.md",
},
},
Message: "test message",
}
filesResponse, err := files_service.ChangeRepoFiles(t.Context(), repo, doer, opts) filesResponse, err := files_service.ChangeRepoFiles(t.Context(), repo, doer, opts)
assert.NoError(t, err) require.NoError(t, err)
expectedFileResponse := getExpectedFileResponseForRepoFilesDelete()
assert.NotNil(t, filesResponse) assert.NotNil(t, filesResponse)
assert.Nil(t, filesResponse.Files[0]) assert.Nil(t, filesResponse.Files[0])
assert.Equal(t, expectedFileResponse.Commit.Message, filesResponse.Commit.Message) assert.Equal(t, "test message\n", filesResponse.Commit.Message)
assert.Equal(t, expectedFileResponse.Commit.Author.Identity, filesResponse.Commit.Author.Identity) MakeRequest(t, NewRequest(t, "GET", urlRaw), http.StatusNotFound)
assert.Equal(t, expectedFileResponse.Commit.Committer.Identity, filesResponse.Commit.Committer.Identity)
assert.Equal(t, expectedFileResponse.Verification, filesResponse.Verification)
}) })
t.Run("Verify README.md has been deleted", func(t *testing.T) { t.Run("Delete README.md with options", func(t *testing.T) {
urlRaw := "/user2/repo1/raw/branch/master/README.md"
MakeRequest(t, NewRequest(t, "GET", urlRaw), http.StatusOK)
opts := &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{
{
Operation: "delete",
TreePath: "README.md",
SHA: "4b4851ad51df6a7d9f25c979345979eaeb5b349f",
},
},
OldBranch: repo.DefaultBranch,
NewBranch: repo.DefaultBranch,
Message: "Message for deleting README.md",
Author: &files_service.IdentityOptions{GitUserName: "Bob Smith", GitUserEmail: "bob@smith.com"},
}
filesResponse, err := files_service.ChangeRepoFiles(t.Context(), repo, doer, opts) filesResponse, err := files_service.ChangeRepoFiles(t.Context(), repo, doer, opts)
assert.Nil(t, filesResponse) require.NoError(t, err)
expectedError := "repository file does not exist [path: " + opts.Files[0].TreePath + "]" require.NotNil(t, filesResponse)
assert.EqualError(t, err, expectedError) assert.Nil(t, filesResponse.Files[0])
assert.Equal(t, "Message for deleting README.md\n", filesResponse.Commit.Message)
assert.Equal(t, api.Identity{Name: "Bob Smith", Email: "bob@smith.com"}, filesResponse.Commit.Author.Identity)
assert.Equal(t, api.Identity{Name: "Bob Smith", Email: "bob@smith.com"}, filesResponse.Commit.Committer.Identity)
assert.Equal(t, &api.PayloadCommitVerification{Reason: "gpg.error.not_signed_commit"}, filesResponse.Verification)
MakeRequest(t, NewRequest(t, "GET", urlRaw), http.StatusNotFound)
}) })
}
// Test opts with branch names removed, same results t.Run("Delete directory", func(t *testing.T) {
func TestChangeRepoFilesForDeleteWithoutBranchNames(t *testing.T) { urlRaw := "/user2/repo1/raw/branch/sub-home-md-img-check/docs/README.md"
onGiteaRun(t, testDeleteRepoFilesWithoutBranchNames) MakeRequest(t, NewRequest(t, "GET", urlRaw), http.StatusOK)
} opts := &files_service.ChangeRepoFilesOptions{
OldBranch: "sub-home-md-img-check",
func testDeleteRepoFilesWithoutBranchNames(t *testing.T, u *url.URL) { LastCommitID: "4649299398e4d39a5c09eb4f534df6f1e1eb87cc",
// setup Files: []*files_service.ChangeRepoFile{
unittest.PrepareTestEnv(t) {
ctx, _ := contexttest.MockContext(t, "user2/repo1") Operation: "delete",
ctx.SetPathParam("id", "1") TreePath: "docs",
contexttest.LoadRepo(t, ctx, 1) DeleteRecursively: true,
contexttest.LoadRepoCommit(t, ctx) },
contexttest.LoadUser(t, ctx, 2) },
contexttest.LoadGitRepo(t, ctx) Message: "test message",
defer ctx.Repo.GitRepo.Close() }
repo := ctx.Repo.Repository
doer := ctx.Doer
opts := getDeleteRepoFilesOptions(repo)
opts.OldBranch = ""
opts.NewBranch = ""
t.Run("Delete README.md without Branch Name", func(t *testing.T) {
filesResponse, err := files_service.ChangeRepoFiles(t.Context(), repo, doer, opts) filesResponse, err := files_service.ChangeRepoFiles(t.Context(), repo, doer, opts)
assert.NoError(t, err) require.NoError(t, err)
expectedFileResponse := getExpectedFileResponseForRepoFilesDelete()
assert.NotNil(t, filesResponse) assert.NotNil(t, filesResponse)
assert.Nil(t, filesResponse.Files[0]) assert.Nil(t, filesResponse.Files[0])
assert.Equal(t, expectedFileResponse.Commit.Message, filesResponse.Commit.Message) assert.Equal(t, "test message\n", filesResponse.Commit.Message)
assert.Equal(t, expectedFileResponse.Commit.Author.Identity, filesResponse.Commit.Author.Identity) MakeRequest(t, NewRequest(t, "GET", urlRaw), http.StatusNotFound)
assert.Equal(t, expectedFileResponse.Commit.Committer.Identity, filesResponse.Commit.Committer.Identity) })
assert.Equal(t, expectedFileResponse.Verification, filesResponse.Verification)
}) })
} }

View File

@@ -1,23 +1,3 @@
.repository.file.editor .tab[data-tab="write"] {
padding: 0 !important;
}
.repository.file.editor .tab[data-tab="write"] .editor-toolbar {
border: 0 !important;
}
.repository.file.editor .tab[data-tab="write"] .CodeMirror {
border-left: 0;
border-right: 0;
border-bottom: 0;
}
.repo-editor-header {
display: flex;
margin: 1rem 0;
padding: 3px 0;
}
.editor-toolbar { .editor-toolbar {
border-color: var(--color-secondary); border-color: var(--color-secondary);
} }

View File

@@ -28,7 +28,7 @@
aspect-ratio: 1; aspect-ratio: 1;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
animation: isloadingspin 1000ms infinite linear; animation: isloadingspin 1000ms infinite linear;
border-width: 4px; border-width: 3px;
border-style: solid; border-style: solid;
border-color: var(--color-secondary) var(--color-secondary) var(--color-secondary-dark-8) var(--color-secondary-dark-8); border-color: var(--color-secondary) var(--color-secondary) var(--color-secondary-dark-8) var(--color-secondary-dark-8);
border-radius: var(--border-radius-full); border-radius: var(--border-radius-full);

View File

@@ -150,63 +150,68 @@ td .commit-summary {
} }
} }
.repository.file.list .non-diff-file-content .header .icon { .non-diff-file-content .header .icon {
font-size: 1em; font-size: 1em;
} }
.repository.file.list .non-diff-file-content .header .small.icon { .non-diff-file-content .header .small.icon {
font-size: 0.75em; font-size: 0.75em;
} }
.repository.file.list .non-diff-file-content .header .tiny.icon { .non-diff-file-content .header .tiny.icon {
font-size: 0.5em; font-size: 0.5em;
} }
.repository.file.list .non-diff-file-content .header .file-actions .btn-octicon { .non-diff-file-content .header .file-actions .btn-octicon {
line-height: var(--line-height-default); line-height: var(--line-height-default);
padding: 8px; padding: 8px;
vertical-align: middle; vertical-align: middle;
color: var(--color-text); color: var(--color-text);
} }
.repository.file.list .non-diff-file-content .header .file-actions .btn-octicon:hover { .non-diff-file-content .header .file-actions .btn-octicon:hover {
color: var(--color-primary); color: var(--color-primary);
} }
.repository.file.list .non-diff-file-content .header .file-actions .btn-octicon-danger:hover { .non-diff-file-content .header .file-actions .btn-octicon-danger:hover {
color: var(--color-red); color: var(--color-red);
} }
.repository.file.list .non-diff-file-content .header .file-actions .btn-octicon.disabled { .non-diff-file-content .header .file-actions .btn-octicon.disabled {
color: inherit; color: inherit;
opacity: var(--opacity-disabled); opacity: var(--opacity-disabled);
cursor: default; cursor: default;
} }
.repository.file.list .non-diff-file-content .plain-text { .non-diff-file-content .plain-text {
padding: 1em 2em; padding: 1em 2em;
} }
.repository.file.list .non-diff-file-content .plain-text pre { .non-diff-file-content .plain-text pre {
overflow-wrap: anywhere; overflow-wrap: anywhere;
white-space: pre-wrap; white-space: pre-wrap;
} }
.repository.file.list .non-diff-file-content .csv { .non-diff-file-content .csv {
overflow-x: auto; overflow-x: auto;
padding: 0 !important; padding: 0 !important;
} }
.repository.file.list .non-diff-file-content pre { .non-diff-file-content pre {
overflow: auto; overflow: auto;
} }
.repository.file.list .non-diff-file-content .asciicast { .non-diff-file-content .asciicast {
padding: 0 !important; padding: 0 !important;
} }
.repo-editor-header { .repo-editor-header {
display: flex;
margin: 1rem 0;
padding: 3px 0;
width: 100%; width: 100%;
gap: 0.5em;
align-items: center;
} }
.repo-editor-header input { .repo-editor-header input {
@@ -216,17 +221,13 @@ td .commit-summary {
margin-right: 5px !important; margin-right: 5px !important;
} }
.repository.file.editor .tabular.menu .svg {
margin-right: 5px;
}
.repository.file.editor .commit-form-wrapper { .repository.file.editor .commit-form-wrapper {
padding-left: 48px; padding-left: 58px;
} }
.repository.file.editor .commit-form-wrapper .commit-avatar { .repository.file.editor .commit-form-wrapper .commit-avatar {
float: left; float: left;
margin-left: -48px; margin-left: -58px;
} }
.repository.file.editor .commit-form-wrapper .commit-form { .repository.file.editor .commit-form-wrapper .commit-form {
@@ -1409,12 +1410,25 @@ td .commit-summary {
flex-grow: 1; flex-grow: 1;
} }
.repo-button-row .ui.button { .repo-button-row .ui.button,
.repo-view-container .ui.button.repo-view-file-tree-toggle {
flex-shrink: 0; flex-shrink: 0;
margin: 0; margin: 0;
min-height: 30px; min-height: 30px;
} }
.repo-view-container .ui.button.repo-view-file-tree-toggle {
padding: 0 6px;
}
.repo-button-row .repo-file-search-container .ui.input {
height: 30px;
}
.repo-button-row .ui.dropdown > .menu {
margin-top: 4px;
}
tbody.commit-list { tbody.commit-list {
vertical-align: baseline; vertical-align: baseline;
} }
@@ -1483,6 +1497,12 @@ tbody.commit-list {
line-height: initial; line-height: initial;
} }
.commit-body a.commit code,
.commit-summary a.commit code {
/* these links are generated by the render: <a class="commit" href="...">...</a> */
background: inherit;
}
.git-notes.top { .git-notes.top {
text-align: left; text-align: left;
} }

View File

@@ -54,7 +54,9 @@
gap: var(--page-spacing); gap: var(--page-spacing);
} }
.repo-view-container .repo-view-file-tree-container { .repo-view-file-tree-container {
display: flex;
flex-direction: column;
flex: 0 0 15%; flex: 0 0 15%;
min-width: 0; min-width: 0;
max-height: 100vh; max-height: 100vh;
@@ -65,6 +67,12 @@
overflow-y: hidden; overflow-y: hidden;
} }
@media (max-width: 767.98px) {
.repo-view-file-tree-container {
display: none;
}
}
.repo-view-content { .repo-view-content {
flex: 1; flex: 1;
min-width: 0; min-width: 0;

View File

@@ -244,6 +244,7 @@ gitea-theme-meta-info {
--color-highlight-fg: #87651e; --color-highlight-fg: #87651e;
--color-highlight-bg: #352c1c; --color-highlight-bg: #352c1c;
--color-overlay-backdrop: #080808c0; --color-overlay-backdrop: #080808c0;
--color-danger: var(--color-red);
accent-color: var(--color-accent); accent-color: var(--color-accent);
color-scheme: dark; color-scheme: dark;
} }

View File

@@ -244,6 +244,7 @@ gitea-theme-meta-info {
--color-highlight-fg: #eed200; --color-highlight-fg: #eed200;
--color-highlight-bg: #fffbdd; --color-highlight-bg: #fffbdd;
--color-overlay-backdrop: #080808c0; --color-overlay-backdrop: #080808c0;
--color-danger: var(--color-red);
accent-color: var(--color-accent); accent-color: var(--color-accent);
color-scheme: light; color-scheme: light;
} }

View File

@@ -0,0 +1,230 @@
<script lang="ts" setup>
import { ref, computed, watch, nextTick, useTemplateRef, onMounted, onUnmounted } from 'vue';
import {generateElemId} from '../utils/dom.ts';
import { GET } from '../modules/fetch.ts';
import { filterRepoFilesWeighted } from '../features/repo-findfile.ts';
import { pathEscapeSegments } from '../utils/url.ts';
import { SvgIcon } from '../svg.ts';
import {throttle} from 'throttle-debounce';
const props = defineProps({
repoLink: { type: String, required: true },
currentRefNameSubURL: { type: String, required: true },
treeListUrl: { type: String, required: true },
noResultsText: { type: String, required: true },
placeholder: { type: String, required: true },
});
const refElemInput = useTemplateRef<HTMLInputElement>('searchInput');
const refElemPopup = useTemplateRef<HTMLElement>('searchPopup');
const searchQuery = ref('');
const allFiles = ref<string[]>([]);
const selectedIndex = ref(0);
const isLoadingFileList = ref(false);
const hasLoadedFileList = ref(false);
const showPopup = computed(() => searchQuery.value.length > 0);
const filteredFiles = computed(() => {
if (!searchQuery.value) return [];
return filterRepoFilesWeighted(allFiles.value, searchQuery.value);
});
const applySearchQuery = throttle(300, () => {
searchQuery.value = refElemInput.value.value;
selectedIndex.value = 0;
});
const handleSearchInput = () => {
loadFileListForSearch();
applySearchQuery();
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
clearSearch();
return;
}
if (!searchQuery.value || filteredFiles.value.length === 0) return;
const handleSelectedItem = (idx: number) => {
e.preventDefault();
selectedIndex.value = idx;
const el = refElemPopup.value.querySelector(`.file-search-results > :nth-child(${idx+1} of .item)`);
el?.scrollIntoView({ block: 'nearest', behavior: 'instant' });
};
if (e.key === 'ArrowDown') {
handleSelectedItem(Math.min(selectedIndex.value + 1, filteredFiles.value.length - 1));
} else if (e.key === 'ArrowUp') {
handleSelectedItem(Math.max(selectedIndex.value - 1, 0))
} else if (e.key === 'Enter') {
e.preventDefault();
const selectedFile = filteredFiles.value[selectedIndex.value];
if (selectedFile) {
handleSearchResultClick(selectedFile.matchResult.join(''));
}
}
};
const clearSearch = () => {
searchQuery.value = '';
refElemInput.value.value = '';
};
const handleClickOutside = (e: MouseEvent) => {
if (!searchQuery.value) return;
const target = e.target as HTMLElement;
const clickInside = refElemInput.value.contains(target) || refElemPopup.value.contains(target);
if (!clickInside) clearSearch();
};
const loadFileListForSearch = async () => {
if (hasLoadedFileList.value || isLoadingFileList.value) return;
isLoadingFileList.value = true;
try {
const response = await GET(props.treeListUrl);
allFiles.value = await response.json();
hasLoadedFileList.value = true;
} finally {
isLoadingFileList.value = false;
}
};
function handleSearchResultClick(filePath: string) {
clearSearch();
window.location.href = `${props.repoLink}/src/${pathEscapeSegments(props.currentRefNameSubURL)}/${pathEscapeSegments(filePath)}`;
}
const updatePosition = () => {
if (!showPopup.value) return;
const rectInput = refElemInput.value.getBoundingClientRect();
const rectPopup = refElemPopup.value.getBoundingClientRect();
const docElem = document.documentElement;
const style = refElemPopup.value.style;
style.top = `${docElem.scrollTop + rectInput.bottom + 4}px`;
if (rectInput.x + rectPopup.width < docElem.clientWidth) {
// enough space to align left with the input
style.left = `${docElem.scrollLeft + rectInput.x}px`;
} else {
// no enough space, align right from the viewport right edge minus page margin
const leftPos = docElem.scrollLeft + docElem.getBoundingClientRect().width - rectPopup.width;
style.left = `calc(${leftPos}px - var(--page-margin-x))`;
}
};
onMounted(() => {
const searchPopupId = generateElemId('file-search-popup-');
refElemPopup.value.setAttribute('id', searchPopupId);
refElemInput.value.setAttribute('aria-controls', searchPopupId);
document.addEventListener('click', handleClickOutside);
window.addEventListener('resize', updatePosition);
});
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
window.removeEventListener('resize', updatePosition);
});
// Position search results below the input
watch([searchQuery, filteredFiles], async () => {
if (searchQuery.value) {
await nextTick();
updatePosition();
}
});
</script>
<template>
<div>
<div class="ui small input">
<input
ref="searchInput" :placeholder="placeholder" autocomplete="off"
role="combobox" aria-autocomplete="list" :aria-expanded="searchQuery ? 'true' : 'false'"
@input="handleSearchInput" @keydown="handleKeyDown"
>
</div>
<Teleport to="body">
<div v-show="showPopup" ref="searchPopup" class="file-search-popup">
<!-- always create the popup by v-show above to avoid null ref, only create the popup content if the popup should be displayed to save memory -->
<template v-if="showPopup">
<div v-if="filteredFiles.length" role="listbox" class="file-search-results flex-items-block">
<div
v-for="(result, idx) in filteredFiles" :key="result.matchResult.join('')"
:class="['item', { 'selected': idx === selectedIndex }]"
role="option" :aria-selected="idx === selectedIndex" @click="handleSearchResultClick(result.matchResult.join(''))"
@mouseenter="selectedIndex = idx" :title="result.matchResult.join('')"
>
<SvgIcon name="octicon-file" class="file-icon"/>
<span class="full-path">
<span v-for="(part, index) in result.matchResult" :key="index">{{ part }}</span>
</span>
</div>
</div>
<div v-else-if="isLoadingFileList">
<div class="is-loading"/>
</div>
<div v-else class="tw-p-4">
{{ props.noResultsText }}
</div>
</template>
</div>
</Teleport>
</div>
</template>
<style scoped>
.file-search-popup {
position: absolute;
background: var(--color-box-body);
border: 1px solid var(--color-secondary);
border-radius: var(--border-radius);
width: max-content;
max-height: min(calc(100vw - 20px), 300px);
max-width: min(calc(100vw - 40px), 600px);
overflow-y: auto;
}
.file-search-popup .is-loading {
width: 200px;
height: 200px;
}
.file-search-results .item {
align-items: flex-start;
padding: 0.5rem 0.75rem;
cursor: pointer;
border-bottom: 1px solid var(--color-secondary);
}
.file-search-results .item:last-child {
border-bottom: none;
}
.file-search-results .item:hover,
.file-search-results .item.selected {
background-color: var(--color-hover);
}
.file-search-results .item .file-icon {
flex-shrink: 0;
margin-top: 0.125rem;
}
.file-search-results .item .full-path {
flex: 1;
overflow-wrap: anywhere;
}
.file-search-results .item .full-path :nth-child(even) {
color: var(--color-red);
font-weight: var(--font-weight-semibold);
}
</style>

View File

@@ -1,13 +1,8 @@
import {svg} from '../svg.ts'; import {createApp} from 'vue';
import {toggleElem} from '../utils/dom.ts'; import RepoFileSearch from '../components/RepoFileSearch.vue';
import {pathEscapeSegments} from '../utils/url.ts'; import {registerGlobalInitFunc} from '../modules/observer.ts';
import {GET} from '../modules/fetch.ts';
const threshold = 50; const threshold = 50;
let files: Array<string> = [];
let repoFindFileInput: HTMLInputElement;
let repoFindFileTableBody: HTMLElement;
let repoFindFileNoResult: HTMLElement;
// return the case-insensitive sub-match result as an array: [unmatched, matched, unmatched, matched, ...] // return the case-insensitive sub-match result as an array: [unmatched, matched, unmatched, matched, ...]
// res[even] is unmatched, res[odd] is matched, see unit tests for examples // res[even] is unmatched, res[odd] is matched, see unit tests for examples
@@ -73,48 +68,14 @@ export function filterRepoFilesWeighted(files: Array<string>, filter: string) {
return filterResult; return filterResult;
} }
function filterRepoFiles(filter: string) { export function initRepoFileSearch() {
const treeLink = repoFindFileInput.getAttribute('data-url-tree-link'); registerGlobalInitFunc('initRepoFileSearch', (el) => {
repoFindFileTableBody.innerHTML = ''; createApp(RepoFileSearch, {
repoLink: el.getAttribute('data-repo-link'),
const filterResult = filterRepoFilesWeighted(files, filter); currentRefNameSubURL: el.getAttribute('data-current-ref-name-sub-url'),
treeListUrl: el.getAttribute('data-tree-list-url'),
toggleElem(repoFindFileNoResult, !filterResult.length); noResultsText: el.getAttribute('data-no-results-text'),
for (const r of filterResult) { placeholder: el.getAttribute('data-placeholder'),
const row = document.createElement('tr'); }).mount(el);
const cell = document.createElement('td'); });
const a = document.createElement('a');
a.setAttribute('href', `${treeLink}/${pathEscapeSegments(r.matchResult.join(''))}`);
a.innerHTML = svg('octicon-file', 16, 'tw-mr-2');
row.append(cell);
cell.append(a);
for (const [index, part] of r.matchResult.entries()) {
const span = document.createElement('span');
// safely escape by using textContent
span.textContent = part;
span.title = span.textContent;
// if the target file path is "abc/xyz", to search "bx", then the matchResult is ['a', 'b', 'c/', 'x', 'yz']
// the matchResult[odd] is matched and highlighted to red.
if (index % 2 === 1) span.classList.add('ui', 'text', 'red');
a.append(span);
}
repoFindFileTableBody.append(row);
}
}
async function loadRepoFiles() {
const response = await GET(repoFindFileInput.getAttribute('data-url-data-link'));
files = await response.json();
filterRepoFiles(repoFindFileInput.value);
}
export function initFindFileInRepo() {
repoFindFileInput = document.querySelector('#repo-file-find-input');
if (!repoFindFileInput) return;
repoFindFileTableBody = document.querySelector('#repo-find-file-table tbody');
repoFindFileNoResult = document.querySelector('#repo-find-file-no-result');
repoFindFileInput.addEventListener('input', () => filterRepoFiles(repoFindFileInput.value));
loadRepoFiles();
} }

View File

@@ -6,8 +6,12 @@ import {registerGlobalEventFunc} from '../modules/observer.ts';
const {appSubUrl} = window.config; const {appSubUrl} = window.config;
function isUserSignedIn() {
return Boolean(document.querySelector('#navbar .user-menu'));
}
async function toggleSidebar(btn: HTMLElement) { async function toggleSidebar(btn: HTMLElement) {
const elToggleShow = document.querySelector('.repo-view-file-tree-toggle-show'); const elToggleShow = document.querySelector('.repo-view-file-tree-toggle[data-toggle-action="show"]');
const elFileTreeContainer = document.querySelector('.repo-view-file-tree-container'); const elFileTreeContainer = document.querySelector('.repo-view-file-tree-container');
const shouldShow = btn.getAttribute('data-toggle-action') === 'show'; const shouldShow = btn.getAttribute('data-toggle-action') === 'show';
toggleElem(elFileTreeContainer, shouldShow); toggleElem(elFileTreeContainer, shouldShow);
@@ -15,7 +19,7 @@ async function toggleSidebar(btn: HTMLElement) {
// FIXME: need to remove "full height" style from parent element // FIXME: need to remove "full height" style from parent element
if (!elFileTreeContainer.hasAttribute('data-user-is-signed-in')) return; if (!isUserSignedIn()) return;
await POST(`${appSubUrl}/user/settings/update_preferences`, { await POST(`${appSubUrl}/user/settings/update_preferences`, {
data: {codeViewShowFileTree: shouldShow}, data: {codeViewShowFileTree: shouldShow},
}); });

View File

@@ -16,7 +16,7 @@ import {initMarkupAnchors} from './markup/anchors.ts';
import {initNotificationCount} from './features/notification.ts'; import {initNotificationCount} from './features/notification.ts';
import {initRepoIssueContentHistory} from './features/repo-issue-content.ts'; import {initRepoIssueContentHistory} from './features/repo-issue-content.ts';
import {initStopwatch} from './features/stopwatch.ts'; import {initStopwatch} from './features/stopwatch.ts';
import {initFindFileInRepo} from './features/repo-findfile.ts'; import {initRepoFileSearch} from './features/repo-findfile.ts';
import {initMarkupContent} from './markup/content.ts'; import {initMarkupContent} from './markup/content.ts';
import {initRepoFileView} from './features/file-view.ts'; import {initRepoFileView} from './features/file-view.ts';
import {initUserAuthOauth2, initUserCheckAppUrl} from './features/user-auth.ts'; import {initUserAuthOauth2, initUserCheckAppUrl} from './features/user-auth.ts';
@@ -101,7 +101,7 @@ const initPerformanceTracer = callInitFunctions([
initSshKeyFormParser, initSshKeyFormParser,
initStopwatch, initStopwatch,
initTableSort, initTableSort,
initFindFileInRepo, initRepoFileSearch,
initCopyContent, initCopyContent,
initAdminCommon, initAdminCommon,