This commit is contained in:
Ilya Kiselev 2026-09-04 11:04:50 +10:00 committed by GitHub
commit 5fdfa94e9e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 183 additions and 0 deletions

View file

@ -762,6 +762,7 @@ keybinding:
viewPullRequestOptions: O
openPullRequestInBrowser: G
copyPullRequestURL: <ctrl+y>
copyBranchURL: "y"
checkoutBranchByName: c
forceCheckoutBranch: F
checkoutPreviousBranch: '-'

View file

@ -183,6 +183,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` O `` | View create pull request options | |
| `` G `` | Open pull request in browser | |
| `` <ctrl+y> `` | Copy pull request URL to clipboard | |
| `` y `` | Copy branch URL to clipboard | |
| `` c `` | Checkout by name | Checkout by name. In the input box you can enter '-' to switch to the previous branch. |
| `` - `` | Checkout previous branch | |
| `` F `` | Force checkout | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. |

View file

@ -381,6 +381,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` O `` | プルリクエスト作成オプションを表示 | |
| `` G `` | Open pull request in browser | |
| `` <ctrl+y> `` | プルリクエストURLをクリップボードにコピー | |
| `` y `` | Copy branch URL to clipboard | |
| `` c `` | 名前でチェックアウト | 名前でチェックアウトします。入力ボックスに「-」を入力すると、最後のブランチをチェックアウトすることができます。 |
| `` - `` | 直前のブランチにチェックアウト | |
| `` F `` | 強制チェックアウト | 選択したブランチを強制的にチェックアウトします。これにより、選択したブランチをチェックアウトする前にワーキングディレクトリ内のすべてのローカル変更が破棄されます。 |

View file

@ -217,6 +217,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` O `` | 풀 리퀘스트 생성 옵션 | |
| `` G `` | Open pull request in browser | |
| `` <ctrl+y> `` | 풀 리퀘스트 URL을 클립보드에 복사 | |
| `` y `` | Copy branch URL to clipboard | |
| `` c `` | 이름으로 체크아웃 | Checkout by name. In the input box you can enter '-' to switch to the previous branch. |
| `` - `` | Checkout previous branch | |
| `` F `` | 강제 체크아웃 | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. |

View file

@ -107,6 +107,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` O `` | Bekijk opties voor pull-aanvraag | |
| `` G `` | Open pull request in browser | |
| `` <ctrl+y> `` | Kopieer de URL van het pull-verzoek naar het klembord | |
| `` y `` | Copy branch URL to clipboard | |
| `` c `` | Uitchecken bij naam | Checkout by name. In the input box you can enter '-' to switch to the previous branch. |
| `` - `` | Vorige branch uitchecken | |
| `` F `` | Forceer checkout | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. |

View file

@ -169,6 +169,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` O `` | Zobacz opcje tworzenia pull requesta | |
| `` G `` | Otwórz żądanie ściągnięcia w przeglądarce | |
| `` <ctrl+y> `` | Kopiuj adres URL żądania ściągnięcia do schowka | |
| `` y `` | Copy branch URL to clipboard | |
| `` c `` | Przełącz według nazwy | Przełącz według nazwy. W polu wprowadzania możesz wpisać '-' aby przełączyć się na ostatnią gałąź. |
| `` - `` | Przełącz na poprzednią gałąź | |
| `` F `` | Wymuś przełączenie | Wymuś przełączenie wybranej gałęzi. To spowoduje odrzucenie wszystkich lokalnych zmian w drzewie roboczym przed przełączeniem na wybraną gałąź. |

View file

@ -99,6 +99,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` O `` | View create pull request options | |
| `` G `` | Open pull request in browser | |
| `` <ctrl+y> `` | Copiar URL do pull request para área de transferência | |
| `` y `` | Copy branch URL to clipboard | |
| `` c `` | Checar por nome | Checar por nome. Na caixa de entrada você pode inserir '-' para trocar para a última branch |
| `` - `` | Checkout da branch anterior | |
| `` F `` | Forçar checagem | Forçar checagem da branch selecionada. Isso irá descartar todas as mudanças no seu diretório de trabalho antes cheque a branch selecionada |

View file

