jesseduffield.lazygit/pkg/gui/controllers/main_view_controller.go
2026-09-14 13:50:03 +02:00

1244 lines
47 KiB
Go

package controllers
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"time"
"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.Main.JumpToFile),
Handler: self.openJumpToFileMenu,
Description: self.c.Tr.JumpToFileInDiff,
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.JumpToFileInDiff),
GetDisabledReason: self.diffSelectionDisabledReason,
OpensMenu: true,
DisplayOnScreen: true,
},
{
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
}
// GetOnFocus brings on the marks over the lines that are in the custom patch, which
// are an affordance of the focused view, so they arrive with the focus.
func (self *MainViewController) GetOnFocus() func(types.OnFocusOpts) {
return func(types.OnFocusOpts) {
self.c.Helpers().DiffLine.RefreshInclusionGutter()
}
}
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 := self.context.GetView().SelectedLineRange()
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 := self.context.GetView().SelectedLineRange()
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 := self.context.GetView().SelectedLineRange()
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.pullRequestBranch() == "" {
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 := view.SelectedLineRange()
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,
// 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}
}
return self.c.Helpers().Host.NoPullRequestDisabledReason(self.pullRequestBranch())
}
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(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
}
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()
}
// Where the focus has gone is already known here, so asking again keeps the
// patch marks over a move to the pane beside this one, and takes them away
// when the focus leaves the pair.
self.c.Helpers().DiffLine.RefreshInclusionGutter()
}
}
// 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)))
}
// openJumpToFileMenu offers the files of the diff in a menu, so that one of them can be
// gone to directly rather than by stepping through the diff a file at a time.
//
// The diff is read to the end before the menu is built: a file below the part of it that
// has been read so far is in neither the list nor the view, and reaching the far end of
// a long diff is what the menu is for.
func (self *MainViewController) openJumpToFileMenu() error {
manager := self.c.GetViewBufferManagerForView(self.context.GetView())
if manager == nil {
return nil
}
manager.ReadToEnd(func() {
self.c.OnUIThread(self.showJumpToFileMenu)
})
return nil
}
// showJumpToFileMenu offers the diff's files by the paths git names them by. Each item
// names its file rather than the row that file begins at, so that a diff re-rendered
// while the menu is up is jumped into at the row the file begins at now.
//
// A menu offering the one file of a single-file diff would be a menu with nothing to
// choose, so it says what it found instead. It says it here rather than as the key's
// disabled reason because how many files there are is only known once the diff has been
// read to the end, which is too much to do for every keypress that asks whether a key
// applies.
func (self *MainViewController) showJumpToFileMenu() error {
files := self.c.Helpers().DiffLine.FilesInDiff(self.context.GetView())
if len(files) == 0 {
return nil
}
if len(files) == 1 {
self.c.ErrorToast(self.c.Tr.DisabledMenuItemPrefix + self.c.Tr.OnlyOneFileInDiff)
return nil
}
worktreePath := self.c.Git().RepoPaths.WorktreePath()
menuItems := lo.Map(files, func(path string, _ int) *types.MenuItem {
label := repoRelativePath(worktreePath, path)
if label == "" {
label = path
}
return &types.MenuItem{
Label: label,
OnPress: func() error { self.jumpToFile(path); return nil },
}
})
return self.c.Menu(types.CreateMenuOptions{
Title: self.c.Tr.JumpToFileInDiff,
Items: menuItems,
FilterAsYouType: true,
})
}
// jumpToFile goes to where the given file's diff begins, the way stepping to it with
// next-file would: the selection moves there, and the file goes to the top of the view
// when it wasn't on screen.
func (self *MainViewController) jumpToFile(path string) {
if target, ok := self.c.Helpers().DiffLine.StartOfFileInDiff(self.context.GetView(), path); ok {
self.placeNavigationTarget(target, true)
}
}
// 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 {
pr, ok := self.c.Helpers().Host.PullRequestForBranch(self.pullRequestBranch())
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)
}
commitHash := self.commitShownInDiff()
if commitHash == "" {
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)
return self.c.OS().OpenLink(githubPullRequestLineURL(pr.Url, commitHash, relativePath, info))
}
// pullRequestBranch returns the branch whose pull request would show the diff in this
// pane, as the panel beneath names it, and "" where no pull request shows it.
func (self *MainViewController) pullRequestBranch() string {
prContext, ok := self.sidePanelBeneath().(types.PullRequestDiffContext)
if !ok {
return ""
}
return prContext.BranchForPullRequest()
}
// commitShownInDiff returns the commit whose diff the pane is showing, and "" when the
// panel beneath has nothing selected. The diff's line numbers are the file's as of that
// commit, so they are the line numbers of the pull request's view of that commit too.
func (self *MainViewController) commitShownInDiff() string {
diffableContext, ok := self.sidePanelBeneath().(types.DiffableContext)
if !ok {
return ""
}
return diffableContext.RefForAdjustingLineNumberInDiff()
}
// githubPullRequestLineURL builds the URL of a line of a file, in the diff of one commit
// of a pull request. 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, commitHash 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, commitHash, anchor)
}
// 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
}