From 494b9ce7cf1c2702e35b12e318d8adaee65eb56e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 18 Jun 2026 09:28:13 +0200 Subject: [PATCH] Stage the selected diff line from the focused main view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs-master/keybindings/Keybindings_en.md | 4 +- docs-master/keybindings/Keybindings_ja.md | 4 +- docs-master/keybindings/Keybindings_ko.md | 4 +- docs-master/keybindings/Keybindings_nl.md | 4 +- docs-master/keybindings/Keybindings_pl.md | 4 +- docs-master/keybindings/Keybindings_pt.md | 4 +- docs-master/keybindings/Keybindings_ru.md | 4 +- docs-master/keybindings/Keybindings_zh-CN.md | 4 +- docs-master/keybindings/Keybindings_zh-TW.md | 4 +- pkg/gui/context/base_context.go | 16 ++++ pkg/gui/context/commit_files_context.go | 9 ++- pkg/gui/context/local_commits_context.go | 9 ++- pkg/gui/context/reflog_commits_context.go | 7 +- pkg/gui/context/stash_context.go | 7 +- pkg/gui/context/sub_commits_context.go | 9 ++- pkg/gui/context/working_tree_context.go | 7 +- pkg/gui/controllers/attach.go | 1 + pkg/gui/controllers/base_controller.go | 4 + pkg/gui/controllers/files_controller.go | 67 +++++++++++++++ .../helpers/diff_line_navigation.go | 23 ++++++ pkg/gui/controllers/main_view_controller.go | 81 ++++++++++++++----- .../switch_to_focused_main_view_controller.go | 20 ++++- pkg/gui/types/context.go | 22 +++++ 23 files changed, 262 insertions(+), 56 deletions(-) diff --git a/docs-master/keybindings/Keybindings_en.md b/docs-master/keybindings/Keybindings_en.md index 5f3598c93..fe18e7316 100644 --- a/docs-master/keybindings/Keybindings_en.md +++ b/docs-master/keybindings/Keybindings_en.md @@ -224,7 +224,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` (fn+down) `` | Scroll up | | | `` `` | Switch view | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | -| `` `` | Show/hide selection | | +| `` `` | Stage | Toggle selection staged / unstaged. | | `` , h `` | Go to previous hunk | | | `` , 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 |-----|--------|-------------| | `` `` | Switch view | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | -| `` `` | Show/hide selection | | +| `` `` | Stage | Toggle selection staged / unstaged. | | `` , h `` | Go to previous hunk | | | `` , l `` | Go to next hunk | | | `` N `` | Go to previous file | | diff --git a/docs-master/keybindings/Keybindings_ja.md b/docs-master/keybindings/Keybindings_ja.md index f763599f6..fe4b5ccc2 100644 --- a/docs-master/keybindings/Keybindings_ja.md +++ b/docs-master/keybindings/Keybindings_ja.md @@ -193,7 +193,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 | | `` `` | サイドパネルに戻る | | -| `` `` | Show/hide selection | | +| `` `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 | | `` , h `` | 前のハンクに移動 | | | `` , 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 | `` (fn+down) `` | 上にスクロール | | | `` `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 | | `` `` | サイドパネルに戻る | | -| `` `` | Show/hide selection | | +| `` `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 | | `` , h `` | 前のハンクに移動 | | | `` , l `` | 次のハンクに移動 | | | `` N `` | Go to previous file | | diff --git a/docs-master/keybindings/Keybindings_ko.md b/docs-master/keybindings/Keybindings_ko.md index 907d4df20..97229aed7 100644 --- a/docs-master/keybindings/Keybindings_ko.md +++ b/docs-master/keybindings/Keybindings_ko.md @@ -85,7 +85,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | 패널 전환 | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | -| `` `` | Show/hide selection | | +| `` `` | Staged 전환 | 선택한 행을 staged / unstaged | | `` , h `` | 이전 hunk를 선택 | | | `` , 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 | `` (fn+down) `` | 위로 스크롤 | | | `` `` | 패널 전환 | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | -| `` `` | Show/hide selection | | +| `` `` | Staged 전환 | 선택한 행을 staged / unstaged | | `` , h `` | 이전 hunk를 선택 | | | `` , l `` | 다음 hunk를 선택 | | | `` N `` | Go to previous file | | diff --git a/docs-master/keybindings/Keybindings_nl.md b/docs-master/keybindings/Keybindings_nl.md index c172069bc..4b24387d6 100644 --- a/docs-master/keybindings/Keybindings_nl.md +++ b/docs-master/keybindings/Keybindings_nl.md @@ -232,7 +232,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` (fn+down) `` | Scroll omhoog | | | `` `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | -| `` `` | Show/hide selection | | +| `` `` | Toggle staged | Toggle lijnen staged / unstaged | | `` , h `` | Selecteer de vorige hunk | | | `` , 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 |-----|--------|-------------| | `` `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | -| `` `` | Show/hide selection | | +| `` `` | Toggle staged | Toggle lijnen staged / unstaged | | `` , h `` | Selecteer de vorige hunk | | | `` , l `` | Selecteer de volgende hunk | | | `` N `` | Go to previous file | | diff --git a/docs-master/keybindings/Keybindings_pl.md b/docs-master/keybindings/Keybindings_pl.md index eab96368c..5d2d0dd68 100644 --- a/docs-master/keybindings/Keybindings_pl.md +++ b/docs-master/keybindings/Keybindings_pl.md @@ -100,7 +100,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). | | `` `` | Exit back to side panel | | -| `` `` | Show/hide selection | | +| `` `` | Zatwierdź | Przełącz zaznaczenie zatwierdzone/niezatwierdzone. | | `` , h `` | Idź do poprzedniego fragmentu | | | `` , 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 | `` (fn+down) `` | Przewiń w górę | | | `` `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). | | `` `` | Exit back to side panel | | -| `` `` | Show/hide selection | | +| `` `` | Zatwierdź | Przełącz zaznaczenie zatwierdzone/niezatwierdzone. | | `` , h `` | Idź do poprzedniego fragmentu | | | `` , l `` | Idź do następnego fragmentu | | | `` N `` | Go to previous file | | diff --git a/docs-master/keybindings/Keybindings_pt.md b/docs-master/keybindings/Keybindings_pt.md index 7eb473481..2e6f87549 100644 --- a/docs-master/keybindings/Keybindings_pt.md +++ b/docs-master/keybindings/Keybindings_pt.md @@ -236,7 +236,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` (fn+down) `` | Rolar para cima | | | `` `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). | | `` `` | Exit back to side panel | | -| `` `` | Show/hide selection | | +| `` `` | Etapa | Ativar/desativar seleção em staged/unstaged | | `` , h `` | Ir para o local anterior | | | `` , 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 |-----|--------|-------------| | `` `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). | | `` `` | Exit back to side panel | | -| `` `` | Show/hide selection | | +| `` `` | Etapa | Ativar/desativar seleção em staged/unstaged | | `` , h `` | Ir para o local anterior | | | `` , l `` | Ir para o próximo trecho | | | `` N `` | Go to previous file | | diff --git a/docs-master/keybindings/Keybindings_ru.md b/docs-master/keybindings/Keybindings_ru.md index 83bc6cba0..527ff4cdd 100644 --- a/docs-master/keybindings/Keybindings_ru.md +++ b/docs-master/keybindings/Keybindings_ru.md @@ -75,7 +75,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | -| `` `` | Show/hide selection | | +| `` `` | Переключить индекс | Переключить строку в проиндексированные / непроиндексированные | | `` , h `` | Выбрать предыдущую часть | | | `` , 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 | `` (fn+down) `` | Прокрутить вверх | | | `` `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | -| `` `` | Show/hide selection | | +| `` `` | Переключить индекс | Переключить строку в проиндексированные / непроиндексированные | | `` , h `` | Выбрать предыдущую часть | | | `` , l `` | Выбрать следующую часть | | | `` N `` | Go to previous file | | diff --git a/docs-master/keybindings/Keybindings_zh-CN.md b/docs-master/keybindings/Keybindings_zh-CN.md index b9735b50a..ec174c05c 100644 --- a/docs-master/keybindings/Keybindings_zh-CN.md +++ b/docs-master/keybindings/Keybindings_zh-CN.md @@ -287,7 +287,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) | | `` `` | 退出回到侧边面板 | | -| `` `` | Show/hide selection | | +| `` `` | 切换暂存状态 | 切换行暂存状态 | | `` , h `` | 选择上一个区块 | | | `` , 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 | `` (fn+down) `` | 向上滚动 | | | `` `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) | | `` `` | 退出回到侧边面板 | | -| `` `` | Show/hide selection | | +| `` `` | 切换暂存状态 | 切换行暂存状态 | | `` , h `` | 选择上一个区块 | | | `` , l `` | 选择下一个区块 | | | `` N `` | Go to previous file | | diff --git a/docs-master/keybindings/Keybindings_zh-TW.md b/docs-master/keybindings/Keybindings_zh-TW.md index 6551f92b6..3605b5e91 100644 --- a/docs-master/keybindings/Keybindings_zh-TW.md +++ b/docs-master/keybindings/Keybindings_zh-TW.md @@ -83,7 +83,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` (fn+down) `` | 向上捲動 | | | `` `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | -| `` `` | Show/hide selection | | +| `` `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) | | `` , h `` | 選擇上一段 | | | `` , 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 |-----|--------|-------------| | `` `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | -| `` `` | Show/hide selection | | +| `` `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) | | `` , h `` | 選擇上一段 | | | `` , l `` | 選擇下一段 | | | `` N `` | Go to previous file | | diff --git a/pkg/gui/context/base_context.go b/pkg/gui/context/base_context.go index 7584b5a12..047ac17c0 100644 --- a/pkg/gui/context/base_context.go +++ b/pkg/gui/context/base_context.go @@ -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 { diff --git a/pkg/gui/context/commit_files_context.go b/pkg/gui/context/commit_files_context.go index f819a2eb4..328e44173 100644 --- a/pkg/gui/context/commit_files_context.go +++ b/pkg/gui/context/commit_files_context.go @@ -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 }, diff --git a/pkg/gui/context/local_commits_context.go b/pkg/gui/context/local_commits_context.go index 4a99259fd..28bbb3409 100644 --- a/pkg/gui/context/local_commits_context.go +++ b/pkg/gui/context/local_commits_context.go @@ -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( diff --git a/pkg/gui/context/reflog_commits_context.go b/pkg/gui/context/reflog_commits_context.go index 6358fbbb0..f3544d01e 100644 --- a/pkg/gui/context/reflog_commits_context.go +++ b/pkg/gui/context/reflog_commits_context.go @@ -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 }, diff --git a/pkg/gui/context/stash_context.go b/pkg/gui/context/stash_context.go index 2014de9f3..50bddf5a2 100644 --- a/pkg/gui/context/stash_context.go +++ b/pkg/gui/context/stash_context.go @@ -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 { diff --git a/pkg/gui/context/sub_commits_context.go b/pkg/gui/context/sub_commits_context.go index b0bcee30a..a41daa6a5 100644 --- a/pkg/gui/context/sub_commits_context.go +++ b/pkg/gui/context/sub_commits_context.go @@ -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 { diff --git a/pkg/gui/context/working_tree_context.go b/pkg/gui/context/working_tree_context.go index d82037e44..51cc169c1 100644 --- a/pkg/gui/context/working_tree_context.go +++ b/pkg/gui/context/working_tree_context.go @@ -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 }, diff --git a/pkg/gui/controllers/attach.go b/pkg/gui/controllers/attach.go index c9ef5d4b0..bbce20478 100644 --- a/pkg/gui/controllers/attach.go +++ b/pkg/gui/controllers/attach.go @@ -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()) diff --git a/pkg/gui/controllers/base_controller.go b/pkg/gui/controllers/base_controller.go index f91f0b4cc..a936af94c 100644 --- a/pkg/gui/controllers/base_controller.go +++ b/pkg/gui/controllers/base_controller.go @@ -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 } diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 2e448ae24..f0e2df951 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -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. diff --git a/pkg/gui/controllers/helpers/diff_line_navigation.go b/pkg/gui/controllers/helpers/diff_line_navigation.go index 5fb3a7015..2f2302fb2 100644 --- a/pkg/gui/controllers/helpers/diff_line_navigation.go +++ b/pkg/gui/controllers/helpers/diff_line_navigation.go @@ -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 diff --git a/pkg/gui/controllers/main_view_controller.go b/pkg/gui/controllers/main_view_controller.go index 8b1205749..822c1b562 100644 --- a/pkg/gui/controllers/main_view_controller.go +++ b/pkg/gui/controllers/main_view_controller.go @@ -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) diff --git a/pkg/gui/controllers/switch_to_focused_main_view_controller.go b/pkg/gui/controllers/switch_to_focused_main_view_controller.go index fcafea3b8..21c1a44c1 100644 --- a/pkg/gui/controllers/switch_to_focused_main_view_controller.go +++ b/pkg/gui/controllers/switch_to_focused_main_view_controller.go @@ -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 } diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go index 79c0a9f0a..ec8b7faf9 100644 --- a/pkg/gui/types/context.go +++ b/pkg/gui/types/context.go @@ -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 {