Stage the selected diff line from the focused main view

The focused main view already lets you point at a diff line and act on it
(dive into staging, edit it, open it in a PR). This makes it a staging surface
in its own right: press space to stage — or unstage — the selected line without
diving into the separate staging view.

To make that usable, the selection is now shown automatically whenever the main
view holds a diff, anchored on the first change line already visible (so the
view doesn't jump), rather than being toggled on demand from the middle of the
view. That frees space for staging and means there's always a line to act on.
Which contexts show a diff is marked by a new types.DiffMainViewContext, because
"shows a diff" is the right signal, not "is stageable": reflog shows a diff (its
selection drives edit/PR/navigation) but can't be staged, while a branch's log
or the status dashboard show no diff and get no selection at all.

Staging is delegated to the side panel beneath via a GetOnStageFocusedMainView
handler mirroring GetOnClickFocusedMainView, so what "stage" means stays the
panel's concern (the working tree stages/unstages; commits will later add to a
custom patch). The files handler resolves the line's patch identity from the
diff-line metadata, maps it to a patch line, and applies a one-line patch,
choosing stage vs unstage from whether the shown diff is the unstaged or staged
side (diffSplitState).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Stefan Haller 2026-06-18 09:28:13 +02:00
parent d23fe24100
commit 494b9ce7cf
23 changed files with 262 additions and 56 deletions

View file

@ -224,7 +224,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel up> (fn+down) `` | Scroll up | |
| `` <tab> `` | Switch view | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Show/hide selection | |
| `` <space> `` | Stage | Toggle selection staged / unstaged. |
| `` <left>, h `` | Go to previous hunk | |
| `` <right>, l `` | Go to next hunk | |
| `` N `` | Go to previous file | |
@ -334,7 +334,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <tab> `` | Switch view | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Show/hide selection | |
| `` <space> `` | Stage | Toggle selection staged / unstaged. |
| `` <left>, h `` | Go to previous hunk | |
| `` <right>, l `` | Go to next hunk | |
| `` N `` | Go to previous file | |

View file

@ -193,7 +193,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <tab> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
| `` <esc> `` | サイドパネルに戻る | |
| `` <space> `` | Show/hide selection | |
| `` <space> `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 |
| `` <left>, h `` | 前のハンクに移動 | |
| `` <right>, l `` | 次のハンクに移動 | |
| `` N `` | Go to previous file | |
@ -311,7 +311,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel up> (fn+down) `` | 上にスクロール | |
| `` <tab> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
| `` <esc> `` | サイドパネルに戻る | |
| `` <space> `` | Show/hide selection | |
| `` <space> `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 |
| `` <left>, h `` | 前のハンクに移動 | |
| `` <right>, l `` | 次のハンクに移動 | |
| `` N `` | Go to previous file | |

View file

@ -85,7 +85,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <tab> `` | 패널 전환 | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Show/hide selection | |
| `` <space> `` | Staged 전환 | 선택한 행을 staged / unstaged |
| `` <left>, h `` | 이전 hunk를 선택 | |
| `` <right>, l `` | 다음 hunk를 선택 | |
| `` N `` | Go to previous file | |
@ -168,7 +168,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel up> (fn+down) `` | 위로 스크롤 | |
| `` <tab> `` | 패널 전환 | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Show/hide selection | |
| `` <space> `` | Staged 전환 | 선택한 행을 staged / unstaged |
| `` <left>, h `` | 이전 hunk를 선택 | |
| `` <right>, l `` | 다음 hunk를 선택 | |
| `` N `` | Go to previous file | |

View file

@ -232,7 +232,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel up> (fn+down) `` | Scroll omhoog | |
| `` <tab> `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Show/hide selection | |
| `` <space> `` | Toggle staged | Toggle lijnen staged / unstaged |
| `` <left>, h `` | Selecteer de vorige hunk | |
| `` <right>, l `` | Selecteer de volgende hunk | |
| `` N `` | Go to previous file | |
@ -312,7 +312,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <tab> `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Show/hide selection | |
| `` <space> `` | Toggle staged | Toggle lijnen staged / unstaged |
| `` <left>, h `` | Selecteer de vorige hunk | |
| `` <right>, l `` | Selecteer de volgende hunk | |
| `` N `` | Go to previous file | |

View file

