Discard the selected diff line(s) from the focused main view

Wire the focused main view's `d` binding to a DiscardSelection action on the
side panel beneath, the discard counterpart of the primary action. As with
stage/toggle, what discard means is the panel's business:

- the files panel discards from the working tree, mirroring the staging
  view's `d` — reverse-apply not cached on the unstaged side (destructive, so
  confirmed), reverse-apply cached on the staged side (i.e. unstage);
- the commit panels (commit files, and the whole-commit diff of the local
  commits / sub-commits / stash panels) remove the selected lines from the
  commit via a rebase, mirroring the patch builder's "discard lines from
  commit" — building a one-off patch from the selection and deleting it.

The commit-discard backend and its guard are shared free functions in
patch_building_from_main_view.go, paralleling the patch toggle. Discarding
from a commit is only possible on a local branch, so DiscardSelection grows a
disabled-reason companion on the FocusedMainViewActions interface: the `d`
binding is greyed with a reason on stash and other-branch sub-commits (never
rebaseable) and while a rebase is in progress, exactly as the patch builder
greys it. The files panel is always available (a zero-context diff is the one
error it reports inline, matching the staging view).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller 2026-06-19 17:53:31 +02:00
parent 99f80b1bc8
commit e9c4425df6
19 changed files with 447 additions and 22 deletions

View file

@ -225,6 +225,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> `` | Stage | Toggle selection staged / unstaged. |
| `` d `` | Discard | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Toggle range select | |
| `` <left>, h `` | Go to previous hunk | |
@ -337,6 +338,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> `` | Stage | Toggle selection staged / unstaged. |
| `` d `` | Discard | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Toggle range select | |
| `` <left>, h `` | Go to previous hunk | |

View file

@ -194,6 +194,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <tab> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
| `` <esc> `` | サイドパネルに戻る | |
| `` <space> `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 |
| `` d `` | 破棄 | ステージされていない変更が選択されている場合、`git reset`を使用して変更を破棄します。ステージされた変更が選択されている場合、変更をアンステージします。 |
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 範囲選択を切り替え | |
| `` <left>, h `` | 前のハンクに移動 | |
@ -314,6 +315,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <tab> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
| `` <esc> `` | サイドパネルに戻る | |
| `` <space> `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 |
| `` d `` | 破棄 | ステージされていない変更が選択されている場合、`git reset`を使用して変更を破棄します。ステージされた変更が選択されている場合、変更をアンステージします。 |
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 範囲選択を切り替え | |
| `` <left>, h `` | 前のハンクに移動 | |

View file

@ -86,6 +86,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> `` | Staged 전환 | 선택한 행을 staged / unstaged |
| `` d `` | 변경을 삭제 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 드래그 선택 전환 | |
| `` <left>, h `` | 이전 hunk를 선택 | |
@ -171,6 +172,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> `` | Staged 전환 | 선택한 행을 staged / unstaged |
| `` d `` | 변경을 삭제 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 드래그 선택 전환 | |
| `` <left>, h `` | 이전 hunk를 선택 | |

View file

