jesseduffield.lazygit/pkg/gui/controllers/remotes_controller.go
Stefan Haller 72bff90822 Rename GetOnClick (et al) to GetOnDoubleClick
When this was originally introduced, it handled single clicks on a list entry
(treating them similar to a double-click by checking whether the click was on
the selected entry). Arguably it should have been called OnDoubleClick back then
already; but when we later changed it to do actual double-click detection (see
37197b8e9a), we should have renamed the methods.
2026-03-25 14:58:53 +01:00

379 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package controllers
import (
"errors"
"fmt"
"regexp"
"slices"
"strings"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
)
type RemotesController struct {
baseController
*ListControllerTrait[*models.Remote]
c *ControllerCommon
setRemoteBranches func([]*models.RemoteBranch)
}
var _ types.IController = &RemotesController{}
func NewRemotesController(
c *ControllerCommon,
setRemoteBranches func([]*models.RemoteBranch),
) *RemotesController {
return &RemotesController{
baseController: baseController{},
ListControllerTrait: NewListControllerTrait(
c,
c.Contexts().Remotes,
c.Contexts().Remotes.GetSelected,
c.Contexts().Remotes.GetSelectedItems,
),
c: c,
setRemoteBranches: setRemoteBranches,
}
}
func (self *RemotesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
bindings := []*types.Binding{
{
Key: opts.GetKey(opts.Config.Universal.GoInto),
Handler: self.withItem(self.enter),
GetDisabledReason: self.require(self.singleItemSelected()),
Description: self.c.Tr.ViewBranches,
DisplayOnScreen: true,
},
{
Key: opts.GetKey(opts.Config.Universal.New),
Handler: self.add,
Description: self.c.Tr.NewRemote,
DisplayOnScreen: true,
},
{
Key: opts.GetKey(opts.Config.Universal.Remove),
Handler: self.withItem(self.remove),
GetDisabledReason: self.require(self.singleItemSelected()),
Description: self.c.Tr.Remove,
Tooltip: self.c.Tr.RemoveRemoteTooltip,
DisplayOnScreen: true,
},
{
Key: opts.GetKey(opts.Config.Universal.Edit),
Handler: self.withItem(self.edit),
GetDisabledReason: self.require(self.singleItemSelected()),
Description: self.c.Tr.Edit,
Tooltip: self.c.Tr.EditRemoteTooltip,
DisplayOnScreen: true,
},
{
Key: opts.GetKey(opts.Config.Branches.FetchRemote),
Handler: self.withItem(self.fetch),
GetDisabledReason: self.require(self.singleItemSelected()),
Description: self.c.Tr.Fetch,
Tooltip: self.c.Tr.FetchRemoteTooltip,
DisplayOnScreen: true,
},
{
Key: opts.GetKey(opts.Config.Branches.AddForkRemote),
Handler: self.addFork,
GetDisabledReason: self.hasOriginRemote(),
Description: self.c.Tr.AddForkRemote,
Tooltip: self.c.Tr.AddForkRemoteTooltip,
DisplayOnScreen: true,
},
}
return bindings
}
func (self *RemotesController) context() *context.RemotesContext {
return self.c.Contexts().Remotes
}
func (self *RemotesController) GetOnRenderToMain() func() {
return func() {
self.c.Helpers().Diff.WithDiffModeCheck(func() {
var task types.UpdateTask
remote := self.context().GetSelected()
if remote == nil {
task = types.NewRenderStringTask("No remotes")
} else {
task = types.NewRenderStringTask(fmt.Sprintf("%s\nUrls:\n%s", style.FgGreen.Sprint(remote.Name), strings.Join(remote.Urls, "\n")))
}
self.c.RenderToMainViews(types.RefreshMainOpts{
Pair: self.c.MainViewPairs().Normal,
Main: &types.ViewUpdateOpts{
Title: "Remote",
Task: task,
},
})
})
}
}
func (self *RemotesController) GetOnDoubleClick() func() error {
return self.withItemGraceful(self.enter)
}
func (self *RemotesController) enter(remote *models.Remote) error {
// naive implementation: get the branches from the remote and render them to the list, change the context
self.setRemoteBranches(remote.Branches)
newSelectedLine := 0
if len(remote.Branches) == 0 {
newSelectedLine = -1
}
remoteBranchesContext := self.c.Contexts().RemoteBranches
remoteBranchesContext.SetSelection(newSelectedLine)
remoteBranchesContext.SetTitleRef(remote.Name)
remoteBranchesContext.SetParentContext(self.Context())
remoteBranchesContext.GetView().TitlePrefix = self.Context().GetView().TitlePrefix
self.c.PostRefreshUpdate(remoteBranchesContext)
self.c.Context().Push(remoteBranchesContext, types.OnFocusOpts{})
return nil
}
// Adds a new remote, refreshes and selects it, then fetches and checks out the specified branch if provided.
func (self *RemotesController) addAndCheckoutRemote(remoteName string, remoteUrl string, branchToCheckout string) error {
err := self.c.Git().Remote.AddRemote(remoteName, remoteUrl)
if err != nil {
return err
}
// Do a sync refresh of the remotes so that we can select
// the new one. Loading remotes is not expensive, so we can
// afford it.
self.c.Refresh(types.RefreshOptions{
Scope: []types.RefreshableView{types.REMOTES},
Mode: types.SYNC,
})
// Select the remote
for idx, remote := range self.c.Model().Remotes {
if remote.Name == remoteName {
self.c.Contexts().Remotes.SetSelection(idx)
break
}
}
// Fetch the remote
return self.fetchAndCheckout(self.c.Contexts().Remotes.GetSelected(), branchToCheckout)
}
// Ensures the fork remote exists (matching the given URL).
// If it exists and matches, its selected and fetched; otherwise, its created and then fetched and checked out.
// If it does exist but with a different URL, an error is returned.
func (self *RemotesController) ensureForkRemoteAndCheckout(remoteName string, remoteUrl string, branchToCheckout string) error {
for idx, remote := range self.c.Model().Remotes {
if remote.Name == remoteName {
hasTheSameUrl := slices.Contains(remote.Urls, remoteUrl)
if !hasTheSameUrl {
return errors.New(utils.ResolvePlaceholderString(
self.c.Tr.IncompatibleForkAlreadyExistsError,
map[string]string{
"remoteName": remoteName,
},
))
}
self.c.Contexts().Remotes.SetSelection(idx)
return self.fetchAndCheckout(remote, branchToCheckout)
}
}
return self.addAndCheckoutRemote(remoteName, remoteUrl, branchToCheckout)
}
func (self *RemotesController) add() error {
self.c.Prompt(types.PromptOpts{
Title: self.c.Tr.NewRemoteName,
HandleConfirm: func(remoteName string) error {
self.c.Prompt(types.PromptOpts{
Title: self.c.Tr.NewRemoteUrl,
HandleConfirm: func(remoteUrl string) error {
self.c.LogAction(self.c.Tr.Actions.AddRemote)
return self.addAndCheckoutRemote(remoteName, remoteUrl, "")
},
})
return nil
},
})
return nil
}
// Regex to match and capture parts of a Git remote URL. Supports the following formats:
// 1. SCP-like SSH: git@host:owner[/subgroups]/repo(.git)
// 2. SSH URL style: ssh://user@host[:port]/owner[/subgroups]/repo(.git)
// 3. HTTPS: https://host/owner[/subgroups]/repo(.git)
// 4. Only for integration tests: ../repo_name
var (
urlRegex = regexp.MustCompile(`^(git@[^:]+:|ssh://[^/]+/|https?://[^/]+/)([^/]+(?:/[^/]+)*)/([^/]+?)(\.git)?$`)
integrationTestUrlRegex = regexp.MustCompile(`^\.\./.+$`)
)
// Rewrites a Git remote URL to use the given fork username,
// keeping the repo name and host intact. Supports SCP-like SSH, SSH URL style, and HTTPS.
func replaceForkUsername(originUrl, forkUsername string, isIntegrationTest bool) (string, error) {
if urlRegex.MatchString(originUrl) {
return urlRegex.ReplaceAllString(originUrl, "${1}"+forkUsername+"/$3$4"), nil
} else if isIntegrationTest && integrationTestUrlRegex.MatchString(originUrl) {
return "../" + forkUsername, nil
}
return "", fmt.Errorf("unsupported or invalid remote URL: %s", originUrl)
}
func (self *RemotesController) getOrigin() *models.Remote {
for _, remote := range self.c.Model().Remotes {
if remote.Name == "origin" {
return remote
}
}
return nil
}
func (self *RemotesController) hasOriginRemote() func() *types.DisabledReason {
return func() *types.DisabledReason {
if self.getOrigin() == nil {
return &types.DisabledReason{Text: self.c.Tr.NoOriginRemote}
}
return nil
}
}
func (self *RemotesController) addFork() error {
origin := self.getOrigin()
self.c.Prompt(types.PromptOpts{
Title: self.c.Tr.AddForkRemoteUsername,
HandleConfirm: func(forkUsername string) error {
branchToCheckout := ""
parts := strings.SplitN(forkUsername, ":", 2)
if len(parts) == 2 {
forkUsername = parts[0]
branchToCheckout = parts[1]
}
originUrl := origin.Urls[0]
remoteUrl, err := replaceForkUsername(originUrl, forkUsername, self.c.RunningIntegrationTest())
if err != nil {
return err
}
self.c.LogAction(self.c.Tr.Actions.AddForkRemote)
return self.ensureForkRemoteAndCheckout(forkUsername, remoteUrl, branchToCheckout)
},
})
return nil
}
func (self *RemotesController) remove(remote *models.Remote) error {
self.c.Confirm(types.ConfirmOpts{
Title: self.c.Tr.RemoveRemote,
Prompt: self.c.Tr.RemoveRemotePrompt + " '" + remote.Name + "'?",
HandleConfirm: func() error {
self.c.LogAction(self.c.Tr.Actions.RemoveRemote)
if err := self.c.Git().Remote.RemoveRemote(remote.Name); err != nil {
return err
}
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}})
return nil
},
})
return nil
}
func (self *RemotesController) edit(remote *models.Remote) error {
editNameMessage := utils.ResolvePlaceholderString(
self.c.Tr.EditRemoteName,
map[string]string{
"remoteName": remote.Name,
},
)
self.c.Prompt(types.PromptOpts{
Title: editNameMessage,
InitialContent: remote.Name,
HandleConfirm: func(updatedRemoteName string) error {
if updatedRemoteName != remote.Name {
self.c.LogAction(self.c.Tr.Actions.UpdateRemote)
if err := self.c.Git().Remote.RenameRemote(remote.Name, updatedRemoteName); err != nil {
return err
}
}
editUrlMessage := utils.ResolvePlaceholderString(
self.c.Tr.EditRemoteUrl,
map[string]string{
"remoteName": updatedRemoteName,
},
)
urls := remote.Urls
url := ""
if len(urls) > 0 {
url = urls[0]
}
self.c.Prompt(types.PromptOpts{
Title: editUrlMessage,
InitialContent: url,
HandleConfirm: func(updatedRemoteUrl string) error {
self.c.LogAction(self.c.Tr.Actions.UpdateRemote)
if err := self.c.Git().Remote.UpdateRemoteUrl(updatedRemoteName, updatedRemoteUrl); err != nil {
return err
}
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}})
return nil
},
})
return nil
},
})
return nil
}
func (self *RemotesController) fetch(remote *models.Remote) error {
return self.fetchAndCheckout(remote, "")
}
func (self *RemotesController) fetchAndCheckout(remote *models.Remote, branchName string) error {
return self.c.WithInlineStatus(remote, types.ItemOperationFetching, context.REMOTES_CONTEXT_KEY, func(task gocui.Task) error {
err := self.c.Git().Sync.FetchRemote(task, remote.Name)
if err != nil {
return err
}
refreshOptions := types.RefreshOptions{
Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES},
Mode: types.ASYNC,
}
if branchName != "" {
err = self.c.Git().Branch.New(branchName, remote.Name+"/"+branchName)
if err == nil {
self.c.Context().Push(self.c.Contexts().Branches, types.OnFocusOpts{})
self.c.Helpers().Refs.SelectFirstBranchAndFirstCommit()
refreshOptions.KeepBranchSelectionIndex = true
}
}
self.c.Refresh(refreshOptions)
return err
})
}