@ -100,7 +100,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <tab> `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Show/hide selection | |
| `` <space> `` | Zatwierdź | Przełącz zaznaczenie zatwierdzone/niezatwierdzone. |
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
| `` <right>, l `` | Idź do następnego fragmentu | |
| `` N `` | Go to previous file | |
@ -207,7 +207,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel up> (fn+down) `` | Przewiń w górę | |
| `` <tab> `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Show/hide selection | |
| `` <space> `` | Zatwierdź | Przełącz zaznaczenie zatwierdzone/niezatwierdzone. |
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
| `` <right>, l `` | Idź do następnego fragmentu | |
| `` N `` | Go to previous file | |

View file

@ -236,7 +236,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel up> (fn+down) `` | Rolar para cima | |
| `` <tab> `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Show/hide selection | |
| `` <space> `` | Etapa | Ativar/desativar seleção em staged/unstaged |
| `` <left>, h `` | Ir para o local anterior | |
| `` <right>, l `` | Ir para o próximo trecho | |
| `` N `` | Go to previous file | |
@ -343,7 +343,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <tab> `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Show/hide selection | |
| `` <space> `` | Etapa | Ativar/desativar seleção em staged/unstaged |
| `` <left>, h `` | Ir para o local anterior | |
| `` <right>, l `` | Ir para o próximo trecho | |
| `` N `` | Go to previous file | |

View file

@ -75,7 +75,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <tab> `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Show/hide selection | |
| `` <space> `` | Переключить индекс | Переключить строку в проиндексированные / непроиндексированные |
| `` <left>, h `` | Выбрать предыдущую часть | |
| `` <right>, l `` | Выбрать следующую часть | |
| `` N `` | Go to previous file | |
@ -112,7 +112,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel up> (fn+down) `` | Прокрутить вверх | |
| `` <tab> `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Show/hide selection | |
| `` <space> `` | Переключить индекс | Переключить строку в проиндексированные / непроиндексированные |
| `` <left>, h `` | Выбрать предыдущую часть | |
| `` <right>, l `` | Выбрать следующую часть | |
| `` N `` | Go to previous file | |

View file

@ -287,7 +287,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
| `` <esc> `` | 退出回到侧边面板 | |
| `` <space> `` | Show/hide selection | |
| `` <space> `` | 切换暂存状态 | 切换行暂存状态 |
| `` <left>, h `` | 选择上一个区块 | |
| `` <right>, l `` | 选择下一个区块 | |
| `` N `` | Go to previous file | |
@ -340,7 +340,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel up> (fn+down) `` | 向上滚动 | |
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
| `` <esc> `` | 退出回到侧边面板 | |
| `` <space> `` | Show/hide selection | |
| `` <space> `` | 切换暂存状态 | 切换行暂存状态 |
| `` <left>, h `` | 选择上一个区块 | |
| `` <right>, l `` | 选择下一个区块 | |
| `` N `` | Go to previous file | |

View file

