Push the staging focus-follow into the side-panel handler

The focused main view's space binding delegates to the side panel beneath
it, but the two handlers were asymmetric: the patch-toggle handler did its
own re-render and re-established the selection itself, while the staging
handler returned a focusViewName so the dispatcher (stageRange) could do the
reveal-and-focus dance on its behalf. That split blocks collapsing the
per-command handler channels into one, since the two have different return
types.

Make staging match the toggle: GetOnStageFocusedMainView returns plain
error, and the FilesController handler does the post-staging reveal and pane
focus itself. The reveal/select-mode logic the two handlers shared (collapse
a range to a line, preserve the change-line ordinal across the re-render,
re-expand a hunk) is extracted into revealSelectionAfterPrimaryAction, with
mainContextForViewName resolving a main view name to its context. The
dispatcher is now uniform: read the selected range, hand it to whichever
handler the panel registered.

Behavior-preserving.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller 2026-06-19 15:07:01 +02:00
parent 1b02020022
commit 96b09bb41d
6 changed files with 67 additions and 103 deletions

View file

@ -39,7 +39,7 @@ type (
onFocusFn = func(types.OnFocusOpts)
onFocusLostFn = func(types.OnFocusLostOpts)
onClickFocusedMainViewFn = func(mainViewName string, clickedLineIdx int) error
onStageFocusedMainViewFn = func(mainViewName string, firstLineIdx int, lastLineIdx int) (focusViewName string, err error)
onStageFocusedMainViewFn = func(mainViewName string, firstLineIdx int, lastLineIdx int) error
onTogglePatchFocusedMainViewFn = func(mainViewName string, firstLineIdx int, lastLineIdx int) error
)

View file

@ -23,7 +23,7 @@ func (self *baseController) GetOnClickFocusedMainView() func(mainViewName string
return nil
}
func (self *baseController) GetOnStageFocusedMainView() func(mainViewName string, firstLineIdx int, lastLineIdx int) (focusViewName string, err error) {
func (self *baseController) GetOnStageFocusedMainView() func(mainViewName string, firstLineIdx int, lastLineIdx int) error {
return nil
}

View file

@ -457,21 +457,21 @@ func (self *FilesController) diffSplitState(node *filetree.FileNode) (split bool
return split, mainShowsStaged
}
func (self *FilesController) GetOnStageFocusedMainView() func(mainViewName string, firstLineIdx int, lastLineIdx int) (string, error) {
return func(mainViewName string, firstLineIdx int, lastLineIdx int) (string, error) {
func (self *FilesController) GetOnStageFocusedMainView() func(mainViewName string, firstLineIdx int, lastLineIdx int) error {
return func(mainViewName string, firstLineIdx int, lastLineIdx int) error {
if self.c.UserConfig().Git.DiffContextSize == 0 {
return "", fmt.Errorf(self.c.Tr.Actions.NotEnoughContextToStage,
return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextToStage,
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)
}
node := self.context().GetSelected()
if node == nil {
return "", nil
return nil
}
infos := self.c.Helpers().Staging.ChangeLinesInViewRange(mainViewName, firstLineIdx, lastLineIdx)
if len(infos) == 0 {
return "", nil
return nil
}
// The whole diff shown in the main view is on one side — the staged diff in
@ -492,7 +492,7 @@ func (self *FilesController) GetOnStageFocusedMainView() func(mainViewName strin
continue
}
if err := self.stageDiffLines(file, fileInfos, reverse); err != nil {
return "", err
return err
}
}
@ -513,7 +513,15 @@ func (self *FilesController) GetOnStageFocusedMainView() func(mainViewName strin
}
}
}
return focusViewName, nil
// The staging Refresh above queued the main-view re-render; re-establish the
// selection in whichever pane now holds the acted-on side once that render lands,
// and focus that pane if staging moved it there.
revealSelectionAfterPrimaryAction(self.c, mainViewName, focusViewName, firstLineIdx)
if focusViewName != mainViewName {
self.c.Context().Push(mainContextForViewName(self.c, focusViewName), types.OnFocusOpts{})
}
return nil
}
}

View file

