mirror of
https://github.com/go-gitea/gitea.git
synced 2025-12-02 13:59:48 +09:00
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:
@@ -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.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_directory = Delete Directory
|
||||
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.directory_delete_success = Directory "%s" has been deleted.
|
||||
editor.delete_directory = Delete directory '%s'
|
||||
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.or = or
|
||||
|
||||
@@ -610,10 +610,6 @@ func handleChangeRepoFilesError(ctx *context.APIContext, err error) {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err)
|
||||
return
|
||||
}
|
||||
if git.IsErrBranchNotExist(err) || files_service.IsErrRepoFileDoesNotExist(err) || git.IsErrNotExist(err) {
|
||||
ctx.APIError(http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIError(http.StatusNotFound, err)
|
||||
return
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
@@ -42,8 +41,8 @@ type blameRow struct {
|
||||
|
||||
// RefBlame render blame page
|
||||
func RefBlame(ctx *context.Context) {
|
||||
ctx.Data["PageIsViewCode"] = true
|
||||
ctx.Data["IsBlame"] = true
|
||||
prepareRepoViewContent(ctx, ctx.Repo.RefTypeNameSubURL())
|
||||
|
||||
// Get current entry user currently looking at.
|
||||
if ctx.Repo.TreePath == "" {
|
||||
@@ -56,17 +55,6 @@ func RefBlame(ctx *context.Context) {
|
||||
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()
|
||||
fileSize := blob.Size()
|
||||
ctx.Data["FileSize"] = fileSize
|
||||
|
||||
@@ -41,7 +41,12 @@ const (
|
||||
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)
|
||||
if cleanedTreePath != ctx.Repo.TreePath {
|
||||
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
|
||||
prepareTreePathFieldsAndPaths(ctx, util.Iif(isNewFile, ctx.Repo.TreePath+"/", ctx.Repo.TreePath))
|
||||
|
||||
prepareEditorCommitFormOptions(ctx, editorAction)
|
||||
prepareEditorPage(ctx, editorAction)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
@@ -376,15 +381,16 @@ func EditFilePost(ctx *context.Context) {
|
||||
|
||||
// DeleteFile render delete file page
|
||||
func DeleteFile(ctx *context.Context) {
|
||||
prepareEditorCommitFormOptions(ctx, "_delete")
|
||||
prepareEditorPage(ctx, "_delete")
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
ctx.Data["PageIsDelete"] = true
|
||||
prepareTreePathFieldsAndPaths(ctx, ctx.Repo.TreePath)
|
||||
ctx.HTML(http.StatusOK, tplDeleteFile)
|
||||
}
|
||||
|
||||
// DeleteFilePost response for deleting file
|
||||
// DeleteFilePost response for deleting file or directory
|
||||
func DeleteFilePost(ctx *context.Context) {
|
||||
parsed := prepareEditorCommitSubmittedForm[*forms.DeleteRepoFileForm](ctx)
|
||||
if ctx.Written() {
|
||||
@@ -392,17 +398,37 @@ func DeleteFilePost(ctx *context.Context) {
|
||||
}
|
||||
|
||||
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,
|
||||
OldBranch: parsed.OldBranchName,
|
||||
NewBranch: parsed.NewBranchName,
|
||||
Files: []*files_service.ChangeRepoFile{
|
||||
{
|
||||
Operation: "delete",
|
||||
TreePath: treePath,
|
||||
Operation: "delete",
|
||||
TreePath: treePath,
|
||||
DeleteRecursively: true,
|
||||
},
|
||||
},
|
||||
Message: parsed.GetCommitMessage(ctx.Locale.TrString("repo.editor.delete", treePath)),
|
||||
Message: commitMessage,
|
||||
Signoff: parsed.form.Signoff,
|
||||
Author: parsed.GitCommitter,
|
||||
Committer: parsed.GitCommitter,
|
||||
@@ -412,7 +438,11 @@ func DeleteFilePost(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("repo.editor.file_delete_success", treePath))
|
||||
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))
|
||||
}
|
||||
redirectTreePath := getClosestParentWithFiles(ctx.Repo.GitRepo, parsed.NewBranchName, treePath)
|
||||
redirectForCommitChoice(ctx, parsed, redirectTreePath)
|
||||
}
|
||||
@@ -420,7 +450,7 @@ func DeleteFilePost(ctx *context.Context) {
|
||||
func UploadFile(ctx *context.Context) {
|
||||
ctx.Data["PageIsUpload"] = true
|
||||
prepareTreePathFieldsAndPaths(ctx, ctx.Repo.TreePath)
|
||||
opts := prepareEditorCommitFormOptions(ctx, "_upload")
|
||||
opts := prepareEditorPage(ctx, "_upload")
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
)
|
||||
|
||||
func NewDiffPatch(ctx *context.Context) {
|
||||
prepareEditorCommitFormOptions(ctx, "_diffpatch")
|
||||
prepareEditorPage(ctx, "_diffpatch")
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
)
|
||||
|
||||
func CherryPick(ctx *context.Context) {
|
||||
prepareEditorCommitFormOptions(ctx, "_cherrypick")
|
||||
prepareEditorPage(ctx, "_cherrypick")
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -245,27 +245,17 @@ func LastCommit(ctx *context.Context) {
|
||||
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)
|
||||
if ctx.Written() {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -289,7 +279,9 @@ func renderDirectoryFiles(ctx *context.Context, timeout time.Duration) git.Entri
|
||||
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.
|
||||
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)
|
||||
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
|
||||
prepareDirectoryFileIcons(ctx, files)
|
||||
for _, f := range files {
|
||||
@@ -334,16 +341,6 @@ func renderDirectoryFiles(ctx *context.Context, timeout time.Duration) git.Entri
|
||||
if !loadLatestCommitData(ctx, latestCommit) {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -362,6 +362,32 @@ func redirectFollowSymlink(ctx *context.Context, treePathEntry *git.TreeEntry) b
|
||||
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
|
||||
func Home(ctx *context.Context) {
|
||||
if handleRepoHomeFeed(ctx) {
|
||||
@@ -383,8 +409,7 @@ func Home(ctx *context.Context) {
|
||||
title += ": " + ctx.Repo.Repository.Description
|
||||
}
|
||||
ctx.Data["Title"] = title
|
||||
ctx.Data["PageIsViewCode"] = true
|
||||
ctx.Data["RepositoryUploadEnabled"] = setting.Repository.Upload.Enabled // show New File / Upload File buttons
|
||||
prepareRepoViewContent(ctx, ctx.Repo.RefTypeNameSubURL())
|
||||
|
||||
if ctx.Repo.Commit == nil || ctx.Repo.Repository.IsEmpty || ctx.Repo.Repository.IsBroken() {
|
||||
// empty or broken repositories need to be handled differently
|
||||
@@ -405,26 +430,6 @@ func Home(ctx *context.Context) {
|
||||
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
|
||||
isTreePathRoot := ctx.Repo.TreePath == ""
|
||||
|
||||
@@ -455,7 +460,7 @@ func Home(ctx *context.Context) {
|
||||
|
||||
if isViewHomeOnlyContent(ctx) {
|
||||
ctx.HTML(http.StatusOK, tplRepoViewContent)
|
||||
} else if len(treeNames) != 0 {
|
||||
} else if ctx.Repo.TreePath != "" {
|
||||
ctx.HTML(http.StatusOK, tplRepoView)
|
||||
} else {
|
||||
ctx.HTML(http.StatusOK, tplRepoHome)
|
||||
|
||||
@@ -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.Group("/{username}/{reponame}", func() {
|
||||
m.Get("/find/*", repo.FindFiles)
|
||||
m.Group("/tree-list", func() {
|
||||
m.Get("/branch/*", context.RepoRefByType(git.RefTypeBranch), repo.TreeList)
|
||||
m.Get("/tag/*", context.RepoRefByType(git.RefTypeTag), repo.TreeList)
|
||||
|
||||
@@ -135,6 +135,14 @@ func (t *TemporaryUploadRepository) LsFiles(ctx context.Context, filenames ...st
|
||||
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
|
||||
func (t *TemporaryUploadRepository) RemoveFilesFromIndex(ctx context.Context, filenames ...string) error {
|
||||
objFmt, err := t.gitRepo.GetObjectFormat()
|
||||
|
||||
@@ -46,7 +46,10 @@ type ChangeRepoFile struct {
|
||||
FromTreePath string
|
||||
ContentReader io.ReadSeeker
|
||||
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
|
||||
@@ -69,26 +72,6 @@ type RepoFileOptions struct {
|
||||
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 {
|
||||
io.ReadSeeker
|
||||
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 {
|
||||
// Get the commit of the original branch
|
||||
commit, err := t.GetBranchCommit(opts.OldBranch)
|
||||
@@ -272,8 +237,14 @@ func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use
|
||||
addedLfsPointers = append(addedLfsPointers, *addedLfsPointer)
|
||||
}
|
||||
case "delete":
|
||||
if err = t.RemoveFilesFromIndex(ctx, file.TreePath); err != nil {
|
||||
return nil, err
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid file operation: %s %s, supported operations are create, update, delete", file.Operation, file.Options.treePath)
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
{{template "base/head" .}}
|
||||
<div role="main" aria-label="{{.Title}}" class="page-content repository file editor delete">
|
||||
{{template "repo/header" .}}
|
||||
<div class="ui container">
|
||||
<div class="ui container fluid padded">
|
||||
{{template "base/alert" .}}
|
||||
<form class="ui form form-fetch-action" method="post" action="{{.CommitFormOptions.TargetFormAction}}">
|
||||
{{.CsrfTokenHtml}}
|
||||
{{template "repo/editor/common_top" .}}
|
||||
{{template "repo/editor/commit_form" .}}
|
||||
</form>
|
||||
<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}}">
|
||||
{{.CsrfTokenHtml}}
|
||||
{{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" .}}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
||||
|
||||
@@ -1,53 +1,59 @@
|
||||
{{template "base/head" .}}
|
||||
<div role="main" aria-label="{{.Title}}" class="page-content repository file editor edit">
|
||||
{{template "repo/header" .}}
|
||||
<div class="ui container">
|
||||
<div class="ui container fluid padded">
|
||||
{{template "base/alert" .}}
|
||||
<form class="ui edit form form-fetch-action" method="post" action="{{.CommitFormOptions.TargetFormAction}}"
|
||||
<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}}"
|
||||
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"}}"
|
||||
>
|
||||
{{.CsrfTokenHtml}}
|
||||
{{template "repo/editor/common_top" .}}
|
||||
<div class="repo-editor-header">
|
||||
{{template "repo/editor/common_breadcrumb" .}}
|
||||
>
|
||||
{{.CsrfTokenHtml}}
|
||||
{{template "repo/editor/common_top" .}}
|
||||
<div class="repo-editor-header">
|
||||
{{template "repo/view_file_tree_toggle_button" .}}
|
||||
{{template "repo/editor/common_breadcrumb" .}}
|
||||
</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 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>
|
||||
{{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>
|
||||
{{end}}
|
||||
{{template "repo/editor/commit_form" .}}
|
||||
</form>
|
||||
</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 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>
|
||||
{{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>
|
||||
{{end}}
|
||||
{{template "repo/editor/commit_form" .}}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
{{template "base/head" .}}
|
||||
<div role="main" aria-label="{{.Title}}" class="page-content repository file editor upload">
|
||||
{{template "repo/header" .}}
|
||||
<div class="ui container">
|
||||
<div class="ui container fluid padded">
|
||||
{{template "base/alert" .}}
|
||||
<form class="ui comment form form-fetch-action" method="post" action="{{.CommitFormOptions.TargetFormAction}}">
|
||||
{{.CsrfTokenHtml}}
|
||||
{{template "repo/editor/common_top" .}}
|
||||
<div class="repo-editor-header">
|
||||
{{template "repo/editor/common_breadcrumb" .}}
|
||||
<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}}">
|
||||
{{.CsrfTokenHtml}}
|
||||
{{template "repo/editor/common_top" .}}
|
||||
<div class="repo-editor-header">
|
||||
{{template "repo/view_file_tree_toggle_button" .}}
|
||||
{{template "repo/editor/common_breadcrumb" .}}
|
||||
</div>
|
||||
<div class="field">
|
||||
{{template "repo/upload" .}}
|
||||
</div>
|
||||
{{template "repo/editor/commit_form" .}}
|
||||
</form>
|
||||
</div>
|
||||
<div class="field">
|
||||
{{template "repo/upload" .}}
|
||||
</div>
|
||||
{{template "repo/editor/commit_form" .}}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
||||
|
||||
@@ -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" .}}
|
||||
@@ -17,9 +17,7 @@
|
||||
{{template "repo/code/recently_pushed_new_branches" dict "RecentBranchesPromptData" .RecentBranchesPromptData}}
|
||||
|
||||
<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" .}}
|
||||
</div>
|
||||
{{template "repo/view_file_tree" .}}
|
||||
<div class="repo-view-content">
|
||||
{{template "repo/view_content" .}}
|
||||
</div>
|
||||
|
||||
@@ -5,11 +5,7 @@
|
||||
<div class="repo-button-row">
|
||||
<div class="repo-button-row-left">
|
||||
{{if not $isTreePathRoot}}
|
||||
<button class="repo-view-file-tree-toggle-show ui compact basic button icon 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>
|
||||
{{template "repo/view_file_tree_toggle_button" .}}
|
||||
{{end}}
|
||||
|
||||
{{template "repo/branch_dropdown" dict
|
||||
@@ -37,31 +33,6 @@
|
||||
</a>
|
||||
{{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}}
|
||||
<a role="button" class="ui primary compact button" href="{{AppSubUrl}}/repo/create?template_id={{.Repository.ID}}">
|
||||
{{ctx.Locale.Tr "repo.use_template"}}
|
||||
@@ -86,12 +57,65 @@
|
||||
</div>
|
||||
|
||||
<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 -->
|
||||
{{if $isTreePathRoot}}
|
||||
{{template "repo/clone_panel" .}}
|
||||
{{end}}
|
||||
{{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"}}
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
<div class="flex-text-block repo-button-row">
|
||||
<button class="ui compact basic icon button"
|
||||
data-global-click="onRepoViewFileTreeToggle" data-toggle-action="hide"
|
||||
data-tooltip-content="{{ctx.Locale.Tr "repo.diff.hide_file_tree"}}">
|
||||
{{svg "octicon-sidebar-expand"}}
|
||||
</button>
|
||||
<b>{{ctx.Locale.Tr "files"}}</b>
|
||||
</div>
|
||||
<div class="repo-view-file-tree-container {{if not .UserSettingCodeViewShowFileTree}}tw-hidden{{end}}">
|
||||
<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-tooltip-content="{{ctx.Locale.Tr "repo.diff.hide_file_tree"}}">
|
||||
{{svg "octicon-sidebar-expand"}}
|
||||
</button>
|
||||
<b>{{ctx.Locale.Tr "files"}}</b>
|
||||
</div>
|
||||
|
||||
{{/* TODO: Dynamically move components such as refSelector and createPR here */}}
|
||||
<div id="view-file-tree" class="tw-overflow-auto tw-h-full is-loading"
|
||||
data-repo-link="{{.RepoLink}}"
|
||||
data-tree-path="{{$.TreePath}}"
|
||||
data-current-ref-name-sub-url="{{.RefTypeNameSubURL}}"
|
||||
></div>
|
||||
{{/* TODO: Dynamically move components such as refSelector and createPR here */}}
|
||||
<div id="view-file-tree" class="tw-overflow-auto tw-h-full is-loading"
|
||||
data-repo-link="{{.RepoLink}}"
|
||||
data-tree-path="{{$.TreePath}}"
|
||||
data-current-ref-name-sub-url="{{.RefTypeNameSubURL}}"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
6
templates/repo/view_file_tree_toggle_button.tmpl
Normal file
6
templates/repo/view_file_tree_toggle_button.tmpl
Normal 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>
|
||||
@@ -47,7 +47,7 @@
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="repo-file-cell message loading-icon-2px">
|
||||
<div class="repo-file-cell message commit-summary loading-icon-2px">
|
||||
{{if $commit}}
|
||||
{{$commitLink := printf "%s/commit/%s" $.RepoLink (PathEscape $commit.ID.String)}}
|
||||
{{ctx.RenderUtils.RenderCommitMessageLinkSubject $commit.Message $commitLink $.Repository}}
|
||||
|
||||
@@ -5,6 +5,7 @@ package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"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 {
|
||||
_, err := deleteFileInBranch(user, repo, treePath, branchName)
|
||||
|
||||
if err != nil && !files_service.IsErrRepoFileDoesNotExist(err) {
|
||||
if err != nil && !errors.Is(err, util.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
@@ -12,7 +13,6 @@ import (
|
||||
"time"
|
||||
|
||||
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/gitrepo"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
files_service "code.gitea.io/gitea/services/repository/files"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
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 {
|
||||
treePath := "new/file.txt"
|
||||
encoding := "base64"
|
||||
@@ -578,75 +530,88 @@ func TestChangeRepoFilesWithoutBranchNames(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestChangeRepoFilesForDelete(t *testing.T) {
|
||||
onGiteaRun(t, testDeleteRepoFiles)
|
||||
}
|
||||
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
||||
ctx, _ := contexttest.MockContext(t, "user2/repo1")
|
||||
ctx.SetPathParam("id", "1")
|
||||
contexttest.LoadRepo(t, ctx, 1)
|
||||
contexttest.LoadRepoCommit(t, ctx)
|
||||
contexttest.LoadUser(t, ctx, 2)
|
||||
contexttest.LoadGitRepo(t, ctx)
|
||||
defer ctx.Repo.GitRepo.Close()
|
||||
repo := ctx.Repo.Repository
|
||||
doer := ctx.Doer
|
||||
|
||||
func testDeleteRepoFiles(t *testing.T, u *url.URL) {
|
||||
// setup
|
||||
unittest.PrepareTestEnv(t)
|
||||
ctx, _ := contexttest.MockContext(t, "user2/repo1")
|
||||
ctx.SetPathParam("id", "1")
|
||||
contexttest.LoadRepo(t, ctx, 1)
|
||||
contexttest.LoadRepoCommit(t, ctx)
|
||||
contexttest.LoadUser(t, ctx, 2)
|
||||
contexttest.LoadGitRepo(t, ctx)
|
||||
defer ctx.Repo.GitRepo.Close()
|
||||
repo := ctx.Repo.Repository
|
||||
doer := ctx.Doer
|
||||
opts := getDeleteRepoFilesOptions(repo)
|
||||
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)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, filesResponse)
|
||||
assert.Nil(t, filesResponse.Files[0])
|
||||
assert.Equal(t, "test message\n", filesResponse.Commit.Message)
|
||||
MakeRequest(t, NewRequest(t, "GET", urlRaw), http.StatusNotFound)
|
||||
})
|
||||
|
||||
t.Run("Delete README.md file", func(t *testing.T) {
|
||||
filesResponse, err := files_service.ChangeRepoFiles(t.Context(), repo, doer, opts)
|
||||
assert.NoError(t, err)
|
||||
expectedFileResponse := getExpectedFileResponseForRepoFilesDelete()
|
||||
assert.NotNil(t, filesResponse)
|
||||
assert.Nil(t, filesResponse.Files[0])
|
||||
assert.Equal(t, expectedFileResponse.Commit.Message, filesResponse.Commit.Message)
|
||||
assert.Equal(t, expectedFileResponse.Commit.Author.Identity, filesResponse.Commit.Author.Identity)
|
||||
assert.Equal(t, expectedFileResponse.Commit.Committer.Identity, filesResponse.Commit.Committer.Identity)
|
||||
assert.Equal(t, expectedFileResponse.Verification, filesResponse.Verification)
|
||||
})
|
||||
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)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, filesResponse)
|
||||
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)
|
||||
})
|
||||
|
||||
t.Run("Verify README.md has been deleted", func(t *testing.T) {
|
||||
filesResponse, err := files_service.ChangeRepoFiles(t.Context(), repo, doer, opts)
|
||||
assert.Nil(t, filesResponse)
|
||||
expectedError := "repository file does not exist [path: " + opts.Files[0].TreePath + "]"
|
||||
assert.EqualError(t, err, expectedError)
|
||||
})
|
||||
}
|
||||
|
||||
// Test opts with branch names removed, same results
|
||||
func TestChangeRepoFilesForDeleteWithoutBranchNames(t *testing.T) {
|
||||
onGiteaRun(t, testDeleteRepoFilesWithoutBranchNames)
|
||||
}
|
||||
|
||||
func testDeleteRepoFilesWithoutBranchNames(t *testing.T, u *url.URL) {
|
||||
// setup
|
||||
unittest.PrepareTestEnv(t)
|
||||
ctx, _ := contexttest.MockContext(t, "user2/repo1")
|
||||
ctx.SetPathParam("id", "1")
|
||||
contexttest.LoadRepo(t, ctx, 1)
|
||||
contexttest.LoadRepoCommit(t, ctx)
|
||||
contexttest.LoadUser(t, ctx, 2)
|
||||
contexttest.LoadGitRepo(t, ctx)
|
||||
defer ctx.Repo.GitRepo.Close()
|
||||
|
||||
repo := ctx.Repo.Repository
|
||||
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)
|
||||
assert.NoError(t, err)
|
||||
expectedFileResponse := getExpectedFileResponseForRepoFilesDelete()
|
||||
assert.NotNil(t, filesResponse)
|
||||
assert.Nil(t, filesResponse.Files[0])
|
||||
assert.Equal(t, expectedFileResponse.Commit.Message, filesResponse.Commit.Message)
|
||||
assert.Equal(t, expectedFileResponse.Commit.Author.Identity, filesResponse.Commit.Author.Identity)
|
||||
assert.Equal(t, expectedFileResponse.Commit.Committer.Identity, filesResponse.Commit.Committer.Identity)
|
||||
assert.Equal(t, expectedFileResponse.Verification, filesResponse.Verification)
|
||||
t.Run("Delete directory", func(t *testing.T) {
|
||||
urlRaw := "/user2/repo1/raw/branch/sub-home-md-img-check/docs/README.md"
|
||||
MakeRequest(t, NewRequest(t, "GET", urlRaw), http.StatusOK)
|
||||
opts := &files_service.ChangeRepoFilesOptions{
|
||||
OldBranch: "sub-home-md-img-check",
|
||||
LastCommitID: "4649299398e4d39a5c09eb4f534df6f1e1eb87cc",
|
||||
Files: []*files_service.ChangeRepoFile{
|
||||
{
|
||||
Operation: "delete",
|
||||
TreePath: "docs",
|
||||
DeleteRecursively: true,
|
||||
},
|
||||
},
|
||||
Message: "test message",
|
||||
}
|
||||
filesResponse, err := files_service.ChangeRepoFiles(t.Context(), repo, doer, opts)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, filesResponse)
|
||||
assert.Nil(t, filesResponse.Files[0])
|
||||
assert.Equal(t, "test message\n", filesResponse.Commit.Message)
|
||||
MakeRequest(t, NewRequest(t, "GET", urlRaw), http.StatusNotFound)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
border-color: var(--color-secondary);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
aspect-ratio: 1;
|
||||
transform: translate(-50%, -50%);
|
||||
animation: isloadingspin 1000ms infinite linear;
|
||||
border-width: 4px;
|
||||
border-width: 3px;
|
||||
border-style: solid;
|
||||
border-color: var(--color-secondary) var(--color-secondary) var(--color-secondary-dark-8) var(--color-secondary-dark-8);
|
||||
border-radius: var(--border-radius-full);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
.repository.file.list .non-diff-file-content .header .small.icon {
|
||||
.non-diff-file-content .header .small.icon {
|
||||
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;
|
||||
}
|
||||
|
||||
.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);
|
||||
padding: 8px;
|
||||
vertical-align: middle;
|
||||
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);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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;
|
||||
opacity: var(--opacity-disabled);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.repository.file.list .non-diff-file-content .plain-text {
|
||||
.non-diff-file-content .plain-text {
|
||||
padding: 1em 2em;
|
||||
}
|
||||
|
||||
.repository.file.list .non-diff-file-content .plain-text pre {
|
||||
.non-diff-file-content .plain-text pre {
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.repository.file.list .non-diff-file-content .csv {
|
||||
.non-diff-file-content .csv {
|
||||
overflow-x: auto;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.repository.file.list .non-diff-file-content pre {
|
||||
.non-diff-file-content pre {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.repository.file.list .non-diff-file-content .asciicast {
|
||||
.non-diff-file-content .asciicast {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.repo-editor-header {
|
||||
display: flex;
|
||||
margin: 1rem 0;
|
||||
padding: 3px 0;
|
||||
width: 100%;
|
||||
gap: 0.5em;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.repo-editor-header input {
|
||||
@@ -216,17 +221,13 @@ td .commit-summary {
|
||||
margin-right: 5px !important;
|
||||
}
|
||||
|
||||
.repository.file.editor .tabular.menu .svg {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.repository.file.editor .commit-form-wrapper {
|
||||
padding-left: 48px;
|
||||
padding-left: 58px;
|
||||
}
|
||||
|
||||
.repository.file.editor .commit-form-wrapper .commit-avatar {
|
||||
float: left;
|
||||
margin-left: -48px;
|
||||
margin-left: -58px;
|
||||
}
|
||||
|
||||
.repository.file.editor .commit-form-wrapper .commit-form {
|
||||
@@ -1409,12 +1410,25 @@ td .commit-summary {
|
||||
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;
|
||||
margin: 0;
|
||||
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 {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
@@ -1483,6 +1497,12 @@ tbody.commit-list {
|
||||
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 {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@@ -54,7 +54,9 @@
|
||||
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%;
|
||||
min-width: 0;
|
||||
max-height: 100vh;
|
||||
@@ -65,6 +67,12 @@
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.repo-view-file-tree-container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.repo-view-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -244,6 +244,7 @@ gitea-theme-meta-info {
|
||||
--color-highlight-fg: #87651e;
|
||||
--color-highlight-bg: #352c1c;
|
||||
--color-overlay-backdrop: #080808c0;
|
||||
--color-danger: var(--color-red);
|
||||
accent-color: var(--color-accent);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@@ -244,6 +244,7 @@ gitea-theme-meta-info {
|
||||
--color-highlight-fg: #eed200;
|
||||
--color-highlight-bg: #fffbdd;
|
||||
--color-overlay-backdrop: #080808c0;
|
||||
--color-danger: var(--color-red);
|
||||
accent-color: var(--color-accent);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
230
web_src/js/components/RepoFileSearch.vue
Normal file
230
web_src/js/components/RepoFileSearch.vue
Normal 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>
|
||||
@@ -1,13 +1,8 @@
|
||||
import {svg} from '../svg.ts';
|
||||
import {toggleElem} from '../utils/dom.ts';
|
||||
import {pathEscapeSegments} from '../utils/url.ts';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {createApp} from 'vue';
|
||||
import RepoFileSearch from '../components/RepoFileSearch.vue';
|
||||
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
||||
|
||||
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, ...]
|
||||
// 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;
|
||||
}
|
||||
|
||||
function filterRepoFiles(filter: string) {
|
||||
const treeLink = repoFindFileInput.getAttribute('data-url-tree-link');
|
||||
repoFindFileTableBody.innerHTML = '';
|
||||
|
||||
const filterResult = filterRepoFilesWeighted(files, filter);
|
||||
|
||||
toggleElem(repoFindFileNoResult, !filterResult.length);
|
||||
for (const r of filterResult) {
|
||||
const row = document.createElement('tr');
|
||||
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();
|
||||
export function initRepoFileSearch() {
|
||||
registerGlobalInitFunc('initRepoFileSearch', (el) => {
|
||||
createApp(RepoFileSearch, {
|
||||
repoLink: el.getAttribute('data-repo-link'),
|
||||
currentRefNameSubURL: el.getAttribute('data-current-ref-name-sub-url'),
|
||||
treeListUrl: el.getAttribute('data-tree-list-url'),
|
||||
noResultsText: el.getAttribute('data-no-results-text'),
|
||||
placeholder: el.getAttribute('data-placeholder'),
|
||||
}).mount(el);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,8 +6,12 @@ import {registerGlobalEventFunc} from '../modules/observer.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
|
||||
function isUserSignedIn() {
|
||||
return Boolean(document.querySelector('#navbar .user-menu'));
|
||||
}
|
||||
|
||||
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 shouldShow = btn.getAttribute('data-toggle-action') === 'show';
|
||||
toggleElem(elFileTreeContainer, shouldShow);
|
||||
@@ -15,7 +19,7 @@ async function toggleSidebar(btn: HTMLElement) {
|
||||
|
||||
// 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`, {
|
||||
data: {codeViewShowFileTree: shouldShow},
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ import {initMarkupAnchors} from './markup/anchors.ts';
|
||||
import {initNotificationCount} from './features/notification.ts';
|
||||
import {initRepoIssueContentHistory} from './features/repo-issue-content.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 {initRepoFileView} from './features/file-view.ts';
|
||||
import {initUserAuthOauth2, initUserCheckAppUrl} from './features/user-auth.ts';
|
||||
@@ -101,7 +101,7 @@ const initPerformanceTracer = callInitFunctions([
|
||||
initSshKeyFormParser,
|
||||
initStopwatch,
|
||||
initTableSort,
|
||||
initFindFileInRepo,
|
||||
initRepoFileSearch,
|
||||
initCopyContent,
|
||||
|
||||
initAdminCommon,
|
||||
|
||||
Reference in New Issue
Block a user