@ -83,7 +83,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel up> (fn+down) `` | 向上捲動 | |
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Show/hide selection | |
| `` <space> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) |
| `` <left>, h `` | 選擇上一段 | |
| `` <right>, l `` | 選擇下一段 | |
| `` N `` | Go to previous file | |
@ -369,7 +369,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|-----|--------|-------------|
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` <space> `` | Show/hide selection | |
| `` <space> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) |
| `` <left>, h `` | 選擇上一段 | |
| `` <right>, l `` | 選擇下一段 | |
| `` N `` | Go to previous file | |

View file

@ -18,6 +18,7 @@ type BaseContext struct {
onDoubleClickFn func() error
onClickFn func(opts gocui.ViewMouseBindingOpts) error
onClickFocusedMainViewFn onClickFocusedMainViewFn
onStageFocusedMainViewFn onStageFocusedMainViewFn
onRenderToMainFn func()
onFocusFns []onFocusFn
onFocusLostFns []onFocusLostFn
@ -37,6 +38,7 @@ type (
onFocusFn = func(types.OnFocusOpts)
onFocusLostFn = func(types.OnFocusLostOpts)
onClickFocusedMainViewFn = func(mainViewName string, clickedLineIdx int) error
onStageFocusedMainViewFn = func(mainViewName string, viewLineIdx int) error
)
var _ types.IBaseContext = &BaseContext{}
@ -146,6 +148,7 @@ func (self *BaseContext) ClearAllAttachedControllerFunctions() {
self.onDoubleClickFn = nil
self.onClickFn = nil
self.onClickFocusedMainViewFn = nil
self.onStageFocusedMainViewFn = nil
self.onRenderToMainFn = nil
}
@ -188,6 +191,19 @@ func (self *BaseContext) GetOnClickFocusedMainView() onClickFocusedMainViewFn {
return self.onClickFocusedMainViewFn
}
func (self *BaseContext) AddOnStageFocusedMainViewFn(fn onStageFocusedMainViewFn) {
if fn != nil {
if self.onStageFocusedMainViewFn != nil {
panic("only one controller is allowed to set an onStageFocusedMainViewFn")
}
self.onStageFocusedMainViewFn = fn
}
}
func (self *BaseContext) GetOnStageFocusedMainView() onStageFocusedMainViewFn {
return self.onStageFocusedMainViewFn
}
func (self *BaseContext) AddOnRenderToMainFn(fn func()) {
if fn != nil {
if self.onRenderToMainFn != nil {

View file

@ -19,11 +19,14 @@ type CommitFilesContext struct {
}
var (
_ types.IListContext = (*CommitFilesContext)(nil)
_ types.DiffableContext = (*CommitFilesContext)(nil)
_ types.IFilterableContext = (*CommitFilesContext)(nil)
_ types.IListContext = (*CommitFilesContext)(nil)
_ types.DiffableContext = (*CommitFilesContext)(nil)
_ types.IFilterableContext = (*CommitFilesContext)(nil)
_ types.DiffMainViewContext = (*CommitFilesContext)(nil)
)
func (self *CommitFilesContext) IsDiffMainViewContext() {}
func NewCommitFilesContext(c *ContextCommon) *CommitFilesContext {
viewModel := filetree.NewCommitFileTreeViewModel(
func() []*models.CommitFile { return c.Model().CommitFiles },

View file

@ -31,11 +31,14 @@ type commitDropIndicator struct {
}
var (
_ types.IListContext = (*LocalCommitsContext)(nil)
_ types.DiffableContext = (*LocalCommitsContext)(nil)
_ types.ISearchableContext = (*LocalCommitsContext)(nil)
_ types.IListContext = (*LocalCommitsContext)(nil)
_ types.DiffableContext = (*LocalCommitsContext)(nil)
_ types.ISearchableContext = (*LocalCommitsContext)(nil)
_ types.DiffMainViewContext = (*LocalCommitsContext)(nil)
)
func (self *LocalCommitsContext) IsDiffMainViewContext() {}
func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
dropIndicator := &commitDropIndicator{insertionIndex: -1}
viewModel := NewLocalCommitsViewModel(

View file

@ -14,10 +14,13 @@ type ReflogCommitsContext struct {
}
var (
_ types.IListContext = (*ReflogCommitsContext)(nil)
_ types.DiffableContext = (*ReflogCommitsContext)(nil)
_ types.IListContext = (*ReflogCommitsContext)(nil)
_ types.DiffableContext = (*ReflogCommitsContext)(nil)
_ types.DiffMainViewContext = (*ReflogCommitsContext)(nil)
)
func (self *ReflogCommitsContext) IsDiffMainViewContext() {}
func NewReflogCommitsContext(c *ContextCommon) *ReflogCommitsContext {
viewModel := NewFilteredListViewModel(
func() []*models.Commit { return c.Model().FilteredReflogCommits },

View file

@ -12,10 +12,13 @@ type StashContext struct {
}
var (
_ types.IListContext = (*StashContext)(nil)
_ types.DiffableContext = (*StashContext)(nil)
_ types.IListContext = (*StashContext)(nil)
_ types.DiffableContext = (*StashContext)(nil)
_ types.DiffMainViewContext = (*StashContext)(nil)
)
func (self *StashContext) IsDiffMainViewContext() {}
func NewStashContext(
c *ContextCommon,
) *StashContext {

View file

@ -21,11 +21,14 @@ type SubCommitsContext struct {
}
var (
_ types.IListContext = (*SubCommitsContext)(nil)
_ types.DiffableContext = (*SubCommitsContext)(nil)
_ types.ISearchableContext = (*SubCommitsContext)(nil)
_ types.IListContext = (*SubCommitsContext)(nil)
_ types.DiffableContext = (*SubCommitsContext)(nil)
_ types.ISearchableContext = (*SubCommitsContext)(nil)
_ types.DiffMainViewContext = (*SubCommitsContext)(nil)
)
func (self *SubCommitsContext) IsDiffMainViewContext() {}
func NewSubCommitsContext(
c *ContextCommon,
) *SubCommitsContext {

View file

@ -15,10 +15,13 @@ type WorkingTreeContext struct {
}
var (
_ types.IListContext = (*WorkingTreeContext)(nil)
_ types.IFilterableContext = (*WorkingTreeContext)(nil)
_ types.IListContext = (*WorkingTreeContext)(nil)
_ types.IFilterableContext = (*WorkingTreeContext)(nil)
_ types.DiffMainViewContext = (*WorkingTreeContext)(nil)
)
func (self *WorkingTreeContext) IsDiffMainViewContext() {}
func NewWorkingTreeContext(c *ContextCommon) *WorkingTreeContext {
viewModel := filetree.NewFileTreeViewModel(
func() []*models.File { return c.Model().Files },

View file

@ -9,6 +9,7 @@ func AttachControllers(context types.Context, controllers ...types.IController)
context.AddOnDoubleClickFn(controller.GetOnDoubleClick())
context.AddOnClickFn(controller.GetOnClick())
context.AddOnClickFocusedMainViewFn(controller.GetOnClickFocusedMainView())
context.AddOnStageFocusedMainViewFn(controller.GetOnStageFocusedMainView())
context.AddOnRenderToMainFn(controller.GetOnRenderToMain())
context.AddOnFocusFn(controller.GetOnFocus())
context.AddOnFocusLostFn(controller.GetOnFocusLost())

View file

@ -23,6 +23,10 @@ func (self *baseController) GetOnClickFocusedMainView() func(mainViewName string
return nil
}
func (self *baseController) GetOnStageFocusedMainView() func(mainViewName string, viewLineIdx int) error {
return nil
}
func (self *baseController) GetOnClick() func(opts gocui.ViewMouseBindingOpts) error {
return nil
}

View file

@ -9,6 +9,7 @@ import (
"github.com/jesseduffield/generics/set"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
@ -456,6 +457,72 @@ func (self *FilesController) diffSplitState(node *filetree.FileNode) (split bool
return split, mainShowsStaged
}
func (self *FilesController) GetOnStageFocusedMainView() func(mainViewName string, viewLineIdx int) error {
return func(mainViewName string, viewLineIdx int) error {
if self.c.UserConfig().Git.DiffContextSize == 0 {
return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextToStage,
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)
}
node := self.context().GetSelected()
if node == nil || !node.IsFile() {
// Staging a line of a multi-file (directory) diff is a later step; for
// now only single-file diffs are stageable from the focused main view.
return nil
}
info, ok := self.c.Helpers().Staging.GetDiffLineInfo(mainViewName, viewLineIdx)
if !ok {
return nil
}
// The staged diff is shown in the secondary half of a split, and in the main
// half when the file has only staged changes; in those cases space unstages,
// otherwise it stages.
_, mainShowsStaged := self.diffSplitState(node)
staged := mainShowsStaged || mainViewName == self.c.Contexts().NormalSecondary.GetViewName()
return self.stageDiffLine(node.File, info, staged)
}
}
// stageDiffLine stages, or when reverse is true unstages, the single diff line
// identified by info. It builds a one-line patch from the file's diff and applies
// it the same way the staging view does, but resolves the patch line from the
// diff-line metadata rather than from a patch-explorer selection. A context or
// header line yields an empty patch and is a no-op.
func (self *FilesController) stageDiffLine(file *models.File, info types.DiffLineInfo, reverse bool) error {
parsedPatch := patch.Parse(self.c.Git().WorkingTree.WorktreeFileDiff(file, true, reverse))
lineNumber, isDeletion := info.PatchSelectLine()
patchLineIdx := parsedPatch.PatchLineForLineNumber(lineNumber)
if isDeletion {
patchLineIdx = parsedPatch.PatchLineForOldLineNumber(lineNumber)
}
patchToApply := parsedPatch.
Transform(patch.TransformOpts{
Reverse: reverse,
IncludedLineIndices: []int{patchLineIdx},
FileNameOverride: file.GetPath(),
}).
FormatPlain()
if patchToApply == "" {
return nil
}
self.c.LogAction(self.c.Tr.Actions.ApplyPatch)
if err := self.c.Git().Patch.ApplyPatch(patchToApply, git_commands.ApplyPatchOpts{
Reverse: reverse,
Cached: true,
}); err != nil {
return err
}
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.STAGING}})
return nil
}
// if we are dealing with a status for which there is no key in this map,
// then we won't optimistically render: we'll just let `git status` tell
// us what the new status is.

View file

@ -58,6 +58,29 @@ func (self *StagingHelper) AdjacentFile(view *gocui.View, anchorViewLine int, fo
return view.ViewLineForBufferLine(target)
}
// FirstChangeLineInView returns the view line of the first change line at or below
// the top of the viewport, for placing the initial selection when focusing the main
// view: we select the first change the user can already see rather than jumping to
// the top of the diff (which would be jarring when the view is scrolled down). If
// the top visible line is itself mid-change-block, that line is returned, so the
// selection stays put. ok is false when no change line is loaded at or below the top
// (e.g. scrolled into trailing context, or the diff isn't loaded that far yet),
// leaving the caller to fall back.
func (self *StagingHelper) FirstChangeLineInView(view *gocui.View) (int, bool) {
top, ok := view.BufferLineForViewLine(view.OriginY())
if !ok {
return 0, false
}
resolved := self.resolveDiffLines(view.DiffLineContents())
for i := top; i < len(resolved); i++ {
if resolved[i].ok && resolved[i].info.IsChange() {
return view.ViewLineForBufferLine(i)
}
}
return 0, false
}
// changeBlockStart finds, in a diff whose lines are flagged by isChange, the first
// line of the change block adjacent to `from` in the given direction. It is the pure
// index arithmetic behind AdjacentChangeBlock, mirroring the staging view's

View file

@ -38,9 +38,10 @@ func NewMainViewController(
}
func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
// When a selection is shown, we surface the bindings that act on it
// 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, escape to hide the selection).
// line in the branch's pull request).
selectionShown := self.context.GetView().Highlight
var enterDescription string
@ -73,9 +74,10 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty
},
{
Keys: opts.GetKeys(opts.Config.Universal.Select),
Handler: self.toggleSelection,
Description: self.c.Tr.ToggleSelectionInFocusedMainView,
DisplayOnScreen: !selectionShown,
Handler: self.stageSelectedLine,
Description: self.c.Tr.Stage,
Tooltip: self.c.Tr.StageSelectionTooltip,
DisplayOnScreen: selectionShown,
},
{
Keys: opts.GetKeys(opts.Config.Universal.GoInto),
@ -180,32 +182,57 @@ func (self *MainViewController) GetOnFocus() func(types.OnFocusOpts) {
}
func (self *MainViewController) togglePanel() error {
if self.otherContext.GetView().Visible {
self.c.Context().Push(self.otherContext, types.OnFocusOpts{})
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.GetView())
}
return nil
}
func (self *MainViewController) escape() error {
v := self.context.GetView()
if v.Highlight {
v.Highlight = false
return nil
// showInitialDiffSelection turns on the focused main view's selection when entering
// a diff view without pointing at a specific line: on the first change line already
// visible (so the view doesn't jump), falling back to the current top line when none
// is visible (scrolled into trailing context, or not loaded that far yet).
func showInitialDiffSelection(c *ControllerCommon, view *gocui.View) {
target, ok := c.Helpers().Staging.FirstChangeLineInView(view)
if !ok {
target = view.OriginY()
}
showSelectionAtLine(view, target, true)
}
func (self *MainViewController) escape() error {
self.c.Context().Pop()
return nil
}
func (self *MainViewController) toggleSelection() error {
v := self.context.GetView()
if v.Highlight {
v.Highlight = false
// 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, 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
}
// Start the selection in the middle of the visible content.
showSelectionAtLine(v, v.MiddleVisibleLineIdx(), false)
return nil
handler := sidePanelContext.GetOnStageFocusedMainView()
if handler == nil {
return nil
}
return handler(self.context.GetViewName(), self.context.GetView().SelectedLineIdx())
}
func (self *MainViewController) enter() error {
@ -279,6 +306,16 @@ func (self *MainViewController) prevFile() error {
return self.navigate(self.c.Helpers().Staging.AdjacentFile, false)
}
// 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 {
@ -422,6 +459,9 @@ func githubPullRequestLineURL(prURL string, commitSha string, relativePath strin
}
func (self *MainViewController) onClickInAlreadyFocusedView(opts gocui.ViewMouseBindingOpts) error {
if !self.isDiffView() {
return nil
}
// A click points at a line, so it sets the selection there; a double-click
// additionally dives into staging/patch-building for that line.
showSelectionAtLine(self.context.GetView(), opts.Y, false)
@ -437,6 +477,9 @@ func (self *MainViewController) editClickedLine(opts gocui.ViewMouseBindingOpts)
func (self *MainViewController) onClickInOtherViewOfMainViewPair(opts gocui.ViewMouseBindingOpts) error {
self.c.Context().Push(self.context, types.OnFocusOpts{})
if !self.isDiffView() {
return nil
}
showSelectionAtLine(self.context.GetView(), opts.Y, false)
if opts.IsDoubleClick {
return self.enterForLine(opts.Y)

View file

@ -69,9 +69,11 @@ func (self *SwitchToFocusedMainViewController) onClickSecondary(opts gocui.ViewM
}
func (self *SwitchToFocusedMainViewController) handleFocusMainView() error {
// Focusing by keyboard doesn't point at any particular line, so we don't
// show a selection; the user is free to scroll. Clicking does point at a
// line, so it selects it (see focusMainView's clickedLineIdx).
// Focusing by keyboard doesn't point at any particular line: in a diff view
// we start at the first change block (like entering the staging view), in a
// non-diff view we show no selection and the user just scrolls. Clicking does
// point at a line, so it selects that line instead (focusMainView's
// clickedLineIdx).
return self.focusMainView(self.c.Contexts().Normal, -1)
}
@ -80,8 +82,18 @@ func (self *SwitchToFocusedMainViewController) focusMainView(mainViewContext typ
context.ClearSearchString()
}
self.c.Context().Push(mainViewContext, types.OnFocusOpts{})
if !sidePanelShowsDiff(self.context) {
// Non-diff main content (e.g. a branch's commit log): focus only, no
// selection, since there's nothing to act on.
return nil
}
view := mainViewContext.GetView()
if clickedLineIdx >= 0 {
showSelectionAtLine(mainViewContext.GetView(), clickedLineIdx, false)
showSelectionAtLine(view, clickedLineIdx, false)
} else {
showInitialDiffSelection(self.c, view)
}
return nil
}

View file

@ -98,6 +98,9 @@ type IBaseContext interface {
// Likewise for the focused main view: we need this to communicate between a
// side panel controller and the focused main view controller.
AddOnClickFocusedMainViewFn(func(mainViewName string, clickedLineIdx int) error)
// And for staging the selected line directly from the focused main view (space),
// delegated to the side panel that owns the diff being shown.
AddOnStageFocusedMainViewFn(func(mainViewName string, viewLineIdx int) error)
// Adding on to the above, this is so that a list-specific handler can register
// a hook for doing additional click handling
AddOnClickFn(func(opts gocui.ViewMouseBindingOpts) error)
@ -174,6 +177,20 @@ type DiffableContext interface {
RefForAdjustingLineNumberInDiff() string
}
// DiffMainViewContext is implemented by the side panel contexts whose focused
// main view shows a unified diff — files, local commits, sub-commits, reflog,
// stash, and commit files — as opposed to a commit log or other non-diff content
// (branches, tags, status, …). It is distinct from DiffableContext, which is
// about producing a diff between two refs for the diff menu. This is the signal
// for whether to show a selection in the focused main view: a selection is only
// meaningful where there are diff lines to act on (stage, edit, jump by hunk,
// open in a pull request).
type DiffMainViewContext interface {
Context
IsDiffMainViewContext()
}
type IListContext interface {
Context
@ -314,6 +331,11 @@ type HasKeybindings interface {
// Implement this in a side-panel controller to get called when there's a click in the main view
// that belongs to your panel while the main view is already focused.
GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error
// Implement this in a side-panel controller to stage/unstage (or, later, add to
// the custom patch) the selected diff line when the user presses space in the
// focused main view. Return nil to do nothing.
GetOnStageFocusedMainView() func(mainViewName string, viewLineIdx int) error
}
type IController interface {