Browse Source

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
Lauris BH 1 year ago
parent
commit
f3833b7ce4

+ 132 - 0
integrations/repo_branch_test.go

@@ -0,0 +1,132 @@
1
+// Copyright 2017 The Gitea Authors. All rights reserved.
2
+// Use of this source code is governed by a MIT-style
3
+// license that can be found in the LICENSE file.
4
+
5
+package integrations
6
+
7
+import (
8
+	"net/http"
9
+	"path"
10
+	"strings"
11
+	"testing"
12
+
13
+	"github.com/Unknwon/i18n"
14
+	"github.com/stretchr/testify/assert"
15
+)
16
+
17
+func testCreateBranch(t *testing.T, session *TestSession, user, repo, oldRefName, newBranchName string, expectedStatus int) string {
18
+	var csrf string
19
+	if expectedStatus == http.StatusNotFound {
20
+		csrf = GetCSRF(t, session, path.Join(user, repo, "src/master"))
21
+	} else {
22
+		csrf = GetCSRF(t, session, path.Join(user, repo, "src", oldRefName))
23
+	}
24
+	req := NewRequestWithValues(t, "POST", path.Join(user, repo, "branches/_new", oldRefName), map[string]string{
25
+		"_csrf":           csrf,
26
+		"new_branch_name": newBranchName,
27
+	})
28
+	resp := session.MakeRequest(t, req, expectedStatus)
29
+	if expectedStatus != http.StatusFound {
30
+		return ""
31
+	}
32
+	return RedirectURL(t, resp)
33
+}
34
+
35
+func TestCreateBranch(t *testing.T) {
36
+	tests := []struct {
37
+		OldBranchOrCommit string
38
+		NewBranch         string
39
+		CreateRelease     string
40
+		FlashMessage      string
41
+		ExpectedStatus    int
42
+	}{
43
+		{
44
+			OldBranchOrCommit: "master",
45
+			NewBranch:         "feature/test1",
46
+			ExpectedStatus:    http.StatusFound,
47
+			FlashMessage:      i18n.Tr("en", "repo.branch.create_success", "feature/test1"),
48
+		},
49
+		{
50
+			OldBranchOrCommit: "master",
51
+			NewBranch:         "",
52
+			ExpectedStatus:    http.StatusFound,
53
+			FlashMessage:      i18n.Tr("en", "form.NewBranchName") + i18n.Tr("en", "form.require_error"),
54
+		},
55
+		{
56
+			OldBranchOrCommit: "master",
57
+			NewBranch:         "feature=test1",
58
+			ExpectedStatus:    http.StatusFound,
59
+			FlashMessage:      i18n.Tr("en", "form.NewBranchName") + i18n.Tr("en", "form.git_ref_name_error"),
60
+		},
61
+		{
62
+			OldBranchOrCommit: "master",
63
+			NewBranch:         strings.Repeat("b", 101),
64
+			ExpectedStatus:    http.StatusFound,
65
+			FlashMessage:      i18n.Tr("en", "form.NewBranchName") + i18n.Tr("en", "form.max_size_error", "100"),
66
+		},
67
+		{
68
+			OldBranchOrCommit: "master",
69
+			NewBranch:         "master",
70
+			ExpectedStatus:    http.StatusFound,
71
+			FlashMessage:      i18n.Tr("en", "repo.branch.branch_already_exists", "master"),
72
+		},
73
+		{
74
+			OldBranchOrCommit: "master",
75
+			NewBranch:         "master/test",
76
+			ExpectedStatus:    http.StatusFound,
77
+			FlashMessage:      i18n.Tr("en", "repo.branch.branch_name_conflict", "master/test", "master"),
78
+		},
79
+		{
80
+			OldBranchOrCommit: "acd1d892867872cb47f3993468605b8aa59aa2e0",
81
+			NewBranch:         "feature/test2",
82
+			ExpectedStatus:    http.StatusNotFound,
83
+		},
84
+		{
85
+			OldBranchOrCommit: "65f1bf27bc3bf70f64657658635e66094edbcb4d",
86
+			NewBranch:         "feature/test3",
87
+			ExpectedStatus:    http.StatusFound,
88
+			FlashMessage:      i18n.Tr("en", "repo.branch.create_success", "feature/test3"),
89
+		},
90
+		{
91
+			OldBranchOrCommit: "master",
92
+			NewBranch:         "v1.0.0",
93
+			CreateRelease:     "v1.0.0",
94
+			ExpectedStatus:    http.StatusFound,
95
+			FlashMessage:      i18n.Tr("en", "repo.branch.tag_collision", "v1.0.0"),
96
+		},
97
+		{
98
+			OldBranchOrCommit: "v1.0.0",
99
+			NewBranch:         "feature/test4",
100
+			CreateRelease:     "v1.0.0",
101
+			ExpectedStatus:    http.StatusFound,
102
+			FlashMessage:      i18n.Tr("en", "repo.branch.create_success", "feature/test4"),
103
+		},
104
+	}
105
+	for _, test := range tests {
106
+		prepareTestEnv(t)
107
+		session := loginUser(t, "user2")
108
+		if test.CreateRelease != "" {
109
+			createNewRelease(t, session, "/user2/repo1", test.CreateRelease, test.CreateRelease, false, false)
110
+		}
111
+		redirectURL := testCreateBranch(t, session, "user2", "repo1", test.OldBranchOrCommit, test.NewBranch, test.ExpectedStatus)
112
+		if test.ExpectedStatus == http.StatusFound {
113
+			req := NewRequest(t, "GET", redirectURL)
114
+			resp := session.MakeRequest(t, req, http.StatusOK)
115
+			htmlDoc := NewHTMLParser(t, resp.Body)
116
+			assert.Equal(t,
117
+				test.FlashMessage,
118
+				strings.TrimSpace(htmlDoc.doc.Find(".ui.message").Text()),
119
+			)
120
+		}
121
+	}
122
+}
123
+
124
+func TestCreateBranchInvalidCSRF(t *testing.T) {
125
+	prepareTestEnv(t)
126
+	session := loginUser(t, "user2")
127
+	req := NewRequestWithValues(t, "POST", "user2/repo1/branches/_new/master", map[string]string{
128
+		"_csrf":           "fake_csrf",
129
+		"new_branch_name": "test",
130
+	})
131
+	session.MakeRequest(t, req, http.StatusBadRequest)
132
+}