@ -280,80 +280,59 @@ func (self *MainViewController) isDiffView() bool {
}
// stageSelectedLine acts on the selected diff line(s) — a single line, a range, or a
// hunk — delegating to the side panel beneath the focused main view, since what the
// action means is the panel's business: the working tree stages, while commits build a
// custom patch. Panels whose diff supports neither register no handler, so this is a
// no-op there.
// hunk — delegating the primary action to the side panel beneath the focused main view,
// since what the action means is the panel's business: the working tree stages, while
// commits toggle the selection into a custom patch. Each handler does its own re-render
// and re-establishes the selection afterwards (see revealSelectionAfterPrimaryAction), so
// the dispatcher just hands over the selected range. Panels whose diff supports neither
// register no handler, so this is a no-op there.
func (self *MainViewController) stageSelectedLine() error {
sidePanelContext := self.c.Context().NextInStack(self.context)
if sidePanelContext == nil {
return nil
}
v := self.context.GetView()
first, last := v.SelectedLineRange()
if handler := sidePanelContext.GetOnStageFocusedMainView(); handler != nil {
return self.stageRange(handler)
return handler(self.context.GetViewName(), first, last)
}
if handler := sidePanelContext.GetOnTogglePatchFocusedMainView(); handler != nil {
// Toggling the selection into the custom patch leaves the diff unchanged (the
// handler repaints the inclusion gutter itself), so unlike staging there's no
// re-render to ride and the selection stays where it is.
v := self.context.GetView()
first, last := v.SelectedLineRange()
return handler(self.context.GetViewName(), first, last)
}
return nil
}
// stageRange stages (or unstages) the current selection through the side panel's
// staging handler. Staging mutates the working tree, so the diff re-renders
// asynchronously and the selection is re-revealed once it lands.
func (self *MainViewController) stageRange(handler func(mainViewName string, firstLineIdx int, lastLineIdx int) (string, error)) error {
v := self.context.GetView()
first, last := v.SelectedLineRange()
// revealSelectionAfterPrimaryAction re-establishes the focused-main-view selection after a
// primary action (staging or a patch toggle) re-renders the diff. The selection's
// change-line ordinal is read from the source pane (still showing the pre-action diff
// until the queued re-render) and re-applied once the target pane re-renders — so the
// selection lands on the change nearest the one acted on rather than at a stale position.
// sourceViewName and targetViewName are usually the same pane, but staging can move the
// acted-on side to the other pane (passing that pane as the target). The target inherits
// the (collapsed) select mode; a range collapses back to a single line, hunk mode stays
// on to land on the next hunk.
func revealSelectionAfterPrimaryAction(c *ControllerCommon, sourceViewName string, targetViewName string, firstLineIdx int) {
sourceContext := mainContextForViewName(c, sourceViewName)
targetContext := mainContextForViewName(c, targetViewName)
// Staging consumes the selected range, so a range selection collapses back to a
// single line; hunk mode stays on, to land on the next hunk.
sel := self.sel()
sel := sourceContext.DiffSelectState()
if sel.Mode == context.DiffSelectModeRange {
sel.Mode = context.DiffSelectModeLine
sel.RangeIsSticky = false
}
// Staging updates the model synchronously and queues the main-view re-render, and
// reports which pane should hold focus afterwards (staging/unstaging can move the
// acted-on side to the other pane). "" means nothing was staged.
focusViewName, err := handler(self.context.GetViewName(), first, last)
if err != nil {
return err
}
if focusViewName == "" {
return nil
}
// Re-select in whichever pane now holds the acted-on side, and focus it. The
// acted-on line's change-line ordinal is read from the pane we acted in (its
// content is still the pre-staging diff until the queued re-render) and re-applied
// in the target pane's re-render. The target inherits our select mode (line/hunk).
targetContext := self.context
if focusViewName == self.otherContext.GetViewName() {
targetContext = self.otherContext
}
*targetContext.DiffSelectState() = *sel
targetView := targetContext.GetView()
mode := sel.Mode
self.c.Helpers().Staging.RevealSelectionAfterStaging(v, targetView, first, func(viewLine int) {
if sel.Mode == context.DiffSelectModeHunk {
selectDiffHunk(self.c, targetContext, viewLine)
sourceView := sourceContext.GetView()
targetView := targetContext.GetView()
c.Helpers().Staging.RevealSelectionAfterStaging(sourceView, targetView, firstLineIdx, func(viewLine int) {
if mode == context.DiffSelectModeHunk {
selectDiffHunk(c, targetContext, viewLine)
} else {
targetView.CancelRangeSelect()
showSelectionAtLine(targetView, viewLine, true)
}
})
if targetContext != self.context {
self.c.Context().Push(targetContext, types.OnFocusOpts{})
}
return nil
}
func (self *MainViewController) enter() error {
@ -674,15 +653,22 @@ func sidePanelShowsDiff(sidePanel types.Context) bool {
return ok
}
// focusedMainViewContextForViewName maps a focused main view's view name (as
// passed to GetOnClickFocusedMainView) to its context.
func focusedMainViewContextForViewName(c *ControllerCommon, viewName string) types.Context {
// mainContextForViewName maps a focused main view's view name (as passed to the
// side-panel handlers) to its main context — the secondary pane for the secondary
// view name, the primary pane otherwise.
func mainContextForViewName(c *ControllerCommon, viewName string) *context.MainContext {
if viewName == c.Contexts().NormalSecondary.GetViewName() {
return c.Contexts().NormalSecondary
}
return c.Contexts().Normal
}
// focusedMainViewContextForViewName is mainContextForViewName as a types.Context, for
// callers that only need the interface.
func focusedMainViewContextForViewName(c *ControllerCommon, viewName string) types.Context {
return mainContextForViewName(c, viewName)
}
// 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

View file

@ -4,7 +4,6 @@ import (
"path/filepath"
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
)
@ -59,7 +58,10 @@ func togglePatchFromFocusedMainView(
}
refresh()
revealSelectionAfterPatchToggle(c, mainViewName, firstLineIdx)
// A toggle doesn't change the diff, so source and target are the same pane;
// the re-render (and the layout re-wrap when the secondary view first appears)
// still moves the selection in view-line space, so re-establish it.
revealSelectionAfterPrimaryAction(c, mainViewName, mainViewName, firstLineIdx)
return nil
},
})
@ -126,37 +128,6 @@ func togglePatchLines(c *ControllerCommon, infos []types.DiffLineInfo) error {
return nil
}
// revealSelectionAfterPatchToggle re-establishes the selection after a toggle's
// re-render. Toggling doesn't change the diff, so the same content is still there — but
// the re-render (and the layout re-wrap when the secondary view first appears) is in
// view-line space, which moves the selection. We preserve the selection's change-line
// ordinal, which is unchanged (the line isn't consumed, unlike staging) and
// width-independent, re-expanding the hunk in hunk mode. A range collapses to a line,
// as a staged range does.
func revealSelectionAfterPatchToggle(c *ControllerCommon, mainViewName string, firstLineIdx int) {
mainContext := c.Contexts().Normal
if mainViewName == c.Contexts().NormalSecondary.GetViewName() {
mainContext = c.Contexts().NormalSecondary
}
view := mainContext.GetView()
sel := mainContext.DiffSelectState()
if sel.Mode == context.DiffSelectModeRange {
sel.Mode = context.DiffSelectModeLine
sel.RangeIsSticky = false
}
mode := sel.Mode
c.Helpers().Staging.RevealSelectionAfterStaging(view, view, firstLineIdx, func(viewLine int) {
if mode == context.DiffSelectModeHunk {
selectDiffHunk(c, mainContext, viewLine)
} else {
view.CancelRangeSelect()
showSelectionAtLine(view, viewLine, true)
}
})
}
// patchFilename maps a diff line's absolute path to the key the patch builder stores
// the file under — its repo-relative, slash-separated path.
func patchFilename(c *ControllerCommon, absPath string) string {

View file

@ -102,7 +102,7 @@ type IBaseContext interface {
// (space), delegated to the side panel that owns the diff being shown. The
// inclusive view-line range is the current selection (a single line, a range, or
// a hunk).
AddOnStageFocusedMainViewFn(func(mainViewName string, firstLineIdx int, lastLineIdx int) (focusViewName string, err error))
AddOnStageFocusedMainViewFn(func(mainViewName string, firstLineIdx int, lastLineIdx int) error)
// And for toggling the selected line(s) into/out of the custom patch from the
// focused main view (space), when the panel beneath builds a patch rather than
// staging.
@ -338,14 +338,13 @@ type HasKeybindings interface {
// that belongs to your panel while the main view is already focused.
GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error
// Implement this in a side-panel controller to stage/unstage (or, later, add to
// the custom patch) the selected diff line(s) when the user presses space in the
// focused main view. The inclusive view-line range is the current selection (a
// single line, a range, or a hunk). It returns the name of the focused main view
// that should hold focus afterwards — staging/unstaging can move the acted-on
// side to the other pane — or "" when nothing was done. Return a nil func to do
// nothing.
GetOnStageFocusedMainView() func(mainViewName string, firstLineIdx int, lastLineIdx int) (focusViewName string, err error)
// Implement this in a side-panel controller to stage/unstage the selected diff
// line(s) when the user presses space in the focused main view. The inclusive
// view-line range is the current selection (a single line, a range, or a hunk).
// The handler re-renders the diff and re-establishes the selection itself
// (staging/unstaging can move the acted-on side to the other pane, which the
// handler then focuses). Return a nil func to do nothing.
GetOnStageFocusedMainView() func(mainViewName string, firstLineIdx int, lastLineIdx int) error
// Implement this in a side-panel controller to toggle the selected diff line(s)
// into or out of the custom patch when the user presses space in the focused main