@ -233,6 +233,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> `` | Toggle staged | Toggle lijnen staged / unstaged |
| `` d `` | Verwijdert change (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Toggle drag selecteer | |
| `` <left>, h `` | Selecteer de vorige hunk | |
@ -315,6 +316,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> `` | Toggle staged | Toggle lijnen staged / unstaged |
| `` d `` | Verwijdert change (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Toggle drag selecteer | |
| `` <left>, h `` | Selecteer de vorige hunk | |

View file

@ -101,6 +101,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> `` | Zatwierdź | Przełącz zaznaczenie zatwierdzone/niezatwierdzone. |
| `` d `` | Odrzuć | Gdy zaznaczona jest niezatwierdzona zmiana, odrzuć ją używając `git reset`. Gdy zaznaczona jest zatwierdzona zmiana, cofnij zatwierdzenie. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Przełącz zaznaczenie zakresu | |
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
@ -210,6 +211,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> `` | Zatwierdź | Przełącz zaznaczenie zatwierdzone/niezatwierdzone. |
| `` d `` | Odrzuć | Gdy zaznaczona jest niezatwierdzona zmiana, odrzuć ją używając `git reset`. Gdy zaznaczona jest zatwierdzona zmiana, cofnij zatwierdzenie. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Przełącz zaznaczenie zakresu | |
| `` <left>, h `` | Idź do poprzedniego fragmentu | |

View file

@ -237,6 +237,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> `` | Etapa | Ativar/desativar seleção em staged/unstaged |
| `` d `` | Descartar | Quando a mudança não desejada for selecionada, descarte a mudança usando `git reset`. Quando a mudança em fase é selecionada, despare a mudança. |
| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. |
| `` v `` | Toggle range select | |
| `` <left>, h `` | Ir para o local anterior | |
@ -346,6 +347,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> `` | Etapa | Ativar/desativar seleção em staged/unstaged |
| `` d `` | Descartar | Quando a mudança não desejada for selecionada, descarte a mudança usando `git reset`. Quando a mudança em fase é selecionada, despare a mudança. |
| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. |
| `` v `` | Toggle range select | |
| `` <left>, h `` | Ir para o local anterior | |

View file

@ -76,6 +76,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> `` | Переключить индекс | Переключить строку в проиндексированные / непроиндексированные |
| `` d `` | Отменить изменение (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Переключить выборку перетаскивания | |
| `` <left>, h `` | Выбрать предыдущую часть | |
@ -115,6 +116,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> `` | Переключить индекс | Переключить строку в проиндексированные / непроиндексированные |
| `` d `` | Отменить изменение (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Переключить выборку перетаскивания | |
| `` <left>, h `` | Выбрать предыдущую часть | |

View file

@ -288,6 +288,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
| `` <esc> `` | 退出回到侧边面板 | |
| `` <space> `` | 切换暂存状态 | 切换行暂存状态 |
| `` d `` | 取消变更(git reset) | 当选择未暂存的变更时使用git reset丢弃该变更。当选择已暂存的变更时取消暂存该变更 |
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
| `` v `` | 切换拖动选择 | |
| `` <left>, h `` | 选择上一个区块 | |
@ -343,6 +344,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
| `` <esc> `` | 退出回到侧边面板 | |
| `` <space> `` | 切换暂存状态 | 切换行暂存状态 |
| `` d `` | 取消变更(git reset) | 当选择未暂存的变更时使用git reset丢弃该变更。当选择已暂存的变更时取消暂存该变更 |
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
| `` v `` | 切换拖动选择 | |
| `` <left>, h `` | 选择上一个区块 | |

View file

@ -84,6 +84,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> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) |
| `` d `` | 刪除變更 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 切換拖曳選擇 | |
| `` <left>, h `` | 選擇上一段 | |
@ -372,6 +373,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> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) |
| `` d `` | 刪除變更 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 切換拖曳選擇 | |
| `` <left>, h `` | 選擇上一段 | |

View file

@ -610,6 +610,18 @@ func (self *CommitFilesController) PrimaryAction(mainViewName string, firstLineI
})
}
// DiscardSelection removes the selected diff line(s) from the commit the focused main
// view shows the files of, via a rebase. Same target derivation as the patch toggle.
func (self *CommitFilesController) DiscardSelection(mainViewName string, firstLineIdx int, lastLineIdx int) error {
from, to, reverse := self.c.Helpers().CommitFiles.CurrentFromToReverseForPatchBuilding()
canRebase := self.context().GetCanRebase()
return discardSelectionFromCommit(self.c, mainViewName, firstLineIdx, lastLineIdx, from, to, reverse, canRebase)
}
func (self *CommitFilesController) DiscardSelectionDisabledReason() *types.DisabledReason {
return discardFromCommitDisabledReason(self.c, self.context().GetCanRebase())
}
// pathsForDiff returns the file paths to use for a diff command. When a text
// filter is active and the node is a directory, only the visible (filtered)
// file paths are returned so the diff reflects what the user sees.