+ 45 - 0
models/error.go

@@ -649,6 +649,51 @@ func (err ErrBranchNotExist) Error() string {
649 649
 	return fmt.Sprintf("branch does not exist [name: %s]", err.Name)
650 650
 }
651 651
 
652
+// ErrBranchAlreadyExists represents an error that branch with such name already exists
653
+type ErrBranchAlreadyExists struct {
654
+	BranchName string
655
+}
656
+
657
+// IsErrBranchAlreadyExists checks if an error is an ErrBranchAlreadyExists.
658
+func IsErrBranchAlreadyExists(err error) bool {
659
+	_, ok := err.(ErrBranchAlreadyExists)
660
+	return ok
661
+}
662
+
663
+func (err ErrBranchAlreadyExists) Error() string {
664
+	return fmt.Sprintf("branch already exists [name: %s]", err.BranchName)
665
+}
666
+
667
+// ErrBranchNameConflict represents an error that branch name conflicts with other branch
668
+type ErrBranchNameConflict struct {
669
+	BranchName string
670
+}
671
+
672
+// IsErrBranchNameConflict checks if an error is an ErrBranchNameConflict.
673
+func IsErrBranchNameConflict(err error) bool {
674
+	_, ok := err.(ErrBranchNameConflict)
675
+	return ok
676
+}
677
+
678
+func (err ErrBranchNameConflict) Error() string {
679
+	return fmt.Sprintf("branch conflicts with existing branch [name: %s]", err.BranchName)
680
+}
681
+
682
+// ErrTagAlreadyExists represents an error that tag with such name already exists
683
+type ErrTagAlreadyExists struct {
684
+	TagName string
685
+}
686
+
687
+// IsErrTagAlreadyExists checks if an error is an ErrTagAlreadyExists.
688
+func IsErrTagAlreadyExists(err error) bool {
689
+	_, ok := err.(ErrTagAlreadyExists)
690
+	return ok
691
+}
692
+
693
+func (err ErrTagAlreadyExists) Error() string {
694
+	return fmt.Sprintf("tag already exists [name: %s]", err.TagName)
695
+}
696
+
652 697
 //  __      __      ___.   .__                   __
653 698
 // /  \    /  \ ____\_ |__ |  |__   ____   ____ |  | __
654 699
 // \   \/\/   // __ \| __ \|  |  \ /  _ \ /  _ \|  |/ /

+ 0 - 35
models/repo.go

@@ -2426,38 +2426,3 @@ func (repo *Repository) GetUserFork(userID int64) (*Repository, error) {
2426 2426
 	}
2427 2427
 	return &forkedRepo, nil
2428 2428
 }
2429
-
2430
-// __________                             .__
2431
-// \______   \____________    ____   ____ |  |__
2432
-//  |    |  _/\_  __ \__  \  /    \_/ ___\|  |  \
2433
-//  |    |   \ |  | \// __ \|   |  \  \___|   Y  \
2434
-//  |______  / |__|  (____  /___|  /\___  >___|  /
2435
-//         \/             \/     \/     \/     \/
2436
-//
2437
-
2438
-// CreateNewBranch creates a new repository branch
2439
-func (repo *Repository) CreateNewBranch(doer *User, oldBranchName, branchName string) (err error) {
2440
-	repoWorkingPool.CheckIn(com.ToStr(repo.ID))
2441
-	defer repoWorkingPool.CheckOut(com.ToStr(repo.ID))
2442
-
2443
-	localPath := repo.LocalCopyPath()
2444
-
2445
-	if err = discardLocalRepoBranchChanges(localPath, oldBranchName); err != nil {
2446
-		return fmt.Errorf("discardLocalRepoChanges: %v", err)
2447
-	} else if err = repo.UpdateLocalCopyBranch(oldBranchName); err != nil {
2448
-		return fmt.Errorf("UpdateLocalCopyBranch: %v", err)
2449
-	}
2450
-
2451
-	if err = repo.CheckoutNewBranch(oldBranchName, branchName); err != nil {
2452
-		return fmt.Errorf("CreateNewBranch: %v", err)
2453
-	}
2454
-
2455
-	if err = git.Push(localPath, git.PushOptions{
2456
-		Remote: "origin",
2457
-		Branch: branchName,
2458
-	}); err != nil {
2459
-		return fmt.Errorf("Push: %v", err)
2460
-	}
2461
-
2462
-	return nil
2463
-}