@ -217,6 +217,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` O `` | Создать параметры запроса принятие изменений | |
| `` G `` | Open pull request in browser | |
| `` <ctrl+y> `` | Скопировать URL запроса на принятие изменений в буфер обмена | |
| `` y `` | Copy branch URL to clipboard | |
| `` c `` | Переключить по названию | Checkout by name. In the input box you can enter '-' to switch to the previous branch. |
| `` - `` | Checkout previous branch | |
| `` F `` | Принудительное переключение | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. |

View file

@ -232,6 +232,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` O `` | 创建拉取请求选项 | |
| `` G `` | 在浏览器中打开拉取请求 | |
| `` <ctrl+y> `` | 复制拉取请求 URL 到剪贴板 | |
| `` y `` | Copy branch URL to clipboard | |
| `` c `` | 按名称检出 | 按名称检出。在输入框中,您可以输入'-' 来切换到最后一个分支。 |
| `` - `` | 签出上一个分支 | |
| `` F `` | 强制检出 | 强制检出所选分支。这将在检出所选分支之前放弃工作目录中的所有本地更改。 |

View file

@ -292,6 +292,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` O `` | 建立拉取請求選項 | |
| `` G `` | Open pull request in browser | |
| `` <ctrl+y> `` | 複製拉取請求的 URL 到剪貼板 | |
| `` y `` | Copy branch URL to clipboard | |
| `` c `` | 根據名稱檢出 | Checkout by name. In the input box you can enter '-' to switch to the previous branch. |
| `` - `` | Checkout previous branch | |
| `` F `` | 強制檢出 | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. |

View file

@ -21,6 +21,7 @@ var githubServiceDef = ServiceDefinition{
pullRequestURLIntoDefaultBranch: "/compare/{{.From}}?expand=1",
pullRequestURLIntoTargetBranch: "/compare/{{.To}}...{{.From}}?expand=1",
commitURL: "/commit/{{.CommitHash}}",
branchURL: "/tree/{{.BranchName}}",
urlRegexps: defaultUrlRegexps,
repoURLTemplate: defaultRepoURLTemplate,
repoNameTemplate: defaultRepoNameTemplate,
@ -31,6 +32,7 @@ var bitbucketServiceDef = ServiceDefinition{
pullRequestURLIntoDefaultBranch: "/pull-requests/new?source={{.From}}&t=1",
pullRequestURLIntoTargetBranch: "/pull-requests/new?source={{.From}}&dest={{.To}}&t=1",
commitURL: "/commits/{{.CommitHash}}",
branchURL: "/branch/{{.BranchName}}",
urlRegexps: []*regexp.Regexp{
regexp.MustCompile(`^(?:https?|ssh)://.*/(?P<owner>.*)/(?P<repo>.*?)(?:\.git)?$`),
regexp.MustCompile(`^.*@.*:/*(?P<owner>.*)/(?P<repo>.*?)(?:\.git)?$`),
@ -44,6 +46,7 @@ var gitLabServiceDef = ServiceDefinition{
pullRequestURLIntoDefaultBranch: "/-/merge_requests/new?merge_request%5Bsource_branch%5D={{.From}}",
pullRequestURLIntoTargetBranch: "/-/merge_requests/new?merge_request%5Bsource_branch%5D={{.From}}&merge_request%5Btarget_branch%5D={{.To}}",
commitURL: "/-/commit/{{.CommitHash}}",
branchURL: "/-/tree/{{.BranchName}}",
urlRegexps: defaultUrlRegexps,
repoURLTemplate: defaultRepoURLTemplate,
repoNameTemplate: defaultRepoNameTemplate,
@ -54,6 +57,7 @@ var azdoServiceDef = ServiceDefinition{
pullRequestURLIntoDefaultBranch: "/pullrequestcreate?sourceRef={{.From}}",
pullRequestURLIntoTargetBranch: "/pullrequestcreate?sourceRef={{.From}}&targetRef={{.To}}",
commitURL: "/commit/{{.CommitHash}}",
branchURL: "?version=GB{{.BranchName}}",
urlRegexps: []*regexp.Regexp{
regexp.MustCompile(`^.+@vs-ssh\.visualstudio\.com[:/](?:v3/)?(?P<org>[^/]+)/(?P<project>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?$`),
regexp.MustCompile(`^git@ssh.dev.azure.com.*/(?P<org>.*)/(?P<project>.*)/(?P<repo>.*?)(?:\.git)?$`),
@ -69,6 +73,7 @@ var bitbucketServerServiceDef = ServiceDefinition{
pullRequestURLIntoDefaultBranch: "/pull-requests?create&sourceBranch={{.From}}",
pullRequestURLIntoTargetBranch: "/pull-requests?create&targetBranch={{.To}}&sourceBranch={{.From}}",
commitURL: "/commits/{{.CommitHash}}",
branchURL: "/browse?at={{.BranchName}}",
urlRegexps: []*regexp.Regexp{
regexp.MustCompile(`^ssh://git@.*/(?P<project>.*)/(?P<repo>.*?)(?:\.git)?$`),
regexp.MustCompile(`^https://.*/scm/(?P<project>.*)/(?P<repo>.*?)(?:\.git)?$`),
@ -82,6 +87,7 @@ var giteaServiceDef = ServiceDefinition{
pullRequestURLIntoDefaultBranch: "/compare/{{.From}}",
pullRequestURLIntoTargetBranch: "/compare/{{.To}}...{{.From}}",
commitURL: "/commit/{{.CommitHash}}",
branchURL: "/src/branch/{{.BranchName}}",
urlRegexps: defaultUrlRegexps,
repoURLTemplate: defaultRepoURLTemplate,
}
@ -91,6 +97,7 @@ var codebergServiceDef = ServiceDefinition{
pullRequestURLIntoDefaultBranch: "/compare/{{.From}}",
pullRequestURLIntoTargetBranch: "/compare/{{.To}}...{{.From}}",
commitURL: "/commit/{{.CommitHash}}",
branchURL: "/src/branch/{{.BranchName}}",
urlRegexps: defaultUrlRegexps,
repoURLTemplate: defaultRepoURLTemplate,
}

View file

@ -109,6 +109,15 @@ func (self *HostingServiceMgr) GetServiceInfo() (ServiceInfo, error) {
}, nil
}
func (self *HostingServiceMgr) GetBranchURL(branchName string) (string, error) {
gitService, err := self.getService()
if err != nil {
return "", err
}
return gitService.getBranchURL(url.QueryEscape(branchName)), nil
}
func (self *HostingServiceMgr) getService() (*Service, error) {
serviceDomain, err := self.getServiceDomain(self.remoteURL)
if err != nil {
@ -196,6 +205,7 @@ type ServiceDefinition struct {
pullRequestURLIntoTargetBranch string
commitURL string
urlRegexps []*regexp.Regexp
branchURL string
// can expect 'webdomain' to be passed in. Otherwise, you get to pick what we match in the regex
repoURLTemplate string
@ -273,6 +283,10 @@ func (self *Service) getCommitURL(commitHash string) string {
return self.resolveUrl(self.commitURL, map[string]string{"CommitHash": commitHash})
}
func (self *Service) getBranchURL(branchName string) string {
return self.resolveUrl(self.branchURL, map[string]string{"BranchName": branchName})
}
func (self *Service) resolveUrl(templateString string, args map[string]string) string {
return self.repoURL + utils.ResolvePlaceholderString(templateString, args)
}

View file

@ -681,3 +681,96 @@ func TestGetServiceInfo(t *testing.T) {
})
}
}
func TestGetBranchURL(t *testing.T) {
type scenario struct {
testName string
branchName string
remoteUrl string
configServiceDomains map[string]string
test func(url string, err error)
}
scenarios := []scenario{
{
testName: "Returns branch URL for github (SSH)",
branchName: "feature/my-feature",
remoteUrl: "git@github.com:peter/calculator.git",
test: func(url string, err error) {
assert.NoError(t, err)
assert.Equal(t, "https://github.com/peter/calculator/tree/feature%2Fmy-feature", url)
},
},
{
testName: "Returns branch URL for github (HTTPS)",
branchName: "feature/my-feature",
remoteUrl: "https://github.com/peter/calculator.git",
test: func(url string, err error) {
assert.NoError(t, err)
assert.Equal(t, "https://github.com/peter/calculator/tree/feature%2Fmy-feature", url)
},
},
{
testName: "Returns branch URL for gitlab",
branchName: "feature/ui",
remoteUrl: "git@gitlab.com:peter/calculator.git",
test: func(url string, err error) {
assert.NoError(t, err)
assert.Equal(t, "https://gitlab.com/peter/calculator/-/tree/feature%2Fui", url)
},
},
{
testName: "Returns branch URL for bitbucket",
branchName: "feature/profile-page",
remoteUrl: "git@bitbucket.org:johndoe/social_network.git",
test: func(url string, err error) {
assert.NoError(t, err)
assert.Equal(t, "https://bitbucket.org/johndoe/social_network/branch/feature%2Fprofile-page", url)
},
},
{
testName: "Returns branch URL for gitea",
branchName: "main",
remoteUrl: "git@try.gitea.io:johndoe/myrepo.git",
test: func(url string, err error) {
assert.NoError(t, err)
assert.Equal(t, "https://try.gitea.io/johndoe/myrepo/src/branch/main", url)
},
},
{
testName: "Returns branch URL for codeberg",
branchName: "develop",
remoteUrl: "git@codeberg.org:johndoe/myrepo.git",
test: func(url string, err error) {
assert.NoError(t, err)
assert.Equal(t, "https://codeberg.org/johndoe/myrepo/src/branch/develop", url)
},
},
{
testName: "Escapes reserved URL characters in branch name",
branchName: "feature/issue#42",
remoteUrl: "git@github.com:peter/calculator.git",
test: func(url string, err error) {
assert.NoError(t, err)
assert.Equal(t, "https://github.com/peter/calculator/tree/feature%2Fissue%2342", url)
},
},
{
testName: "Returns error for unsupported service",
branchName: "main",
remoteUrl: "git@unknown-host.com:peter/calculator.git",
test: func(url string, err error) {
assert.Error(t, err)
},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
tr := i18n.EnglishTranslationSet()
log := &fakes.FakeFieldLogger{}
hostingServiceMgr := NewHostingServiceMgr(log, tr, s.remoteUrl, s.configServiceDomains)
s.test(hostingServiceMgr.GetBranchURL(s.branchName))
})
}
}

View file

@ -595,6 +595,7 @@ type KeybindingBranchesConfig struct {
ViewPullRequestOptions Keybinding `yaml:"viewPullRequestOptions"`
OpenPullRequestInBrowser Keybinding `yaml:"openPullRequestInBrowser"`
CopyPullRequestURL Keybinding `yaml:"copyPullRequestURL"`
CopyBranchURL Keybinding `yaml:"copyBranchURL"`
CheckoutBranchByName Keybinding `yaml:"checkoutBranchByName"`
ForceCheckoutBranch Keybinding `yaml:"forceCheckoutBranch"`
CheckoutPreviousBranch Keybinding `yaml:"checkoutPreviousBranch"`
@ -1110,6 +1111,7 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
CreatePullRequest: Keybinding{"o"},
ViewPullRequestOptions: Keybinding{"O"},
OpenPullRequestInBrowser: Keybinding{"G"},
CopyBranchURL: Keybinding{"y"},
CheckoutBranchByName: Keybinding{"c"},
ForceCheckoutBranch: Keybinding{"F"},
CheckoutPreviousBranch: Keybinding{"-"},

View file

@ -97,6 +97,12 @@ func (self *BranchesController) GetKeybindings(opts types.KeybindingsOpts) []*ty
GetDisabledReason: self.require(self.singleItemSelected()),
Description: self.c.Tr.CopyPullRequestURL,
},
{
Keys: opts.GetKeys(opts.Config.Branches.CopyBranchURL),
Handler: self.copyBranchURL,
GetDisabledReason: self.require(self.singleItemSelected()),
Description: self.c.Tr.CopyBranchURL,
},
{
Keys: opts.GetKeys(opts.Config.Branches.CheckoutBranchByName),
Handler: self.checkoutByName,
@ -491,6 +497,29 @@ func (self *BranchesController) copyPullRequestURL() error {
return nil
}
func (self *BranchesController) copyBranchURL() error {
branch := self.context().GetSelected()
branchExistsOnRemote := self.c.Git().Remote.CheckRemoteBranchExists(branch.Name)
if !branchExistsOnRemote {
return errors.New(self.c.Tr.NoBranchOnRemote)
}
url, err := self.c.Helpers().Host.GetBranchURL(branch.Name)
if err != nil {
return err
}
self.c.LogAction(self.c.Tr.Actions.CopyBranchURL)
if err := self.c.OS().CopyToClipboard(url); err != nil {
return err
}
self.c.Toast(self.c.Tr.BranchURLCopiedToClipboard)
return nil
}
func (self *BranchesController) forceCheckout() error {
branch := self.context().GetSelected()
message := self.c.Tr.SureForceCheckout

View file

@ -34,6 +34,14 @@ func (self *HostHelper) GetCommitURL(commitHash string) (string, error) {
return mgr.GetCommitURL(commitHash)
}
func (self *HostHelper) GetBranchURL(branchName string) (string, error) {
mgr, err := self.getHostingServiceMgr()
if err != nil {
return "", err
}
return mgr.GetBranchURL(branchName)
}
// getting this on every request rather than storing it in state in case our remoteURL changes
// from one invocation to the next.
func (self *HostHelper) getHostingServiceMgr() (*hosting_service.HostingServiceMgr, error) {

View file

@ -287,6 +287,7 @@ type TranslationSet struct {
CopyPullRequestURL string
OpenPullRequestInBrowser string
NoPullRequestForBranch string
CopyBranchURL string
NoBranchOnRemote string
Fetch string
FetchTooltip string
@ -766,6 +767,7 @@ type TranslationSet struct {
SuggestionsSubtitle string
ExtrasTitle string
PullRequestURLCopiedToClipboard string
BranchURLCopiedToClipboard string
CommitDiffCopiedToClipboard string
CommitURLCopiedToClipboard string
CommitMessageCopiedToClipboard string
@ -1126,6 +1128,7 @@ type Actions struct {
Undo string
Redo string
CopyPullRequestURL string
CopyBranchURL string
OpenMergeTool string
OpenCommitInBrowser string
OpenPullRequest string
@ -1445,6 +1448,7 @@ func EnglishTranslationSet() *TranslationSet {
CopyPullRequestURL: `Copy pull request URL to clipboard`,
OpenPullRequestInBrowser: `Open pull request in browser`,
NoPullRequestForBranch: `No pull request found for this branch`,
CopyBranchURL: `Copy branch URL to clipboard`,
NoBranchOnRemote: `This branch doesn't exist on remote. You need to push it to remote first.`,
Fetch: `Fetch`,
FetchTooltip: "Fetch changes from remote.",
@ -1928,6 +1932,7 @@ func EnglishTranslationSet() *TranslationSet {
SuggestionsSubtitle: "(press %s to delete, %s to edit)",
ExtrasTitle: "Command log",
PullRequestURLCopiedToClipboard: "Pull request URL copied to clipboard",
BranchURLCopiedToClipboard: "Branch URL copied to clipboard",
CommitDiffCopiedToClipboard: "Commit diff copied to clipboard",
CommitURLCopiedToClipboard: "Commit URL copied to clipboard",
CommitMessageCopiedToClipboard: "Commit message copied to clipboard",
@ -2245,6 +2250,7 @@ func EnglishTranslationSet() *TranslationSet {
Undo: "Undo",
Redo: "Redo",
CopyPullRequestURL: "Copy pull request URL",
CopyBranchURL: "Copy branch URL",
OpenMergeTool: "Open merge tool",
OpenCommitInBrowser: "Open commit in browser",
OpenPullRequest: "Open pull request in browser",

View file

@ -1049,6 +1049,20 @@
],
"default": "\u003cctrl+y\u003e"
},
"copyBranchURL": {
"oneOf": [
{
"type": "string"
},
{
"items": {
"type": "string"
},
"type": "array"
}
],
"default": "y"
},
"checkoutBranchByName": {
"oneOf": [
{