jesseduffield.lazygit/pkg/gui/controllers/helpers/patch_building_helper.go
Stefan Haller 0b11400a9d Use the default select mode when diving into a patch explorer from a focused main view
Diving into staging or patch building from a focused main view (by
double-clicking a line, or pressing enter on the selected line) always
landed on a single-line selection. Entering the same views through the
side panel honours the UseHunkModeInStagingView config and selects the
whole hunk by default. The two ways in should agree, so that diving in
from the main view feels like the established flow.

A non-negative line index in NewState was overloaded for two intents:
clicking directly on the patch explorer view (where a single-line range
is the start of a drag) and diving in from the main view (where we want
the default select mode). Distinguish them with SelectLineInDefaultMode
on OnFocusOpts: the main-view entry points set it; the click-to-drag
path does not.

In hunk mode the selection covers the block of changes around the
clicked line. A context line has no surrounding changes, so we snap to
the next change line (as toggling hunk mode does); the clicked context
line itself is then not part of the selection.

This is a separate commit only because the branch is a throwaway
prototype; in a real history it would be folded into the commit that
introduces the focused-main-view enter behavior rather than landing on
top of it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00

185 lines
6.1 KiB
Go

package helpers
import (
"fmt"
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/jesseduffield/lazygit/pkg/gui/patch_exploring"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
type PatchBuildingHelper struct {
c *HelperCommon
}
func NewPatchBuildingHelper(
c *HelperCommon,
) *PatchBuildingHelper {
return &PatchBuildingHelper{
c: c,
}
}
func (self *PatchBuildingHelper) ShowHunkStagingHint() {
if !self.c.AppState.DidShowHunkStagingHint && self.c.UserConfig().Gui.UseHunkModeInStagingView {
self.c.AppState.DidShowHunkStagingHint = true
self.c.SaveAppStateAndLogError()
message := fmt.Sprintf(self.c.Tr.HunkStagingHint, self.c.UserConfig().Keybinding.Main.ToggleSelectHunk)
self.c.Confirm(types.ConfirmOpts{
Prompt: message,
})
}
}
// takes us from the patch building panel back to the commit files panel, or to
// the focused main view if that's where we entered it from
func (self *PatchBuildingHelper) Escape() {
EscapeFromPatchExplorer(self.c, self.c.Contexts().CustomPatchBuilder)
}
// EscapeFromPatchExplorer returns from a patch explorer context (staging or
// patch building). If we entered it from a focused main view, we go back to
// where we came from (re-rendering the side panel's content into the main view,
// like the plain escape does), then focus the main view and restore its scroll
// position and selection. Otherwise we just pop to the side panel.
func EscapeFromPatchExplorer(c *HelperCommon, context types.IPatchExplorerContext) {
snapshot := context.GetFocusedMainViewSnapshot()
if snapshot == nil {
c.Context().Pop()
return
}
context.SetFocusedMainViewSnapshot(nil)
// Restore the side panel's selection before we render it, so it shows the
// same content the main view had (diving into staging can change it, e.g.
// from a directory to a file in the files panel).
if listContext, ok := snapshot.SidePanel.(types.IListContext); ok && snapshot.SidePanelSelectedLineIdx >= 0 {
listContext.GetList().SetSelectedLineIdx(snapshot.SidePanelSelectedLineIdx)
}
view := snapshot.MainView.GetView()
// Ask the upcoming re-render to restore the scroll position. Pushing the side
// panel re-renders its content into the main view via a cmd/pty task. Until
// that content is ready, the main view keeps showing the placeholder that
// CopyContent left in it (the view we're leaving) at its current scroll; the
// task then scrolls to the saved position as part of the first paint that
// shows the real content. Doing it this way (rather than setting the origin up
// front) avoids both a jump to the top and a misplaced placeholder frame.
if manager := c.GetViewBufferManagerForView(view); manager != nil {
manager.ScrollToOriginYForNextTask(snapshot.OriginY)
}
// Land on the side panel first (this re-renders the original content into the
// main view), then focus the main view on top of it.
c.Context().Push(snapshot.SidePanel, types.OnFocusOpts{})
c.Context().Push(snapshot.MainView, types.OnFocusOpts{})
restore := func() {
view.FocusPoint(0, snapshot.SelectedLineIdx, false)
view.Highlight = true
view.HighlightInactive = false
}
// The scroll position is handled by the re-render above, but the selection
// still needs the content loaded down to the selected line, which happens
// asynchronously. Wait until the diff has been fully read before restoring
// it. We do this on the next UI tick, by which point the re-render task is
// live and ReadToEnd can hook into it.
c.OnUIThread(func() error {
manager := c.GetViewBufferManagerForView(view)
if manager == nil {
restore()
return nil
}
manager.ReadToEnd(func() {
c.OnUIThread(func() error {
restore()
return nil
})
})
return nil
})
}
// kills the custom patch and returns us back to the commit files panel if needed
func (self *PatchBuildingHelper) Reset() error {
self.c.Git().Patch.PatchBuilder.Reset()
if self.c.Context().CurrentStatic().GetKind() != types.SIDE_CONTEXT {
self.Escape()
}
self.c.Refresh(types.RefreshOptions{
Scope: []types.RefreshableView{types.COMMIT_FILES},
})
// refreshing the current context so that the secondary panel is hidden if necessary.
self.c.PostRefreshUpdate(self.c.Context().Current())
return nil
}
func (self *PatchBuildingHelper) RefreshPatchBuildingPanel(opts types.OnFocusOpts) {
selectedLineIdx := -1
selectedRealLineIdx := -1
if opts.ClickedWindowName == "main" {
selectedLineIdx = opts.ClickedViewLineIdx
selectedRealLineIdx = opts.ClickedViewRealLineIdx
}
if !self.c.Git().Patch.PatchBuilder.Active() {
self.Escape()
return
}
// get diff from commit file that's currently selected
file := self.c.Contexts().CommitFiles.GetSelectedFile()
if file == nil {
return
}
from, to := self.c.Contexts().CommitFiles.GetFromAndToForDiff()
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
diff, err := self.c.Git().WorkingTree.ShowFileDiff(from, to, reverse, file.Path, file.PreviousPath, true)
if err != nil {
return
}
secondaryDiff := self.c.Git().Patch.PatchBuilder.RenderPatchForFile(patch.RenderPatchForFileOpts{
Filename: file.Path,
PreviousPath: file.PreviousPath,
Plain: false,
Reverse: false,
TurnAddedFilesIntoDiffAgainstEmptyFile: true,
})
context := self.c.Contexts().CustomPatchBuilder
oldState := context.GetState()
state := patch_exploring.NewState(diff, selectedLineIdx, selectedRealLineIdx, context.GetView(), oldState, self.c.UserConfig().Gui.UseHunkModeInStagingView, opts.SelectLineInDefaultMode)
context.SetState(state)
if state == nil {
self.Escape()
return
}
mainContent := context.GetContentToRender()
self.c.Contexts().CustomPatchBuilder.FocusSelection()
self.c.RenderToMainViews(types.RefreshMainOpts{
Pair: self.c.MainViewPairs().PatchBuilding,
Main: &types.ViewUpdateOpts{
Task: types.NewRenderStringWithoutScrollTask(mainContent),
Title: self.c.Tr.Patch,
},
Secondary: &types.ViewUpdateOpts{
Task: types.NewRenderStringWithoutScrollTask(secondaryDiff),
Title: self.c.Tr.CustomPatch,
},
})
}