View file

@ -467,23 +467,78 @@ func (self *FilesController) PrimaryAction(mainViewName string, firstLineIdx int
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)
}
infos, onStagedSide, ok := self.diffLineSelection(mainViewName, firstLineIdx, lastLineIdx)
if !ok {
return nil
}
// The whole diff shown in the main view is on one side; space unstages when that's
// the staged side (reverse), otherwise it stages. Either way it applies to the index.
return self.applyDiffLineSelection(mainViewName, firstLineIdx, infos, onStagedSide,
git_commands.ApplyPatchOpts{Reverse: onStagedSide, Cached: true})
}
// DiscardSelectionDisabledReason: discarding from the working tree is always available
// (a zero-context diff is reported as an error from DiscardSelection itself, matching the
// staging view).
func (self *FilesController) DiscardSelectionDisabledReason() *types.DisabledReason {
return nil
}
func (self *FilesController) DiscardSelection(mainViewName string, firstLineIdx int, lastLineIdx int) error {
if self.c.UserConfig().Git.DiffContextSize == 0 {
return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextToDiscard,
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)
}
infos, onStagedSide, ok := self.diffLineSelection(mainViewName, firstLineIdx, lastLineIdx)
if !ok {
return nil
}
// Discarding always reverses the change. On the staged side that's just unstaging
// (apply reverse to the index) — same as space there — so it follows the identical
// path and isn't destructive, hence no confirm. On the unstaged side it removes the
// change from the working tree (reverse, not cached), which is destructive.
return self.c.ConfirmIf(!onStagedSide && !self.c.UserConfig().Gui.SkipDiscardChangeWarning,
types.ConfirmOpts{
Title: self.c.Tr.DiscardChangeTitle,
Prompt: self.c.Tr.DiscardChangePrompt,
HandleConfirm: func() error {
return self.applyDiffLineSelection(mainViewName, firstLineIdx, infos, onStagedSide,
git_commands.ApplyPatchOpts{Reverse: true, Cached: onStagedSide})
},
})
}
// diffLineSelection resolves the change lines the user has selected in the focused main
// view and which side of the diff they're on. onStagedSide is true when the diff shown
// is the staged side — the secondary half of a split, or the main half of an only-staged
// file; it's the same for every file in a multi-file (directory) diff. ok is false when
// there's no file or the selection covers no change lines (the caller does nothing).
func (self *FilesController) diffLineSelection(mainViewName string, firstLineIdx int, lastLineIdx int) (infos []types.DiffLineInfo, onStagedSide bool, ok bool) {
node := self.context().GetSelected()
if node == nil {
return nil
return nil, false, false
}
infos := self.c.Helpers().Staging.ChangeLinesInViewRange(mainViewName, firstLineIdx, lastLineIdx)
infos = self.c.Helpers().Staging.ChangeLinesInViewRange(mainViewName, firstLineIdx, lastLineIdx)
if len(infos) == 0 {
return nil
return nil, false, false
}
// The whole diff shown in the main view is on one side — the staged diff in
// the secondary half of a split, and in the main half when there are only
// staged changes; in those cases space unstages, otherwise it stages. The
// direction is the same for every file in a multi-file (directory) diff.
_, mainShowsStaged := self.diffSplitState(node)
reverse := mainShowsStaged || mainViewName == self.c.Contexts().NormalSecondary.GetViewName()
onStagedSide = mainShowsStaged || mainViewName == self.c.Contexts().NormalSecondary.GetViewName()
return infos, onStagedSide, true
}
// applyDiffLineSelection applies the selected change lines (grouped per file for a
// multi-file diff) with opts, then re-establishes the selection and focus the way the
// staging view does — shared by the primary action (stage/unstage) and discard, since
// discarding on the staged side is just unstaging and must follow the identical path.
// onStagedSide names which diff each file's patch is read from and drives the focus-
// follow; opts says how to apply it.
func (self *FilesController) applyDiffLineSelection(mainViewName string, firstLineIdx int, infos []types.DiffLineInfo, onStagedSide bool, opts git_commands.ApplyPatchOpts) error {
// A directory diff spans several files; group the selected change lines by
// file and apply one patch per file.
infosByFile := lo.GroupBy(infos, func(info types.DiffLineInfo) string { return info.Path })
@ -494,24 +549,21 @@ func (self *FilesController) PrimaryAction(mainViewName string, firstLineIdx int
if file == nil {
continue
}
// Staging reads the side being acted on (unstaged when staging, staged when
// unstaging — both = reverse here) and applies to the index either way.
if err := self.applyDiffLines(file, fileInfos, reverse, git_commands.ApplyPatchOpts{Reverse: reverse, Cached: true}); err != nil {
if err := self.applyDiffLines(file, fileInfos, onStagedSide, opts); err != nil {
return err
}
}
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.STAGING}})
// Focus follows the side that was acted on. Staging keeps it in the main half
// (which always holds the unstaged side, or the staged side once the file has
// only staged changes). Unstaging keeps it on the staged side, which lives in
// the secondary half once the file is split into staged + unstaged, and moves
// back to the main half when the staged side empties and the split collapses.
// The model is up to date now (Refresh above is synchronous), so the post-op
// split is read from the freshly selected node.
// Focus follows the side that was acted on. The unstaged side stays in the main half
// (which always holds the unstaged side, or the staged side once the file has only
// staged changes). The staged side lives in the secondary half once the file is split
// into staged + unstaged, and moves back to the main half when the staged side empties
// and the split collapses. The model is up to date now (Refresh above is synchronous),
// so the post-op split is read from the freshly selected node.
focusViewName := self.c.Contexts().Normal.GetViewName()
if reverse {
if onStagedSide {
if node := self.context().GetSelected(); node != nil {
if split, _ := self.diffSplitState(node); split {
focusViewName = self.c.Contexts().NormalSecondary.GetViewName()
@ -519,9 +571,9 @@ func (self *FilesController) PrimaryAction(mainViewName string, firstLineIdx int
}
}
// The staging Refresh above queued the main-view re-render; re-establish the
// selection in whichever pane now holds the acted-on side once that render lands,
// and focus that pane if staging moved it there.
// The Refresh above queued the main-view re-render; re-establish the selection at its
// change-line ordinal once that render lands, and focus the pane now holding the
// acted-on side.
revealSelectionAfterPrimaryAction(self.c, mainViewName, focusViewName, firstLineIdx)
if focusViewName != mainViewName {
self.c.Context().Push(mainContextForViewName(self.c, focusViewName), types.OnFocusOpts{})

View file

@ -79,6 +79,14 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty
Tooltip: self.c.Tr.StageSelectionTooltip,
DisplayOnScreen: selectionShown,
},
{
Keys: opts.GetKeys(opts.Config.Universal.Remove),
Handler: self.discardSelection,
GetDisabledReason: self.discardSelectionDisabledReason,
Description: self.c.Tr.DiscardSelection,
Tooltip: self.c.Tr.DiscardSelectionTooltip,
DisplayOnScreen: selectionShown,
},
{
Keys: opts.GetKeys(opts.Config.Universal.GoInto),
Handler: self.enter,
@ -306,6 +314,29 @@ func (self *MainViewController) primaryAction() error {
return actions.PrimaryAction(self.context.GetViewName(), first, last)
}
// discardSelection discards the selected diff line(s), delegating to the panel beneath
// (working-tree discard for files, line-removal from the commit for the commit panels).
// As with the primary action, the handler does its own re-render. A no-op when the panel
// beneath offers no actions; discardSelectionDisabledReason gates the cases where the
// panel has discard but it isn't applicable right now.
func (self *MainViewController) discardSelection() error {
actions := self.focusedMainViewActions()
if actions == nil {
return nil
}
v := self.context.GetView()
first, last := v.SelectedLineRange()
return actions.DiscardSelection(self.context.GetViewName(), first, last)
}
func (self *MainViewController) discardSelectionDisabledReason() *types.DisabledReason {
actions := self.focusedMainViewActions()
if actions == nil {
return nil
}
return actions.DiscardSelectionDisabledReason()
}
// revealSelectionAfterPrimaryAction re-establishes the focused-main-view selection after a
// primary action (staging or a patch toggle) re-renders the diff. The selection's
// change-line ordinal is read from the source pane (still showing the pre-action diff

View file

@ -1,9 +1,11 @@
package controllers
import (
"fmt"
"path/filepath"
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
)
@ -128,6 +130,102 @@ func togglePatchLines(c *ControllerCommon, infos []types.DiffLineInfo) error {
return nil
}
// discardFromCommitDisabledReason reports why discarding lines from the commit shown in
// the focused main view is unavailable, or nil when it's available. It mirrors the patch
// builder's own guard: the diff must be a local commit's (canRebase), no rebase may be in
// progress, and the diff context size must be non-zero (a patch can't be built from a
// zero-context diff). Stash and sub-commits of another branch are never rebaseable, so
// they always get the local-commits reason.
func discardFromCommitDisabledReason(c *ControllerCommon, canRebase bool) *types.DisabledReason {
if !canRebase {
return &types.DisabledReason{Text: c.Tr.CanOnlyDiscardFromLocalCommits}
}
if c.Git().Status.WorkingTreeState().Any() {
return &types.DisabledReason{Text: c.Tr.CantPatchWhileRebasingError}
}
if c.UserConfig().Git.DiffContextSize == 0 {
return &types.DisabledReason{Text: fmt.Sprintf(c.Tr.Actions.NotEnoughContextToRemoveLines,
c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)}
}
return nil
}
// discardSelectionFromCommit discards the selected diff line(s) — a single line, a
// range, or a hunk — from the commit whose diff the focused main view shows, by building
// a one-off patch from the selection and removing it from the commit via a rebase. It is
// the patch-building counterpart of the working-tree discard the files panel does, and
// mirrors the patch builder's own "discard lines from commit": any in-progress custom
// patch is reset first (the confirm prompt warns when one exists), then a fresh patch
// holding exactly the selection is built for (from, to, reverse) and removed from the
// commit. The caller has already established (via canRebase) that the commit is on a
// local branch; the target commit is the one identified by `to`.
func discardSelectionFromCommit(
c *ControllerCommon,
mainViewName string,
firstLineIdx int,
lastLineIdx int,
from string,
to string,
reverse bool,
canRebase bool,
) error {
infos := c.Helpers().Staging.ChangeLinesInViewRange(mainViewName, firstLineIdx, lastLineIdx)
if len(infos) == 0 {
return nil
}
commitIndex := -1
for i, commit := range c.Model().Commits {
if commit.Hash() == to {
commitIndex = i
break
}
}
if commitIndex == -1 {
return nil
}
patchBuilder := c.Git().Patch.PatchBuilder
prompt := lo.Ternary(patchBuilder.Active(),
c.Tr.DiscardLinesFromCommitPromptWithReset,
c.Tr.DiscardLinesFromCommitPrompt)
c.Confirm(types.ConfirmOpts{
Title: c.Tr.DiscardLinesFromCommitTitle,
Prompt: prompt,
HandleConfirm: func() error {
// Build a fresh patch holding exactly the selection: reset any active patch,
// then add the selected lines (togglePatchLines adds them, the patch being empty).
if patchBuilder.Active() {
patchBuilder.Reset()
}
patchBuilder.Start(from, to, reverse, canRebase)
if err := togglePatchLines(c, infos); err != nil {
return err
}
if patchBuilder.IsEmpty() {
return nil
}
return c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
Message: c.Tr.RebasingStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
c.LogAction(c.Tr.Actions.RemovePatchFromCommit)
err := c.Git().Patch.DeletePatchesFromCommit(c.Model().Commits, commitIndex)
// The rebase removes the selected lines, so the commit's diff shrinks
// there. Re-establish the selection at its change-line ordinal once the
// refresh below re-renders the diff, advancing to the next surviving change
// just like staging — installed before the refresh so the re-render rides
// it. The diff stays in the same pane, so source and target are the same.
revealSelectionAfterPrimaryAction(c, mainViewName, mainViewName, firstLineIdx)
return c.Helpers().MergeAndRebase.CheckMergeOrRebase(err)
})
},
})
return nil
}
// patchFilename maps a diff line's absolute path to the key the patch builder stores
// the file under — its repo-relative, slash-separated path.
func patchFilename(c *ControllerCommon, absPath string) string {

View file

@ -190,6 +190,32 @@ func (self *SwitchToDiffFilesController) PrimaryAction(mainViewName string, firs
})
}
// DiscardSelection removes the selected line(s) of the whole-commit diff from the commit,
// via a rebase. Same target derivation as the patch toggle; only enabled for local
// commits (see DiscardSelectionDisabledReason), so stash and other-branch sub-commits
// don't reach the rebase.
func (self *SwitchToDiffFilesController) DiscardSelection(mainViewName string, firstLineIdx int, lastLineIdx int) error {
ref := self.context.GetSelectedRef()
if ref == nil {
return nil
}
refsRange := self.context.GetSelectedRefRangeForDiffFiles()
from, to := context.FromAndToForDiff(ref, refsRange)
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
canRebase := self.canRebase(ref, refsRange)
return discardSelectionFromCommit(self.c, mainViewName, firstLineIdx, lastLineIdx, from, to, reverse, canRebase)
}
func (self *SwitchToDiffFilesController) DiscardSelectionDisabledReason() *types.DisabledReason {
canRebase := false
if ref := self.context.GetSelectedRef(); ref != nil {
canRebase = self.canRebase(ref, self.context.GetSelectedRefRangeForDiffFiles())
}
return discardFromCommitDisabledReason(self.c, canRebase)
}
func (self *SwitchToDiffFilesController) canEnter() *types.DisabledReason {
refRange := self.context.GetSelectedRefRangeForDiffFiles()
if refRange != nil {

View file

@ -373,6 +373,14 @@ type FocusedMainViewActions interface {
// the selection itself (staging can move the acted-on side to the other pane,
// which the handler then focuses).
PrimaryAction(mainViewName string, firstLineIdx int, lastLineIdx int) error
// DiscardSelection discards the selected diff line(s) when the user presses the
// remove key: from the working tree for the files panel, or from the commit (via a
// rebase) for the commit panels. DiscardSelectionDisabledReason reports why discard
// is unavailable here — e.g. the diff isn't a local commit's, or a rebase is in
// progress — or nil when it's available.
DiscardSelection(mainViewName string, firstLineIdx int, lastLineIdx int) error
DiscardSelectionDisabledReason() *DisabledReason
}
type IController interface {

View file

@ -0,0 +1,62 @@
package patch_building
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var DiscardLinesFromCommitMainView = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Discard a line from a local commit straight from the focused main view, without entering the commit files",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
},
SetupRepo: func(shell *Shell) {
shell.EmptyCommit("first commit")
shell.CreateFileAndAdd("file1", "1st line\n2nd line\n3rd line\n")
shell.Commit("commit to remove from")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("commit to remove from").IsSelected(),
Contains("first commit"),
).
// Focus the commit's diff straight from the commits panel, rather than
// entering the commit files panel first.
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectedLines(
Contains("+1st line"),
).
// Move down to the second line and discard just that one from the commit.
Press(keys.Universal.NextItem).
SelectedLines(
Contains("+2nd line"),
).
Press(keys.Universal.Remove)
t.ExpectPopup().Confirmation().
Title(Equals("Discard lines from commit")).
Content(Equals("Are you sure you want to discard the selected lines from this commit?")).
Confirm()
// After the rebase the commit keeps only the 1st and 3rd lines, and the selection
// advances to the next surviving change (the 3rd line, now at the discarded line's
// ordinal) rather than staying stale — like staging or discarding in the files panel.
t.Views().Main().
ContainsLines(
Equals("+1st line"),
Equals("+3rd line"),
).
Content(DoesNotContain("2nd line")).
SelectedLines(
Contains("+3rd line"),
)
},
})

