jesseduffield.lazygit/pkg/gui/controllers/main_view_controller.go
Stefan Haller 1fcbc4d5a5 Parse the f/h header records a conforming pager emits
The spec regained file-header (f) and hunk-header (h) records: f never
carries a line number, h always does (the first line of the hunk it
heads). Accept them in the OSC metadata backend, so a conforming pager's
header rows resolve to the same DiffLineFileHeader/DiffLineHunkHeader
identities the buffer parser already reports for raw diffs.

With header rows located, next/previous file navigation and the
jump-to-file menu land on a file's header row, and header rows become
usable scroll-restore anchors. The consumers need a few adjustments:

- File navigation used to reach a file's top by backing up over the
  untagged rows above its first located row. With tagged headers that
  overshoots onto the blank separator row above the file header, so drop
  the back-up (backUpOverHeader) and land on the first located row
  itself: the header for any conforming source, or the first content
  line under a pager that leaves its headers untagged — an accepted
  degradation for non-conforming pagers, now that the spec makes f/h
  mandatory.

- SamePatchLine now requires headers to match headers of the same kind.
  A hunk header shares its line number with the hunk's first content
  line (and a file header shares "0" with a deleted file's hunk header),
  so without this a position restore aiming at one could land on the
  other. This also applied to raw diffs before, but headers used to be
  unlikely restore targets; now that navigation deliberately lands on
  them, the ambiguity would bite.

- Editing a file-header row opens the file without jumping to a line,
  like pressing edit on a whole file in a side panel. (It used to open
  at line 1 for raw diffs, where headers resolved already.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00

1269 lines
50 KiB
Go

package controllers
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"path/filepath"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
"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
}
var _ types.IController = &MainViewController{}
func NewMainViewController(
c *ControllerCommon,
context *context.MainContext,
otherContext *context.MainContext,
) *MainViewController {
return &MainViewController{
baseController: baseController{},
c: c,
context: context,
otherContext: otherContext,
}
}
func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
// A selection is shown whenever the main view holds a diff (see
// sidePanelShowsDiff); we surface the bindings that act on it
// (enter to dive into staging, e to edit the selected line, G to open the
// line in the branch's pull request).
selectionShown := self.context.GetView().Highlight
// The commit and find-fixup-base commands act on the working tree, so — like the
// old staging panel, which was always the working-tree diff — we only surface them
// while the focused main view plays that role (see workingTreeAction).
stagingMode := self.diffMainViewType() == types.DiffMainViewTypeStaging
var enterDescription string
var editDescription string
var editTooltip string
var openPullRequestDescription string
var openPullRequestTooltip string
var jumpToFileDescription string
if selectionShown {
enterDescription = self.c.Tr.EnterStaging
editDescription = self.c.Tr.EditFile
editTooltip = self.c.Tr.EditFileTooltip
// TODO: i18n-ize these
openPullRequestDescription = "Open pull request for selected line"
openPullRequestTooltip = "Open a browser at the selected line in the diff of the current branch's pull request, so that you can comment on it. Only works for local branches that have a pull request on GitHub."
jumpToFileDescription = "Jump to file"
}
var commitDescription string
var commitTooltip string
var commitWithoutHookDescription string
var commitWithEditorDescription string
var findBaseCommitDescription string
var findBaseCommitTooltip string
if stagingMode {
commitDescription = self.c.Tr.Commit
commitTooltip = self.c.Tr.CommitTooltip
commitWithoutHookDescription = self.c.Tr.CommitChangesWithoutHook
commitWithEditorDescription = self.c.Tr.CommitChangesWithEditor
findBaseCommitDescription = self.c.Tr.FindBaseCommitForFixup
findBaseCommitTooltip = self.c.Tr.FindBaseCommitForFixupTooltip
}
return []*types.Binding{
{
Keys: opts.GetKeys(opts.Config.Universal.TogglePanel),
Handler: self.togglePanel,
Description: self.c.Tr.ToggleStagingView,
Tooltip: self.c.Tr.ToggleStagingViewTooltip,
DisplayOnScreen: true,
},
{
Keys: opts.GetKeys(opts.Config.Universal.Return),
Handler: self.escape,
Description: self.c.Tr.ExitFocusedMainView,
DisplayOnScreen: true,
},
{
Keys: opts.GetKeys(opts.Config.Universal.Select),
Handler: self.primaryAction,
Description: self.c.Tr.Stage,
Tooltip: self.c.Tr.StageSelectionTooltip,
DisplayOnScreen: selectionShown,
},
{
Keys: opts.GetKeys(opts.Config.Universal.Remove),
Handler: self.discardSelection,
GetDisabledReason: self.discardSelectionDisabledReason,
Description: self.c.Tr.DiscardSelection,
Tooltip: self.c.Tr.DiscardSelectionTooltip,
DisplayOnScreen: selectionShown,
},
{
Keys: opts.GetKeys(opts.Config.Universal.CopyToClipboard),
Handler: self.copySelection,
Description: self.c.Tr.CopySelectedTextToClipboard,
DisplayOnScreen: selectionShown,
},
{
Keys: opts.GetKeys(opts.Config.Universal.GoInto),
Handler: self.enter,
Description: enterDescription,
DisplayOnScreen: selectionShown,
},
{
Keys: opts.GetKeys(opts.Config.Universal.Edit),
Handler: self.editLine,
Description: editDescription,
Tooltip: editTooltip,
},
{
Keys: opts.GetKeys(opts.Config.Commits.OpenPullRequestInBrowser),
Handler: self.openPullRequestForSelectedLine,
Description: openPullRequestDescription,
Tooltip: openPullRequestTooltip,
},
{
Keys: opts.GetKeys(opts.Config.Files.CommitChanges),
Handler: self.workingTreeAction(self.c.Helpers().WorkingTree.HandleCommitPress),
Description: commitDescription,
Tooltip: commitTooltip,
},
{
Keys: opts.GetKeys(opts.Config.Files.CommitChangesWithoutHook),
Handler: self.workingTreeAction(self.c.Helpers().WorkingTree.HandleWIPCommitPress),
Description: commitWithoutHookDescription,
},
{
Keys: opts.GetKeys(opts.Config.Files.CommitChangesWithEditor),
Handler: self.workingTreeAction(self.c.Helpers().WorkingTree.HandleCommitEditorPress),
Description: commitWithEditorDescription,
},
{
Keys: opts.GetKeys(opts.Config.Files.FindBaseCommitForFixup),
Handler: self.workingTreeAction(self.c.Helpers().FixupHelper.HandleFindBaseCommitForFixupPress),
Description: findBaseCommitDescription,
Tooltip: findBaseCommitTooltip,
},
{
Keys: opts.GetKeys(opts.Config.Main.ToggleSelectHunk),
Handler: self.toggleSelectHunk,
Description: self.c.Tr.ToggleSelectHunk,
DescriptionFunc: func() string {
if self.sel().Mode == context.DiffSelectModeHunk {
return self.c.Tr.SelectLineByLine
}
return self.c.Tr.SelectHunk
},
Tooltip: self.c.Tr.ToggleSelectHunkTooltip,
DisplayOnScreen: selectionShown,
},
{
Keys: opts.GetKeys(opts.Config.Universal.ToggleRangeSelect),
Handler: self.toggleRangeSelect,
Description: self.c.Tr.ToggleRangeSelect,
},
{
Keys: opts.GetKeys(opts.Config.Main.PrevHunk),
Handler: self.prevChangeBlock,
Description: self.c.Tr.PrevHunk,
},
{
Keys: opts.GetKeys(opts.Config.Main.NextHunk),
Handler: self.nextChangeBlock,
Description: self.c.Tr.NextHunk,
},
{
Keys: opts.GetKeys(config.Keybinding{"N"}),
Handler: self.prevFile,
Description: self.c.Tr.PrevFile,
},
{
Keys: opts.GetKeys(config.Keybinding{"n"}),
Handler: self.nextFile,
Description: self.c.Tr.NextFile,
},
{
Keys: opts.GetKeys(config.Keybinding{"f"}),
Handler: self.openJumpToFileMenu,
Description: jumpToFileDescription,
},
{
// 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},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.RangeSelectDown), Handler: self.extendRangeDown, Description: self.c.Tr.RangeSelectDown},
{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(),
},
{
// Alt- or shift-click anywhere on a diff line opens it in the editor,
// without focusing the view or creating a selection. Two modifiers
// because no single one survives every terminal: Ghostty forwards Alt
// (and keeps shift for text selection), iTerm2 forwards only shift, and
// VS Code forwards both. Whichever the terminal delivers triggers the
// edit; the one it keeps for itself never arrives. No FocusedView, so it
// fires whatever has focus, and HandleWhenPopupPanelFocused so it stays
// live when a popup (e.g. the commit-message panel) is in front.
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
}
// Transient focus shifts (popups, search) leave HighlightInactive=true on our
// view (set by ContextMgr.Activate when a different view becomes current). Our
// context's highlightOnFocus is false, so SimpleContext.HandleFocus never
// resets it. Reset it here on the way back in, so that if we still hold a
// selection it's drawn as active. The flag is a no-op when Highlight is false.
func (self *MainViewController) GetOnFocus() func(types.OnFocusOpts) {
return func(types.OnFocusOpts) {
self.context.GetView().HighlightInactive = false
// The inclusion gutter is shown only while the focused main view holds focus, so
// (re-)establish it now (a no-op unless the panel beneath builds a custom patch).
self.c.Helpers().Staging.RefreshInclusionGutter()
}
}
// GetOnFocusLost re-evaluates the inclusion gutter as the focused main view loses focus.
// It's a focused-main-view affordance of the whole pair, so it must persist when tabbing
// between the two panes but hide when focus leaves the pair entirely. By the time this runs
// the new context is already current, so RefreshInclusionGutter decides correctly (and so
// the gutter doesn't flicker off-then-on across a pane switch).
func (self *MainViewController) GetOnFocusLost() func(types.OnFocusLostOpts) {
return func(types.OnFocusLostOpts) {
self.c.Helpers().Staging.RefreshInclusionGutter()
}
}
func (self *MainViewController) togglePanel() error {
if !self.otherContext.GetView().Visible {
return nil
}
// Capture diff-view-ness while our context is still the focused main view (so
// NextInStack finds the side panel beneath it), before pushing the other pane.
isDiff := self.isDiffView()
self.c.Context().Push(self.otherContext, types.OnFocusOpts{})
if isDiff {
showInitialDiffSelection(self.c, self.otherContext)
}
return nil
}
// showInitialDiffSelection turns on the focused main view's selection when entering a
// diff view by keyboard, without pointing at a specific line. See
// establishFocusedDiffSelection, which it defers to (clicks go through there too,
// passing the clicked line).
func showInitialDiffSelection(c *ControllerCommon, mainContext *context.MainContext) {
establishFocusedDiffSelection(c, mainContext, -1)
}
// establishFocusedDiffSelection turns on the focused main view's selection after the
// view is focused. Under a pager that doesn't speak the metadata protocol (probed, so
// known up front; see StagingHelper.DiffMainViewShouldRenderRaw) the diff on screen was
// rendered pretty for browsing and isn't resolvable, so it re-renders it raw and places
// the selection once that lands; otherwise it places the selection on the diff directly.
// clickedViewLine is the view line a click pointed at, or -1 for keyboard focus (start
// at the first change block).
func establishFocusedDiffSelection(c *ControllerCommon, mainContext *context.MainContext, clickedViewLine int) {
staging := c.Helpers().Staging
resetDiffSelectMode(mainContext)
if staging.DiffMainViewShouldRenderRaw() {
sidePanel := c.Context().NextInStack(mainContext)
staging.RenderFocusedMainViewRaw(mainContext.GetView(), sidePanel, func() {
placeOrHideInitialDiffSelection(c, mainContext, clickedViewLine, true)
})
return
}
placeOrHideInitialDiffSelection(c, mainContext, clickedViewLine, clickedViewLine < 0)
}
// placeOrHideInitialDiffSelection puts the focused main view's selection on the clicked
// line (clickedViewLine >= 0) or, for keyboard focus, on the first change line at or
// below the top of the viewport — so the view barely moves — falling back to the top
// line when none is visible. With hunk mode configured as the default the selection
// widens to the whole change block, like entering the staging view does: keyboard focus
// snaps to the block at the first visible change, and a click on a change line selects
// that line's block, ready to stage. A click on context still selects just that line —
// the click points at it precisely, so it can be edited (e) rather than staged.
// When the diff has nothing to act on (a placeholder, a binary file, an all-context
// diff) it shows no selection rather than highlighting a stray line.
func placeOrHideInitialDiffSelection(c *ControllerCommon, mainContext *context.MainContext, clickedViewLine int, scrollIntoView bool) {
view := mainContext.GetView()
if !c.Helpers().Staging.ViewHasChangeLines(view) {
view.Highlight = false
return
}
if clickedViewLine >= 0 {
// Remember where the click landed so a drag that follows anchors its range there,
// even when the click selects a whole hunk (whose anchor is the block's far end).
mainContext.SetDragAnchorViewLine(clickedViewLine)
if c.UserConfig().Gui.UseHunkModeInStagingView && c.Helpers().Staging.IsChangeLine(view, clickedViewLine) {
mainContext.DiffSelectState().Mode = context.DiffSelectModeHunk
selectDiffHunk(c, mainContext, clickedViewLine)
return
}
showSelectionAtLine(view, clickedViewLine, scrollIntoView)
return
}
target, ok := c.Helpers().Staging.FirstChangeLineInView(view)
if !ok {
showSelectionAtLine(view, view.OriginY(), true)
return
}
if c.UserConfig().Gui.UseHunkModeInStagingView {
mainContext.DiffSelectState().Mode = context.DiffSelectModeHunk
selectDiffHunk(c, mainContext, target)
return
}
showSelectionAtLine(view, target, true)
}
// updateFocusedMainViewSelectionVisibility shows or hides the focused-main-view selection
// to match what a side panel is rendering into the main view, called from the panel's
// render-to-main so the selection tracks content changes (a refresh after the last change
// is discarded, or changes vanishing / appearing outside lazygit). A selection is shown
// only on the main pane that currently holds focus, and only when it's rendering a diff
// (something to act on) — never over "No changed files" or a merge-conflict message.
// normalHasDiff/secondaryHasDiff say whether each pane is being given a diff; the caller
// knows this from which content it's about to render (the rendered content can't be read
// here, since the render it triggers is asynchronous). Initial keyboard/click focus is
// handled separately by showInitialDiffSelection, since focusing reuses the already-
// rendered content rather than re-rendering.
func updateFocusedMainViewSelectionVisibility(c *ControllerCommon, normalHasDiff bool, secondaryHasDiff bool) {
focusedKey := c.Context().CurrentStatic().GetKey()
normal := c.Contexts().Normal
secondary := c.Contexts().NormalSecondary
normal.GetView().Highlight = normalHasDiff && focusedKey == normal.GetKey()
secondary.GetView().Highlight = secondaryHasDiff && focusedKey == secondary.GetKey()
}
// resetDiffSelectMode returns the focused main view to its default select mode — a
// single line, no range — used whenever the selection is (re-)established from
// scratch (on focus, on a click). The view's range anchor is cleared too so the
// next render highlights only the cursor line.
func resetDiffSelectMode(mainContext *context.MainContext) {
sel := mainContext.DiffSelectState()
sel.Mode = context.DiffSelectModeLine
sel.RangeIsSticky = false
sel.UserEnabledHunkMode = false
mainContext.GetView().CancelRangeSelect()
}
func (self *MainViewController) escape() error {
self.c.Context().Pop()
return nil
}
// isDiffView reports whether the focused main view currently shows a diff (so we
// show a selection in it). See sidePanelShowsDiff.
func (self *MainViewController) isDiffView() bool {
return sidePanelShowsDiff(self.c.Context().NextInStack(self.context))
}
// focusedMainViewActions returns the actions the side panel beneath the focused main
// view offers on its diff (diving in, staging, patch toggling), or nil when there is no
// panel beneath or its diff offers none.
func (self *MainViewController) focusedMainViewActions() types.FocusedMainViewActions {
sidePanelContext := self.c.Context().NextInStack(self.context)
if sidePanelContext == nil {
return nil
}
return sidePanelContext.GetFocusedMainViewActions()
}
// diffMainViewType reports what the side panel beneath the focused main view makes its
// primary action mean — staging (the working tree), patch-building, or nothing — or
// DiffMainViewTypeNone when this pane isn't focused or has no diff panel beneath it. The
// IsInStack guard is essential: NextInStack panics for a context that isn't in the stack,
// and GetKeybindings (which calls this) runs for off-stack panes too — at startup and
// during cheatsheet generation, where the stack is empty.
func (self *MainViewController) diffMainViewType() types.DiffMainViewType {
if !self.c.Context().IsInStack(self.context) {
return types.DiffMainViewTypeNone
}
if diffContext, ok := self.c.Context().NextInStack(self.context).(types.DiffMainViewContext); ok {
return diffContext.GetDiffMainViewType()
}
return types.DiffMainViewTypeNone
}
// workingTreeAction wraps a working-tree command (commit, find-fixup-base) so it runs
// only while the focused main view shows the working-tree diff — the role the staging
// panel used to play, and the only place these commands mean anything. Over any other
// diff (a commit, a stash) the key is a no-op, so committing can't be triggered by
// accident while browsing history. Re-checks on each press rather than capturing the
// state at keybinding-registration time.
func (self *MainViewController) workingTreeAction(action func() error) func() error {
return func() error {
if self.diffMainViewType() != types.DiffMainViewTypeStaging {
return nil
}
return action()
}
}
// primaryAction acts on the selected diff line(s) — a single line, a range, or a hunk —
// delegating to the side panel beneath the focused main view, since what the action means
// is the panel's business: the working tree stages, while commits toggle the selection
// into a custom patch. The handler does its own re-render and re-establishes the selection
// afterwards (see revealSelectionAfterPrimaryAction), so the dispatcher just hands over
// the selected range. A no-op when the panel beneath offers no actions.
func (self *MainViewController) primaryAction() error {
actions := self.focusedMainViewActions()
if actions == nil {
return nil
}
v := self.context.GetView()
first, last := v.SelectedLineRange()
return actions.PrimaryAction(self.context.GetViewName(), first, last)
}
// discardSelection discards the selected diff line(s), delegating to the panel beneath
// (working-tree discard for files, line-removal from the commit for the commit panels).
// As with the primary action, the handler does its own re-render. A no-op when the panel
// beneath offers no actions; discardSelectionDisabledReason gates the cases where the
// panel has discard but it isn't applicable right now.
func (self *MainViewController) discardSelection() error {
actions := self.focusedMainViewActions()
if actions == nil {
return nil
}
v := self.context.GetView()
first, last := v.SelectedLineRange()
return actions.DiscardSelection(self.context.GetViewName(), first, last)
}
func (self *MainViewController) discardSelectionDisabledReason() *types.DisabledReason {
actions := self.focusedMainViewActions()
if actions == nil {
return nil
}
return actions.DiscardSelectionDisabledReason(self.context.GetViewName())
}
// copySelection copies the selected diff line(s) to the clipboard. Unlike the primary
// action and discard, this is the same for every diff panel (it just reads the text the
// focused main view shows), so it lives here rather than on FocusedMainViewActions — and
// so it works over panels with no actions, like the reflog. A no-op when there's no
// selection (non-diff content).
//
// With no pager the main view shows the raw diff, so the +/-/space column is stripped
// from a homogeneous selection (dropDiffPrefix) to ease pasting into code, mirroring the
// staging view's copy. With a pager configured we can't assume that column is present —
// some pagers keep it and only colorize (e.g. ydiff), others drop or restructure it (e.g.
// delta) — and we can't tell which, so we conservatively don't strip and copy verbatim.
// (The cost is missing the convenience for column-preserving pagers.)
func (self *MainViewController) copySelection() error {
v := self.context.GetView()
if !v.Highlight {
return nil
}
// The selection is in (wrapped) view-line space; map it to the unwrapped buffer lines
// it covers and copy those, so a wrapped line is copied whole and once — not indexed
// into the buffer by a view-line number (which copies the wrong line, or panics when
// the view line is past the buffer's length).
firstView, lastView := v.SelectedLineRange()
firstBuffer, ok := v.BufferLineForViewLine(firstView)
if !ok {
return nil
}
lastBuffer, ok := v.BufferLineForViewLine(lastView)
if !ok {
return nil
}
contents := v.DiffLineContents()
lastBuffer = min(lastBuffer, len(contents)-1)
if firstBuffer > lastBuffer {
return nil
}
lines := make([]string, 0, lastBuffer-firstBuffer+1)
for i := firstBuffer; i <= lastBuffer; i++ {
lines = append(lines, contents[i].Text)
}
// Trailing newline included so the last line is terminated too (dropDiffPrefix keeps
// it; the pager-verbatim path needs it added here).
selected := strings.Join(lines, "\n") + "\n"
if !self.usingExternalDiff() {
selected = dropDiffPrefix(selected)
}
self.c.LogAction(self.c.Tr.Actions.CopySelectedTextToClipboard)
return self.c.OS().CopyToClipboard(selected)
}
// usingExternalDiff reports whether the focused main view's diff is produced by an
// external diff command (a pager), in which case the rendered lines may not carry the raw
// +/-/space column (see copySelection).
func (self *MainViewController) usingExternalDiff() bool {
return self.c.State().GetDiffRendererConfigManager().GetDiffRendererType() == config.DiffRendererType_RawGit
}
// revealSelectionAfterPrimaryAction re-establishes the focused-main-view selection after a
// primary action (staging or a patch toggle) re-renders the diff. The selection's
// change-line ordinal is read from the source pane (still showing the pre-action diff
// until the queued re-render) and re-applied once the target pane re-renders — so the
// selection lands on the change nearest the one acted on rather than at a stale position.
// sourceViewName and targetViewName are usually the same pane, but staging can move the
// acted-on side to the other pane (passing that pane as the target). The target inherits
// the (collapsed) select mode; a range collapses back to a single line, hunk mode stays
// on to land on the next hunk. advanceBy is forwarded to RevealSelectionAfterStaging: 0 for
// staging/removal (the acted-on lines are consumed), the toggled change-line count for a
// custom-patch toggle (which leaves the diff intact, so the selection must be advanced past
// the just-toggled lines to land on the next stageable hunk).
func revealSelectionAfterPrimaryAction(c *ControllerCommon, sourceViewName string, targetViewName string, firstLineIdx int, advanceBy int) {
sourceContext := mainContextForViewName(c, sourceViewName)
targetContext := mainContextForViewName(c, targetViewName)
sel := sourceContext.DiffSelectState()
if sel.Mode == context.DiffSelectModeRange {
sel.Mode = context.DiffSelectModeLine
sel.RangeIsSticky = false
}
*targetContext.DiffSelectState() = *sel
mode := sel.Mode
sourceView := sourceContext.GetView()
targetView := targetContext.GetView()
c.Helpers().Staging.RevealSelectionAfterStaging(sourceView, targetView, firstLineIdx, advanceBy, func(viewLine int) {
if mode == context.DiffSelectModeHunk {
selectDiffHunk(c, targetContext, viewLine)
} else {
targetView.CancelRangeSelect()
showSelectionAtLine(targetView, viewLine, true)
}
})
}
// preserveFocusedMainViewSelectionAcrossContentChange keeps the focused main view's
// selection sensible when its diff is about to re-render with *different* content — e.g. a
// commit's diff shrinking after the custom patch built from it is moved into the index, or
// the whole diff changing after an undo. Those mutations don't run through the
// focused-main-view action handlers (which install their own reveal; see
// revealSelectionAfterPrimaryAction), so without this the stale selection — often a large,
// now-meaningless range — would be left painted over the new content.
//
// It is the command-agnostic counterpart of revealSelectionAfterPrimaryAction: rather than
// teaching every mutating command to capture the selection, the focused main view preserves
// it itself, by its change-line ordinal, as the diff re-renders. Call it from a diff side
// panel's render-to-main with the task about to render into the main pane, before
// triggering the render so the restore rides it.
//
// It stands down — leaving the selection exactly as is — unless all of these hold, so it
// neither fights a more precise restore nor disturbs the selection needlessly:
// - the focused main view is the current context and shows a selection;
// - no precise restore is already pending (escape / post-stage reveal / context-size),
// which places the selection more precisely than an ordinal can;
// - the diff command is changing. A plain refresh re-renders the *same* commit's diff,
// identical content under an identical command, and there the selection — range and
// all — must be left alone; only a command change (e.g. a rebase rewriting the commit's
// hash) means the content, and so the old selection's position, actually moved.
func preserveFocusedMainViewSelectionAcrossContentChange(c *ControllerCommon, mainTask types.UpdateTask) {
mainContext := c.Contexts().Normal
if c.Context().Current() != mainContext {
return
}
view := mainContext.GetView()
if !view.Highlight {
return
}
manager := c.GetViewBufferManagerForView(view)
if manager == nil || manager.GetRestoreForNextTask() != nil {
return
}
newKey, ok := diffTaskCommandKey(mainTask)
if !ok || newKey == manager.GetTaskKey() {
return
}
// Anchor on the selection's first line, matching every revealSelectionAfterPrimaryAction
// caller, so a range collapses to its top and the ordinal lands on the nearest surviving
// change there.
first, _ := view.SelectedLineRange()
revealSelectionAfterPrimaryAction(c, mainContext.GetViewName(), mainContext.GetViewName(), first, 0)
}
// diffTaskCommandKey returns the buffer-manager task key a diff render task will register
// under — the joined command line, as newCmdTask/newPtyTask derive it — or false for a
// non-command task (a placeholder string), which has no command to compare.
func diffTaskCommandKey(task types.UpdateTask) (string, bool) {
switch t := task.(type) {
case *types.RunCommandTask:
return strings.Join(t.Cmd.Args, " "), true
case *types.RunPtyTask:
return strings.Join(t.Cmd.Args, " "), true
}
return "", false
}
func (self *MainViewController) enter() error {
if !self.context.GetView().Highlight {
return nil
}
return self.enterForLine(self.context.GetView().SelectedLineIdx())
}
// enterForLine dives into staging/patch-building for the given line, by
// delegating to the side panel beneath the focused main view (the same action
// taken when clicking).
func (self *MainViewController) enterForLine(lineIdx int) error {
if actions := self.focusedMainViewActions(); actions != nil {
return actions.OnClick(self.context.GetViewName(), lineIdx)
}
return nil
}
// showSelectionAtLine turns on the focused main view's selection and moves it to
// the given view line, clamped to the content. scrollIntoView scrolls the line into
// view if it's off-screen (used when navigating to it); a click leaves it false, the
// clicked line being visible already.
func showSelectionAtLine(view *gocui.View, lineIdx int, scrollIntoView bool) {
view.Highlight = true
view.HighlightInactive = false
lineIdx = lo.Clamp(lineIdx, 0, view.ViewLinesHeight()-1)
view.FocusPoint(0, lineIdx, scrollIntoView)
}
// sel returns our pane's diff selection mode state.
func (self *MainViewController) sel() *context.DiffSelectState {
return self.context.DiffSelectState()
}
// navigate jumps the focused main view by file or change block (hunk), using find to
// locate the target row from the current anchor. The anchor is the selected line if a
// selection is showing, otherwise the top visible line. With a selection showing we
// move it to the target and scroll it into view, like the staging 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.
func (self *MainViewController) navigate(find func(*gocui.View, int, bool) (int, bool), forward bool) {
v := self.context.GetView()
if !v.Highlight {
if target, ok := find(v, v.OriginY(), forward); ok {
v.SetOrigin(0, target)
}
return
}
target, ok := find(v, v.SelectedLineIdx(), forward)
if !ok {
return
}
if self.sel().Mode == context.DiffSelectModeHunk {
self.selectHunkAround(target)
} else {
// Line mode leaves a single-line selection at the target; an active range
// extends to it, since the anchor is untouched.
showSelectionAtLine(v, target, true)
}
}
func (self *MainViewController) nextChangeBlock() error {
self.navigate(self.c.Helpers().Staging.AdjacentChangeBlock, true)
return nil
}
func (self *MainViewController) prevChangeBlock() error {
self.navigate(self.c.Helpers().Staging.AdjacentChangeBlock, false)
return nil
}
func (self *MainViewController) nextFile() error {
self.navigate(self.c.Helpers().Staging.AdjacentFile, true)
return nil
}
func (self *MainViewController) prevFile() error {
self.navigate(self.c.Helpers().Staging.AdjacentFile, false)
return nil
}
// openJumpToFileMenu pops up a menu listing the files in the focused main view's diff, in
// the order they appear, as repo-relative paths; picking one jumps straight to it. It's a
// complement to n / N for a diff that spans many files. A no-op when the main view holds
// no diff (nothing to list).
//
// The diff loads lazily, so we read it to the end first — otherwise a file past the loaded
// portion of a long diff would be missing from the menu (and have no view line to jump to).
// Same as handleGotoBottom.
func (self *MainViewController) openJumpToFileMenu() error {
manager := self.c.GetViewBufferManagerForView(self.context.GetView())
if manager == nil {
return nil
}
manager.ReadToEnd(func() {
self.c.OnUIThread(func() error {
return self.showJumpToFileMenu()
})
})
return nil
}
func (self *MainViewController) showJumpToFileMenu() error {
files := self.c.Helpers().Staging.FilesInDiff(self.context.GetView())
if len(files) == 0 {
return nil
}
worktreePath := self.c.Git().RepoPaths.WorktreePath()
menuItems := lo.Map(files, func(file helpers.DiffFile, index int) *types.MenuItem {
label := file.Path
if rel, err := filepath.Rel(worktreePath, file.Path); err == nil {
label = rel
}
var keys []gocui.Key
if index < 9 {
keys = menuKey(rune(index + 1 + '0')) // Convert 1-based index to key
}
firstViewLine := file.FirstViewLine
return &types.MenuItem{
Label: label,
Keys: keys,
OnPress: func() error { self.jumpToFile(firstViewLine); return nil },
}
})
// TODO: i18n-ize this title
return self.c.Menu(types.CreateMenuOptions{Title: "Jump to file", Items: menuItems, HideCancel: true})
}
// jumpToFile moves the focused main view to the given view line exactly the way
// next/previous-file navigation does (see navigate), so jumping from the menu and
// stepping with n / N land identically — scrolling the file to the top with no selection,
// or moving the selection (or hunk selection) to it when one is showing.
func (self *MainViewController) jumpToFile(firstViewLine int) {
self.navigate(func(*gocui.View, int, bool) (int, bool) {
return firstViewLine, true
}, true)
}
// selectHunkAround re-selects the whole change block around the given change line,
// for hunk mode: the cursor goes to the block's first line and the range anchor to
// its last, so the native range highlight spans the block. With no change block
// there (the line is trailing context) it falls back to a single-line selection.
func (self *MainViewController) selectHunkAround(changeViewLine int) {
selectDiffHunk(self.c, self.context, changeViewLine)
}
// selectDiffHunk is the body of selectHunkAround, as a free function so the focus
// entry point (showInitialDiffSelection) can establish a hunk selection too.
func selectDiffHunk(c *ControllerCommon, mainContext *context.MainContext, changeViewLine int) {
view := mainContext.GetView()
start, end, ok := c.Helpers().Staging.ChangeBlockBounds(view, changeViewLine)
if !ok {
mainContext.DiffSelectState().Mode = context.DiffSelectModeLine
view.CancelRangeSelect()
showSelectionAtLine(view, changeViewLine, true)
return
}
view.SetRangeSelectStart(end)
showSelectionAtLine(view, start, 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 in a range this extends/contracts it; in
// line mode it just moves the selected line.
func (self *MainViewController) moveCursor(delta int) {
v := self.context.GetView()
if delta > 0 {
if manager := self.c.GetViewBufferManagerForView(v); manager != nil {
manager.ReadLines(delta)
}
}
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)
}
v.FocusPoint(0, after, true)
}
// scroll moves the view by delta lines without a selection, for non-diff main
// content where there's nothing to select.
func (self *MainViewController) scroll(delta int) {
v := self.context.GetView()
if delta > 0 {
if manager := self.c.GetViewBufferManagerForView(v); manager != nil {
manager.ReadLines(delta)
}
v.ScrollDown(delta)
} else {
v.ScrollUp(-delta)
}
}
// 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.sel()
if sel.Mode == context.DiffSelectModeHunk ||
(sel.Mode == context.DiffSelectModeRange && !sel.RangeIsSticky) {
sel.Mode = context.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 (delta ±1) jumps to the adjacent hunk; a
// larger page step drops out of hunk mode first, like the staging view. 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) {
v := self.context.GetView()
if !v.Highlight {
self.scroll(delta)
return
}
if self.sel().Mode == context.DiffSelectModeHunk && (delta == 1 || delta == -1) {
self.navigate(self.c.Helpers().Staging.AdjacentChangeBlock, delta > 0)
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()
v := self.context.GetView()
v.FocusPoint(0, lo.Clamp(target, 0, v.ViewLinesHeight()-1), 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 (a non-sticky
// range that has actually been extended).
func (self *MainViewController) selectingRange() bool {
if self.sel().Mode != context.DiffSelectModeRange {
return false
}
start, end := self.context.GetView().SelectedLineRange()
return self.sel().RangeIsSticky || start != end
}
// toggleSelectHunk switches between selecting the change block (hunk) around the
// cursor and a single line, mirroring the staging view's `a`.
func (self *MainViewController) toggleSelectHunk() error {
v := self.context.GetView()
if !v.Highlight {
return nil
}
sel := self.sel()
if sel.Mode == context.DiffSelectModeHunk {
sel.Mode = context.DiffSelectModeLine
v.CancelRangeSelect()
} else {
sel.Mode = context.DiffSelectModeHunk
sel.UserEnabledHunkMode = true
self.selectHunkAround(v.SelectedLineIdx())
}
return nil
}
// toggleRangeSelect starts or cancels a sticky range selection (extended by plain
// up/down), mirroring the staging view's `v`.
func (self *MainViewController) toggleRangeSelect() error {
v := self.context.GetView()
if !v.Highlight {
return nil
}
sel := self.sel()
if self.selectingRange() {
sel.Mode = context.DiffSelectModeLine
sel.RangeIsSticky = false
v.CancelRangeSelect()
} else {
sel.Mode = context.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. Mirrors the
// staging view's range-select keys.
func (self *MainViewController) extendRange(forward bool) error {
v := self.context.GetView()
if !v.Highlight {
return nil
}
sel := self.sel()
if !self.selectingRange() {
sel.Mode = context.DiffSelectModeRange
v.SetRangeSelectStart(v.SelectedLineIdx())
}
sel.RangeIsSticky = false
delta := 1
if !forward {
delta = -1
}
self.moveCursor(delta)
return nil
}
func (self *MainViewController) extendRangeUp() error {
return self.extendRange(false)
}
func (self *MainViewController) extendRangeDown() error {
return self.extendRange(true)
}
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.scroll(-v.ViewLinesHeight())
return nil
}
self.selectAbsoluteLine(0)
return nil
}
func (self *MainViewController) handleGotoBottom() error {
manager := self.c.GetViewBufferManagerForView(self.context.GetView())
if manager == nil {
return nil
}
manager.ReadToEnd(func() {
self.c.OnUIThread(func() error {
v := self.context.GetView()
if !v.Highlight {
self.scroll(v.ViewLinesHeight())
return nil
}
self.selectAbsoluteLine(v.ViewLinesHeight() - 1)
return nil
})
})
return nil
}
// sidePanelShowsDiff reports whether the given side panel's focused main view
// shows a diff, which is when we show a selection in it (so the user can stage a
// line, edit it, jump by hunk/file, or open it in a PR). Panels whose main view
// shows non-diff content (a branch's commit log, the status dashboard, …) show no
// selection because there's nothing to act on. See types.DiffMainViewContext.
func sidePanelShowsDiff(sidePanel types.Context) bool {
_, ok := sidePanel.(types.DiffMainViewContext)
return ok
}
// mainContextForViewName maps a focused main view's view name (as passed to the
// side-panel handlers) to its main context — the secondary pane for the secondary
// view name, the primary pane otherwise.
func mainContextForViewName(c *ControllerCommon, viewName string) *context.MainContext {
if viewName == c.Contexts().NormalSecondary.GetViewName() {
return c.Contexts().NormalSecondary
}
return c.Contexts().Normal
}
// focusedMainViewContextForViewName is mainContextForViewName as a types.Context, for
// callers that only need the interface.
func focusedMainViewContextForViewName(c *ControllerCommon, viewName string) types.Context {
return mainContextForViewName(c, viewName)
}
// focusedMainViewSnapshot records the focused main view to return to when diving
// into a patch explorer from it, so escaping can come back with the main view
// focused. sidePanel is the panel to land on first (which re-renders the
// content); for commits/stash it's the originating panel, skipping the commit
// files panel we pass through. Where to scroll to and select on return isn't
// captured: escape lands on the line the explorer ended up on (see
// EscapeFromPatchExplorer). Call this before any mutation that might re-render
// the main view.
func focusedMainViewSnapshot(c *ControllerCommon, mainViewName string, sidePanel types.Context) *types.FocusedMainViewSnapshot {
mainView := focusedMainViewContextForViewName(c, mainViewName)
sidePanelSelectedLineIdx := -1
if listContext, ok := sidePanel.(types.IListContext); ok {
sidePanelSelectedLineIdx = listContext.GetList().GetSelectedLineIdx()
}
return &types.FocusedMainViewSnapshot{
SidePanel: sidePanel,
SidePanelSelectedLineIdx: sidePanelSelectedLineIdx,
MainView: mainView,
}
}
func (self *MainViewController) editLine() error {
if !self.context.GetView().Highlight {
return nil
}
return self.editDiffLine(self.context.GetView().SelectedLineIdx())
}
// editDiffLine opens the file the given diff line belongs to in the editor, at
// that line. The file and line are resolved the same way entering staging does.
// A file-header row points at the file as a whole, not at a line in it, so it
// opens the file without jumping anywhere — the same behavior as pressing edit
// on a file in a side panel.
func (self *MainViewController) editDiffLine(viewLineIdx int) error {
info, ok := self.c.Helpers().Staging.GetDiffLineInfo(self.context.GetViewName(), viewLineIdx)
if !ok {
return nil
}
if info.Type == types.DiffLineFileHeader {
return self.c.Helpers().Files.EditFiles([]string{info.Path})
}
lineNumber := self.c.Helpers().Diff.AdjustLineNumber(info.Path, info.NewLine, self.context.GetViewName())
return self.c.Helpers().Files.EditFileAtLine(info.Path, lineNumber)
}
func (self *MainViewController) openPullRequestForSelectedLine() error {
if !self.context.GetView().Highlight {
return nil
}
sidePanelContext := self.c.Context().NextInStack(self.context)
if sidePanelContext == nil {
return nil
}
// The branch whose PR to open depends on where we navigated from: the
// checked-out branch when looking at its own commits, but the branch we
// drilled into when in the sub-commits or commit-files panels.
branchName, ok := self.branchForPullRequest(sidePanelContext)
if !ok {
return nil
}
pr, ok := self.c.Model().PullRequestsMap[branchName]
if !ok {
return errors.New(self.c.Tr.NoPullRequestForBranch)
}
// The diff shown is the diff of a particular commit, so we deep-link into
// that commit's view of the PR; its right-side line numbers match what we're
// showing, so (unlike editLine) no line-number adjustment is needed.
diffableContext, ok := sidePanelContext.(types.DiffableContext)
if !ok {
return nil
}
commitSha := diffableContext.RefForAdjustingLineNumberInDiff()
if commitSha == "" {
return nil
}
// Figure out the clicked file and line the same way entering staging does.
info, ok := self.c.Helpers().Staging.GetDiffLineInfo(
self.context.GetViewName(), self.context.GetView().SelectedLineIdx())
if !ok {
return nil
}
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), info.Path)
if err != nil {
return err
}
// A deletion isn't on the right (new) side of the diff, so anchor it on the
// left (old) side; everything else on the right.
side, lineNumber := info.PullRequestAnchor()
self.c.LogAction(self.c.Tr.Actions.OpenPullRequest)
return self.c.OS().OpenLink(
githubPullRequestLineURL(pr.Url, commitSha, filepath.ToSlash(relativePath), side, lineNumber))
}
// branchForPullRequest returns the local branch whose pull request applies to
// the diff currently shown in the focused main view, given the side panel
// beneath it. It returns false for contexts that don't map to a local branch
// (e.g. the working-tree files panel, stashes, tags, or remote branches).
func (self *MainViewController) branchForPullRequest(sidePanelContext types.Context) (string, bool) {
switch sidePanelContext.GetKey() {
case context.LOCAL_COMMITS_CONTEXT_KEY:
return self.c.Model().CheckedOutBranch, true
case context.SUB_COMMITS_CONTEXT_KEY:
ref := self.c.Contexts().SubCommits.GetRef()
if ref == nil {
return "", false
}
return ref.RefName(), true
case context.COMMIT_FILES_CONTEXT_KEY:
// The commit files panel doesn't itself know which branch it belongs to;
// that's determined by the panel we entered it from.
parent := self.c.Contexts().CommitFiles.GetParentContext()
if parent == nil {
return "", false
}
return self.branchForPullRequest(parent)
default:
return "", false
}
}
// githubPullRequestLineURL builds a URL that opens the given line of a file in
// the diff of a specific commit within a GitHub pull request. The file is
// identified by the SHA-256 of its repo-relative path, and side ("R"/"L")
// selects the right (new) or left (old) side of the diff. See
// https://github.com/orgs/community/discussions/55764.
func githubPullRequestLineURL(prURL string, commitSha string, relativePath string, side string, lineNumber int) string {
pathHash := sha256.Sum256([]byte(relativePath))
anchor := fmt.Sprintf("diff-%s%s%d", hex.EncodeToString(pathHash[:]), side, lineNumber)
return fmt.Sprintf("%s/changes/%s#%s", prURL, commitSha, anchor)
}
func (self *MainViewController) onClickInAlreadyFocusedView(opts gocui.ViewMouseBindingOpts) error {
return self.selectClickedDiffLine(opts)
}
func (self *MainViewController) editClickedLine(opts gocui.ViewMouseBindingOpts) error {
return self.editDiffLine(opts.Y)
}
func (self *MainViewController) onClickInOtherViewOfMainViewPair(opts gocui.ViewMouseBindingOpts) error {
// Carry the select mode over from the pane we're leaving, so clicking into the
// other pane keeps hunk mode even the first time we enter it — without this its
// mode would still be the default single line until it had been focused at least
// once (e.g. by tabbing to it). selectClickedDiffLine then preserves or collapses
// that mode depending on where we clicked.
*self.context.DiffSelectState() = *self.otherContext.DiffSelectState()
self.c.Context().Push(self.context, types.OnFocusOpts{})
return self.selectClickedDiffLine(opts)
}
// selectClickedDiffLine sets the focused main view's selection from a click at view
// line opts.Y. In hunk mode a click on a change line keeps hunk mode and selects that
// whole block, so clicking from hunk to hunk stays ready to stage; a click on context
// drops to a single line, as does any click when we weren't in hunk mode — the click
// points at the line precisely (e.g. to edit it). A double-click additionally dives
// into staging/patch-building for that line.
func (self *MainViewController) selectClickedDiffLine(opts gocui.ViewMouseBindingOpts) error {
if !self.isDiffView() {
return nil
}
view := self.context.GetView()
// Remember where the click landed so a drag that follows anchors its range there,
// even when this click selects a whole hunk (whose anchor is the block's far end).
self.context.SetDragAnchorViewLine(opts.Y)
if self.sel().Mode == context.DiffSelectModeHunk && self.c.Helpers().Staging.IsChangeLine(view, opts.Y) {
self.selectHunkAround(opts.Y)
} else {
resetDiffSelectMode(self.context)
showSelectionAtLine(view, opts.Y, false)
}
if opts.IsDoubleClick {
return self.enterForLine(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 (DragAnchorViewLine) — not wherever the click
// left the selection, since 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. A no-op when the
// view holds no selectable diff.
func (self *MainViewController) onDragInFocusedView(gocui.ViewMouseBindingOpts) error {
view := self.context.GetView()
if !self.isDiffView() || !view.Highlight {
return nil
}
sel := self.sel()
sel.Mode = context.DiffSelectModeRange
sel.RangeIsSticky = false
sel.UserEnabledHunkMode = false
view.SetRangeSelectStart(self.context.DragAnchorViewLine())
return nil
}
func (self *MainViewController) openSearch() error {
if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil {
manager.ReadToEnd(func() {
self.c.OnUIThread(func() error {
return self.c.Helpers().Search.OpenSearchPrompt(self.context)
})
})
}
return nil
}