mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 15:46:26 -04:00
The reveal advanced the selection by capturing the next/previous change as a patch identity and finding it again after the re-render. No line-number identity is stable and unique across a stage, though: a deletion shares its new-file number with the rest of its block (so staging one deletion of a block landed back on the block's first line), and in the staged pane the new-file number is the index, which staging/unstaging shifts (so unstaging a modification's deletion missed its replacement line and fell back to an earlier block). The previous worktree-line and adjacent-change-line matching each fixed one case and left another. Do instead what the staging view does: preserve the selection's ordinal among change lines. Read the acted-on line's ordinal from the pane it was in before the op; after the re-render, select the change line at that ordinal in the target pane (clamped to the last). Since the op removes the acted-on change line(s), that ordinal then holds the next surviving change — the next line of the same block, or the next block when a whole block was staged. This needs no reasoning about which side's line numbers are stable, so it handles deletions and the staged pane uniformly. The matching machinery (matchByWorktreeChange, AdjacentChangeLine) is gone; the identity-matched restore (escape, -U preserve) and the new positional restore now share installDiffLineRestore. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
836 lines
30 KiB
Go
836 lines
30 KiB
Go
package controllers
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
|
|
"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/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
|
|
|
|
var enterDescription string
|
|
var editDescription string
|
|
var editTooltip string
|
|
var openPullRequestDescription string
|
|
var openPullRequestTooltip string
|
|
if selectionShown {
|
|
enterDescription = self.c.Tr.EnterStaging
|
|
editDescription = self.c.Tr.EditFile
|
|
editTooltip = self.c.Tr.EditFileTooltip
|
|
// TODO: i18n-ize these
|
|
openPullRequestDescription = "Open pull request for selected line"
|
|
openPullRequestTooltip = "Open a browser at the selected line in the diff of the current branch's pull request, so that you can comment on it. Only works for local branches that have a pull request on GitHub."
|
|
}
|
|
|
|
return []*types.Binding{
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Universal.TogglePanel),
|
|
Handler: self.togglePanel,
|
|
Description: self.c.Tr.ToggleStagingView,
|
|
Tooltip: self.c.Tr.ToggleStagingViewTooltip,
|
|
DisplayOnScreen: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Universal.Return),
|
|
Handler: self.escape,
|
|
Description: self.c.Tr.ExitFocusedMainView,
|
|
DisplayOnScreen: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Universal.Select),
|
|
Handler: self.stageSelectedLine,
|
|
Description: self.c.Tr.Stage,
|
|
Tooltip: self.c.Tr.StageSelectionTooltip,
|
|
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.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,
|
|
},
|
|
{
|
|
// 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(),
|
|
},
|
|
{
|
|
// 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
|
|
}
|
|
}
|
|
|
|
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 without pointing at a specific line, anchored on the first change line
|
|
// already visible (so the view barely moves), falling back to the current top line
|
|
// when none is visible (scrolled into trailing context, or not loaded that far yet).
|
|
// With hunk mode configured as the default it selects the whole change block around
|
|
// that line, like entering the staging view does; otherwise a single line.
|
|
func showInitialDiffSelection(c *ControllerCommon, mainContext *context.MainContext) {
|
|
resetDiffSelectMode(mainContext)
|
|
view := mainContext.GetView()
|
|
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)
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
|
|
// stageSelectedLine stages (or unstages) 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 "stage" means is the panel's business (the working tree stages; later,
|
|
// commits add to a custom patch). Panels whose diff isn't stageable register no
|
|
// handler, so this is a no-op there.
|
|
func (self *MainViewController) stageSelectedLine() error {
|
|
sidePanelContext := self.c.Context().NextInStack(self.context)
|
|
if sidePanelContext == nil {
|
|
return nil
|
|
}
|
|
handler := sidePanelContext.GetOnStageFocusedMainView()
|
|
if handler == nil {
|
|
return nil
|
|
}
|
|
|
|
v := self.context.GetView()
|
|
first, last := v.SelectedLineRange()
|
|
|
|
// Staging consumes the selected range, so a range selection collapses back to a
|
|
// single line; hunk mode stays on, to land on the next hunk.
|
|
sel := self.sel()
|
|
if sel.Mode == context.DiffSelectModeRange {
|
|
sel.Mode = context.DiffSelectModeLine
|
|
sel.RangeIsSticky = false
|
|
}
|
|
|
|
// Staging updates the model synchronously and queues the main-view re-render, and
|
|
// reports which pane should hold focus afterwards (staging/unstaging can move the
|
|
// acted-on side to the other pane). "" means nothing was staged.
|
|
focusViewName, err := handler(self.context.GetViewName(), first, last)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if focusViewName == "" {
|
|
return nil
|
|
}
|
|
|
|
// Re-select in whichever pane now holds the acted-on side, and focus it. The
|
|
// acted-on line's change-line ordinal is read from the pane we acted in (its
|
|
// content is still the pre-staging diff until the queued re-render) and re-applied
|
|
// in the target pane's re-render. The target inherits our select mode (line/hunk).
|
|
targetContext := self.context
|
|
if focusViewName == self.otherContext.GetViewName() {
|
|
targetContext = self.otherContext
|
|
}
|
|
*targetContext.DiffSelectState() = *sel
|
|
targetView := targetContext.GetView()
|
|
|
|
self.c.Helpers().Staging.RevealSelectionAfterStaging(v, targetView, first, func(viewLine int) {
|
|
if sel.Mode == context.DiffSelectModeHunk {
|
|
selectDiffHunk(self.c, targetContext, viewLine)
|
|
} else {
|
|
targetView.CancelRangeSelect()
|
|
showSelectionAtLine(targetView, viewLine, true)
|
|
}
|
|
})
|
|
|
|
if targetContext != self.context {
|
|
self.c.Context().Push(targetContext, types.OnFocusOpts{})
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *MainViewController) enter() error {
|
|
if !self.context.GetView().Highlight {
|
|
return nil
|
|
}
|
|
return self.enterForLine(self.context.GetView().SelectedLineIdx())
|
|
}
|
|
|
|
// enterForLine dives into staging/patch-building for the given line, by
|
|
// delegating to the side panel beneath the focused main view (the same handler
|
|
// used when clicking).
|
|
func (self *MainViewController) enterForLine(lineIdx int) error {
|
|
sidePanelContext := self.c.Context().NextInStack(self.context)
|
|
if sidePanelContext != nil && sidePanelContext.GetOnClickFocusedMainView() != nil {
|
|
return sidePanelContext.GetOnClickFocusedMainView()(self.context.GetViewName(), lineIdx)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// showSelectionAtLine turns on the focused main view's selection and moves it to
|
|
// the given view line, clamped to the content. 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// focusedMainViewContextForViewName maps a focused main view's view name (as
|
|
// passed to GetOnClickFocusedMainView) to its context.
|
|
func focusedMainViewContextForViewName(c *ControllerCommon, viewName string) types.Context {
|
|
if viewName == c.Contexts().NormalSecondary.GetViewName() {
|
|
return c.Contexts().NormalSecondary
|
|
}
|
|
return c.Contexts().Normal
|
|
}
|
|
|
|
// focusedMainViewSnapshot 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.
|
|
func (self *MainViewController) editDiffLine(viewLineIdx int) error {
|
|
info, ok := self.c.Helpers().Staging.GetDiffLineInfo(self.context.GetViewName(), viewLineIdx)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
lineNumber := self.c.Helpers().Diff.AdjustLineNumber(info.Path, info.NewLine, self.context.GetViewName())
|
|
return self.c.Helpers().Files.EditFileAtLine(info.Path, lineNumber)
|
|
}
|
|
|
|
func (self *MainViewController) openPullRequestForSelectedLine() error {
|
|
if !self.context.GetView().Highlight {
|
|
return nil
|
|
}
|
|
|
|
sidePanelContext := self.c.Context().NextInStack(self.context)
|
|
if sidePanelContext == nil {
|
|
return nil
|
|
}
|
|
|
|
// The branch whose PR to open depends on where we navigated from: the
|
|
// checked-out branch when looking at its own commits, but the branch we
|
|
// drilled into when in the sub-commits or commit-files panels.
|
|
branchName, ok := self.branchForPullRequest(sidePanelContext)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
pr, ok := self.c.Model().PullRequestsMap[branchName]
|
|
if !ok {
|
|
return errors.New(self.c.Tr.NoPullRequestForBranch)
|
|
}
|
|
|
|
// The diff shown is the diff of a particular commit, so we deep-link into
|
|
// that commit's view of the PR; its right-side line numbers match what we're
|
|
// showing, so (unlike editLine) no line-number adjustment is needed.
|
|
diffableContext, ok := sidePanelContext.(types.DiffableContext)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
commitSha := diffableContext.RefForAdjustingLineNumberInDiff()
|
|
if commitSha == "" {
|
|
return nil
|
|
}
|
|
|
|
// Figure out the clicked file and line the same way entering staging does.
|
|
info, ok := self.c.Helpers().Staging.GetDiffLineInfo(
|
|
self.context.GetViewName(), self.context.GetView().SelectedLineIdx())
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), info.Path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// A deletion isn't on the right (new) side of the diff, so anchor it on the
|
|
// left (old) side; everything else on the right.
|
|
side, lineNumber := info.PullRequestAnchor()
|
|
|
|
self.c.LogAction(self.c.Tr.Actions.OpenPullRequest)
|
|
return self.c.OS().OpenLink(
|
|
githubPullRequestLineURL(pr.Url, commitSha, filepath.ToSlash(relativePath), side, lineNumber))
|
|
}
|
|
|
|
// branchForPullRequest returns the local branch whose pull request applies to
|
|
// the diff currently shown in the focused main view, given the side panel
|
|
// beneath it. It returns false for contexts that don't map to a local branch
|
|
// (e.g. the working-tree files panel, stashes, tags, or remote branches).
|
|
func (self *MainViewController) branchForPullRequest(sidePanelContext types.Context) (string, bool) {
|
|
switch sidePanelContext.GetKey() {
|
|
case context.LOCAL_COMMITS_CONTEXT_KEY:
|
|
return self.c.Model().CheckedOutBranch, true
|
|
case context.SUB_COMMITS_CONTEXT_KEY:
|
|
ref := self.c.Contexts().SubCommits.GetRef()
|
|
if ref == nil {
|
|
return "", false
|
|
}
|
|
return ref.RefName(), true
|
|
case context.COMMIT_FILES_CONTEXT_KEY:
|
|
// The commit files panel doesn't itself know which branch it belongs to;
|
|
// that's determined by the panel we entered it from.
|
|
parent := self.c.Contexts().CommitFiles.GetParentContext()
|
|
if parent == nil {
|
|
return "", false
|
|
}
|
|
return self.branchForPullRequest(parent)
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
// githubPullRequestLineURL builds a URL that opens the given line of a file in
|
|
// the diff of a specific commit within a GitHub pull request. The file is
|
|
// identified by the SHA-256 of its repo-relative path, and side ("R"/"L")
|
|
// selects the right (new) or left (old) side of the diff. See
|
|
// https://github.com/orgs/community/discussions/55764.
|
|
func githubPullRequestLineURL(prURL string, commitSha string, relativePath string, side string, lineNumber int) string {
|
|
pathHash := sha256.Sum256([]byte(relativePath))
|
|
anchor := fmt.Sprintf("diff-%s%s%d", hex.EncodeToString(pathHash[:]), side, lineNumber)
|
|
return fmt.Sprintf("%s/changes/%s#%s", prURL, commitSha, anchor)
|
|
}
|
|
|
|
func (self *MainViewController) onClickInAlreadyFocusedView(opts gocui.ViewMouseBindingOpts) error {
|
|
if !self.isDiffView() {
|
|
return nil
|
|
}
|
|
// A click points at a line, so it sets a single-line selection there; a
|
|
// double-click additionally dives into staging/patch-building for that line.
|
|
resetDiffSelectMode(self.context)
|
|
showSelectionAtLine(self.context.GetView(), opts.Y, false)
|
|
if opts.IsDoubleClick {
|
|
return self.enterForLine(opts.Y)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (self *MainViewController) editClickedLine(opts gocui.ViewMouseBindingOpts) error {
|
|
return self.editDiffLine(opts.Y)
|
|
}
|
|
|
|
func (self *MainViewController) onClickInOtherViewOfMainViewPair(opts gocui.ViewMouseBindingOpts) error {
|
|
self.c.Context().Push(self.context, types.OnFocusOpts{})
|
|
if !self.isDiffView() {
|
|
return nil
|
|
}
|
|
resetDiffSelectMode(self.context)
|
|
showSelectionAtLine(self.context.GetView(), opts.Y, false)
|
|
if opts.IsDoubleClick {
|
|
return self.enterForLine(opts.Y)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (self *MainViewController) openSearch() error {
|
|
if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil {
|
|
manager.ReadToEnd(func() {
|
|
self.c.OnUIThread(func() error {
|
|
return self.c.Helpers().Search.OpenSearchPrompt(self.context)
|
|
})
|
|
})
|
|
}
|
|
|
|
return nil
|
|
}
|