+ 133 - 0
models/repo_branch.go

@@ -5,7 +5,13 @@
5 5
 package models
6 6
 
7 7
 import (
8
+	"fmt"
9
+	"time"
10
+
8 11
 	"code.gitea.io/git"
12
+	"code.gitea.io/gitea/modules/setting"
13
+
14
+	"github.com/Unknwon/com"
9 15
 )
10 16
 
11 17
 // Branch holds the branch information
@@ -36,6 +42,11 @@ func GetBranchesByPath(path string) ([]*Branch, error) {
36 42
 	return branches, nil
37 43
 }
38 44
 
45
+// CanCreateBranch returns true if repository meets the requirements for creating new branches.
46
+func (repo *Repository) CanCreateBranch() bool {
47
+	return !repo.IsMirror
48
+}
49
+
39 50
 // GetBranch returns a branch by it's name
40 51
 func (repo *Repository) GetBranch(branch string) (*Branch, error) {
41 52
 	if !git.IsBranchExist(repo.RepoPath(), branch) {
@@ -52,6 +63,128 @@ func (repo *Repository) GetBranches() ([]*Branch, error) {
52 63
 	return GetBranchesByPath(repo.RepoPath())
53 64
 }
54 65
 
66
+// CheckBranchName validates branch name with existing repository branches
67
+func (repo *Repository) CheckBranchName(name string) error {
68
+	gitRepo, err := git.OpenRepository(repo.RepoPath())
69
+	if err != nil {
70
+		return err
71
+	}
72
+
73
+	if _, err := gitRepo.GetTag(name); err == nil {
74
+		return ErrTagAlreadyExists{name}
75
+	}
76
+
77
+	branches, err := repo.GetBranches()
78
+	if err != nil {
79
+		return err
80
+	}
81
+
82
+	for _, branch := range branches {
83
+		if branch.Name == name {
84
+			return ErrBranchAlreadyExists{branch.Name}
85
+		} else if (len(branch.Name) < len(name) && branch.Name+"/" == name[0:len(branch.Name)+1]) ||
86
+			(len(branch.Name) > len(name) && name+"/" == branch.Name[0:len(name)+1]) {
87
+			return ErrBranchNameConflict{branch.Name}
88
+		}
89
+	}
90
+	return nil
91
+}
92
+
93
+// CreateNewBranch creates a new repository branch
94
+func (repo *Repository) CreateNewBranch(doer *User, oldBranchName, branchName string) (err error) {
95
+	repoWorkingPool.CheckIn(com.ToStr(repo.ID))
96
+	defer repoWorkingPool.CheckOut(com.ToStr(repo.ID))
97
+
98
+	// Check if branch name can be used
99
+	if err := repo.CheckBranchName(branchName); err != nil {
100
+		return err
101
+	}
102
+
103
+	localPath := repo.LocalCopyPath()
104
+
105
+	if err = discardLocalRepoBranchChanges(localPath, oldBranchName); err != nil {
106
+		return fmt.Errorf("discardLocalRepoChanges: %v", err)
107
+	} else if err = repo.UpdateLocalCopyBranch(oldBranchName); err != nil {
108
+		return fmt.Errorf("UpdateLocalCopyBranch: %v", err)
109
+	}
110
+
111
+	if err = repo.CheckoutNewBranch(oldBranchName, branchName); err != nil {
112
+		return fmt.Errorf("CreateNewBranch: %v", err)
113
+	}
114
+
115
+	if err = git.Push(localPath, git.PushOptions{
116
+		Remote: "origin",
117
+		Branch: branchName,
118
+	}); err != nil {
119
+		return fmt.Errorf("Push: %v", err)
120
+	}
121
+
122
+	return nil
123
+}
124
+
125
+// updateLocalCopyToCommit pulls latest changes of given commit from repoPath to localPath.
126
+// It creates a new clone if local copy does not exist.
127
+// This function checks out target commit by default, it is safe to assume subsequent
128
+// operations are operating against target commit when caller has confidence for no race condition.
129
+func updateLocalCopyToCommit(repoPath, localPath, commit string) error {
130
+	if !com.IsExist(localPath) {
131
+		if err := git.Clone(repoPath, localPath, git.CloneRepoOptions{
132
+			Timeout: time.Duration(setting.Git.Timeout.Clone) * time.Second,
133
+		}); err != nil {
134
+			return fmt.Errorf("git clone: %v", err)
135
+		}
136
+	} else {
137
+		_, err := git.NewCommand("fetch", "origin").RunInDir(localPath)
138
+		if err != nil {
139
+			return fmt.Errorf("git fetch origin: %v", err)
140
+		}
141
+		if err := git.ResetHEAD(localPath, true, "HEAD"); err != nil {
142
+			return fmt.Errorf("git reset --hard HEAD: %v", err)
143
+		}
144
+	}
145
+	if err := git.Checkout(localPath, git.CheckoutOptions{
146
+		Branch: commit,
147
+	}); err != nil {
148
+		return fmt.Errorf("git checkout %s: %v", commit, err)
149
+	}
150
+	return nil
151
+}
152
+
153
+// updateLocalCopyToCommit makes sure local copy of repository is at given commit.
154
+func (repo *Repository) updateLocalCopyToCommit(commit string) error {
155
+	return updateLocalCopyToCommit(repo.RepoPath(), repo.LocalCopyPath(), commit)
156
+}
157
+
158
+// CreateNewBranchFromCommit creates a new repository branch
159
+func (repo *Repository) CreateNewBranchFromCommit(doer *User, commit, branchName string) (err error) {
160
+	repoWorkingPool.CheckIn(com.ToStr(repo.ID))
161
+	defer repoWorkingPool.CheckOut(com.ToStr(repo.ID))
162
+
163
+	// Check if branch name can be used
164
+	if err := repo.CheckBranchName(branchName); err != nil {
165
+		return err
166
+	}
167
+
168
+	localPath := repo.LocalCopyPath()
169
+
170
+	if err = repo.updateLocalCopyToCommit(commit); err != nil {
171
+		return fmt.Errorf("UpdateLocalCopyBranch: %v", err)
172
+	}
173
+
174
+	if err = repo.CheckoutNewBranch(commit, branchName); err != nil {
175
+		return fmt.Errorf("CheckoutNewBranch: %v", err)
176
+	}
177
+
178
+	if err = git.Push(localPath, git.PushOptions{
179
+		Remote: "origin",
180
+		Branch: branchName,
181
+	}); err != nil {
182
+		return fmt.Errorf("Push: %v", err)
183
+	}
184
+
185
+	return nil
186
+}
187
+
55 188
 // GetCommit returns all the commits of a branch
56 189
 func (branch *Branch) GetCommit() (*git.Commit, error) {
57 190
 	gitRepo, err := git.OpenRepository(branch.Path)

+ 20 - 0
modules/auth/repo_branch_form.go

@@ -0,0 +1,20 @@
1
+// Copyright 2017 The Gitea Authors. All rights reserved.
2
+// Use of this source code is governed by a MIT-style
3
+// license that can be found in the LICENSE file.
4
+
5
+package auth
6
+
7
+import (
8
+	"github.com/go-macaron/binding"
9
+	macaron "gopkg.in/macaron.v1"
10
+)
11
+
12
+// NewBranchForm form for creating a new branch
13
+type NewBranchForm struct {
14
+	NewBranchName string `binding:"Required;MaxSize(100);GitRefName"`
15
+}
16
+
17
+// Validate validates the fields
18
+func (f *NewBranchForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors {
19
+	return validate(errs, ctx.Data, f, ctx.Locale)
20
+}

+ 6 - 0
modules/context/repo.go

@@ -76,6 +76,11 @@ func (r *Repository) CanEnableEditor() bool {
76 76
 	return r.Repository.CanEnableEditor() && r.IsViewBranch && r.IsWriter()
77 77
 }
78 78
 
79
+// CanCreateBranch returns true if repository is editable and user has proper access level.
80
+func (r *Repository) CanCreateBranch() bool {
81
+	return r.Repository.CanCreateBranch() && r.IsWriter()
82
+}
83
+
79 84
 // CanCommitToBranch returns true if repository is editable and user has proper access level
80 85
 //   and branch is not protected
81 86
 func (r *Repository) CanCommitToBranch(doer *models.User) (bool, error) {
@@ -528,6 +533,7 @@ func RepoRef() macaron.Handler {
528 533
 		ctx.Data["IsViewBranch"] = ctx.Repo.IsViewBranch
529 534
 		ctx.Data["IsViewTag"] = ctx.Repo.IsViewTag
530 535
 		ctx.Data["IsViewCommit"] = ctx.Repo.IsViewCommit
536
+		ctx.Data["CanCreateBranch"] = ctx.Repo.CanCreateBranch()
531 537
 
532 538
 		ctx.Repo.CommitsCount, err = ctx.Repo.Commit.CommitsCount()
533 539
 		if err != nil {

+ 9 - 3
modules/validation/binding.go

@@ -44,12 +44,18 @@ func addGitRefNameBindingRule() {
44 44
 			}
45 45
 			// Additional rules as described at https://www.kernel.org/pub/software/scm/git/docs/git-check-ref-format.html
46 46
 			if strings.HasPrefix(str, "/") || strings.HasSuffix(str, "/") ||
47
-				strings.HasPrefix(str, ".") || strings.HasSuffix(str, ".") ||
48
-				strings.HasSuffix(str, ".lock") ||
49
-				strings.Contains(str, "..") || strings.Contains(str, "//") {
47
+				strings.HasSuffix(str, ".") || strings.Contains(str, "..") ||
48
+				strings.Contains(str, "//") {
50 49
 				errs.Add([]string{name}, ErrGitRefName, "GitRefName")
51 50
 				return false, errs
52 51
 			}
52
+			parts := strings.Split(str, "/")
53
+			for _, part := range parts {
54
+				if strings.HasSuffix(part, ".lock") || strings.HasPrefix(part, ".") {
55
+					errs.Add([]string{name}, ErrGitRefName, "GitRefName")
56
+					return false, errs
57
+				}
58
+			}
53 59
 
54 60
 			return true, errs
55 61
 		},

+ 6 - 0
options/locale/locale_en-US.ini

@@ -1061,6 +1061,12 @@ branch.delete_notices_2 = - This operation will permanently delete everything in
1061 1061
 branch.deletion_success = %s has been deleted.
1062 1062
 branch.deletion_failed = Failed to delete branch %s.
1063 1063
 branch.delete_branch_has_new_commits = %s cannot be deleted because new commits have been added after merging.
1064
+branch.create_branch = Create branch <strong>%s</strong>
1065
+branch.create_from = from '%s'
1066
+branch.create_success = Branch '%s' has been created successfully!
1067
+branch.branch_already_exists = Branch '%s' already exists in this repository.
1068
+branch.branch_name_conflict = Branch name '%s' conflicts with already existing branch '%s'.
1069
+branch.tag_collision = Branch '%s' can not be created as tag with same name already exists in this repository.
1064 1070
 
1065 1071
 [org]
1066 1072
 org_name_holder = Organization Name

File diff suppressed because it is too large
+ 1 - 1
public/css/index.css


+ 193 - 12
public/js/index.js

@@ -362,9 +362,11 @@ function initRepository() {
362 362
         var $dropdown = $(selector);
363 363
         $dropdown.dropdown({
364 364
             fullTextSearch: true,
365
+            selectOnKeydown: false,
365 366
             onChange: function (text, value, $choice) {
366
-                window.location.href = $choice.data('url');
367
-                console.log($choice.data('url'))
367
+                if ($choice.data('url')) {
368
+                    window.location.href = $choice.data('url');
369
+                }
368 370
             },
369 371
             message: {noResults: $dropdown.data('no-results')}
370 372
         });
@@ -373,15 +375,7 @@ function initRepository() {
373 375
     // File list and commits
374 376
     if ($('.repository.file.list').length > 0 ||
375 377
         ('.repository.commits').length > 0) {
376
-        initFilterSearchDropdown('.choose.reference .dropdown');
377
-
378
-        $('.reference.column').click(function () {
379
-            $('.choose.reference .scrolling.menu').css('display', 'none');
380
-            $('.choose.reference .text').removeClass('black');
381
-            $($(this).data('target')).css('display', 'block');
382
-            $(this).find('.text').addClass('black');
383
-            return false;
384
-        });
378
+        initFilterBranchTagDropdown('.choose.reference .dropdown');
385 379
     }
386 380
 
387 381
     // Wiki
@@ -1318,7 +1312,7 @@ $(document).ready(function () {
1318 1312
     });
1319 1313
 
1320 1314
     // Semantic UI modules.
1321
-    $('.dropdown').dropdown();
1315
+    $('.dropdown:not(.custom)').dropdown();
1322 1316
     $('.jump.dropdown').dropdown({
1323 1317
         action: 'hide',
1324 1318
         onShow: function () {
@@ -1780,3 +1774,190 @@ function toggleStopwatch() {
1780 1774
 function cancelStopwatch() {
1781 1775
     $("#cancel_stopwatch_form").submit();
1782 1776
 }
1777
+
1778
+function initFilterBranchTagDropdown(selector) {
1779
+    $(selector).each(function() {
1780
+        var $dropdown = $(this);
1781
+        var $data = $dropdown.find('.data');
1782
+        var data = {
1783
+            items: [],
1784
+            mode: $data.data('mode'),
1785
+            searchTerm: '',
1786
+            noResults: '',
1787
+            canCreateBranch: false,
1788
+            menuVisible: false,
1789
+            active: 0
1790
+        };
1791
+        $data.find('.item').each(function() {
1792
+            data.items.push({
1793
+                name: $(this).text(),
1794
+                url: $(this).data('url'),
1795
+                branch: $(this).hasClass('branch'),
1796
+                tag: $(this).hasClass('tag'),
1797
+                selected: $(this).hasClass('selected')
1798
+            });
1799
+        });
1800
+        $data.remove();
1801
+        new Vue({
1802
+            delimiters: ['${', '}'],
1803
+            el: this,
1804
+            data: data,
1805
+
1806
+            beforeMount: function () {
1807
+                var vm = this;
1808
+
1809
+                this.noResults = vm.$el.getAttribute('data-no-results');
1810
+                this.canCreateBranch = vm.$el.getAttribute('data-can-create-branch') === 'true';
1811
+
1812
+                document.body.addEventListener('click', function(event) {
1813
+                    if (vm.$el.contains(event.target)) {
1814
+                        return;
1815
+                    }
1816
+                    if (vm.menuVisible) {
1817
+                        Vue.set(vm, 'menuVisible', false);
1818
+                    }
1819
+                });
1820
+            },
1821
+
1822
+            watch: {
1823
+                menuVisible: function(visible) {
1824
+                    if (visible) {
1825
+                        this.focusSearchField();
1826
+                    }
1827
+                }
1828
+            },
1829
+
1830
+            computed: {
1831
+                filteredItems: function() {
1832
+                    var vm = this;
1833
+
1834
+                    var items = vm.items.filter(function (item) {
1835
+                        return ((vm.mode === 'branches' && item.branch)
1836
+                                || (vm.mode === 'tags' && item.tag))
1837
+                            && (!vm.searchTerm
1838
+                                || item.name.toLowerCase().indexOf(vm.searchTerm.toLowerCase()) >= 0);
1839
+                    });
1840
+
1841
+                    vm.active = (items.length === 0 && vm.showCreateNewBranch ? 0 : -1);
1842
+
1843
+                    return items;
1844
+                },
1845
+                showNoResults: function() {
1846
+                    return this.filteredItems.length === 0
1847
+                            && !this.showCreateNewBranch;
1848
+                },
1849
+                showCreateNewBranch: function() {
1850
+                    var vm = this;
1851
+                    if (!this.canCreateBranch || !vm.searchTerm || vm.mode === 'tags') {
1852
+                        return false;
1853
+                    }
1854
+
1855
+                    return vm.items.filter(function (item) {
1856
+                        return item.name.toLowerCase() === vm.searchTerm.toLowerCase()
1857
+                    }).length === 0;
1858
+                }
1859
+            },
1860
+
1861
+            methods: {
1862
+                selectItem: function(item) {
1863
+                    var prev = this.getSelected();
1864
+                    if (prev !== null) {
1865
+                        prev.selected = false;
1866
+                    }
1867
+                    item.selected = true;
1868
+                    window.location.href = item.url;
1869
+                },
1870
+                createNewBranch: function() {
1871
+                    if (!this.showCreateNewBranch) {
1872
+                        return;
1873
+                    }
1874
+                    this.$refs.newBranchForm.submit();
1875
+                },
1876
+                focusSearchField: function() {
1877
+                    var vm = this;
1878
+                    Vue.nextTick(function() {
1879
+                        vm.$refs.searchField.focus();
1880
+                    });
1881
+                },
1882
+                getSelected: function() {
1883
+                    for (var i = 0, j = this.items.length; i < j; ++i) {
1884
+                        if (this.items[i].selected)
1885
+                            return this.items[i];
1886
+                    }
1887
+                    return null;
1888
+                },
1889
+                getSelectedIndexInFiltered() {
1890
+                    for (var i = 0, j = this.filteredItems.length; i < j; ++i) {
1891
+                        if (this.filteredItems[i].selected)
1892
+                            return i;
1893
+                    }
1894
+                    return -1;
1895
+                },
1896
+                scrollToActive() {
1897
+                    var el = this.$refs['listItem' + this.active];
1898
+                    if (!el || el.length === 0) {
1899
+                        return;
1900
+                    }
1901
+                    if (Array.isArray(el)) {
1902
+                        el = el[0];
1903
+                    }
1904
+
1905
+                    var cont = this.$refs.scrollContainer;
1906
+
1907
+                     if (el.offsetTop < cont.scrollTop) {
1908
+                         cont.scrollTop = el.offsetTop;
1909
+                     }
1910
+                     else if (el.offsetTop + el.clientHeight > cont.scrollTop + cont.clientHeight) {
1911
+                        cont.scrollTop = el.offsetTop + el.clientHeight - cont.clientHeight;
1912
+                    }
1913
+                },
1914
+                keydown: function(event) {
1915
+                    var vm = this;
1916
+                    if (event.keyCode === 40) {
1917
+                        // arrow down
1918
+                        event.preventDefault();
1919
+
1920
+                        if (vm.active === -1) {
1921
+                            vm.active = vm.getSelectedIndexInFiltered();
1922
+                        }
1923
+
1924
+                        if (vm.active + (vm.showCreateNewBranch ? 0 : 1) >= vm.filteredItems.length) {
1925
+                            return;
1926
+                        }
1927
+                        vm.active++;
1928
+                        vm.scrollToActive();
1929
+                    }
1930
+                    if (event.keyCode === 38) {
1931
+                        // arrow up
1932
+                        event.preventDefault();
1933
+
1934
+                         if (vm.active === -1) {
1935
+                            vm.active = vm.getSelectedIndexInFiltered();
1936
+                        }
1937
+
1938
+                         if (vm.active <= 0) {
1939
+                            return;
1940
+                        }
1941
+                        vm.active--;
1942
+                        vm.scrollToActive();
1943
+                    }
1944
+                    if (event.keyCode == 13) {
1945
+                        // enter
1946
+                        event.preventDefault();
1947
+
1948
+                         if (vm.active >= vm.filteredItems.length) {
1949
+                            vm.createNewBranch();
1950
+                        } else if (vm.active >= 0) {
1951
+                            vm.selectItem(vm.filteredItems[vm.active]);
1952
+                        }
1953
+                    }
1954
+                    if (event.keyCode == 27) {
1955
+                        // escape
1956
+                        event.preventDefault();
1957
+                        vm.menuVisible = false;
1958
+                    }
1959
+                }
1960
+            }
1961
+        });
1962
+    });
1963
+}

+ 4 - 0
public/less/_base.less

@@ -329,6 +329,10 @@ pre, code {
329 329
 			background-color: #a1882b !important;
330 330
 		}
331 331
 	}
332
+
333
+	.branch-tag-choice {
334
+		line-height: 20px;
335
+	}
332 336
 }
333 337
 
334 338
 

+ 49 - 0
routers/repo/branch.go

@@ -5,6 +5,8 @@
5 5
 package repo
6 6
 
7 7
 import (
8
+	"code.gitea.io/gitea/models"
9
+	"code.gitea.io/gitea/modules/auth"
8 10
 	"code.gitea.io/gitea/modules/base"
9 11
 	"code.gitea.io/gitea/modules/context"
10 12
 )
@@ -30,3 +32,50 @@ func Branches(ctx *context.Context) {
30 32
 	ctx.Data["Branches"] = brs
31 33
 	ctx.HTML(200, tplBranch)
32 34
 }
35
+
36
+// CreateBranch creates new branch in repository
37
+func CreateBranch(ctx *context.Context, form auth.NewBranchForm) {
38
+	if !ctx.Repo.CanCreateBranch() {
39
+		ctx.Handle(404, "CreateBranch", nil)
40
+		return
41
+	}
42
+
43
+	if ctx.HasError() {
44
+		ctx.Flash.Error(ctx.GetErrMsg())
45
+		ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchName)
46
+		return
47
+	}
48
+
49
+	var err error
50
+	if ctx.Repo.IsViewBranch {
51
+		err = ctx.Repo.Repository.CreateNewBranch(ctx.User, ctx.Repo.BranchName, form.NewBranchName)
52
+	} else {
53
+		err = ctx.Repo.Repository.CreateNewBranchFromCommit(ctx.User, ctx.Repo.BranchName, form.NewBranchName)
54
+	}
55
+	if err != nil {
56
+		if models.IsErrTagAlreadyExists(err) {
57
+			e := err.(models.ErrTagAlreadyExists)
58
+			ctx.Flash.Error(ctx.Tr("repo.branch.tag_collision", e.TagName))
59
+			ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchName)
60
+			return
61
+		}
62
+		if models.IsErrBranchAlreadyExists(err) {
63
+			e := err.(models.ErrBranchAlreadyExists)
64
+			ctx.Flash.Error(ctx.Tr("repo.branch.branch_already_exists", e.BranchName))
65
+			ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchName)
66
+			return
67
+		}
68
+		if models.IsErrBranchNameConflict(err) {
69
+			e := err.(models.ErrBranchNameConflict)
70
+			ctx.Flash.Error(ctx.Tr("repo.branch.branch_name_conflict", form.NewBranchName, e.BranchName))
71
+			ctx.Redirect(ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchName)
72
+			return
73
+		}
74
+
75
+		ctx.Handle(500, "CreateNewBranch", err)
76
+		return
77
+	}
78
+
79
+	ctx.Flash.Success(ctx.Tr("repo.branch.create_success", form.NewBranchName))
80
+	ctx.Redirect(ctx.Repo.RepoLink + "/src/" + form.NewBranchName)
81
+}

+ 4 - 0
routers/routes/routes.go

@@ -554,6 +554,10 @@ func RegisterRoutes(m *macaron.Macaron) {
554 554
 				return
555 555
 			}
556 556
 		})
557
+
558
+		m.Group("/branches", func() {
559
+			m.Post("/_new/*", context.RepoRef(), bindIgnErr(auth.NewBranchForm{}), repo.CreateBranch)
560
+		}, reqRepoWriter, repo.MustBeNotBare)
557 561
 	}, reqSignIn, context.RepoAssignment(), context.UnitTypes(), context.LoadRepoUnits())
558 562
 
559 563
 	// Releases

+ 39 - 18
templates/repo/branch_dropdown.tmpl

@@ -1,6 +1,6 @@
1 1
 <div class="fitted item choose reference">
2
-	<div class="ui floating filter dropdown" data-no-results="{{.i18n.Tr "repo.pulls.no_results"}}">
3
-			<div class="ui basic compact tiny button">
2
+	<div class="ui floating filter dropdown custom" data-can-create-branch="{{.CanCreateBranch}}" data-no-results="{{.i18n.Tr "repo.pulls.no_results"}}">
3
+		<div class="ui basic small button" @click="menuVisible = !menuVisible" @keyup.enter="menuVisible = !menuVisible">
4 4
 			<span class="text">
5 5
 				<i class="octicon octicon-git-branch"></i>
6 6
 				{{if .IsViewBranch}}{{.i18n.Tr "repo.branch"}}{{else}}{{.i18n.Tr "repo.tree"}}{{end}}:
@@ -8,37 +8,58 @@
8 8
 			</span>
9 9
 			<i class="dropdown icon"></i>
10 10
 		</div>
11
-		<div class="menu">
11
+		<div class="data" style="display: none" data-mode="{{if .IsViewTag}}tags{{else}}branches{{end}}">
12
+			{{range .Branches}}
13
+				<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>
14
+			{{end}}
15
+			{{range .Tags}}
16
+				<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>
17
+			{{end}}
18
+		</div>
19
+		<div class="menu transition visible" v-if="menuVisible" v-cloak>
12 20
 			<div class="ui icon search input">
13 21
 				<i class="filter icon"></i>
14
-				<input name="search" placeholder="{{.i18n.Tr "repo.filter_branch_and_tag"}}...">
22
+				<input name="search" ref="searchField" v-model="searchTerm" @keydown="keydown($event)" placeholder="{{.i18n.Tr "repo.filter_branch_and_tag"}}...">
15 23
 			</div>
16
-			<div class="header">
24
+			<div class="header branch-tag-choice">
17 25
 				<div class="ui grid">
18 26
 					<div class="two column row">
19
-						<a class="reference column" href="#" data-target="#branch-list">
20
-							<span class="text {{if not .IsViewTag}}black{{end}}">
27
+						<a class="reference column" href="#" @click="mode = 'branches'; focusSearchField()">
28
+							<span class="text" :class="{black: mode == 'branches'}">
21 29
 								<i class="octicon octicon-git-branch"></i> {{.i18n.Tr "repo.branches"}}
22 30
 							</span>
23 31
 						</a>
24
-						<a class="reference column" href="#" data-target="#tag-list">
25
-							<span class="text {{if .IsViewTag}}black{{end}}">
32
+						<a class="reference column" href="#" @click="mode = 'tags'; focusSearchField()">
33
+							<span class="text" :class="{black: mode == 'tags'}">
26 34
 								<i class="reference tags icon"></i> {{.i18n.Tr "repo.tags"}}
27 35
 							</span>
28 36
 						</a>
29 37
 					</div>
30 38
 				</div>
31 39
 			</div>
32
-			<div id="branch-list" class="scrolling menu" {{if .IsViewTag}}style="display: none"{{end}}>
33
-				{{range .Branches}}
34
-					<div class="item {{if eq $.BranchName .}}selected{{end}}" data-url="{{$.RepoLink}}/{{if $.PageIsCommits}}commits{{else}}src{{end}}/{{EscapePound .}}{{if $.TreePath}}/{{EscapePound $.TreePath}}{{end}}">{{.}}</div>
35
-				{{end}}
36
-			</div>
37
-			<div id="tag-list" class="scrolling menu" {{if not .IsViewTag}}style="display: none"{{end}}>
38
-				{{range .Tags}}
39
-					<div class="item {{if eq $.BranchName .}}selected{{end}}" data-url="{{$.RepoLink}}/{{if $.PageIsCommits}}commits{{else}}src{{end}}/{{EscapePound .}}{{if $.TreePath}}/{{EscapePound $.TreePath}}{{end}}">{{.}}</div>
40
-				{{end}}
40
+			<div class="scrolling menu" ref="scrollContainer">
41
+				<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>
42
+				<div class="item" v-if="showCreateNewBranch" :class="{active: active == filteredItems.length}" :ref="'listItem' + filteredItems.length">
43
+					<a href="#" @click="createNewBranch()">
44
+						<div>
45
+							<i class="octicon octicon-git-branch"></i>
46
+							{{.i18n.Tr "repo.branch.create_branch" `${ searchTerm }` | Safe}}
47
+						</div>
48
+						<div class="text small">
49
+							{{if .IsViewBranch}}
50
+								{{.i18n.Tr "repo.branch.create_from" .BranchName | Safe}}
51
+							{{else}}
52
+								{{.i18n.Tr "repo.branch.create_from" (ShortSha .BranchName) | Safe}}
53
+							{{end}}
54
+						</div>
55
+					</a>
56
+					<form ref="newBranchForm" action="{{.RepoLink}}/branches/_new/{{EscapePound .BranchName}}" method="post">
57
+						{{.CsrfTokenHtml}}
58
+						<input type="hidden" name="new_branch_name" v-model="searchTerm">
59
+					</form>
60
+				</div>
41 61
 			</div>
62
+			<div class="message" v-if="showNoResults">${ noResults }</div>
42 63
 		</div>
43 64
 	</div>
44 65
 </div>