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>
364 lines
12 KiB
Go
364 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 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.
|
|
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
|
|
}
|