View file

@ -0,0 +1,58 @@
package staging
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var DiscardFromMainView = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Discard a hunk from the working tree in the focused main view, without diving into the staging view",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n")
shell.Commit("one")
// Two separate change blocks, far enough apart to stay distinct hunks.
shell.UpdateFile("file1", "one\ntwo\nTHREE\nfour\nfive\nsix\nseven\neight\nNINE\nten\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Lines(
Contains("file1").IsSelected(),
).
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectedLines(
Contains("-three"),
).
// `a` extends the selection to the whole change block around the cursor.
Press(keys.Main.ToggleSelectHunk).
SelectedLines(
Contains("-three"),
Contains("+THREE"),
).
Press(keys.Universal.Remove)
t.ExpectPopup().Confirmation().
Title(Equals("Discard change")).
Content(Contains("Are you sure you want to discard this change")).
Confirm()
// The first block was discarded from the working tree, leaving line 3 at its
// committed value; the other block stays.
t.Views().Main().
IsFocused().
Content(DoesNotContain("THREE")).
ContainsLines(
Contains("-nine"),
Contains("+NINE"),
)
},
})

View file

@ -0,0 +1,57 @@
package staging
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var DiscardFromStagedMainView = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Discarding a hunk on the staged half of the focused main view just unstages it, advancing the selection to the next staged hunk like unstaging does",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInStagingView = true
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven\n")
shell.Commit("one")
// Two staged hunks...
shell.UpdateFileAndAdd("file1", "one\ntwo\nTHREE\nfour\nfive\nsix\nseven\neight\nNINE\nten\neleven\n")
// ...plus an unstaged change, so the main view splits into staged/unstaged.
shell.UpdateFile("file1", "one\ntwo\nTHREE\nfour\nfive\nSIX\nseven\neight\nNINE\nten\neleven\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
// The unstaged half is focused first; switch to the staged half.
t.Views().Main().
IsFocused().
Press(keys.Universal.TogglePanel)
t.Views().Secondary().
IsFocused().
SelectedLines(
Contains("-three"),
Contains("+THREE"),
).
// Discarding on the staged side is just unstaging — no discard confirm — so the
// selection stays in the staged half and advances to the next staged hunk
// rather than getting lost or jumping to the unstaged half.
Press(keys.Universal.Remove)
t.Views().Secondary().
IsFocused().
SelectedLines(
Contains("-nine"),
Contains("+NINE"),
)
// The unstaged change is untouched.
t.Views().Main().
Content(Contains("-six")).
Content(Contains("+SIX"))
},
})

View file

@ -366,6 +366,7 @@ var tests = []*components.IntegrationTest{
patch_building.BuildMultiFileFromWholeCommitMainView,
patch_building.CopyRenamedFileDiff,
patch_building.DiscardLinesFromCommit,
patch_building.DiscardLinesFromCommitMainView,
patch_building.EditLineInPatchBuildingPanel,
patch_building.MoveRangeToIndex,
patch_building.MoveToEarlierCommit,
@ -414,6 +415,8 @@ var tests = []*components.IntegrationTest{
staging.DiffChangeScreenMode,
staging.DiffContextChange,
staging.DiscardAllChanges,
staging.DiscardFromMainView,
staging.DiscardFromStagedMainView,
staging.FocusFollowsStagedSideToSecondaryAfterUnstaging,
staging.FocusReturnsToMainAfterUnstagingLastStagedHunk,
staging.Search,