jesseduffield.lazygit/pkg/gui/controllers/main_view_controller.go
Stefan Haller d3bf88c52c Restore the focused main view by patch identity on escape
Escaping a patch explorer (staging / patch building) back to the focused main
view it was entered from used to replay a numeric scroll position and selection
index captured on the way in. But the reason to escape after staging or dropping
a hunk is that the content changed, so a saved index points at the wrong line —
and the host auto-advances the explorer's selection to a still-valid line anyway,
which is the line the user actually cares about returning to.

Restore by *patch identity* instead. On escape, read the (file, type, source
line) the explorer currently has selected, then have the main view's re-render
land on the row that matches it: scan the incoming content as it loads (the
inverse of the diff-line primitive), and once the matching row plus a screenful
below it have loaded, swap the off-screen render in and scroll to / select that
row in one step. FocusPoint with scrollIntoView centres the row only if it's
off-screen, so the common unchanged-content escape — where the row is already
where it was — doesn't move at all. If the line is gone (the content really
changed), nothing is forced.

This generalizes the scroll restore from a fixed origin to a predicate
(RenderRestore: FirstPaintReady decides when the saved position is reachable,
Apply re-establishes it), folding the separate selection restore into the same
first paint — so it no longer rides a post-load callback that could fire early.

The restore also now survives task replacement, which the numeric version did
not: a periodic refresh can stop the escape's re-render before it first-paints.
The pending restore is held on the buffer manager and is *not* cleared when a
task starts, so the replacement task picks it up. It is not gated on the command
key — staging the last unstaged hunk re-renders `git diff` as `git diff --cached`,
a different command, yet the line to land on is still in the new content — but
validates itself: the scan finds the target line only when the content still
contains it, so applying it to a different item is a harmless no-op. A task
clears it once it has applied it (found or not), so it lives for exactly one
re-render. Because the restore is anchored on content identity and is idempotent,
"survive replacement" and "restore by identity" are one mechanism, not two.

With the identity in hand the snapshot no longer needs the captured scroll/index;
they're derived from the explorer's live selection.
2026-08-08 12:58:59 +02:00

363 lines
12 KiB
Go

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 records the focused main view to return to when diving
// into a patch explorer from it, so escaping can come back 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. Where to scroll to and select on return isn't
// captured: escape lands on the line the explorer ended up on (see
// EscapeFromPatchExplorer). Call this before any mutation that might re-render
// the main view.
func focusedMainViewSnapshot(c *ControllerCommon, mainViewName string, sidePanel types.Context) *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,
}
}
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.
info, ok := self.c.Helpers().Staging.GetDiffLineInfo(
self.context.GetViewName(), self.context.GetView().SelectedLineIdx())
if !ok {
return nil
}
lineNumber := self.c.Helpers().Diff.AdjustLineNumber(info.Path, info.NewLine, self.context.GetViewName())
return self.c.Helpers().Files.EditFileAtLine(info.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.
info, ok := self.c.Helpers().Staging.GetDiffLineInfo(
self.context.GetViewName(), self.context.GetView().SelectedLineIdx())
if !ok {
return nil
}
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), info.Path)
if err != nil {
return err
}
// A deletion isn't on the right (new) side of the diff, so anchor it on the
// left (old) side; everything else on the right.
side, lineNumber := info.PullRequestAnchor()
self.c.LogAction(self.c.Tr.Actions.OpenPullRequest)
return self.c.OS().OpenLink(
githubPullRequestLineURL(pr.Url, commitSha, filepath.ToSlash(relativePath), side, 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 side ("R"/"L")
// selects the right (new) or left (old) side of the diff. See
// https://github.com/orgs/community/discussions/55764.
func githubPullRequestLineURL(prURL string, commitSha string, relativePath string, side string, lineNumber int) string {
pathHash := sha256.Sum256([]byte(relativePath))
anchor := fmt.Sprintf("diff-%s%s%d", hex.EncodeToString(pathHash[:]), side, 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
}