package controllers import ( "crypto/sha256" "encoding/hex" "errors" "fmt" "path/filepath" "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" ) type MainViewController struct { baseController c *ControllerCommon context *context.MainContext otherContext *context.MainContext } var _ types.IController = &MainViewController{} func NewMainViewController( c *ControllerCommon, context *context.MainContext, otherContext *context.MainContext, ) *MainViewController { return &MainViewController{ baseController: baseController{}, c: c, context: context, otherContext: otherContext, } } func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { // When a selection is shown, we surface the bindings that act on it // (enter to dive into staging, e to edit the selected line, G to open the // line in the branch's pull request, escape to hide the selection). selectionShown := self.context.GetView().Highlight var enterDescription string var editDescription string var editTooltip string var openPullRequestDescription string var openPullRequestTooltip string if selectionShown { enterDescription = self.c.Tr.EnterStaging editDescription = self.c.Tr.EditFile editTooltip = self.c.Tr.EditFileTooltip // TODO: i18n-ize these openPullRequestDescription = "Open pull request for selected line" openPullRequestTooltip = "Open a browser at the selected line in the diff of the current branch's pull request, so that you can comment on it. Only works for local branches that have a pull request on GitHub." } return []*types.Binding{ { Keys: opts.GetKeys(opts.Config.Universal.TogglePanel), Handler: self.togglePanel, Description: self.c.Tr.ToggleStagingView, Tooltip: self.c.Tr.ToggleStagingViewTooltip, DisplayOnScreen: true, }, { Keys: opts.GetKeys(opts.Config.Universal.Return), Handler: self.escape, Description: self.c.Tr.ExitFocusedMainView, DisplayOnScreen: true, }, { Keys: opts.GetKeys(opts.Config.Universal.Select), Handler: self.toggleSelection, Description: self.c.Tr.ToggleSelectionInFocusedMainView, DisplayOnScreen: !selectionShown, }, { Keys: opts.GetKeys(opts.Config.Universal.GoInto), Handler: self.enter, Description: enterDescription, DisplayOnScreen: selectionShown, }, { Keys: opts.GetKeys(opts.Config.Universal.Edit), Handler: self.editLine, Description: editDescription, Tooltip: editTooltip, }, { Keys: opts.GetKeys(opts.Config.Commits.OpenPullRequestInBrowser), Handler: self.openPullRequestForSelectedLine, Description: openPullRequestDescription, Tooltip: openPullRequestTooltip, }, { // overriding this because we want to read all of the task's output before we start searching Keys: opts.GetKeys(opts.Config.Universal.StartSearch), Handler: self.openSearch, Description: self.c.Tr.StartSearch, Tag: "navigation", }, } } func (self *MainViewController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding { return []*gocui.ViewMouseBinding{ { ViewName: self.context.GetViewName(), Key: gocui.MouseLeft, Handler: self.onClickInAlreadyFocusedView, FocusedView: self.context.GetViewName(), }, { ViewName: self.context.GetViewName(), Key: gocui.MouseLeft, Handler: self.onClickInOtherViewOfMainViewPair, FocusedView: self.otherContext.GetViewName(), }, } } func (self *MainViewController) Context() types.Context { return self.context } // Transient focus shifts (popups, search) leave HighlightInactive=true on our // view (set by ContextMgr.Activate when a different view becomes current). Our // context's highlightOnFocus is false, so SimpleContext.HandleFocus never // resets it. Reset it here on the way back in, so that if we still hold a // selection it's drawn as active. The flag is a no-op when Highlight is false. func (self *MainViewController) GetOnFocus() func(types.OnFocusOpts) { return func(types.OnFocusOpts) { self.context.GetView().HighlightInactive = false } } func (self *MainViewController) togglePanel() error { if self.otherContext.GetView().Visible { self.c.Context().Push(self.otherContext, types.OnFocusOpts{}) } return nil } func (self *MainViewController) escape() error { v := self.context.GetView() if v.Highlight { v.Highlight = false return nil } self.c.Context().Pop() return nil } func (self *MainViewController) toggleSelection() error { v := self.context.GetView() if v.Highlight { v.Highlight = false return nil } // Start the selection in the middle of the visible area. showSelectionAtLine(v, v.OriginY()+v.InnerHeight()/2) return nil } func (self *MainViewController) enter() error { if !self.context.GetView().Highlight { return nil } return self.enterForLine(self.context.GetView().SelectedLineIdx()) } // enterForLine dives into staging/patch-building for the given line, by // delegating to the side panel beneath the focused main view (the same handler // used when clicking). func (self *MainViewController) enterForLine(lineIdx int) error { sidePanelContext := self.c.Context().NextInStack(self.context) if sidePanelContext != nil && sidePanelContext.GetOnClickFocusedMainView() != nil { return sidePanelContext.GetOnClickFocusedMainView()(self.context.GetViewName(), lineIdx) } return nil } // showSelectionAtLine turns on the focused main view's selection and moves it to // the given view line, clamped to the content. func showSelectionAtLine(view *gocui.View, lineIdx int) { view.Highlight = true view.HighlightInactive = false lineIdx = lo.Clamp(lineIdx, 0, view.ViewLinesHeight()-1) view.FocusPoint(0, lineIdx, false) } // focusedMainViewContextForViewName maps a focused main view's view name (as // passed to GetOnClickFocusedMainView) to its context. func focusedMainViewContextForViewName(c *ControllerCommon, viewName string) types.Context { if viewName == c.Contexts().NormalSecondary.GetViewName() { return c.Contexts().NormalSecondary } return c.Contexts().Normal } // focusedMainViewSnapshot captures where a focused main view is (scroll + // selected line) when diving into a patch explorer from it, so escaping can // return there with the main view focused. sidePanel is the panel to land on // first (which re-renders the content); for commits/stash it's the originating // panel, skipping the commit files panel we pass through. selectedLineIdx is the // view line that was selected in the focused main view. Call this before any // mutation that might re-render the main view. func focusedMainViewSnapshot(c *ControllerCommon, mainViewName string, sidePanel types.Context, selectedLineIdx int) *types.FocusedMainViewSnapshot { mainView := focusedMainViewContextForViewName(c, mainViewName) sidePanelSelectedLineIdx := -1 if listContext, ok := sidePanel.(types.IListContext); ok { sidePanelSelectedLineIdx = listContext.GetList().GetSelectedLineIdx() } return &types.FocusedMainViewSnapshot{ SidePanel: sidePanel, SidePanelSelectedLineIdx: sidePanelSelectedLineIdx, MainView: mainView, OriginY: mainView.GetView().OriginY(), SelectedLineIdx: selectedLineIdx, } } func (self *MainViewController) editLine() error { if !self.context.GetView().Highlight { return nil } // Figure out the clicked file and line the same way entering staging does. path, lineNumber, ok := self.c.Helpers().Staging.GetFileAndLineForClickedDiffLine( self.context.GetViewName(), self.context.GetView().SelectedLineIdx()) if !ok { return nil } lineNumber = self.c.Helpers().Diff.AdjustLineNumber(path, lineNumber, self.context.GetViewName()) return self.c.Helpers().Files.EditFileAtLine(path, lineNumber) } func (self *MainViewController) openPullRequestForSelectedLine() error { if !self.context.GetView().Highlight { return nil } sidePanelContext := self.c.Context().NextInStack(self.context) if sidePanelContext == nil { return nil } // The branch whose PR to open depends on where we navigated from: the // checked-out branch when looking at its own commits, but the branch we // drilled into when in the sub-commits or commit-files panels. branchName, ok := self.branchForPullRequest(sidePanelContext) if !ok { return nil } pr, ok := self.c.Model().PullRequestsMap[branchName] if !ok { return errors.New(self.c.Tr.NoPullRequestForBranch) } // The diff shown is the diff of a particular commit, so we deep-link into // that commit's view of the PR; its right-side line numbers match what we're // showing, so (unlike editLine) no line-number adjustment is needed. diffableContext, ok := sidePanelContext.(types.DiffableContext) if !ok { return nil } commitSha := diffableContext.RefForAdjustingLineNumberInDiff() if commitSha == "" { return nil } // Figure out the clicked file and line the same way entering staging does. path, lineNumber, ok := self.c.Helpers().Staging.GetFileAndLineForClickedDiffLine( self.context.GetViewName(), self.context.GetView().SelectedLineIdx()) if !ok { return nil } relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), path) if err != nil { return err } self.c.LogAction(self.c.Tr.Actions.OpenPullRequest) return self.c.OS().OpenLink( githubPullRequestLineURL(pr.Url, commitSha, filepath.ToSlash(relativePath), lineNumber)) } // branchForPullRequest returns the local branch whose pull request applies to // the diff currently shown in the focused main view, given the side panel // beneath it. It returns false for contexts that don't map to a local branch // (e.g. the working-tree files panel, stashes, tags, or remote branches). func (self *MainViewController) branchForPullRequest(sidePanelContext types.Context) (string, bool) { switch sidePanelContext.GetKey() { case context.LOCAL_COMMITS_CONTEXT_KEY: return self.c.Model().CheckedOutBranch, true case context.SUB_COMMITS_CONTEXT_KEY: ref := self.c.Contexts().SubCommits.GetRef() if ref == nil { return "", false } return ref.RefName(), true case context.COMMIT_FILES_CONTEXT_KEY: // The commit files panel doesn't itself know which branch it belongs to; // that's determined by the panel we entered it from. parent := self.c.Contexts().CommitFiles.GetParentContext() if parent == nil { return "", false } return self.branchForPullRequest(parent) default: return "", false } } // githubPullRequestLineURL builds a URL that opens the given line of a file in // the diff of a specific commit within a GitHub pull request. The file is // identified by the SHA-256 of its repo-relative path, and R targets the // right (new) side of the diff. See // https://github.com/orgs/community/discussions/55764. func githubPullRequestLineURL(prURL string, commitSha string, relativePath string, lineNumber int) string { pathHash := sha256.Sum256([]byte(relativePath)) anchor := fmt.Sprintf("diff-%sR%d", hex.EncodeToString(pathHash[:]), lineNumber) return fmt.Sprintf("%s/changes/%s#%s", prURL, commitSha, anchor) } func (self *MainViewController) onClickInAlreadyFocusedView(opts gocui.ViewMouseBindingOpts) error { // A click points at a line, so it sets the selection there; a double-click // additionally dives into staging/patch-building for that line. showSelectionAtLine(self.context.GetView(), opts.Y) if opts.IsDoubleClick { return self.enterForLine(opts.Y) } return nil } func (self *MainViewController) onClickInOtherViewOfMainViewPair(opts gocui.ViewMouseBindingOpts) error { self.c.Context().Push(self.context, types.OnFocusOpts{}) showSelectionAtLine(self.context.GetView(), opts.Y) if opts.IsDoubleClick { return self.enterForLine(opts.Y) } return nil } func (self *MainViewController) openSearch() error { if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil { manager.ReadToEnd(func() { self.c.OnUIThread(func() error { return self.c.Helpers().Search.OpenSearchPrompt(self.context) }) }) } return nil }