Files
lazygit/pkg/gui/controllers/main_view_controller.go
T
Stefan HallerandClaude Opus 5 c5fadcd07e Open the selected diff line in the branch's pull request
Reading a change in lazygit and saying something about it on GitHub
means finding the line again in the browser: open the pull request, find
the commit, find the file, scroll to the line. The line is already under
the cursor here.

Bind G in the focused main view, the key the commits panel opens the
pull request with, to open it at the line the selection is on. The URL
names the commits whose diff is on screen, so that the line numbers of
the diff are the ones the page shows, the file by the SHA-256 of its
repo-relative path, and the line by the side of the diff it is on: R for
the new version of the file, L for the old one, where a deleted line is.
GitHub documents none of that; the form was read off the URLs its own
pages carry.

One commit is named by its hash. A range of them is named by the commit
the range starts after and the commit it ends at, the form the chooser
above a pull request's files uses. The commit a range starts after is
the parent of its oldest commit; where the range starts where the pull
request itself does, that parent is none of the pull request's own
commits, and the keyword BASE stands for it.

Which branch's pull request that is depends on the panel beneath. The
commits panel lists the commits of the checked-out branch, the
sub-commits panel those of the branch drilled into, and the commit files
panel shows the files of a commit from either. In a stack of branches,
each with a pull request of its own, those lists include the commits of
the branches below, and each of those commits is in the pull request of
its own branch. So the command looks upwards from the commit for the
nearest head of a branch with a pull request, and takes the listed
branch if it finds none. Panels showing a diff that no pull request has
a view of don't answer, and the command isn't offered over their diffs
at all.

Neither is it offered over a diff that is not the commit's own, where
the line numbers on screen are not the ones the page shows: a diff
against another ref in diffing mode, and the custom patch, whose lines
sit at the numbers the patch gives them.

A pull request holds only the commits of its branch that are pushed, and
its pages say they can't find any other commit. So the command refuses
where a commit of the diff is not one of the pull request's. Amend a
commit in the middle of the branch, and the diffs of the commits below
it still open; the ones above it sit on hashes the remote doesn't have.
A commit from before the branch, in a main branch already, is refused
too, and so is a range of commits that reaches across the head of a
branch in a stack, since its commits are in two pull requests. Whether a
commit is pushed is known only for the upstream of the listed branch.
For a branch lower in a stack, that is right as long as the branches of
the stack are pushed together.

Only GitHub pull requests are known, since that is where the pull
request data comes from. The whole path can't be exercised headlessly:
no pull request reaches the model without a GitHub token, so the test
covers where the command is offered and the three reasons it refuses.
The URL is unit-tested instead, both the anchor of a line and the way
the commits are named, and so is the choice of a branch in a stack.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 11:20:33 +02:00

1225 lines
46 KiB
Go

