Create new branch from branch selection dropdown (#2130)
* Create new branch from branch selection dropdown and rewrite it to VueJS * Make updateLocalCopyToCommit as not exported * Move branch name validation to model * Fix possible race condition
This commit is contained in:
parent
c25303b11c
commit
f3833b7ce4
|
@ -0,0 +1,132 @@
|
||||||
|
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package integrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Unknwon/i18n"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testCreateBranch(t *testing.T, session *TestSession, user, repo, oldRefName, newBranchName string, expectedStatus int) string {
|
||||||
|
var csrf string
|
||||||
|
if expectedStatus == http.StatusNotFound {
|
||||||
|
csrf = GetCSRF(t, session, path.Join(user, repo, "src/master"))
|
||||||
|
} else {
|
||||||
|
csrf = GetCSRF(t, session, path.Join(user, repo, "src", oldRefName))
|
||||||
|
}
|
||||||
|
req := NewRequestWithValues(t, "POST", path.Join(user, repo, "branches/_new", oldRefName), map[string]string{
|
||||||
|
"_csrf": csrf,
|
||||||
|
"new_branch_name": newBranchName,
|
||||||
|
})
|
||||||
|
resp := session.MakeRequest(t, req, expectedStatus)
|
||||||
|
if expectedStatus != http.StatusFound {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return RedirectURL(t, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateBranch(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
OldBranchOrCommit string
|
||||||
|
NewBranch string
|
||||||
|
CreateRelease string
|
||||||
|
FlashMessage string
|
||||||
|
ExpectedStatus int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
OldBranchOrCommit: "master",
|
||||||
|
NewBranch: "feature/test1",
|
||||||
|
ExpectedStatus: http.StatusFound,
|
||||||
|
FlashMessage: i18n.Tr("en", "repo.branch.create_success", "feature/test1"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
OldBranchOrCommit: "master",
|
||||||
|
NewBranch: "",
|
||||||
|
ExpectedStatus: http.StatusFound,
|
||||||
|
FlashMessage: i18n.Tr("en", "form.NewBranchName") + i18n.Tr("en", "form.require_error"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
OldBranchOrCommit: "master",
|
||||||
|
NewBranch: "feature=test1",
|
||||||
|
ExpectedStatus: http.StatusFound,
|
||||||
|
FlashMessage: i18n.Tr("en", "form.NewBranchName") + i18n.Tr("en", "form.git_ref_name_error"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
OldBranchOrCommit: "master",
|
||||||
|
NewBranch: strings.Repeat("b", 101),
|
||||||
|
ExpectedStatus: http.StatusFound,
|
||||||
|
FlashMessage: i18n.Tr("en", "form.NewBranchName") + i18n.Tr("en", "form.max_size_error", "100"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
OldBranchOrCommit: "master",
|
||||||
|
NewBranch: "master",
|
||||||
|
ExpectedStatus: http.StatusFound,
|
||||||
|
FlashMessage: i18n.Tr("en", "repo.branch.branch_already_exists", "master"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
OldBranchOrCommit: "master",
|
||||||
|
NewBranch: "master/test",
|
||||||
|
ExpectedStatus: http.StatusFound,
|
||||||
|
FlashMessage: i18n.Tr("en", "repo.branch.branch_name_conflict", "master/test", "master"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
OldBranchOrCommit: "acd1d892867872cb47f3993468605b8aa59aa2e0",
|
||||||
|
NewBranch: "feature/test2",
|
||||||
|
ExpectedStatus: http.StatusNotFound,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
OldBranchOrCommit: "65f1bf27bc3bf70f64657658635e66094edbcb4d",
|
||||||
|
NewBranch: "feature/test3",
|
||||||
|
ExpectedStatus: http.StatusFound,
|
||||||
|
FlashMessage: i18n.Tr("en", "repo.branch.create_success", "feature/test3"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
OldBranchOrCommit: "master",
|
||||||
|
NewBranch: "v1.0.0",
|
||||||
|
CreateRelease: "v1.0.0",
|
||||||
|
ExpectedStatus: http.StatusFound,
|
||||||
|
FlashMessage: i18n.Tr("en", "repo.branch.tag_collision", "v1.0.0"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
OldBranchOrCommit: "v1.0.0",
|
||||||
|
NewBranch: "feature/test4",
|
||||||
|
CreateRelease: "v1.0.0",
|
||||||
|
ExpectedStatus: http.StatusFound,
|
||||||
|
FlashMessage: i18n.Tr("en", "repo.branch.create_success", "feature/test4"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
prepareTestEnv(t)
|
||||||
|
session := loginUser(t, "user2")
|
||||||
|
if test.CreateRelease != "" {
|
||||||
|
createNewRelease(t, session, "/user2/repo1", test.CreateRelease, test.CreateRelease, false, false)
|
||||||
|
}
|
||||||
|
redirectURL := testCreateBranch(t, session, "user2", "repo1", test.OldBranchOrCommit, test.NewBranch, test.ExpectedStatus)
|
||||||
|
if test.ExpectedStatus == http.StatusFound {
|
||||||
|
req := NewRequest(t, "GET", redirectURL)
|
||||||
|
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||||
|
htmlDoc := NewHTMLParser(t, resp.Body)
|
||||||
|
assert.Equal(t,
|
||||||
|
test.FlashMessage,
|
||||||
|
strings.TrimSpace(htmlDoc.doc.Find(".ui.message").Text()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateBranchInvalidCSRF(t *testing.T) {
|
||||||
|
prepareTestEnv(t)
|
||||||
|
session := loginUser(t, "user2")
|
||||||
|
req := NewRequestWithValues(t, "POST", "user2/repo1/branches/_new/master", map[string]string{
|
||||||
|
"_csrf": "fake_csrf",
|
||||||
|
"new_branch_name": "test",
|
||||||
|
})
|
||||||
|
session.MakeRequest(t, req, http.StatusBadRequest)
|
||||||
|
}
|
|
@ -649,6 +649,51 @@ func (err ErrBranchNotExist) Error() string {
|
||||||
return fmt.Sprintf("branch does not exist [name: %s]", err.Name)
|
return fmt.Sprintf("branch does not exist [name: %s]", err.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrBranchAlreadyExists represents an error that branch with such name already exists
|
||||||
|
type ErrBranchAlreadyExists struct {
|
||||||
|
BranchName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsErrBranchAlreadyExists checks if an error is an ErrBranchAlreadyExists.
|
||||||
|
func IsErrBranchAlreadyExists(err error) bool {
|
||||||
|
_, ok := err.(ErrBranchAlreadyExists)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err ErrBranchAlreadyExists) Error() string {
|
||||||
|
return fmt.Sprintf("branch already exists [name: %s]", err.BranchName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrBranchNameConflict represents an error that branch name conflicts with other branch
|
||||||
|
type ErrBranchNameConflict struct {
|
||||||
|
BranchName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsErrBranchNameConflict checks if an error is an ErrBranchNameConflict.
|
||||||
|
func IsErrBranchNameConflict(err error) bool {
|
||||||
|
_, ok := err.(ErrBranchNameConflict)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err ErrBranchNameConflict) Error() string {
|
||||||
|
return fmt.Sprintf("branch conflicts with existing branch [name: %s]", err.BranchName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrTagAlreadyExists represents an error that tag with such name already exists
|
||||||
|
type ErrTagAlreadyExists struct {
|
||||||
|
TagName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsErrTagAlreadyExists checks if an error is an ErrTagAlreadyExists.
|
||||||
|
func IsErrTagAlreadyExists(err error) bool {
|
||||||
|
_, ok := err.(ErrTagAlreadyExists)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err ErrTagAlreadyExists) Error() string {
|
||||||
|
return fmt.Sprintf("tag already exists [name: %s]", err.TagName)
|
||||||
|
}
|
||||||
|
|
||||||
// __ __ ___. .__ __
|
// __ __ ___. .__ __
|
||||||
// / \ / \ ____\_ |__ | |__ ____ ____ | | __
|
// / \ / \ ____\_ |__ | |__ ____ ____ | | __
|
||||||
// \ \/\/ // __ \| __ \| | \ / _ \ / _ \| |/ /
|
// \ \/\/ // __ \| __ \| | \ / _ \ / _ \| |/ /
|
||||||
|
|
|
@ -2426,38 +2426,3 @@ func (repo *Repository) GetUserFork(userID int64) (*Repository, error) {
|
||||||
}
|
}
|
||||||
return &forkedRepo, nil
|
return &forkedRepo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// __________ .__
|
|
||||||
// \______ \____________ ____ ____ | |__
|
|
||||||
// | | _/\_ __ \__ \ / \_/ ___\| | \
|
|
||||||
// | | \ | | \// __ \| | \ \___| Y \
|
|
||||||
// |______ / |__| (____ /___| /\___ >___| /
|
|
||||||
// \/ \/ \/ \/ \/
|
|
||||||
//
|
|
||||||
|
|
||||||
// CreateNewBranch creates a new repository branch
|
|
||||||
func (repo *Repository) CreateNewBranch(doer *User, oldBranchName, branchName string) (err error) {
|
|
||||||
repoWorkingPool.CheckIn(com.ToStr(repo.ID))
|
|
||||||
defer repoWorkingPool.CheckOut(com.ToStr(repo.ID))
|
|
||||||
|
|
||||||
localPath := repo.LocalCopyPath()
|
|
||||||
|
|
||||||
if err = discardLocalRepoBranchChanges(localPath, oldBranchName); err != nil {
|
|
||||||
return fmt.Errorf("discardLocalRepoChanges: %v", err)
|
|
||||||
} else if err = repo.UpdateLocalCopyBranch(oldBranchName); err != nil {
|
|
||||||
return fmt.Errorf("UpdateLocalCopyBranch: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = repo.CheckoutNewBranch(oldBranchName, branchName); err != nil {
|
|
||||||
return fmt.Errorf("CreateNewBranch: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = git.Push(localPath, git.PushOptions{
|
|
||||||
Remote: "origin",
|
|
||||||
Branch: branchName,
|
|
||||||
}); err != nil {
|
|
||||||
return fmt.Errorf("Push: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
|
@ -5,7 +5,13 @@
|
||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"code.gitea.io/git"
|
"code.gitea.io/git"
|
||||||
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
|
||||||
|
"github.com/Unknwon/com"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Branch holds the branch information
|
// Branch holds the branch information
|
||||||
|
@ -36,6 +42,11 @@ func GetBranchesByPath(path string) ([]*Branch, error) {
|
||||||
return branches, nil
|
return branches, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CanCreateBranch returns true if repository meets the requirements for creating new branches.
|
||||||
|
func (repo *Repository) CanCreateBranch() bool {
|
||||||
|
return !repo.IsMirror
|
||||||
|
}
|
||||||
|
|
||||||
// GetBranch returns a branch by it's name
|
// GetBranch returns a branch by it's name
|
||||||
func (repo *Repository) GetBranch(branch string) (*Branch, error) {
|
func (repo *Repository) GetBranch(branch string) (*Branch, error) {
|
||||||
if !git.IsBranchExist(repo.RepoPath(), branch) {
|
if !git.IsBranchExist(repo.RepoPath(), branch) {
|
||||||
|
@ -52,6 +63,128 @@ func (repo *Repository) GetBranches() ([]*Branch, error) {
|
||||||
return GetBranchesByPath(repo.RepoPath())
|
return GetBranchesByPath(repo.RepoPath())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CheckBranchName validates branch name with existing repository branches
|
||||||
|
func (repo *Repository) CheckBranchName(name string) error {
|
||||||
|
gitRepo, err := git.OpenRepository(repo.RepoPath())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := gitRepo.GetTag(name); err == nil {
|
||||||
|
return ErrTagAlreadyExists{name}
|
||||||
|
}
|
||||||
|
|
||||||
|
branches, err := repo.GetBranches()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, branch := range branches {
|
||||||
|
if branch.Name == name {
|
||||||
|
return ErrBranchAlreadyExists{branch.Name}
|
||||||
|
} else if (len(branch.Name) < len(name) && branch.Name+"/" == name[0:len(branch.Name)+1]) ||
|
||||||
|
(len(branch.Name) > len(name) && name+"/" == branch.Name[0:len(name)+1]) {
|
||||||
|
return ErrBranchNameConflict{branch.Name}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateNewBranch creates a new repository branch
|
||||||
|
func (repo *Repository) CreateNewBranch(doer *User, oldBranchName, branchName string) (err error) {
|
||||||
|
repoWorkingPool.CheckIn(com.ToStr(repo.ID))
|
||||||
|
defer repoWorkingPool.CheckOut(com.ToStr(repo.ID))
|
||||||
|
|
||||||
|
// Check if branch name can be used
|
||||||
|
if err := repo.CheckBranchName(branchName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
localPath := repo.LocalCopyPath()
|
||||||
|
|
||||||
|
if err = discardLocalRepoBranchChanges(localPath, oldBranchName); err != nil {
|
||||||
|
return fmt.Errorf("discardLocalRepoChanges: %v", err)
|
||||||
|
} else if err = repo.UpdateLocalCopyBranch(oldBranchName); err != nil {
|
||||||
|
return fmt.Errorf("UpdateLocalCopyBranch: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = repo.CheckoutNewBranch(oldBranchName, branchName); err != nil {
|
||||||
|
return fmt.Errorf("CreateNewBranch: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = git.Push(localPath, git.PushOptions{
|
||||||
|
Remote: "origin",
|
||||||
|
Branch: branchName,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("Push: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateLocalCopyToCommit pulls latest changes of given commit from repoPath to localPath.
|
||||||
|
// It creates a new clone if local copy does not exist.
|
||||||
|
// This function checks out target commit by default, it is safe to assume subsequent
|
||||||
|
// operations are operating against target commit when caller has confidence for no race condition.
|
||||||
|
func updateLocalCopyToCommit(repoPath, localPath, commit string) error {
|
||||||
|
if !com.IsExist(localPath) {
|
||||||
|
if err := git.Clone(repoPath, localPath, git.CloneRepoOptions{
|
||||||
|
Timeout: time.Duration(setting.Git.Timeout.Clone) * time.Second,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("git clone: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_, err := git.NewCommand("fetch", "origin").RunInDir(localPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("git fetch origin: %v", err)
|
||||||
|
}
|
||||||
|
if err := git.ResetHEAD(localPath, true, "HEAD"); err != nil {
|
||||||
|
return fmt.Errorf("git reset --hard HEAD: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := git.Checkout(localPath, git.CheckoutOptions{
|
||||||
|
Branch: commit,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("git checkout %s: %v", commit, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateLocalCopyToCommit makes sure local copy of repository is at given commit.
|
||||||
|
func (repo *Repository) updateLocalCopyToCommit(commit string) error {
|
||||||
|
return updateLocalCopyToCommit(repo.RepoPath(), repo.LocalCopyPath(), commit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateNewBranchFromCommit creates a new repository branch
|
||||||
|
func (repo *Repository) CreateNewBranchFromCommit(doer *User, commit, branchName string) (err error) {
|
||||||
|
repoWorkingPool.CheckIn(com.ToStr(repo.ID))
|
||||||
|
defer repoWorkingPool.CheckOut(com.ToStr(repo.ID))
|
||||||
|
|
||||||
|
// Check if branch name can be used
|
||||||
|
if err := repo.CheckBranchName(branchName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
localPath := repo.LocalCopyPath()
|
||||||
|
|
||||||
|
if err = repo.updateLocalCopyToCommit(commit); err != nil {
|
||||||
|
return fmt.Errorf("UpdateLocalCopyBranch: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = repo.CheckoutNewBranch(commit, branchName); err != nil {
|
||||||
|
return fmt.Errorf("CheckoutNewBranch: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = git.Push(localPath, git.PushOptions{
|
||||||
|
Remote: "origin",
|
||||||
|
Branch: branchName,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("Push: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetCommit returns all the commits of a branch
|
// GetCommit returns all the commits of a branch
|
||||||
func (branch *Branch) GetCommit() (*git.Commit, error) {
|
func (branch *Branch) GetCommit() (*git.Commit, error) {
|
||||||
gitRepo, err := git.OpenRepository(branch.Path)
|
gitRepo, err := git.OpenRepository(branch.Path)
|
||||||
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-macaron/binding"
|
||||||
|
macaron "gopkg.in/macaron.v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewBranchForm form for creating a new branch
|
||||||
|
type NewBranchForm struct {
|
||||||
|
NewBranchName string `binding:"Required;MaxSize(100);GitRefName"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate validates the fields
|
||||||
|
func (f *NewBranchForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors {
|
||||||
|
return validate(errs, ctx.Data, f, ctx.Locale)
|
||||||
|
}
|
|
@ -76,6 +76,11 @@ func (r *Repository) CanEnableEditor() bool {
|
||||||
return r.Repository.CanEnableEditor() && r.IsViewBranch && r.IsWriter()
|
return r.Repository.CanEnableEditor() && r.IsViewBranch && r.IsWriter()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CanCreateBranch returns true if repository is editable and user has proper access level.
|
||||||
|
func (r *Repository) CanCreateBranch() bool {
|
||||||
|
return r.Repository.CanCreateBranch() && r.IsWriter()
|
||||||
|
}
|
||||||
|
|
||||||
// CanCommitToBranch returns true if repository is editable and user has proper access level
|
// CanCommitToBranch returns true if repository is editable and user has proper access level
|
||||||
// and branch is not protected
|
// and branch is not protected
|
||||||
func (r *Repository) CanCommitToBranch(doer *models.User) (bool, error) {
|
func (r *Repository) CanCommitToBranch(doer *models.User) (bool, error) {
|
||||||
|
@ -528,6 +533,7 @@ func RepoRef() macaron.Handler {
|
||||||
ctx.Data["IsViewBranch"] = ctx.Repo.IsViewBranch
|
ctx.Data["IsViewBranch"] = ctx.Repo.IsViewBranch
|
||||||
ctx.Data["IsViewTag"] = ctx.Repo.IsViewTag
|
ctx.Data["IsViewTag"] = ctx.Repo.IsViewTag
|
||||||
ctx.Data["IsViewCommit"] = ctx.Repo.IsViewCommit
|
ctx.Data["IsViewCommit"] = ctx.Repo.IsViewCommit
|
||||||
|
ctx.Data["CanCreateBranch"] = ctx.Repo.CanCreateBranch()
|
||||||
|
|
||||||
ctx.Repo.CommitsCount, err = ctx.Repo.Commit.CommitsCount()
|
ctx.Repo.CommitsCount, err = ctx.Repo.Commit.CommitsCount()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -44,12 +44,18 @@ func addGitRefNameBindingRule() {
|
||||||
}
|
}
|
||||||
// Additional rules as described at https://www.kernel.org/pub/software/scm/git/docs/git-check-ref-format.html
|
// Additional rules as described at https://www.kernel.org/pub/software/scm/git/docs/git-check-ref-format.html
|
||||||
if strings.HasPrefix(str, "/") || strings.HasSuffix(str, "/") ||
|
if strings.HasPrefix(str, "/") || strings.HasSuffix(str, "/") ||
|
||||||
strings.HasPrefix(str, ".") || strings.HasSuffix(str, ".") ||
|
strings.HasSuffix(str, ".") || strings.Contains(str, "..") ||
|
||||||
strings.HasSuffix(str, ".lock") ||
|
strings.Contains(str, "//") {
|
||||||
strings.Contains(str, "..") || strings.Contains(str, "//") {
|
|
||||||
errs.Add([]string{name}, ErrGitRefName, "GitRefName")
|
errs.Add([]string{name}, ErrGitRefName, "GitRefName")
|
||||||
return false, errs
|
return false, errs
|
||||||
}
|
}
|
||||||
|
parts := strings.Split(str, "/")
|
||||||
|
for _, part := range parts {
|
||||||
|
if strings.HasSuffix(part, ".lock") || strings.HasPrefix(part, ".") {
|
||||||
|
errs.Add([]string{name}, ErrGitRefName, "GitRefName")
|
||||||
|
return false, errs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return true, errs
|
return true, errs
|
||||||
},
|
},
|
||||||
|
|
|
@ -1061,6 +1061,12 @@ branch.delete_notices_2 = - This operation will permanently delete everything in
|
||||||
branch.deletion_success = %s has been deleted.
|
branch.deletion_success = %s has been deleted.
|
||||||
branch.deletion_failed = Failed to delete branch %s.
|
branch.deletion_failed = Failed to delete branch %s.
|
||||||
branch.delete_branch_has_new_commits = %s cannot be deleted because new commits have been added after merging.
|
branch.delete_branch_has_new_commits = %s cannot be deleted because new commits have been added after merging.
|
||||||
|
branch.create_branch = Create branch <strong>%s</strong>
|
||||||
|
branch.create_from = from '%s'
|
||||||
|
branch.create_success = Branch '%s' has been created successfully!
|
||||||
|
branch.branch_already_exists = Branch '%s' already exists in this repository.
|
||||||
|
branch.branch_name_conflict = Branch name '%s' conflicts with already existing branch '%s'.
|
||||||
|
branch.tag_collision = Branch '%s' can not be created as tag with same name already exists in this repository.
|
||||||
|
|
||||||
[org]
|
[org]
|
||||||
org_name_holder = Organization Name
|
org_name_holder = Organization Name
|
||||||
|
|
File diff suppressed because one or more lines are too long
|
@ -362,9 +362,11 @@ function initRepository() {
|
||||||
var $dropdown = $(selector);
|
var $dropdown = $(selector);
|
||||||
$dropdown.dropdown({
|
$dropdown.dropdown({
|
||||||
fullTextSearch: true,
|
fullTextSearch: true,
|
||||||
|
selectOnKeydown: false,
|
||||||
onChange: function (text, value, $choice) {
|
onChange: function (text, value, $choice) {
|
||||||
window.location.href = $choice.data('url');
|
if ($choice.data('url')) {
|
||||||
console.log($choice.data('url'))
|
window.location.href = $choice.data('url');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
message: {noResults: $dropdown.data('no-results')}
|
message: {noResults: $dropdown.data('no-results')}
|
||||||
});
|
});
|
||||||
|
@ -373,15 +375,7 @@ function initRepository() {
|
||||||
// File list and commits
|
// File list and commits
|
||||||
if ($('.repository.file.list').length > 0 ||
|
if ($('.repository.file.list').length > 0 ||
|
||||||
('.repository.commits').length > 0) {
|
('.repository.commits').length > 0) {
|
||||||
initFilterSearchDropdown('.choose.reference .dropdown');
|
initFilterBranchTagDropdown('.choose.reference .dropdown');
|
||||||
|
|
||||||
$('.reference.column').click(function () {
|
|
||||||
$('.choose.reference .scrolling.menu').css('display', 'none');
|
|
||||||
$('.choose.reference .text').removeClass('black');
|
|
||||||
$($(this).data('target')).css('display', 'block');
|
|
||||||
$(this).find('.text').addClass('black');
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wiki
|
// Wiki
|
||||||
|
@ -1318,7 +1312,7 @@ $(document).ready(function () {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Semantic UI modules.
|
// Semantic UI modules.
|
||||||
$('.dropdown').dropdown();
|
$('.dropdown:not(.custom)').dropdown();
|
||||||
$('.jump.dropdown').dropdown({
|
$('.jump.dropdown').dropdown({
|
||||||
action: 'hide',
|
action: 'hide',
|
||||||
onShow: function () {
|
onShow: function () {
|
||||||
|
@ -1780,3 +1774,190 @@ function toggleStopwatch() {
|
||||||
function cancelStopwatch() {
|
function cancelStopwatch() {
|
||||||
$("#cancel_stopwatch_form").submit();
|
$("#cancel_stopwatch_form").submit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initFilterBranchTagDropdown(selector) {
|
||||||
|
$(selector).each(function() {
|
||||||
|
var $dropdown = $(this);
|
||||||
|
var $data = $dropdown.find('.data');
|
||||||
|
var data = {
|
||||||
|
items: [],
|
||||||
|
mode: $data.data('mode'),
|
||||||
|
searchTerm: '',
|
||||||
|
noResults: '',
|
||||||
|
canCreateBranch: false,
|
||||||
|
menuVisible: false,
|
||||||
|
active: 0
|
||||||
|
};
|
||||||
|
$data.find('.item').each(function() {
|
||||||
|
data.items.push({
|
||||||
|
name: $(this).text(),
|
||||||
|
url: $(this).data('url'),
|
||||||
|
branch: $(this).hasClass('branch'),
|
||||||
|
tag: $(this).hasClass('tag'),
|
||||||
|
selected: $(this).hasClass('selected')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
$data.remove();
|
||||||
|
new Vue({
|
||||||
|
delimiters: ['${', '}'],
|
||||||
|
el: this,
|
||||||
|
data: data,
|
||||||
|
|
||||||
|
beforeMount: function () {
|
||||||
|
var vm = this;
|
||||||
|
|
||||||
|
this.noResults = vm.$el.getAttribute('data-no-results');
|
||||||
|
this.canCreateBranch = vm.$el.getAttribute('data-can-create-branch') === 'true';
|
||||||
|
|
||||||
|
document.body.addEventListener('click', function(event) {
|
||||||
|
if (vm.$el.contains(event.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (vm.menuVisible) {
|
||||||
|
Vue.set(vm, 'menuVisible', false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
menuVisible: function(visible) {
|
||||||
|
if (visible) {
|
||||||
|
this.focusSearchField();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
filteredItems: function() {
|
||||||
|
var vm = this;
|
||||||
|
|
||||||
|
var items = vm.items.filter(function (item) {
|
||||||
|
return ((vm.mode === 'branches' && item.branch)
|
||||||
|
|| (vm.mode === 'tags' && item.tag))
|
||||||
|
&& (!vm.searchTerm
|
||||||
|
|| item.name.toLowerCase().indexOf(vm.searchTerm.toLowerCase()) >= 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
vm.active = (items.length === 0 && vm.showCreateNewBranch ? 0 : -1);
|
||||||
|
|
||||||
|
return items;
|
||||||
|
},
|
||||||
|
showNoResults: function() {
|
||||||
|
return this.filteredItems.length === 0
|
||||||
|
&& !this.showCreateNewBranch;
|
||||||
|
},
|
||||||
|
showCreateNewBranch: function() {
|
||||||
|
var vm = this;
|
||||||
|
if (!this.canCreateBranch || !vm.searchTerm || vm.mode === 'tags') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return vm.items.filter(function (item) {
|
||||||
|
return item.name.toLowerCase() === vm.searchTerm.toLowerCase()
|
||||||
|
}).length === 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
selectItem: function(item) {
|
||||||
|
var prev = this.getSelected();
|
||||||
|
if (prev !== null) {
|
||||||
|
prev.selected = false;
|
||||||
|
}
|
||||||
|
item.selected = true;
|
||||||
|
window.location.href = item.url;
|
||||||
|
},
|
||||||
|
createNewBranch: function() {
|
||||||
|
if (!this.showCreateNewBranch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.$refs.newBranchForm.submit();
|
||||||
|
},
|
||||||
|
focusSearchField: function() {
|
||||||
|
var vm = this;
|
||||||
|
Vue.nextTick(function() {
|
||||||
|
vm.$refs.searchField.focus();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getSelected: function() {
|
||||||
|
for (var i = 0, j = this.items.length; i < j; ++i) {
|
||||||
|
if (this.items[i].selected)
|
||||||
|
return this.items[i];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
getSelectedIndexInFiltered() {
|
||||||
|
for (var i = 0, j = this.filteredItems.length; i < j; ++i) {
|
||||||
|
if (this.filteredItems[i].selected)
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
},
|
||||||
|
scrollToActive() {
|
||||||
|
var el = this.$refs['listItem' + this.active];
|
||||||
|
if (!el || el.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(el)) {
|
||||||
|
el = el[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
var cont = this.$refs.scrollContainer;
|
||||||
|
|
||||||
|
if (el.offsetTop < cont.scrollTop) {
|
||||||
|
cont.scrollTop = el.offsetTop;
|
||||||
|
}
|
||||||
|
else if (el.offsetTop + el.clientHeight > cont.scrollTop + cont.clientHeight) {
|
||||||
|
cont.scrollTop = el.offsetTop + el.clientHeight - cont.clientHeight;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
keydown: function(event) {
|
||||||
|
var vm = this;
|
||||||
|
if (event.keyCode === 40) {
|
||||||
|
// arrow down
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (vm.active === -1) {
|
||||||
|
vm.active = vm.getSelectedIndexInFiltered();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vm.active + (vm.showCreateNewBranch ? 0 : 1) >= vm.filteredItems.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
vm.active++;
|
||||||
|
vm.scrollToActive();
|
||||||
|
}
|
||||||
|
if (event.keyCode === 38) {
|
||||||
|
// arrow up
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (vm.active === -1) {
|
||||||
|
vm.active = vm.getSelectedIndexInFiltered();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vm.active <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
vm.active--;
|
||||||
|
vm.scrollToActive();
|
||||||
|
}
|
||||||
|
if (event.keyCode == 13) {
|
||||||
|
// enter
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (vm.active >= vm.filteredItems.length) {
|
||||||
|
vm.createNewBranch();
|
||||||
|
} else if (vm.active >= 0) {
|
||||||
|
vm.selectItem(vm.filteredItems[vm.active]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event.keyCode == 27) {
|
||||||
|
// escape
|
||||||
|
event.preventDefault();
|
||||||
|
vm.menuVisible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
@ -329,6 +329,10 @@ pre, code {
|
||||||
background-color: #a1882b !important;
|
background-color: #a1882b !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.branch-tag-choice {
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,8 @@
|
||||||
package repo
|
package repo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"code.gitea.io/gitea/models"
|
||||||
|
"code.gitea.io/gitea/modules/auth"
|
||||||
"code.gitea.io/gitea/modules/base"
|
"code.gitea.io/gitea/modules/base"
|
||||||
"code.gitea.io/gitea/modules/context"
|
"code.gitea.io/gitea/modules/context"
|
||||||
)
|
)
|
||||||
|
@ -30,3 +32,50 @@ func Branches(ctx *context.Context) {
|
||||||
ctx.Data["Branches"] = brs
|
ctx.Data["Branches"] = brs
|
||||||
ctx.HTML(200, tplBranch)
|
ctx.HTML(200, tplBranch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateBranch creates new branch in repository
|
||||||
|
func CreateBranch(ctx *context.Context, form auth.NewBranchForm) {
|
||||||
|
if !ctx.Repo.CanCreateBranch() {
|
||||||
|
ctx.Handle(404, "CreateBranch", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.HasError() {
|
||||||
|
ctx.Flash.Error(ctx.GetErrMsg())
|
||||||
|
ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if ctx.Repo.IsViewBranch {
|
||||||
|
err = ctx.Repo.Repository.CreateNewBranch(ctx.User, ctx.Repo.BranchName, form.NewBranchName)
|
||||||
|
} else {
|
||||||
|
err = ctx.Repo.Repository.CreateNewBranchFromCommit(ctx.User, ctx.Repo.BranchName, form.NewBranchName)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if models.IsErrTagAlreadyExists(err) {
|
||||||
|
e := err.(models.ErrTagAlreadyExists)
|
||||||
|
ctx.Flash.Error(ctx.Tr("repo.branch.tag_collision", e.TagName))
|
||||||
|
ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if models.IsErrBranchAlreadyExists(err) {
|
||||||
|
e := err.(models.ErrBranchAlreadyExists)
|
||||||
|
ctx.Flash.Error(ctx.Tr("repo.branch.branch_already_exists", e.BranchName))
|
||||||
|
ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if models.IsErrBranchNameConflict(err) {
|
||||||
|
e := err.(models.ErrBranchNameConflict)
|
||||||
|
ctx.Flash.Error(ctx.Tr("repo.branch.branch_name_conflict", form.NewBranchName, e.BranchName))
|
||||||
|
ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Handle(500, "CreateNewBranch", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Flash.Success(ctx.Tr("repo.branch.create_success", form.NewBranchName))
|
||||||
|
ctx.Redirect(ctx.Repo.RepoLink + "/src/" + form.NewBranchName)
|
||||||
|
}
|
||||||
|
|
|
@ -554,6 +554,10 @@ func RegisterRoutes(m *macaron.Macaron) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
m.Group("/branches", func() {
|
||||||
|
m.Post("/_new/*", context.RepoRef(), bindIgnErr(auth.NewBranchForm{}), repo.CreateBranch)
|
||||||
|
}, reqRepoWriter, repo.MustBeNotBare)
|
||||||
}, reqSignIn, context.RepoAssignment(), context.UnitTypes(), context.LoadRepoUnits())
|
}, reqSignIn, context.RepoAssignment(), context.UnitTypes(), context.LoadRepoUnits())
|
||||||
|
|
||||||
// Releases
|
// Releases
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<div class="fitted item choose reference">
|
<div class="fitted item choose reference">
|
||||||
<div class="ui floating filter dropdown" data-no-results="{{.i18n.Tr "repo.pulls.no_results"}}">
|
<div class="ui floating filter dropdown custom" data-can-create-branch="{{.CanCreateBranch}}" data-no-results="{{.i18n.Tr "repo.pulls.no_results"}}">
|
||||||
<div class="ui basic compact tiny button">
|
<div class="ui basic small button" @click="menuVisible = !menuVisible" @keyup.enter="menuVisible = !menuVisible">
|
||||||
<span class="text">
|
<span class="text">
|
||||||
<i class="octicon octicon-git-branch"></i>
|
<i class="octicon octicon-git-branch"></i>
|
||||||
{{if .IsViewBranch}}{{.i18n.Tr "repo.branch"}}{{else}}{{.i18n.Tr "repo.tree"}}{{end}}:
|
{{if .IsViewBranch}}{{.i18n.Tr "repo.branch"}}{{else}}{{.i18n.Tr "repo.tree"}}{{end}}:
|
||||||
|
@ -8,37 +8,58 @@
|
||||||
</span>
|
</span>
|
||||||
<i class="dropdown icon"></i>
|
<i class="dropdown icon"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="menu">
|
<div class="data" style="display: none" data-mode="{{if .IsViewTag}}tags{{else}}branches{{end}}">
|
||||||
|
{{range .Branches}}
|
||||||
|
<div class="item branch {{if eq $.BranchName .}}selected{{end}}" data-url="{{$.RepoLink}}/{{if $.PageIsCommits}}commits{{else}}src{{end}}/{{EscapePound .}}{{if $.TreePath}}/{{EscapePound $.TreePath}}{{end}}">{{.}}</div>
|
||||||
|
{{end}}
|
||||||
|
{{range .Tags}}
|
||||||
|
<div class="item tag {{if eq $.BranchName .}}selected{{end}}" data-url="{{$.RepoLink}}/{{if $.PageIsCommits}}commits{{else}}src{{end}}/{{EscapePound .}}{{if $.TreePath}}/{{EscapePound $.TreePath}}{{end}}">{{.}}</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="menu transition visible" v-if="menuVisible" v-cloak>
|
||||||
<div class="ui icon search input">
|
<div class="ui icon search input">
|
||||||
<i class="filter icon"></i>
|
<i class="filter icon"></i>
|
||||||
<input name="search" placeholder="{{.i18n.Tr "repo.filter_branch_and_tag"}}...">
|
<input name="search" ref="searchField" v-model="searchTerm" @keydown="keydown($event)" placeholder="{{.i18n.Tr "repo.filter_branch_and_tag"}}...">
|
||||||
</div>
|
</div>
|
||||||
<div class="header">
|
<div class="header branch-tag-choice">
|
||||||
<div class="ui grid">
|
<div class="ui grid">
|
||||||
<div class="two column row">
|
<div class="two column row">
|
||||||
<a class="reference column" href="#" data-target="#branch-list">
|
<a class="reference column" href="#" @click="mode = 'branches'; focusSearchField()">
|
||||||
<span class="text {{if not .IsViewTag}}black{{end}}">
|
<span class="text" :class="{black: mode == 'branches'}">
|
||||||
<i class="octicon octicon-git-branch"></i> {{.i18n.Tr "repo.branches"}}
|
<i class="octicon octicon-git-branch"></i> {{.i18n.Tr "repo.branches"}}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="reference column" href="#" data-target="#tag-list">
|
<a class="reference column" href="#" @click="mode = 'tags'; focusSearchField()">
|
||||||
<span class="text {{if .IsViewTag}}black{{end}}">
|
<span class="text" :class="{black: mode == 'tags'}">
|
||||||
<i class="reference tags icon"></i> {{.i18n.Tr "repo.tags"}}
|
<i class="reference tags icon"></i> {{.i18n.Tr "repo.tags"}}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="branch-list" class="scrolling menu" {{if .IsViewTag}}style="display: none"{{end}}>
|
<div class="scrolling menu" ref="scrollContainer">
|
||||||
{{range .Branches}}
|
<div v-for="(item, index) in filteredItems" :key="item.name" class="item" :class="{selected: item.selected, active: active == index}" @click="selectItem(item)" :ref="'listItem' + index">${ item.name }</div>
|
||||||
<div class="item {{if eq $.BranchName .}}selected{{end}}" data-url="{{$.RepoLink}}/{{if $.PageIsCommits}}commits{{else}}src{{end}}/{{EscapePound .}}{{if $.TreePath}}/{{EscapePound $.TreePath}}{{end}}">{{.}}</div>
|
<div class="item" v-if="showCreateNewBranch" :class="{active: active == filteredItems.length}" :ref="'listItem' + filteredItems.length">
|
||||||
{{end}}
|
<a href="#" @click="createNewBranch()">
|
||||||
</div>
|
<div>
|
||||||
<div id="tag-list" class="scrolling menu" {{if not .IsViewTag}}style="display: none"{{end}}>
|
<i class="octicon octicon-git-branch"></i>
|
||||||
{{range .Tags}}
|
{{.i18n.Tr "repo.branch.create_branch" `${ searchTerm }` | Safe}}
|
||||||
<div class="item {{if eq $.BranchName .}}selected{{end}}" data-url="{{$.RepoLink}}/{{if $.PageIsCommits}}commits{{else}}src{{end}}/{{EscapePound .}}{{if $.TreePath}}/{{EscapePound $.TreePath}}{{end}}">{{.}}</div>
|
</div>
|
||||||
{{end}}
|
<div class="text small">
|
||||||
|
{{if .IsViewBranch}}
|
||||||
|
{{.i18n.Tr "repo.branch.create_from" .BranchName | Safe}}
|
||||||
|
{{else}}
|
||||||
|
{{.i18n.Tr "repo.branch.create_from" (ShortSha .BranchName) | Safe}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<form ref="newBranchForm" action="{{.RepoLink}}/branches/_new/{{EscapePound .BranchName}}" method="post">
|
||||||
|
{{.CsrfTokenHtml}}
|
||||||
|
<input type="hidden" name="new_branch_name" v-model="searchTerm">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="message" v-if="showNoResults">${ noResults }</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
Loading…
Reference in New Issue