mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-11 16:16:28 -04:00
The focused main view's click/enter/e/G handlers all need the same thing: given a rendered diff row, the patch-space line it corresponds to. Until now that came solely from delta's lazygit-edit:// hyperlinks, which only carry a path and a single line number — no side. That's lossy: for a deletion the number is the old line, but the consumers fed it into new-file lookups, and two consecutive deletions (which share a new-file line number) couldn't be told apart at all. Replace GetFileAndLineForClickedDiffLine with GetDiffLineInfo, returning the fuller (file, type, new-line, old-line) record from diff-line-metadata-notes.md. This is mechanism #1: parse the decolorized view buffer — walk up to the file's "diff --git" section, reuse patch.Parse on it (splitting multi-file commit diffs on the "diff --git" boundaries), and read the type and line numbers off the patch arithmetic. It serves the structure-preserving renderings — no pager, git diff --color, and delta --color-only without line numbers — with no external dependency. To avoid trusting a mis-parse, the parser bails when a hunk's body no longer matches its header (Patch.IsWellFormed). That's what happens when a pager keeps the diff/hunk headers but restructures the body: delta's line-number gutters push the +/- marker off the start of each line, so every body line reads as context. Such renderings fall through to the next backend rather than yielding a confident wrong answer. (diff-so-fancy goes further and rewrites the headers too, so it fails even earlier, on the missing "diff --git".) GetDiffLineInfo is a seam with swappable backends: the buffer parser first, then the old hyperlink reader as a fallback for renderings the parser can't handle (delta's default mode, or delta with line-number gutters). The future #2 OSC per-cell metadata reader plugs in ahead of both, behind the same record shape. Wire the consumers to the record per that doc's field mapping: - dive into staging/patch building lands on the exact patch line, looking a deletion up by its old-file line number (PatchLineForOldLineNumber) so the two-deletions case resolves correctly; - `e` edits at the new-file line; - `G` anchors the PR link on the left (old) side for a deletion, the right (new) side otherwise. The hyperlink fallback can't convey the side, so it reports DiffLineOther, which the consumers treat as a non-deletion — i.e. exactly today's behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
185 lines
6.1 KiB
Go
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, opts.ClickedViewRealLineIsDeletion, 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,
|
|
},
|
|
})
|
|
}
|