package controllers
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
)
type MainViewController struct {
baseController
c *ControllerCommon
context *context.MainContext
otherContext *context.MainContext
dragAutoscroller *helpers.DragAutoscroller
draggingWithMouse bool
lineFlashGeneration uint64
}
const editedLineFlashDuration = 200 * time.Millisecond
var _ types.IController = &MainViewController{}
func NewMainViewController(
c *ControllerCommon,
context *context.MainContext,
otherContext *context.MainContext,
) *MainViewController {
controller := &MainViewController{
baseController: baseController{},
c: c,
context: context,
otherContext: otherContext,
}
controller.dragAutoscroller = helpers.NewDragAutoscroller(
c.HelperCommon,
context,
controller.canDragAutoscroll,
controller.handleDragAutoscroll,
)
return controller
}
func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
return []*types.Binding{
{
Keys: opts.GetKeys(opts.Config.Universal.TogglePanel),
Handler: self.togglePanel,
Description: self.c.Tr.ToggleDiffPane,
Tooltip: self.c.Tr.ToggleDiffPaneTooltip,
DisplayOnScreen: true,
},
{
Keys: opts.GetKeys(opts.Config.Main.ToggleSelectHunk),
Handler: self.toggleSelectHunk,
DescriptionFunc: self.diffSelectionDescription(func() string {
if self.diffSelectState().Mode == types.DiffSelectModeHunk {
return self.c.Tr.SelectLineByLine
}
return self.c.Tr.SelectHunk
}),
Description: self.c.Tr.ToggleSelectHunk,
GetDisabledReason: self.diffSelectionDisabledReason,
Tooltip: self.c.Tr.ToggleSelectHunkTooltip,
DisplayOnScreen: true,
},
{
Keys: opts.GetKeys(opts.Config.Universal.ToggleRangeSelect),
Handler: self.toggleRangeSelect,
Description: self.c.Tr.ToggleRangeSelect,
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.ToggleRangeSelect),
GetDisabledReason: self.diffSelectionDisabledReason,
},
{
Keys: opts.GetKeys(opts.Config.Universal.Edit),
Handler: self.editLine,
Description: self.c.Tr.EditFile,
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.EditFile),
GetDisabledReason: self.diffSelectionDisabledReason,
Tooltip: self.c.Tr.EditFileTooltip,
},
{
Keys: opts.GetKeys(opts.Config.Universal.Select),
Handler: self.primaryAction,
// The description is of the working tree's diff, which is where the key does
// the thing users know it for; over a commit's diff it says so for itself.
Description: self.c.Tr.Stage,
DescriptionFunc: self.diffActionDescription(self.c.Tr.Stage, self.c.Tr.ToggleSelectionForPatch),
GetDisabledReason: self.diffSelectionDisabledReason,
Tooltip: self.c.Tr.StageSelectionTooltip,
// Over a commit's diff the key toggles lines in the custom patch, which the
// description says for itself; there is nothing to add to it.
TooltipFunc: self.diffActionDescription(self.c.Tr.StageSelectionTooltip, ""),
DisplayOnScreen: true,
},
{
Keys: opts.GetKeys(opts.Config.Universal.Remove),
Handler: self.discardSelection,
Description: self.c.Tr.DiscardSelection,
DescriptionFunc: self.diffActionDescription(self.c.Tr.DiscardSelection, self.c.Tr.RemoveSelectionFromPatch),
GetDisabledReason: self.discardSelectionDisabledReason,
Tooltip: self.c.Tr.DiscardSelectionTooltip,
// Over a commit's diff the key rewrites the commit rather than touching the
// index, which is worth the warning the other tooltip carries.
TooltipFunc: self.diffActionDescription(
self.c.Tr.DiscardSelectionTooltip, self.c.Tr.RemoveSelectionFromPatchTooltip),
DisplayOnScreen: true,
},
{
Keys: opts.GetKeys(opts.Config.Main.EditSelectHunk),
Handler: self.editHunk,
Description: self.c.Tr.EditHunk,
DescriptionFunc: self.workingTreeActionDescription(self.c.Tr.EditHunk),
GetDisabledReason: self.diffSelectionDisabledReason,
Tooltip: self.c.Tr.EditHunkTooltip,
},
{
Keys: opts.GetKeys(opts.Config.Universal.CopyToClipboard),
Handler: self.copySelection,
Description: self.c.Tr.CopySelectedDiffLinesToClipboard,
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.CopySelectedDiffLinesToClipboard),
GetDisabledReason: self.diffSelectionDisabledReason,
},
{
Keys: opts.GetKeys(opts.Config.Main.PrevHunk),
Handler: self.prevChangeBlock,
Description: self.c.Tr.PrevHunk,
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.PrevHunk),
GetDisabledReason: self.diffSelectionDisabledReason,
},
{
Keys: opts.GetKeys(opts.Config.Main.NextHunk),
Handler: self.nextChangeBlock,
Description: self.c.Tr.NextHunk,
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.NextHunk),
GetDisabledReason: self.diffSelectionDisabledReason,
},
{
Keys: opts.GetKeys(opts.Config.Main.PrevFile),
Handler: self.prevFile,
Description: self.c.Tr.PrevFileInDiff,
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.PrevFileInDiff),
GetDisabledReason: self.diffSelectionDisabledReason,
},
{
Keys: opts.GetKeys(opts.Config.Main.NextFile),
Handler: self.nextFile,
Description: self.c.Tr.NextFileInDiff,
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.NextFileInDiff),
GetDisabledReason: self.diffSelectionDisabledReason,
},
{
Keys: opts.GetKeys(opts.Config.Commits.OpenPullRequestInBrowser),
Handler: self.openPullRequestAtSelectedLine,
Description: self.c.Tr.OpenPullRequestAtSelectedLine,
DescriptionFunc: self.pullRequestDescription(self.c.Tr.OpenPullRequestAtSelectedLine),
GetDisabledReason: self.openPullRequestDisabledReason,
Tooltip: self.c.Tr.OpenPullRequestAtSelectedLineTooltip,
},
{
Keys: opts.GetKeys(opts.Config.Universal.Return),
Handler: self.escape,
Description: self.c.Tr.ExitFocusedMainView,
DescriptionFunc: self.escapeDescription,
DisplayOnScreen: true,
},
{
// 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",
},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevItem), Handler: self.handlePrevLine},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextItem), Handler: self.handleNextLine},
{
Tag: "navigation",
Keys: opts.GetKeys(opts.Config.Universal.RangeSelectUp),
Handler: self.extendRangeUp,
Description: self.c.Tr.RangeSelectUp,
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.RangeSelectUp),
GetDisabledReason: self.diffSelectionDisabledReason,
},
{
Tag: "navigation",
Keys: opts.GetKeys(opts.Config.Universal.RangeSelectDown),
Handler: self.extendRangeDown,
Description: self.c.Tr.RangeSelectDown,
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.RangeSelectDown),
GetDisabledReason: self.diffSelectionDisabledReason,
},
{
Keys: opts.GetKeys(opts.Config.Files.CommitChanges),
Handler: self.workingTreeAction(self.c.Helpers().WorkingTree.HandleCommitPress),
Description: self.c.Tr.Commit,
DescriptionFunc: self.workingTreeActionDescription(self.c.Tr.Commit),
Tooltip: self.c.Tr.CommitTooltip,
},
{
Keys: opts.GetKeys(opts.Config.Files.CommitChangesWithoutHook),
Handler: self.workingTreeAction(self.c.Helpers().WorkingTree.HandleWIPCommitPress),
Description: self.c.Tr.CommitChangesWithoutHook,
DescriptionFunc: self.workingTreeActionDescription(self.c.Tr.CommitChangesWithoutHook),
},
{
Keys: opts.GetKeys(opts.Config.Files.CommitChangesWithEditor),
Handler: self.workingTreeAction(self.c.Helpers().WorkingTree.HandleCommitEditorPress),
Description: self.c.Tr.CommitChangesWithEditor,
DescriptionFunc: self.workingTreeActionDescription(self.c.Tr.CommitChangesWithEditor),
},
{
Keys: opts.GetKeys(opts.Config.Files.FindBaseCommitForFixup),
Handler: self.workingTreeAction(self.c.Helpers().FixupHelper.HandleFindBaseCommitForFixupPress),
Description: self.c.Tr.FindBaseCommitForFixup,
DescriptionFunc: self.workingTreeActionDescription(self.c.Tr.FindBaseCommitForFixup),
Tooltip: self.c.Tr.FindBaseCommitForFixupTooltip,
},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevPage), Handler: self.handlePrevPage, Description: self.c.Tr.PrevPage},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextPage), Handler: self.handleNextPage, Description: self.c.Tr.NextPage},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoTop), Handler: self.handleGotoTop, Description: self.c.Tr.GotoTop},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoBottom), Handler: self.handleGotoBottom, Description: self.c.Tr.GotoBottom},
}
}
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(),
},
{
// Dragging after a click extends a range selection from the clicked line.
ViewName: self.context.GetViewName(),
Key: gocui.MouseLeft,
Modifier: gocui.ModMotion,
Handler: self.onDragInFocusedView,
FocusedView: self.context.GetViewName(),
},
{
ViewName: self.context.GetViewName(),
Key: gocui.MouseRelease,
Handler: self.onDragRelease,
},
{
ViewName: self.context.GetViewName(),
Key: gocui.MouseLeft,
Modifier: gocui.ModAlt,
Handler: self.editClickedLine,
HandleWhenPopupPanelFocused: true,
},
{
ViewName: self.context.GetViewName(),
Key: gocui.MouseLeft,
Modifier: gocui.ModShift,
Handler: self.editClickedLine,
HandleWhenPopupPanelFocused: true,
},
}
}
func (self *MainViewController) Context() types.Context {
return self.context
}
func (self *MainViewController) togglePanel() error {
if !self.otherContext.GetView().Visible {
return nil
}
// Whether the pair holds a diff is decided by the side panel beneath, which
// NextInStack only finds while our context is still the focused main view, so
// read it before pushing the other pane.
isDiff := self.isDiffView()
self.c.Context().Push(self.otherContext, types.OnFocusOpts{})
if isDiff {
self.c.Helpers().DiffLine.EstablishSelection(self.otherContext, -1)
}
return nil
}
// escape dismisses the selection a step at a time before leaving the view: a range
// collapses to its cursor line, and hunk mode the user turned on goes back to
// line-by-line. Hunk mode that is merely the configured default is not something to
// escape from, so there escape leaves.
func (self *MainViewController) escape() error {
if self.selectingRange() || self.selectingHunkEnabledByUser() {
self.context.ResetDiffSelectMode()
return nil
}
self.c.Context().Pop()
return nil
}
func (self *MainViewController) escapeDescription() string {
if self.selectingRange() {
return self.c.Tr.DismissRangeSelect
}
if self.selectingHunkEnabledByUser() {
return self.c.Tr.SelectLineByLine
}
return self.c.Tr.ExitFocusedMainView
}
// selectingHunkEnabledByUser reports whether we are in hunk mode because the user
// asked for it, as opposed to it being the configured default.
func (self *MainViewController) selectingHunkEnabledByUser() bool {
return self.diffSelectState().Mode == types.DiffSelectModeHunk && self.diffSelectState().UserEnabledHunkMode
}
// isDiffView reports whether the focused main view currently shows a diff, and so
// shows a selection. See types.DiffMainViewContext.
func (self *MainViewController) isDiffView() bool {
return self.diffMainViewType() != types.DiffMainViewTypeNone
}
// sidePanelBeneath returns the side panel this pane is showing the content of, and nil
// when there is none. The IsInStack guard is essential: NextInStack panics for a context
// that isn't in the stack, and GetKeybindings (which leads here) also runs for off-stack
// panes — at startup and while generating the cheatsheets, where the stack is empty.
func (self *MainViewController) sidePanelBeneath() types.Context {
if !self.c.Context().IsInStack(self.context) {
return nil
}
return self.c.Context().NextInStack(self.context)
}
// diffMainViewType reports what the diff in the focused main view belongs to, taken
// from the side panel beneath it, or DiffMainViewTypeNone when this pane isn't on the
// stack or has no diff panel beneath it.
func (self *MainViewController) diffMainViewType() types.DiffMainViewType {
if diffContext, ok := self.sidePanelBeneath().(types.DiffMainViewContext); ok {
return diffContext.GetDiffMainViewType()
}
return types.DiffMainViewTypeNone
}
// diffSource returns the panel beneath the focused main view, as the thing that can
// hand out the diff it rendered there. nil when this pane isn't on the stack, or the
// panel beneath shows no diff.
func (self *MainViewController) diffSource() types.FocusedMainViewDiffSource {
sidePanel := self.sidePanelBeneath()
if sidePanel == nil {
return nil
}
return sidePanel.GetFocusedMainViewDiffSource()
}
// focusedMainViewActions returns what the panel beneath the focused main view does to
// a selection in its diff, or nil where it does nothing to it — a panel whose diff can
// be read and copied but not acted on.
func (self *MainViewController) focusedMainViewActions() types.FocusedMainViewActions {
actions, _ := self.diffSource().(types.FocusedMainViewActions)
return actions
}
// primaryAction acts on the selected diff lines, leaving what that means to the panel
// beneath — which also re-renders the diff, since it is the one that changed it.
func (self *MainViewController) primaryAction() error {
actions := self.focusedMainViewActions()
if actions == nil {
return nil
}
first, last, ok := self.context.GetView().SelectedBufferLineRange()
if !ok {
return nil
}
return actions.PrimaryAction(self.context, first, last)
}
// discardSelection takes the selected diff lines back out of what they are part of,
// which — like the primary action — is the panel's business, and so is the re-render
// that follows.
func (self *MainViewController) discardSelection() error {
actions := self.focusedMainViewActions()
if actions == nil {
return nil
}
first, last, ok := self.context.GetView().SelectedBufferLineRange()
if !ok {
return nil
}
return actions.DiscardSelection(self.context, first, last)
}
// editHunk hands the hunk around the selection to an editor, and is only offered over
// the working tree's diff: what comes back is applied to the index, which is not
// something a commit's diff has any use for.
func (self *MainViewController) editHunk() error {
actions, ok := self.diffSource().(*WorkingTreeDiffActions)
if !ok {
return nil
}
first, last, ok := self.context.GetView().SelectedBufferLineRange()
if !ok {
return nil
}
return actions.EditHunk(self.context, first, last)
}
// workingTreeAction wraps a command that acts on the working tree — committing, finding
// the commit to fix up — so that it only runs while the focused main view is showing the
// working tree's diff. Over a commit's diff the key does nothing, so that browsing
// through history can't commit by accident. The check is per press, since what the main
// view shows changes as the user moves around while the keybindings are registered once.
func (self *MainViewController) workingTreeAction(action func() error) func() error {
return func() error {
if self.diffMainViewType() != types.DiffMainViewTypeStaging {
return nil
}
return action()
}
}
// workingTreeActionDescription gives a command's description only where the command
// applies — over the working tree's diff — so that it is listed there and nowhere else.
func (self *MainViewController) workingTreeActionDescription(description string) func() string {
return self.diffActionDescription(description, "")
}
// diffActionDescription describes a command in the words that suit the diff it applies
// to: acting on the working tree's diff stages, acting on a commit's builds a custom
// patch. Over content that is no diff at all the command doesn't apply, and describes
// itself as nothing, which keeps it out of the keybindings menu there.
func (self *MainViewController) diffActionDescription(staging string, patchBuilding string) func() string {
return func() string {
switch self.diffMainViewType() {
case types.DiffMainViewTypeStaging:
return staging
case types.DiffMainViewTypePatchBuilding:
return patchBuilding
default:
return ""
}
}
}
// pullRequestDescription describes a command that acts on the pull request of the
// branch the diff belongs to. Over a diff that belongs to no branch (the working tree's,
// a stash entry's) it describes it as nothing; this keeps the command out of the
// keybindings menu there.
func (self *MainViewController) pullRequestDescription(description string) func() string {
return self.diffSelectionDescription(func() string {
if self.pullRequestDiff().Branch == "" {
return ""
}
return description
})
}
// copySelection copies the selected diff lines to the clipboard — not as the diff
// renderer drew them, but as they read in the diff itself, which is both what you meant
// to copy and the only form a renderer can't have mangled. A selection that is all
// additions or all deletions loses its +/- column, so that it can be pasted straight
// into code.
//
// The rows above the diff (a commit's message, git's summary of it) belong to no file,
// so they are copied as they stand on screen.
func (self *MainViewController) copySelection() error {
text := self.textOfSelection()
if text == "" {
self.c.ErrorToast(self.c.Tr.SelectionNotFoundInDiffToast)
return nil
}
self.c.LogAction(self.c.Tr.Actions.CopySelectedTextToClipboard)
if err := self.c.OS().CopyToClipboard(text); err != nil {
return err
}
self.c.Toast(self.c.Tr.SelectedDiffLinesCopiedToast)
return nil
}
// textOfSelection is what copying the selection puts on the clipboard. It is "" when
// none of the selected rows could be placed in the diff, which is what a rendering's
// own decoration comes to.
func (self *MainViewController) textOfSelection() string {
source := self.diffSource()
if source == nil {
return ""
}
view := self.context.GetView()
first, last, ok := view.SelectedBufferLineRange()
if !ok {
return ""
}
aboveDiff, fromDiff := self.c.Helpers().DiffLine.PlainDiffOfSelection(view, first, last,
func(paths []string) string { return source.PlainDiff(self.context, paths) })
if aboveDiff == "" {
// Only text that is all diff has a +/- column to lose: a line of a commit message
// may begin with a '-' without being a deletion of anything.
fromDiff = dropDiffPrefix(fromDiff)
}
return aboveDiff + fromDiff
}
// diffSelectState returns this pane's diff selection mode state.
func (self *MainViewController) diffSelectState() *types.DiffSelectState {
return self.context.DiffSelectState()
}
// diffSelectionDescription qualifies the description of a command that acts on the
// selection, so that it is listed only where it applies: the main view also shows
// content with nothing to select in it — a branch's commit log, the status dashboard —
// and a command with no description is left out of the keybindings menu.
//
// The static Description stays as it is: the cheatsheets are generated from that, and
// they document what a key does rather than when it applies.
func (self *MainViewController) diffSelectionDescription(describe func() string) func() string {
return func() string {
if !self.isDiffView() {
return ""
}
return describe()
}
}
func (self *MainViewController) diffSelectionDescriptionText(description string) func() string {
return self.diffSelectionDescription(func() string { return description })
}
// diffSelectionDisabledReason disables the commands that act on the selection while
// there is none to act on: a diff view whose diff holds nothing selectable (a binary
// file, an empty commit) or which is showing a placeholder message.
func (self *MainViewController) diffSelectionDisabledReason() *types.DisabledReason {
if !self.context.GetView().Highlight {
return &types.DisabledReason{Text: self.c.Tr.NothingToSelectInDiff}
}
return nil
}
// discardSelectionDisabledReason disables discarding while there is nothing to discard,
// and where the panel beneath won't have it: taking lines out of a commit means
// rewriting it, which isn't always something we may do.
func (self *MainViewController) discardSelectionDisabledReason() *types.DisabledReason {
if reason := self.diffSelectionDisabledReason(); reason != nil {
return reason
}
if actions := self.focusedMainViewActions(); actions != nil {
return actions.DiscardSelectionDisabledReason(self.context)
}
return nil
}
// openPullRequestDisabledReason disables opening a line in the pull request where the
// pull request has no view of what is on screen. The branch may have no pull request,
// the commits may be on several branches of a stack and so in several pull requests,
// and the pane may be showing a diff that is not the commit's own: a diff against
// another ref, or the custom patch, whose lines sit at the numbers the patch gives them
// rather than the commit's.
func (self *MainViewController) openPullRequestDisabledReason() *types.DisabledReason {
if reason := self.diffSelectionDisabledReason(); reason != nil {
return reason
}
if self.c.Modes().Diffing.Active() {
return &types.DisabledReason{Text: self.c.Tr.NotAvailableInDiffingMode}
}
if self.c.Helpers().DiffLine.ShowsCustomPatch(self.context.GetView()) {
return &types.DisabledReason{Text: self.c.Tr.NotAvailableForCustomPatch}
}
diff := self.pullRequestDiff()
if diff.SpansBranches {
return &types.DisabledReason{Text: self.c.Tr.CommitsInSeveralPullRequests}
}
if reason := self.c.Helpers().Host.NoPullRequestDisabledReason(diff.Branch); reason != nil {
return reason
}
return self.commitsOutsidePullRequestDisabledReason(diff.Commits)
}
// commitsOutsidePullRequestDisabledReason disables opening a line of a diff whose
// commits the pull request doesn't hold: it holds the commits of its branch that are on
// the remote, so an unpushed commit is none of its own, and neither is one that is in a
// main branch already and so from before the branch. Asked for such a commit, its pages
// say they can't find it.
func (self *MainViewController) commitsOutsidePullRequestDisabledReason(commits []*models.Commit) *types.DisabledReason {
if lo.EveryBy(commits, func(commit *models.Commit) bool {
return commit.Status == models.StatusPushed
}) {
return nil
}
if len(commits) == 1 {
return &types.DisabledReason{Text: self.c.Tr.CommitNotInPullRequest}
}
return &types.DisabledReason{Text: self.c.Tr.CommitsNotInPullRequest}
}
func (self *MainViewController) onClickInAlreadyFocusedView(opts gocui.ViewMouseBindingOpts) error {
self.selectClickedDiffLine(opts.Y)
return nil
}
func (self *MainViewController) editClickedLine(opts gocui.ViewMouseBindingOpts) error {
var flashGeneration uint64
err := self.editDiffLine(opts.Y, func() {
self.lineFlashGeneration++
flashGeneration = self.lineFlashGeneration
self.context.GetView().SetLineFlash(self.lineToFlash(opts.Y))
self.c.GocuiGui().ForceFlushViewsContentOnly(self.c.GocuiGui().Views())
})
if flashGeneration != 0 {
time.AfterFunc(editedLineFlashDuration, func() {
self.c.OnUIThreadContentOnlyBackground(func() error {
if self.lineFlashGeneration == flashGeneration {
self.context.GetView().ClearLineFlash()
}
return nil
})
})
}
return err
}
// lineToFlash returns the view line to flash for an edit of the line clicked at the
// given one. The editor is sent to where that line begins, so a long line wrapped over
// several view lines is flashed at the first of them. If the editor wraps the line too,
// its cursor ends up on the flashed line. When the line begins above the top of the
// viewport, the clicked line is flashed, as the only part of the line on screen.
func (self *MainViewController) lineToFlash(clickedViewLine int) int {
view := self.context.GetView()
bufferLine, ok := view.BufferLineForViewLine(clickedViewLine)
if !ok {
return clickedViewLine
}
firstViewLine, ok := view.ViewLineForBufferLine(bufferLine)
if !ok || firstViewLine < view.OriginY() {
return clickedViewLine
}
return firstViewLine
}
func (self *MainViewController) onClickInOtherViewOfMainViewPair(opts gocui.ViewMouseBindingOpts) error {
// Carry the select mode over from the pane we're leaving, so that clicking into
// the other pane keeps hunk mode even the first time we enter it — its own mode
// would otherwise still be the default single line until it had been focused at
// least once. selectClickedDiffLine then keeps or collapses that mode depending on
// where the click landed.
*self.context.DiffSelectState() = *self.otherContext.DiffSelectState()
self.c.Context().Push(self.context, types.OnFocusOpts{})
self.selectClickedDiffLine(opts.Y)
return nil
}
// onDragInFocusedView extends a range selection as the mouse is dragged after a
// click, anchored at the line the click landed on rather than wherever the click left
// the selection — a click can select a whole hunk, whose far end would otherwise
// become the anchor. Dragging turns hunk mode off: you get a plain range from the
// clicked line to the line under the cursor, which gocui has already moved here.
func (self *MainViewController) onDragInFocusedView(opts gocui.ViewMouseBindingOpts) error {
view := self.context.GetView()
if !self.isDiffView() || !view.Highlight {
return nil
}
sel := self.diffSelectState()
sel.Mode = types.DiffSelectModeRange
sel.RangeIsSticky = false
sel.UserEnabledHunkMode = false
view.SetRangeSelectStart(self.context.DragAnchorViewLine())
// A drag that reaches the edge of the view keeps going: mouse capture means the
// pointer can be dragged past the edge, and there is more diff down there than
// fits on screen. opts.Y is where the pointer is in the content, which the
// autoscroller wants relative to the viewport.
self.draggingWithMouse = true
originY, _ := self.context.GetViewTrait().ViewPortYBounds()
self.dragAutoscroller.Update(opts.Y - originY)
return nil
}
func (self *MainViewController) onDragRelease(gocui.ViewMouseBindingOpts) error {
self.draggingWithMouse = false
self.dragAutoscroller.Cancel()
// The drag moved the selection without going through showSelectionAtLine: gocui
// moves the cursor for it. Let the search catch up with where it ended.
self.context.GetView().SetNearestSearchPosition()
return nil
}
// GetOnFocusLost stops an autoscroll that is still running when the view loses focus
// mid-drag, e.g. because a popup appeared, and gives up the mouse capture with it —
// otherwise the pointer would keep driving a view that no longer has focus.
func (self *MainViewController) GetOnFocusLost() func(types.OnFocusLostOpts) {
return func(types.OnFocusLostOpts) {
self.dragAutoscroller.Cancel()
if self.draggingWithMouse {
self.draggingWithMouse = false
self.c.GocuiGui().CancelMouseCapture()
}
}
}
// canDragAutoscroll reports whether the autoscroller should run: only while a drag is
// actually extending a range in a diff. Scrolling down also has to keep the lazily
// loaded content ahead of the scroll, or it would stop at the loaded edge.
func (self *MainViewController) canDragAutoscroll(direction int) bool {
if !self.draggingWithMouse || !self.isDiffView() {
return false
}
view := self.context.GetView()
if !view.Highlight || self.diffSelectState().Mode != types.DiffSelectModeRange {
return false
}
if direction > 0 {
self.c.ReadLinesToFillView(view)
}
return true
}
// handleDragAutoscroll extends the selection to the line the pointer ends up over
// after the autoscroller has scrolled, leaving the range anchored where the drag
// started. It reports whether the autoscroll should carry on.
//
// The pointer is usually outside the view by now — that is what mouse capture is for —
// so the line it is over is clamped to the visible ones, leaving the selection's far
// end at the edge the scroll is moving towards.
func (self *MainViewController) handleDragAutoscroll(viewLine int) bool {
if !self.canDragAutoscroll(0) {
return false
}
view := self.context.GetView()
originY, viewportHeight := self.context.GetViewTrait().ViewPortYBounds()
target := lo.Clamp(viewLine, 0, max(0, view.ViewLinesHeight()-1))
view.SetCursorY(lo.Clamp(target-originY, 0, max(0, viewportHeight-1)))
return true
}
// selectClickedDiffLine sets the focused main view's selection from a click at the
// given view line. In hunk mode, clicking inside the selected block collapses it to
// that line; clicking a change line outside it keeps hunk mode and selects that block.
// A click on context, or any click outside hunk mode, selects just that line too.
func (self *MainViewController) selectClickedDiffLine(viewLine int) {
if !self.isDiffView() {
return
}
view := self.context.GetView()
// Remember where the click landed so that a drag that follows anchors its range
// there, even when this click selects a whole hunk.
self.context.SetDragAnchorViewLine(viewLine)
if self.diffSelectState().Mode == types.DiffSelectModeHunk {
if start, end, ok := self.c.Helpers().DiffLine.SelectedHunkBounds(view); ok &&
viewLine >= start && viewLine <= end {
self.context.ResetDiffSelectMode()
self.c.Helpers().DiffLine.ShowSelectionAtLine(view, viewLine, false)
return
}
if self.c.Helpers().DiffLine.IsChangeLine(view, viewLine) {
self.selectHunkAround(viewLine, false)
return
}
}
self.context.ResetDiffSelectMode()
self.c.Helpers().DiffLine.ShowSelectionAtLine(view, viewLine, false)
}
func (self *MainViewController) selectHunkAround(changeViewLine int, scrollIntoView bool) {
self.c.Helpers().DiffLine.SelectChangeBlock(self.context, changeViewLine, scrollIntoView)
}
// navigate moves the focused main view to the row find locates from the current
// anchor — the selected line when a selection is showing, otherwise the top visible
// line. With a selection we move it there and scroll it into view, re-selecting the
// whole block in hunk mode; with none we stay in scroll mode, bringing the target
// to the top without selecting anything.
// alignTop says what a jump does with a target it has to scroll to: bring it to the
// top of the view, or leave the scrolling to place it as it sees fit.
func (self *MainViewController) navigate(find findDiffRowFn, forward bool, alignTop bool) {
v := self.context.GetView()
anchor := v.OriginY()
if v.Highlight {
anchor = v.SelectedLineIdx()
}
if target, ok := find(v, anchor, forward); ok {
self.placeNavigationTarget(target, alignTop)
return
}
if !forward {
// Everything above the anchor has loaded, so a backward target that wasn't
// found doesn't exist.
return
}
// The diff loads lazily, so a target below the loaded portion isn't there to be
// found yet. Read the rest of it in and look again before concluding there is none.
manager := self.c.GetViewBufferManagerForView(v)
if manager == nil {
return
}
manager.ReadToEnd(func() {
self.c.OnUIThread(func() error {
if target, ok := find(v, anchor, forward); ok {
self.placeNavigationTarget(target, alignTop)
}
return nil
})
})
}
// findDiffRowFn locates a row of the rendered diff to navigate to, given the view,
// the anchor view line to start from, and the direction.
type findDiffRowFn func(view *gocui.View, anchorViewLine int, forward bool) (int, bool)
func (self *MainViewController) nextChangeBlock() error {
self.navigate(self.c.Helpers().DiffLine.AdjacentChangeBlock, true, false)
return nil
}
func (self *MainViewController) prevChangeBlock() error {
self.navigate(self.c.Helpers().DiffLine.AdjacentChangeBlock, false, false)
return nil
}
// nextFile and prevFile bring the file they go to to the top of the view, since what
// you are going there for is the file, and the more of it is on screen the better.
func (self *MainViewController) nextFile() error {
self.navigate(self.c.Helpers().DiffLine.AdjacentFile, true, true)
return nil
}
func (self *MainViewController) prevFile() error {
self.navigate(self.c.Helpers().DiffLine.AdjacentFile, false, true)
return nil
}
// placeNavigationTarget moves the selection to the row a jump found, bringing it on
// screen if it isn't already.
//
// alignTop asks for the target to become the view's top line, so that everything that
// begins there is on screen. It only applies to a target the view has to scroll to: a
// jump to something already on screen leaves the view alone, there being nothing to
// gain from moving what the user is looking at. In hunk mode what ends up selected is
// the first change block at or below the target, which a large context size can put
// further down than a screenful; the selection is then scrolled into view as any other
// jump's is, and the alignment gives way to that.
func (self *MainViewController) placeNavigationTarget(target int, alignTop bool) {
v := self.context.GetView()
if !v.Highlight {
v.SetOrigin(0, target)
return
}
if alignTop {
self.scrollTargetToTop(target)
}
// Jumping to another block or file moves the cursor without shift held, so a
// range that grows only while shift is held collapses rather than stretching all
// the way to the target. A sticky range stretches instead; this is the point of
// being sticky.
self.collapseNonStickyRange()
if self.diffSelectState().Mode == types.DiffSelectModeHunk {
self.selectHunkAround(target, true)
return
}
// Line mode leaves a single-line selection at the target; an active range extends
// to it, the anchor being untouched.
self.c.Helpers().DiffLine.ShowSelectionAtLine(v, target, true)
}
// scrollTargetToTop scrolls the given row of the diff to the top of the view, leaving
// the view where it is when that row is on screen already. The last screenful of the
// diff is as far as it goes, so that the view doesn't scroll past the end of what it is
// showing.
func (self *MainViewController) scrollTargetToTop(target int) {
view := self.context.GetView()
originY, height := self.context.GetViewTrait().ViewPortYBounds()
if target >= originY && target < originY+height {
return
}
view.SetOriginY(min(target, max(0, view.ViewLinesHeight()-height)))
}
// moveCursor moves the selection cursor by delta view lines (negative = up), with the
// configured scroll-off margin, reading more content in first when moving down. The
// range anchor is left untouched, so this extends or contracts a range and just moves
// the selected line otherwise.
func (self *MainViewController) moveCursor(delta int) {
v := self.context.GetView()
if delta > 0 {
self.c.ReadLinesToFillView(v)
}
before := v.SelectedLineIdx()
after := lo.Clamp(before+delta, 0, v.ViewLinesHeight()-1)
if delta == -1 {
checkScrollUp(self.context.GetViewTrait(), self.c.UserConfig(), before, after)
} else if delta == 1 {
checkScrollDown(self.context.GetViewTrait(), self.c.UserConfig(), before, after)
}
self.c.Helpers().DiffLine.ShowSelectionAtLine(v, after, true)
}
// collapseForLineMove drops hunk mode, and a non-sticky range, back to a single-line
// selection — what a plain (non-shift, non-hunk-step) move does before moving. A
// sticky range is kept, so the move extends it.
func (self *MainViewController) collapseForLineMove() {
sel := self.diffSelectState()
if sel.Mode == types.DiffSelectModeHunk {
sel.Mode = types.DiffSelectModeLine
self.context.GetView().CancelRangeSelect()
return
}
self.collapseNonStickyRange()
}
// collapseNonStickyRange drops a range that only grows while shift is held back to a
// single line at the cursor.
func (self *MainViewController) collapseNonStickyRange() {
sel := self.diffSelectState()
if sel.Mode == types.DiffSelectModeRange && !sel.RangeIsSticky {
sel.Mode = types.DiffSelectModeLine
self.context.GetView().CancelRangeSelect()
}
}
// adjustSelection moves the selection by delta view lines, for the plain up/down and
// page keys. In hunk mode a single-line step jumps to the adjacent block, while a
// larger page step drops out of hunk mode first. A non-sticky range collapses back to
// a single line on a plain move. With no selection — non-diff content — it scrolls.
func (self *MainViewController) adjustSelection(delta int) {
if !self.context.GetView().Highlight {
self.handleLineChange(delta)
return
}
if self.diffSelectState().Mode == types.DiffSelectModeHunk && (delta == 1 || delta == -1) {
self.navigate(self.c.Helpers().DiffLine.AdjacentChangeBlock, delta > 0, false)
return
}
self.collapseForLineMove()
self.moveCursor(delta)
}
// selectAbsoluteLine moves the selection to a specific view line — the top or bottom
// of the diff — dropping hunk mode and a non-sticky range like a plain move does.
func (self *MainViewController) selectAbsoluteLine(target int) {
self.collapseForLineMove()
self.c.Helpers().DiffLine.ShowSelectionAtLine(self.context.GetView(), target, true)
}
// selectingRange reports whether a range selection is currently active: we're in
// range mode and either it's sticky or the anchor and cursor differ, i.e. a
// non-sticky range that has actually been extended.
func (self *MainViewController) selectingRange() bool {
if self.diffSelectState().Mode != types.DiffSelectModeRange {
return false
}
start, end := self.context.GetView().SelectedLineRange()
return self.diffSelectState().RangeIsSticky || start != end
}
// toggleSelectHunk switches between selecting the change block around the cursor and
// a single line.
func (self *MainViewController) toggleSelectHunk() error {
v := self.context.GetView()
if !v.Highlight {
return nil
}
sel := self.diffSelectState()
if sel.Mode == types.DiffSelectModeHunk {
sel.Mode = types.DiffSelectModeLine
v.CancelRangeSelect()
} else {
sel.Mode = types.DiffSelectModeHunk
sel.UserEnabledHunkMode = true
self.selectHunkAround(v.SelectedLineIdx(), true)
}
return nil
}
// toggleRangeSelect starts or cancels a sticky range selection, which the plain
// up/down keys extend.
func (self *MainViewController) toggleRangeSelect() error {
v := self.context.GetView()
if !v.Highlight {
return nil
}
sel := self.diffSelectState()
if self.selectingRange() {
sel.Mode = types.DiffSelectModeLine
sel.RangeIsSticky = false
v.CancelRangeSelect()
} else {
sel.Mode = types.DiffSelectModeRange
sel.RangeIsSticky = true
v.SetRangeSelectStart(v.SelectedLineIdx())
}
return nil
}
// extendRange grows a non-sticky range selection by one line in response to
// shift+up/down, starting one at the cursor if there isn't one yet.
func (self *MainViewController) extendRange(forward bool) error {
v := self.context.GetView()
if !v.Highlight {
return nil
}
sel := self.diffSelectState()
if !self.selectingRange() {
sel.Mode = types.DiffSelectModeRange
v.SetRangeSelectStart(v.SelectedLineIdx())
}
sel.RangeIsSticky = false
if forward {
self.moveCursor(1)
} else {
self.moveCursor(-1)
}
return nil
}
func (self *MainViewController) extendRangeUp() error {
return self.extendRange(false)
}
func (self *MainViewController) extendRangeDown() error {
return self.extendRange(true)
}
func (self *MainViewController) handleLineChange(delta int) {
v := self.context.GetView()
if delta < 0 {
v.ScrollUp(-delta)
} else {
v.ScrollDown(delta)
self.c.ReadLinesToFillView(v)
}
}
func (self *MainViewController) handlePrevLine() error {
self.adjustSelection(-1)
return nil
}
func (self *MainViewController) handleNextLine() error {
self.adjustSelection(1)
return nil
}
func (self *MainViewController) handlePrevPage() error {
self.adjustSelection(-self.context.GetViewTrait().PageDelta())
return nil
}
func (self *MainViewController) handleNextPage() error {
self.adjustSelection(self.context.GetViewTrait().PageDelta())
return nil
}
func (self *MainViewController) handleGotoTop() error {
v := self.context.GetView()
if !v.Highlight {
self.handleLineChange(-v.ViewLinesHeight())
return nil
}
self.selectAbsoluteLine(0)
return nil
}
func (self *MainViewController) handleGotoBottom() error {
if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil {
manager.ReadToEnd(func() {
self.c.OnUIThread(func() error {
v := self.context.GetView()
if !v.Highlight {
self.handleLineChange(v.ViewLinesHeight())
return nil
}
self.selectAbsoluteLine(v.ViewLinesHeight() - 1)
return nil
})
})
}
return nil
}
func (self *MainViewController) editLine() error {
view := self.context.GetView()
if !view.Highlight {
return nil
}
return self.editDiffLine(view.SelectedLineIdx(), nil)
}
func (self *MainViewController) editDiffLine(viewLine int, beforeEdit func()) error {
info, ok := self.c.Helpers().DiffLine.GetDiffLineInfo(self.context.GetView(), viewLine)
if !ok {
return nil
}
if beforeEdit != nil {
beforeEdit()
}
// A file-header row points at the file as a whole rather than at a line in it, so
// it opens the file without jumping anywhere — as pressing edit on a file in a side
// panel does.
if info.Type == types.DiffLineFileHeader {
return self.c.Helpers().Files.EditFiles([]string{info.Path})
}
// The diff may be of an older commit, whose line numbers aren't the file's current
// ones, so they have to be carried forward before we can point an editor at them.
lineNumber := self.c.Helpers().Diff.AdjustLineNumber(info.Path, info.NewLine, self.context.GetViewName())
return self.c.Helpers().Files.EditFileAtLine(info.Path, lineNumber)
}
// openPullRequestAtSelectedLine opens the pull request of the branch whose commit the
// main view is showing the diff of, at the line the selection is on, so that the line
// can be commented on there.
func (self *MainViewController) openPullRequestAtSelectedLine() error {
diff := self.pullRequestDiff()
pr, ok := self.c.Helpers().Host.PullRequestForBranch(diff.Branch)
if !ok {
// Guarded against by the disabled reason, but a refresh in the background may
// have taken the pull request away since it was asked.
return errors.New(self.c.Tr.NoPullRequestForBranch)
}
if len(diff.Commits) == 0 {
return nil
}
view := self.context.GetView()
info, ok := self.c.Helpers().DiffLine.GetDiffLineInfo(view, view.SelectedLineIdx())
if !ok {
return nil
}
relativePath := repoRelativePath(self.c.Git().RepoPaths.WorktreePath(), info.Path)
if relativePath == "" {
return nil
}
self.c.LogAction(self.c.Tr.Actions.OpenPullRequest)
url := githubPullRequestLineURL(pr.Url, githubCommitRange(diff.Commits, diff.BaseHash), relativePath, info)
return self.c.OS().OpenLink(url)
}
// pullRequestDiff returns the commits whose diff this pane is showing, and the branch
// whose pull request would show that diff, as the panel beneath names them. The diff's
// line numbers are the ones the pull request's page for those commits shows. Where no
// pull request shows the diff, the branch is "".
func (self *MainViewController) pullRequestDiff() types.PullRequestDiff {
prContext, ok := self.sidePanelBeneath().(types.PullRequestDiffContext)
if !ok {
return types.PullRequestDiff{}
}
return prContext.PullRequestDiff()
}
// githubPullRequestLineURL builds the URL of a line of a file, in the diff a pull request
// shows for the given commits. The file is named by the SHA-256 of its path as git spells
// it, and the line by which side of the diff it is on.
//
// GitHub documents none of this; the form was read off the URLs its own pages carry (see
// https://github.com/orgs/community/discussions/55764).
func githubPullRequestLineURL(
prURL string, commitRange string, relativePath string, info types.DiffLineInfo,
) string {
pathHash := sha256.Sum256([]byte(relativePath))
anchor := "diff-" + hex.EncodeToString(pathHash[:]) + githubDiffLineSuffix(info)
return fmt.Sprintf("%s/changes/%s#%s", prURL, commitRange, anchor)
}
// githubCommitRange names the commits a pull request is to show the diff of: a single
// commit by its hash, and a range of them as the commit the diff starts after, then the
// commit it ends at. A range that starts where the pull request itself does names BASE
// as the commit it starts after, the keyword its pages use for the commit the pull
// request was opened against; naming that commit by its hash gets a page that says it
// can't find those commits.
func githubCommitRange(commits []*models.Commit, baseHash string) string {
newest := commits[0].Hash()
if len(commits) == 1 {
return newest
}
if baseHash == "" {
baseHash = "BASE"
}
return baseHash + ".." + newest
}
// githubDiffLineSuffix names a line within a file's diff: R for the new version of the
// file, L for the old one, which is where a deleted line is found. Some rows are no line
// of the file at all (the header naming it, or a marker like "\ No newline at end of
// file"); those name none, and the anchor points at the file itself.
func githubDiffLineSuffix(info types.DiffLineInfo) string {
switch info.Type {
case types.DiffLineDeleted:
return fmt.Sprintf("L%d", info.OldLine)
case types.DiffLineAdded, types.DiffLineContext, types.DiffLineHunkHeader:
return fmt.Sprintf("R%d", info.NewLine)
default:
return ""
}
}
func (self *MainViewController) openSearch() error {
if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil {
manager.ReadToEnd(func() {
self.c.OnUIThread(func() error {
self.c.Helpers().Search.OpenSearchPrompt(self.context)
return nil
})
})
}
return nil
}